如何在PowerShell中添加事件动作处理程序

9

Terminal.Gui(gui.cs)提供了一个Button类,其中定义了一个Clicked事件:

        public event Action Clicked;

我正在尝试在PowerShell中编写Terminal.Gui的示例应用程序,但是在连接事件处理程序方面遇到了困难。

Add-Type -AssemblyName Terminal.Gui
[Terminal.Gui.Application]::Init() 
$win = New-Object Terminal.Gui.Window
$win.Title = "Hello World"
$btn = New-Object Terminal.Gui.Button
$btn.X = [Terminal.Gui.Pos]::Center()
$btn.Y = [Terminal.Gui.Pos]::Center()
$btn.Text= "Press me"

# Here lies dragons
[Action]$btn.Clicked = {
    [Terminal.Gui.Application]::RequestStop() 
}

$win.Add($btn)

[Terminal.Gui.Application]::Top.Add($win)
[Terminal.Gui.Application]::Run()  

在上面的示例中,Clicked = 赋值会返回错误: InvalidOperation: 无法在此对象上找到属性 'Clicked'。请验证该属性是否存在且可以被设置。 但是智能感知为我自动完成了Clicked... 所以我猜它是一个类型问题?
我在PowerShell文档中找不到任何关于[Action]的信息,我找到的其他示例都没有给我带来任何帮助。
如何在PowerShell中定义一个基于Action的dotnet事件处理程序呢?
3个回答

16
Steve Lee的有用回答提供了重要的指针;让我来补充背景信息
PowerShell提供两种基本的事件订阅机制:
(a) .NET原生,如Steve的答案所示,其中您通过.add_<Name>()实例方法将脚本块({ ... })作为委托附加到对象的<Name>事件(委托是用户提供的回调代码片段,当事件触发时会被调用)- 请参见下一节。
(b) 由PowerShell介导,使用Register-ObjectEvent及相关cmdlets:
  • 传递脚本块给-Action参数,即可使用类似(a)的基于回调的方法。
  • 或者,可以通过Get-Event cmdlet按需检索排队的事件。
  • (b)方法的回调方式仅在PowerShell控制前台线程的时候才能及时运行,而这里并不是这种情况,因为[Terminal.Gui.Application]::Run()调用会阻塞它。 因此,必须使用(a)方法。

    Re (a):

    C#提供了语法糖,即+=-=操作符,用于附加和分离事件处理程序委托,它们看起来像赋值,但实际上被转换为add_<Event>()remove_<Event>() 方法调用

    您可以使用[PowerShell]类型作为示例,如下所示查看这些方法名称:

    PS> [powershell].GetEvents() | select Name, *Method, EventHandlerType
    
    
    Name             : InvocationStateChanged
    AddMethod        : Void add_InvocationStateChanged(System.EventHandler`1[System.Management.Automation.PSInvocationStateChangedEventArgs])
    RemoveMethod     : Void remove_InvocationStateChanged(System.EventHandler`1[System.Management.Automation.PSInvocationStateChangedEventArgs])
    RaiseMethod      : 
    EventHandlerType : System.EventHandler`1[System.Management.Automation.PSInvocationStateChangedEventArgs]
    
    

    PowerShell没有提供简化的语法来添加/移除事件处理程序,所以必须直接调用这些方法。

    不幸的是,Get-Member和制表符自动完成都不知道这些方法,反之,原始事件名称会被自动完成,即使你不能直接对它们进行操作,这很令人困惑。

    Github建议#12926旨在解决这两个问题。

    事件定义使用的约定

    上面的EventHandlerType属性显示了事件处理程序委托的类型名称,在这种情况下,它正确地遵循了基于通用类型System.EventHandler<TEventArgs>的委托的约定,其签名为:

    public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);
    

    TEventArgs代表包含事件特定信息的实例类型。 另一个惯例是这种事件参数类型派生自System.EventArgs类,而手头的类型PSInvocationStateChangedEventArgs也是如此。

    按照惯例,不提供任何事件特定信息的事件使用非泛型System.EventHandler委托:

    public delegate void EventHandler(object? sender, EventArgs e);
    

    据推测,因为这个委托历史上被用于所有委托,甚至是那些带有事件参数的委托——直到.NET 2中引入泛型之前——所以仍然存在一个EventArgs参数,并且约定是传递EventArgs.Empty而不是null来表示没有参数。
    同样,长期使用的框架类型定义了非泛型的自定义委托,具有其特定的事件参数类型,例如System.Windows.Forms.KeyPressEventHandler

    然而,这些约定都不受CLR的强制执行,这可以从事件被定义为public event Action Clicked;,使用无参数的委托作为事件处理程序中看出。

    通常最好遵循这些约定,以免违反用户的期望,尽管这样做有时不太方便。


    PowerShell在使用脚本块({ ... })作为委托时非常灵活,它显然不通过param(...)来强制执行特定的参数签名:

    无论脚本块是否声明了任何参数,太多或太少的参数,都可以接受该脚本块,尽管那些实际上由事件发起对象传递并绑定到脚本块参数的参数必须与类型兼容(假设脚本块的参数已明确指定类型)。

    因此,Steve的代码:

    $btn.Add_Clicked({
        param($sender, $e)
        [Terminal.Gui.Application]::RequestStop()
    })
    

    尽管参数声明是无用的,仍然可以工作,因为从未向脚本块传递任何参数,并且 System.Action 委托类型是 无参 的。

    以下内容已足够:

    $btn.Add_Clicked({
      [Terminal.Gui.Application]::RequestStop()
    })
    

    注意:即使没有声明参数,您也可以通过自动$this变量(在这种情况下与$btn相同)引用事件发送器(触发事件的对象)。

    简化的示例代码:

    • 在退出应用程序后,调用[Terminal.Gui.Application]::Shutdown()使终端返回可用状态是很重要的。

    • 至少有一个Terminal.Gui类型不适合PowerShell:

      • 概念上是文本属性的东西没有实现为[string]类型,而是[NStack.ustring];虽然可以使用[string]实例透明地分配给这样的属性,但再次显示它们会执行枚举并单独呈现底层字符的代码点
        • 解决方法:调用.ToString();例如:$btn.Text.ToString()
    • 截至PowerShell 7.3.2,没有与NuGet包直接集成,因此将安装包的程序集加载到PowerShell会话中相当麻烦 - 参见this answer,其中展示了如何使用.NET Core SDK下载包并使其依赖项可用

      • PowerShell(Core) 7.2+中,可以通过以下方式解决此问题:Microsoft.PowerShell.ConsoleGuiTools moduleTerminal.Gui.dll一起提供,因此您可以安装该模块并引用其中的DLL。

        • 这个解决方法来自于this GitHub comment中的Jonathan DeMarks,并已集成到下面的示例代码中。

        • Windows PowerShell中,您必须先按照aforementioned answer中的步骤使Terminal.Gui.dll可用,以便运行示例代码。

      • 请注意,Add-Type -AssemblyName仅适用于当前目录(而不是脚本目录)中的程序集或与PowerShell本身(PowerShell [Core] v6+)/ GAC(Windows PowerShell)一起提供的程序集。

      • 考虑到从PowerShell使用NuGet包的麻烦程度,GitHub feature suggestion #6724要求增强Add-Type以直接支持NuGet包。

    using namespace Terminal.Gui
    
    # Load the Terminal.Gui.dll assembly.
    if ($PSVersionTable.PSVersion -ge '7.2') {
      # Load the Terminal.Gui assembly via the 'Microsoft.PowerShell.ConsoleGuiTools'
      # module, by installing that module on demand.
      if (-not (Get-Module -ListAvailable Microsoft.PowerShell.ConsoleGuiTools)) {
        Write-Verbose -Verbose "Installing module Microsoft.PowerShell.ConsoleGuiTools on demand, in the current user's scope."
        Install-Module -Scope CurrentUser -ErrorAction Stop Microsoft.PowerShell.ConsoleGuiTools
      }
      # Terminal.Gui.dll is inside the module's folder.
      try { Add-Type -LiteralPath (Join-Path (Get-Module -ListAvailable Microsoft.PowerShell.ConsoleGuiTools).ModuleBase Terminal.Gui.dll) } catch { throw }
    }
    else {
      # Windows PowerShell (or earlier PS Core versions)
      # Unfortunately, there's no easy way to gain access to Terminal.Gui.dll, and the
      # best option is to use an aux. NET SDK project as shown in https://dev59.com/mVkS5IYBdhLWcg3w8ai-#50004706
      # The next command assumes that the steps there have been followed.
      try { Add-Type -Path C:\Users\jdoe\.nuget-pwsh\packages-winps\terminal.gui\*\Terminal.Gui.dll } catch { throw }
    }
    
    # Initialize the "GUI".
    # Note: This must come before creating windows and controls.
    [Application]::Init()
    
    $win = [Window] @{
      Title = 'Hello World'
    }
    
    $btn = [Button] @{
      X    = [Pos]::Center()
      Y    = [Pos]::Center()
      Text = 'Quit'
    }
    $win.Add($btn)
    [Application]::Top.Add($win)
    
    # Attach an event handler to the button.
    # Note: Register-ObjectEvent -Action is NOT an option, because
    # the [Application]::Run() method that isused to display the window is blocking.
    $btn.add_Clicked({
        # Close the modal window.
        # This call is also necessary to stop printing garbage in response to mouse
        # movements later.
        [Application]::RequestStop()
      })
    
    # Show the window (takes over the whole screen). 
    # Note: This is a blocking call.
    [Application]::Run()
    
    # Required to restore the previous terminal screen
    # and for being able to rerun the application in the same session.
    [Application]::Shutdown()
    

    我正在学习有关事件的内容,并注意到你提到了“你可以通过自动的 $this 变量引用事件发送者...”。为了完整起见,你可以补充说明 $e 可以通过自动的 $_ 变量引用。我不完全理解这在何时何地起作用,但似乎在将从 EventArgs 继承的类的实例作为参数传递时是有效的。我有一段代码,在 WinForms 中的 MouseDown、MouseMove 和 Paint 事件中它起作用了。昨天我从头开始创建了一个 C# 类,而在使用它的 PowerShell 脚本中,$_ 变量也是可用的。 - Darin
    @Darin,即使那样可以工作(我从未尝试过),我也不会依赖它,因为它既没有记录,也在概念上没有意义。 - mklement0
    看起来我能找到的最接近文档的是 James Brundage 的 WPF & PowerShell — Part 3 (Handling Events):“可以将脚本块转换为事件处理程序。脚本块有两个变量:$this(即发送者)和 $_,其中包含事件参数。” - Darin
    1
    @Darin,如果你能让它始终正常工作,你可以尝试说服文档团队将其记录为这样的内容。然而,正如所述,从概念上讲,我不认为这是有意义的,而且即使目前$this的工作方式也存在问题(https://github.com/MicrosoftDocs/PowerShell-Docs/issues/8207#issuecomment-940127593)。 - mklement0
    1
    谢谢提供链接。有点遗憾他们从一开始就没有将其命名为$sender$e。所有程序员只需要记住在C#中使用的参数名称前面加上$符号即可。基于$this的问题以及我现在在Register-EngineEventNew-Event中遇到的问题,我想我会通过param()或根据需要直接赋值来标准化我的代码,使用$sender$e。感谢您抽出时间回复我的评论,祝您晚上/白天愉快。 - Darin

    8
    C#代码将要添加一个Lambda表达式:
    btn.Clicked += ...
    

    所以在PowerShell中,您需要显式调用Add_Clicked()方法:

    $btn.Add_Clicked({
        [Terminal.Gui.Application]::RequestStop()
    })
    

    这些参数与方法签名匹配,尽管在此示例中未使用。


    2
    不错,但请注意,您正在显示的标准签名(更具体地说,param([object] $sender, [EventArgs] $eventArgs),其中从[EventArgs]派生的类按照惯例用于传递实际参数)在这里不适用,因为所讨论的事件是以非标准方式定义的:它被声明为采用Action委托,该委托是_无参数_的。术语争议:事件处理程序是_委托_,在C#中,常规方法和lambda表达式(匿名方法)都可以作为委托。 - mklement0

    2

    这次更改不会显示错误,但事件似乎没有触发。

    Register-ObjectEvent -InputObject $btn -EventName Clicked --Action {
            [Terminal.Gui.Application]::RequestStop() 
    }
    

    编辑:

    @Steve Lee的解决方案非常好,但还需要在结尾处添加[Terminal.Gui.Application]::Shutdown()param($sender,$e)不需要,因为它不是一个EventHandler而是一个event Action。谢谢。


    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接