【问题标题】:Powershell GUI Freezing, even with runspacePowershell GUI 冻结,即使使用运行空间
【发布时间】:2021-01-03 11:02:50
【问题描述】:

我正在创建一个带有 GUI 的 powershell 脚本,它将用户配置文件从选定的源磁盘复制到目标磁盘。我已经使用 VS Community 2019 在 XAML 中创建了 GUI。 该脚本的工作方式如下:您选择源磁盘、目标磁盘、用户配置文件和要复制的文件夹。当您按下“开始”按钮时,它会调用一个名为 Backup_data 的函数,其中会创建一个运行空间。在这个运行空间中,只有一个小Copy-Item,,其中包含您选择的参数。

脚本运行良好,所有想要的项目都被正确复制。问题是 GUI 在复制期间冻结(没有“不响应”消息或其他任何信息,它只是完全冻结;无法单击任何位置,无法移动窗口)。我已经看到使用运行空间可以解决这个问题,但对我来说并没有。我错过了什么吗?

这是Backup_Data的函数:

Function BackupData {  
  ##CREATE RUNSPACE
  $PowerShell = [powershell]::Create()
  [void]$PowerShell.AddScript( {
      Param ($global:ReturnedDiskSource, $global:SelectedUser, $global:SelectedFolders, $global:ReturnedDiskDestination)
      ##SCRIPT BLOCK
      foreach ($item in $global:SelectedFolders) {
        Copy-Item -Path "$global:ReturnedDiskSource\Users\$global:SelectedUser\$item" -Destination "$global:ReturnedDiskDestination\Users\$global:SelectedUser\$item" -Force -Recurse
      }
    }).AddArgument($global:ReturnedDiskSource).AddArgument($global:SelectedUser).AddArgument($global:SelectedFolders).AddArgument($global:ReturnedDiskDestination)
  #Invoke the command
  $PowerShell.Invoke()
  $PowerShell.Dispose()
}

【问题讨论】:

  • 顺便说一句:param(...) 块声明了定义参数的 scope-local 变量,因此不应使用 $global: 范围说明符。

标签: wpf powershell xaml runspace powershell-sdk


【解决方案1】:

PowerShell SDK 的 PowerShell.Invoke() 方法是同步的,因此设计为 blocks,而其他运行空间(线程)中的脚本运行。

您必须改用异步PowerShell.BeginInvoke() 方法

简单示例图中没有 WPF(参见底部的 WPF 解决方案):

$ps = [powershell]::Create()

# Add the script and invoke it *asynchronously*
$asyncResult = $ps.AddScript({ Start-Sleep 3; 'done' }).BeginInvoke()

# Wait in a loop and check periodically if the script has completed.
Write-Host -NoNewline 'Doing other things..'
while (-not $asyncResult.IsCompleted) {
  Write-Host -NoNewline .
  Start-Sleep 1
}
Write-Host

# Get the script's success output.
"result: " + $ps.EndInvoke($asyncResult)

$ps.Dispose()

请注意,有一个使用 PowerShell SDK 的更简单的替代方法ThreadJob 模块的 Start-ThreadJob cmdlet,一个基于 线程替代以Start-Job 开头的基于子进程 的常规后台作业,它与所有其他*-Job cmdlet 兼容。

Start-ThreadJob 附带 PowerShell [Core] 7+,并且可以从 Windows PowerShell 中的 PowerShell Gallery 安装 (Install-Module ThreadJob)。

# Requires module ThreadJob (preinstalled in v6+)

# Start the thread job, always asynchronously.
$threadJob = Start-ThreadJob { Start-Sleep 3; 'done' }

# Wait in a loop and check periodically if the job has terminated.
Write-Host -NoNewline 'Doing other things..'
while ($threadJob.State -notin 'Completed', 'Failed') {
  Write-Host -NoNewline .
  Start-Sleep 1
}
Write-Host

# Get the job's success output.
"result: " + ($threadJob | Receive-Job -Wait -AutoRemoveJob)

完整示例 WPF:

如果在您的情况下,代码需要从附加到 WPF 窗口中的控件的事件处理程序中运行,则需要做更多的工作,因为 Start-Sleep 可以已使用,因为它阻止了 GUI 事件的处理,因此冻结了窗口。

WinForms 不同,后者具有用于按需处理挂起的 GUI 事件的内置方法([System.Windows.Forms.Application]::DoEvents(),WPF 有 no 等效方法,但它可以手动添加,如DispatcherFrame documentation所示。

下面的例子:

  • 创建一个带有两个背景操作启动按钮和相应状态文本框的窗口。

  • 使用按钮单击事件处理程序通过Start-ThreadJob 启动后台操作:

    • 注意:Start-Job 也可以工作,但它会在 子进程 中运行代码,而不是在线程中运行,这会慢得多,并且会产生其他重要影响。

    • 调整示例以使用 PowerShell SDK ([powershell]) 也不难,但线程作业更符合 PowerShell 的习惯,并且更易于通过常规的 *-Job cmdlet 进行管理。

  • 模式地显示 WPF 窗口并进入 自定义 事件循环:

    • 一个自定义类似DoEvents()的函数DoWpfEvents,改编自DispatcherFrame documentation,在GUI事件处理的每个循环操作中被调用。

      • 注意:对于 WinForms 代码,您可以简单地调用[System.Windows.Forms.Application]::DoEvents()
    • 此外,后台线程作业的进度会受到监控,并且接收到的输出会附加到作业特定的状态文本框中。已完成的作业将被清理。

注意:就像您调用窗口模态(使用.ShowModal())一样,前台线程和控制台会话被阻塞 在显示窗口时。避免这种情况的最简单方法是在隐藏的子进程中运行代码;假设代码在脚本wpfDemo.ps1:

# In PowerShell [Core] 7+, use `pwsh` instead of `powershell`
Start-Process -WindowStyle Hidden powershell '-noprofile -file wpfDemo.ps1'

您也可以通过 SDK 执行此操作,这样会更快,但更加冗长和繁琐:
$runspace = [runspacefactory]::CreateRunspace() $runspace.ApartmentState = 'STA'; $runspace.Open(); $ps = [powershell]::Create(); $ps.Runspace = $runspace; $null = $ps.AddScript((Get-Content -Raw wpfDemo.ps1)).BeginInvoke()

截图

此示例屏幕截图显示了一项已完成的后台操作和一项正在进行的操作(支持并行运行它们);注意启动正在进行的操作的按钮在操作期间是如何被禁用的,以防止重新进入:

源代码:

using namespace System.Windows
using namespace System.Windows.Threading

# Load WPF assemblies.
Add-Type -AssemblyName PresentationCore, PresentationFramework

# Define the XAML document, containing a pair of background-operation-launching
# buttons plus associated status text boxes.
[xml] $xaml = @"
<Window
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Test"
        Title="MainWindow" Height="220" Width="600">
    <Grid>
        <TextBox x:Name="Status1" Height="140" Width="280" Margin="10,10" TextWrapping="Wrap" VerticalAlignment="Top" HorizontalAlignment="Left" AcceptsReturn="True" AcceptsTab="True" Padding="4" VerticalScrollBarVisibility="Auto" />
        <TextBox x:Name="Status2" Height="140" Width="280" Margin="10,10" TextWrapping="Wrap" VerticalAlignment="Top" HorizontalAlignment="Right" AcceptsReturn="True" AcceptsTab="True" Padding="4" VerticalScrollBarVisibility="Auto" />
        <Button x:Name="DoThing1" Content="Do Thing 1" HorizontalAlignment="Left" VerticalAlignment="Bottom" Width="100" Height="22" Margin="10,5" IsDefault="True" />
        <Button x:Name="DoThing2" Content="Do Thing 2" HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="100" Height="22" Margin="10,5" />
    </Grid>
</Window>
"@

# Parse the XAML, which returns a [System.Windows.Window] instance.
$Window = [Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))

# Save the window's relevant controls in PowerShell variables.
# Background-operation-launching buttons.
$btns = $Window.FindName('DoThing1'), $Window.FindName('DoThing2')

# Use a [hashtable] to map the buttons to the associated status text boxes.
$txtBoxes = @{
  $btns[0] = $Window.FindName('Status1')
  $btns[1] = $Window.FindName('Status2')
}
# Use a [hashtable] to map the buttons to the associated background
# operations, defined as script blocks to be passed to Start-ThreadJob later.
# The sample operations here run for a few seconds, 
# emitting '.' every second and a message on completion.
$scriptBlocks = @{
  $btns[0] = 
    {
      1..3 | ForEach-Object { '.'; Start-Sleep 1 }
      'Thing 1 is done.'
    }
  $btns[1] = 
    {
      1..2 | ForEach-Object { '.'; Start-Sleep 1 }
      'Thing 2 is done.'
    }
}

# Attach the button-click event handlers that
# launch the background operations (thread jobs).
foreach ($btn in $btns) {

  $btn.Add_Click({

    # Temporarily disable this button to prevent re-entry.
    $this.IsEnabled = $false

    # Show a status message in the associated text box.
    $txtBoxes[$this].Text = "Started thing $($this.Name -replace '\D') at $(Get-Date -Format T)."

    # Asynchronously start a background thread job named for this button.
    # Note: Would work with Start-Job too, but that runs the code in *child process*, 
    #       which is much slower and has other implications.
    $null = Start-ThreadJob -Name $this.Name $scriptBlocks[$this]

  })

}

# Define a custom DoEvents()-like function that processes GUI WPF events and can be 
# called in a custom event loop in the foreground thread.
# Adapted from: https://docs.microsoft.com/en-us/dotnet/api/system.windows.threading.dispatcherframe
function DoWpfEvents {
  [DispatcherFrame] $frame = [DispatcherFrame]::new($True)
  $null = [Dispatcher]::CurrentDispatcher.BeginInvoke(
    'Background', 
    [DispatcherOperationCallback] {
      param([object] $f)
      ($f -as [DispatcherFrame]).Continue = $false
      return $null
    }, 
    $frame)
  [Dispatcher]::PushFrame($frame)
}


# Finally, display the window NON-modally...
$Window.Show() 
$null = $Windows.Activate() # Ensures that the window gets the focus.
# ... and enter a custom event loop based on calling the custom .DoEvents() method
while ($Window.IsVisible) {

  # Process GUI events.
  DoWpfEvents

  # Process pending background (thread) jobs, if any.
  Get-Job | ForEach-Object {
    
    # Get the originating button via the job name.
    $btn = $Window.FindName($_.Name)
    # Get the corresponding status text box.
    $txtBox = $txtBoxes[$btn]

    # Test if the job has terminated.
    $completed = $_.State -in 'Completed', 'Failed', 'Stopped'

    # Append any new results to the respective status text boxes.
    # Note the use of redirection *>&1 to capture ALL streams, notably including the error stream.
    if ($data = Receive-Job $_ *>&1) {
      $txtBox.Text += "`n" + ($data -join "`n")
    }

    # Clean up, if the job is completed.
    if ($completed) {
      Remove-Job $_
      $btn.IsEnabled = $true # re-enable the button.
      $txtBox.Text += "`nJob terminated on: $(Get-Date -Format T); status: $($_.State)."
    }

  }

  # Note: If there are no GUI events pending, this loop will cycle very rapidly.
  #       To mitigate this, we *also* sleep a little, but short enough to still keep
  #       the GUI responsive.
  Start-Sleep -Milliseconds 50

}

# Window was closed; clean up:
# If the window was closed before all jobs completed, 
# get the incomplete jobs' remaining output, wait for them to finish, and delete them.
Get-Job | Receive-Job -Wait -AutoRemoveJob


【讨论】:

  • 问题是,我应该创建的 WPF 应用程序将被技术人员用来复制我公司的用户配置文件。而且我知道 powershell 不是他的最高版本..
  • @OscarLoret,它不必是最高版本:你有 3 个选项:: (a) 安装 ThreadModule 模块,它可以降到 v3(虽然它很简单在 v5.1 中) (b) 鉴于您的任务的性质,只需将 Start-ThreadJob 替换为内置的 Start-Job,它也可以工作 - 它速度较慢,但​​长时间运行的复制操作并不重要。 (c) 调整示例以使用 PowerShell SDK:而不是创建作业,而是将从 .BeginInvoke() 返回的异步结果添加到同步的脚本级集合并在自定义事件循环中处理。
【解决方案2】:

我一直在寻找解决方案,我终于找到了一个,所以我将在那里发布给那些有同样问题的人。

首先,查看这篇文章:https://smsagent.blog/2015/09/07/powershell-tip-utilizing-runspaces-for-responsive-wpf-gui-applications/ 它得到了很好的解释,并向您展示了如何通过 WPF GUI 正确使用运行空间。您只需将 $Window 变量替换为 $Synchhash.Window :

$syncHash = [hashtable]::Synchronized(@{})
$reader = (New-Object System.Xml.XmlNodeReader $xaml)
$syncHash.window = [Windows.Markup.XamlReader]::Load( $reader )

在您的代码中插入运行空间函数:

function RunspaceBackupData {
$Runspace = [runspacefactory]::CreateRunspace()
$Runspace.ApartmentState = "STA"
$Runspace.ThreadOptions = "ReuseThread"
$Runspace.Open()
$Runspace.SessionStateProxy.SetVariable("syncHash",$syncHash)
$Runspace.SessionStateProxy.SetVariable("SelectedFolders",$global:SelectedFolders)
$Runspace.SessionStateProxy.SetVariable("SelectedUser",$global:SelectedUser)
$Runspace.SessionStateProxy.SetVariable("ReturnedDiskSource",$global:ReturnedDiskSource)
$Runspace.SessionStateProxy.SetVariable("ReturnedDiskDestination",$global:ReturnedDiskDestination)
$code = {
    foreach ($item in $global:SelectedFolders) {
        copy-item -Path "$global:ReturnedDiskSource\Users\$global:SelectedUser\$item" -Destination "$global:ReturnedDiskDestination\Users\$global:SelectedUser\$item" -Force -Recurse
        }
}
$PSinstance = [powershell]::Create().AddScript($Code)
$PSinstance.Runspace = $Runspace
$job = $PSinstance.BeginInvoke()
}

并使用您指定的参数在您想要的事件处理程序中调用它:

$var_btnStart.Add_Click( {
    RunspaceBackupData -syncHash $syncHash -SelectedFolders $global:SelectedFolders -SelectedUser $global:SelectedUser -ReturnedDiskSource $global:ReturnedDiskSource -ReturnedDiskDestination $global:ReturnedDiskDestination 
})

别忘了结束你的运行空间:

$syncHash.window.ShowDialog()
$Runspace.Close()
$Runspace.Dispose()

【讨论】:

  • 最好显示改进的代码而不是描述它应该是什么样子。
  • 链接的文章使用了先进的 PowerShell SDK 技术,令人印象深刻,但由于其复杂性也存在问题。在以 non 模式显示窗口后,我改进了在自定义事件循环中执行所有后台处理的原始方法。结合Start-ThreadJob的使用,示例代码不需要SDK(虽然可以改用SDK,但线程作业更可取),并且显示了独立运行两个后台操作。让示例适应您的用例应该没有问题。
  • 我完全同意这个解决方案不是最简单的。尤其是当您必须更改大部分脚本以将所有事件处理程序放入同步变量时。我今天要检查 Start-ThreadJob cmdlet
猜你喜欢
  • 2011-10-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-08-15
  • 2018-10-11
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多