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 启动后台操作:
-
非模式地显示 WPF 窗口并进入 自定义 事件循环:
注意:就像您调用窗口模态(使用.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