使用异步处理的 WPF 应用程序响应式 UI






4.64/5 (20投票s)
演示了如何通过异步编程在运行长时间进程时保持 WPF UI 的响应性。
引言
目标:演示如何在运行长时间进程时通过异步方式保持 WPF UI 的响应性。
方法:通过一个示例应用程序,我将首先演示什么是无响应的 UI 以及如何产生无响应的 UI。接下来,我将演示如何通过异步代码使 UI 响应。最后,我将演示如何通过一种更简单、基于事件的异步方法使 UI 响应。我还会通过更新 TextBlock
和 ProgressBar
来展示如何在处理过程中让用户了解进度。
什么是无响应的 UI?当然,我们都曾见过 Windows 窗体或 WPF 应用程序偶尔会“死机”。您是否曾想过这是为什么?简而言之,这通常是因为应用程序在单个线程上运行。无论是更新 UI 还是在后台运行某个长时间进程(例如调用数据库),所有操作都必须排成一条单行道,等待 CPU 执行命令。因此,当我们调用那个耗时几秒钟才能运行的数据库时,UI 就被搁置在队伍中等待,无法自我更新,从而导致“死机”。
如何解决这种无响应 UI 的问题?无论是 Windows 窗体还是 WPF 应用程序,UI 都在主线程或首要线程上更新。为了让此线程保持空闲,以便 UI 保持响应,我们需要创建一个新线程来处理后台的任何大型任务。.NET Framework 不同版本中用于实现这一点的类一直在发展,变得越来越简单、功能越来越丰富。然而,这可能会造成一些困惑。如果您在 Google 上搜索 C# 或 VB 和异步,或类似的内容,您肯定会看到许多执行异步处理的不同方法。对“我应该使用哪种方法?”这个问题的回答,当然取决于您正在做什么以及您的目标是什么。是的,我也讨厌这个答案。
由于我不可能涵盖所有异步场景,因此本文将重点介绍我发现自己大多数时候需要异步处理的场景。那就是在运行数据库查询时保持 WPF 应用程序 UI 的响应性。请注意,通过一些小修改,本文中的代码以及可下载的源代码也可以用于 Windows 窗体应用程序。此外,本文仅展示了如何解决异步编程中的一个特定问题,这绝不是异步编程的唯一用途。
为了帮助演示同步、异步和事件驱动的异步处理,我将通过一个包含多个演示的应用程序来讲解。
- 同步演示(不要这样做):在单个线程上处理所有操作并导致 UI 死机。
- 异步演示:添加一个辅助线程来释放 UI。我还会向 UI 添加一些响应文本,作为视觉指示器,让用户了解当前的状态。
- 基于事件的异步模型演示:在此基础上,我还会添加一个进度条和一些响应文本。
本文中的代码将以 VB 编写;但是,完整的源代码下载将提供 C# 和 VB 版本。
不要这样做
正如我之前提到的,您不希望做的是在单个线程上运行所有后台和 UI 处理。这几乎总是会导致 UI 死机。您可以下载 C# 和 VB 两个版本的演示应用程序。运行应用程序,然后点击“同步演示”下的“开始”按钮。一旦您点击按钮,请尝试将窗口拖动到屏幕上的其他位置。您无法做到。如果您尝试几次,窗口甚至可能会变黑,并且您会在标题栏看到“(未响应)”的警告。然而,几秒钟后,窗口将解锁,UI 将更新,您可以再次自由地拖动它。
让我们看看这段代码,了解发生了什么。如果您查看此演示的代码,您会看到以下内容:
首先,我们有一个委托,它有点像一个函数指针,但具有更多的功能并提供类型安全。
Delegate Function SomeLongRunningMethodHandler(ByVal rowsToIterate As Integer) As String
在这个示例中,我们可以轻松地不使用委托,而直接从方法处理程序调用长时间运行的方法。事实上,如果我还没有打算将此调用改为异步运行,我就不会使用委托。但是,通过使用委托,我可以演示从同步调用转到异步调用有多么容易。换句话说,假设您有一个方法,您可能希望异步运行,但又不确定。通过使用委托,您现在可以同步调用,稍后可以轻松地切换到异步调用。
我不会详细介绍委托,但关键是要记住,委托的签名必须与它将引用的函数(或 VB 中的 Sub
)的签名完全匹配。在此 VB 示例中,委托的签名是为一个 Function
,该函数接受一个 Integer
作为参数并返回一个 String
。
接下来,我们有了按钮 Click
事件的方法处理程序。在将 TextBlock
重置为空字符串后,声明委托。然后,实例化委托(是的,创建委托时会创建一个类)。在这种情况下,将要由委托调用的函数的指针作为参数传递给构造函数。现在,我们有了一个指向函数 SomeLongRunningSynchronousMethod
的委托实例(synchronousFunctionHandler
)。如果您再向下看一行,您可以看到如何通过委托同步调用此方法。我们拥有的委托实例实际上是一个具有多个方法的类的实例。其中一个方法称为 Invoke
。这就是我们同步调用委托附加的方法的方式。您可能还注意到了 BeginInvoke
和 EndInvoke
方法(如果您使用了 Intellisense)。
还记得我说过通过使用委托,我们可以轻松地从同步切换到异步吗?您现在已经得到了线索,我们很快就会详细介绍。
回到我们的异步示例,您可以看到在委托实例上调用了 Invoke
方法。它接受一个整数作为参数,并返回一个字符串。然后将该字符串分配给一个 TextBlock
,以告知用户操作已完成。
Private Sub SynchronousStart_Click(ByVal sender As System.Object, _
ByVal e As System.Windows.RoutedEventArgs) _
Handles synchronousStart.Click
Me.synchronousCount.Text = ""
Dim synchronousFunctionHandler As SomeLongRunningMethodHandler
synchronousFunctionHandler = _
New SomeLongRunningMethodHandler(AddressOf _
Me.SomeLongRunningSynchronousMethod)
Dim returnValue As String = _
synchronousFunctionHandler.Invoke(1000000000)
Me.synchronousCount.Text = _
"Processing completed."&
returnValue & " rows processed."
End Sub
这是委托调用的函数。如前所述,也可以在不使用委托的情况下直接调用它。它只需接受一个整数,并迭代该次数,完成后返回计数作为字符串。此方法用于模拟您可能拥有的任何长时间运行的进程。
Private Function SomeLongRunningSynchronousMethod _
ByVal rowsToIterate As Integer) As String
Dim cnt As Double = 0
For i As Long = 0 To rowsToIterate
cnt = cnt + 1
Next
Return cnt.ToString()
End Function
坏消息是,异步实现此演示会导致 UI 无响应。好消息是,通过使用委托,我们已经为轻松切换到异步方法和响应式 UI 做好了准备。
更具响应性的方法
现在,再次运行下载的演示,但这次点击第二个“运行”按钮(同步演示)。然后,尝试将窗口拖动到屏幕上。注意到什么不同了吗?您现在可以同时点击调用长时间运行方法的按钮并拖动窗口,而不会出现任何死锁。这是因为长时间运行的方法是在辅助线程上运行的,从而释放了主线程来处理所有 UI 请求。
此演示使用了与上一个示例相同的 SomeLongRunningSynchronousMethod
。它还将首先声明然后实例化一个委托,该委托最终将引用长时间运行的方法。此外,您将看到创建了第二个名为 UpdateUIHandler
的委托,我们稍后将讨论它。以下是委托和第二个演示按钮点击事件的处理程序:
Delegate Function AsyncMethodHandler _
ByVal rowsToIterate As Integer) As String
Delegate Sub UpdateUIHandler _
ByVal rowsupdated As String)
Private Sub AsynchronousStart_Click( _
ByVal sender As System.Object, _
ByVal e As System.Windows.RoutedEventArgs)
Me.asynchronousCount.Text = ""
Me.visualIndicator.Text = "Processing, Please Wait...."
Me.visualIndicator.Visibility = Windows.Visibility.Visible
Dim caller As AsyncMethodHandler
caller = New AsyncMethodHandler _
(AsyncMethodHandlerAddressOf Me.SomeLongRunningSynchronousMethod)
caller.BeginInvoke(1000000000, AddressOf CallbackMethod, Nothing)
End Sub
请注意,事件方法与上一个示例开头类似。我们设置了一些 UI 控件,然后声明并实例化了第一个委托。之后,情况就有些不同了。注意委托实例“caller
”对 BeginInvoke
的调用。BeginInvoke
是一个异步调用,取代了上一个示例中对 Invoke
的调用。调用 Invoke
时,我们传递了委托和委托方法在其签名中都拥有的参数。我们对 BeginInvoke
也这样做;但是,传递了两个额外参数,这两个参数在委托或委托方法签名中都看不到。这两个额外参数是类型为 AsyncCallback
的 DelegateCallback
和类型为 Object
的 DelegateAsyncState
。同样,您不会将这两个额外参数添加到您的委托声明或委托实例指向的方法中;但是,您必须在 BeginInvoke
调用中同时处理它们。
本质上,有多种方法可以使用 BeginInvoke
来处理异步执行。这些参数的值取决于使用哪种技术。其中一些技术包括:
- 调用
BeginInvoke
,执行一些处理,然后调用EndInvoke
。 - 使用
BeginInvoke
返回的类型为IAsyncResult
的WaitHandle
。 - 使用
BeginInvoke
返回的IAsyncResult
的IsCompleted
属性进行轮询。 - 在异步调用完成后执行回调方法。
我们将使用最后一种技术,即在异步调用完成后执行回调方法。我们可以使用此方法,因为启动异步调用的主线程不需要处理该调用的结果。本质上,这使我们能够调用 BeginInvoke
在新线程上启动长时间运行的方法。BeginInvoke
会立即返回给调用者(在本例中是主线程),这样 UI 处理就可以继续进行而不会死锁。一旦长时间运行的方法完成,就会调用回调方法,并将长时间运行的方法的结果作为类型 IAsyncResult
传递。我们可以到此结束;但是,在我们的演示中,我们希望获取传递给回调方法的结果并用它们更新 UI。
您可以看到,我们对 BeginInvoke
的调用传递了一个整数,这是委托和委托方法作为第一个参数所必需的。第二个参数是指向回调方法的指针。最后一个值是“Nothing
”,因为在我们的方法中不需要使用 DelegateAsyncState
。另外,请注意,我们在此处设置了 visualIndicator TextBlock
的 Text
和 Visibility
属性。我们可以访问此控件,因为此方法是在主线程上调用的,而这些控件也是在该主线程上创建的。
Protected Sub CallbackMethod(ByVal ar As IAsyncResult)
Try
Dim result As AsyncResult = CType(ar, AsyncResult)
Dim caller As AsyncMethodHandler = CType(result.AsyncDelegate, _
AsyncMethodHandler)
Dim returnValue As String = caller.EndInvoke(ar)
UpdateUI(returnValue)
Catch ex As Exception
Dim exMessage As String
exMessage = "Error: " & ex.Message
UpdateUI(exMessage)
End Try
End Sub
在回调方法中,我们需要做的第一件事是获取对调用委托(调用 BeginInvoke
的那个)的引用,以便我们可以对其调用 EndInoke
并获取长时间运行的结果。EndInvoke
总是会阻止进一步的处理,直到 BeginInvoke
完成。但是,我们不必担心这一点,因为我们在回调方法中,该方法仅在 BeginInvoke
已完成后触发。
调用 EndInvoke
后,我们就得到了长时间运行的结果。如果我们能用此结果更新 UI 会很好;但是,我们不能。为什么?回调方法仍在辅助线程上运行。由于 UI 对象是在主线程上创建的,因此它们不能在创建它们的线程以外的任何线程上访问。不过别担心;我们有一个计划,可以让我们仍然通过异步调用中的数据来更新 UI。
调用 EndInvoke
后,会调用 Sub UpdateUI
,并将 EndInvoke
的返回值作为参数传递。另请注意,此方法包装在 Try-Catch
块中。始终调用 EndInvoke
并将其调用包装在 Try-Catch
中被认为是良好的编码标准,如果您希望处理异常。这是知道 BeginInvoke
所做的异步调用没有发生异常的唯一积极方法。
Sub UpdateUI(ByVal rowsUpdated As String)
Dim uiHandler As New UpdateUIHandler(AddressOf UpdateUIIndicators)
Dim results As String = rowsUpdated
Me.Dispatcher.Invoke(Windows.Threading.DispatcherPriority.Normal, _
uiHandler, results)
End Sub
Sub UpdateUIIndicators(ByVal rowsupdated As String)
Me.visualIndicator.Text = "Processing Completed."
Me.asynchronousCount.Text = rowsupdated & " rows processed."
End Sub
接下来,我们看到 UpdateUI
方法。它以回调方法中 EndInvoke
的返回值作为参数。它做的第一件事是声明并实例化一个委托。这个委托是一个 Sub
,它接受一个类型为 String
的单个参数。当然,这意味着它在其构造函数中采用的函数指针也必须指向具有完全相同签名的 Sub
。对于我们的演示,那将是 UpdateUIIndicators Sub
。设置好委托后,我们将 UpdateUI
参数放入一个字符串中。这最终将被传递给 BeginInvoke
。
接下来,您将看到对 Invoke
的调用。我们也可以在这里调用 BeginInvoke
,但是由于此方法仅更新两个 UI 属性,因此它应该运行得很快,而无需进一步的异步处理。请注意,对 Invoke
的调用是在 Me.Dispatcher
上运行的。WPF 中的 Dispatcher 是您应用程序的线程管理器。为了让 Invoke
调用(在后台线程上)更新主线程上的 UI 控件,后台线程必须将工作委托给与 UI 线程关联的 Dispatcher。这可以通过调用异步方法 BeginInvoke
或我们在 Dispatcher 上执行的同步方法 Invoke
来完成。最后,Sub
UpdateUIIndicators
接受传递给它的结果并更新 UI 上的 TextBlock
。它还会更改另一个 TextBlock
上的文本,以指示处理已完成。
我们现在已经成功编写了一个响应式的多线程 WPF 应用程序。我们通过使用委托、BeginInvoke
、EndInvoke
、回调方法和 WPF Dispatcher 来实现这一点。工作量不算太大,但比一点点要多。然而,这种传统的实现多线程的方法现在可以使用一种更简单的 WPF 异步方法来完成。
基于事件的异步模型
编写异步代码的方法有很多。我们已经看过其中一种方法,如果需要,它非常灵活。但是,从 .NET 2.0 开始,有一种我认为更简单、更安全的方法。System.ComponentModel.BackgroundWorker
(BackgroundWorker
) 为我们提供了一种几乎不会出错的方式来创建异步代码。当然,提供这种简单性和安全性的抽象通常会付出代价,那就是灵活性。然而,对于在后台运行长时间进程时保持 UI 响应的任务来说,它非常完美。此外,它还提供了用于处理进度跟踪和取消的事件,具有相同的简单性。
考虑以下我们将要拆分到单独线程的方法,以便 UI 可以保持响应。
Private Function SomeLongRunningMethodWPF() As String
Dim iteration As Integer = CInt(100000000 / 100)
Dim cnt As Double = 0
For i As Long = 0 To 100000000
cnt = cnt + 1
If (i Mod iteration = 0) And (backgroundWorker IsNot Nothing) _
AndAlso backgroundWorker.WorkerReportsProgress Then
backgroundWorker.ReportProgress(i \ iteration)
End If
Next
Return cnt.ToString()
End Function
请注意,还有一些代码用于跟踪进度。我们将稍后处理它;现在,只需记住我们正在向 backgroundWorker.ReportProgress
方法报告进度。
使用 BackgroundWorker
和事件驱动模型,我们需要做的第一件事是创建一个 BackgroundWorker
实例。有以下两种方法可以完成此任务:
- 在代码中声明式地创建
BackgroundWorker
实例。 - 将
BackgroundWorker
作为资源创建在 XAML 标记中。使用此方法允许您使用属性连接事件方法处理程序。
我将快速演示后一种方法,但在演示的其余部分,我们将使用声明式方法。
首先,您必须引用 System.ComponentModel
的命名空间。
<Window x:Class="AsynchronousDemo"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cm="clr-namespace:System.ComponentModel;assembly=System"
Title="Asynchronous Demo" Height="400" Width="450">
然后,您可以创建一个 BackgroundWorker
实例。由于没有 UI 元素,您可以将此 XAML 放在页面上的任何位置。
<Window.Resources>
<cm:BackgroundWorker x:Key="backgroundWorker" _
WorkerReportsProgress="True" _
WorkerSupportsCancellation="False" />
</Window.Resources>
声明式地,我们可以完成同样的事情:
Private WithEvents backgroundWorker As New BackgroundWorker()
接下来,我们需要一个调用长时间运行进程来启动它的东西。在我们的演示中,我们将通过按钮的 Click
事件来触发。以下是调用并启动一切的方法处理程序:
Private Sub WPFAsynchronousStart_Click(ByVal sender As System.Object, _
ByVal e As System.Windows.RoutedEventArgs)
Me.wpfCount.Text = ""
Me.wpfAsynchronousStart.IsEnabled = False
backgroundWorker.RunWorkerAsync()
wpfProgressBarAndText.Visibility = Windows.Visibility.Visible
End Sub
让我们逐步分析按钮点击事件中发生的情况。首先,我们清除用于在 UI 上显示消息的 TextBlock
中的所有文本,并设置两个按钮的 IsEnabled
状态。接下来,我们调用 RunWorkerAsync
,它会启动一个新线程并开始我们的异步进程。RunWorkerAsync
调用的事件是 DoWork
。DoWork
在新线程上运行,为我们提供了一个调用长时间运行方法的场所。RunWorkerAsync
还有一个重载,它接受一个 Object
。此对象可以传递给 DoWork
方法,并在后续处理中使用。请注意,我们在这里不需要任何委托,也不需要自己创建任何新线程。
当按钮被点击时,我们也在 XAML 中的 Storyboard 中捕获该事件。这个 Storyboard 触发了指向 ProgressBar
的动画,该动画一直运行直到异步进程完成。
<StackPanel.Triggers>
<EventTrigger RoutedEvent="Button.Click"
SourceName="wpfAsynchronousStart">
<BeginStoryboard Name="myBeginStoryboard">
<Storyboard Name="myStoryboard"
TargetName="wpfProgressBar"
TargetProperty="Value">
<DoubleAnimation
From="0"
To="100"
Duration="0:0:2"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</StackPanel.Triggers>
Private Sub backgroundWorker_DoWork(ByVal sender As Object, _
ByVal e As DoWorkEventArgs) _
Handles backgroundWorker.DoWork
Dim result As String
result = Me.SomeLongRunningMethodWPF()
e.Result = result
End Sub
关于 DoWork
有几点需要注意。首先,一旦进入此方法,就会从托管 CLR 线程池中启动一个新线程。其次,重要的是要记住这是一个辅助线程,因此相同的规则适用于无法更新在主线程上创建的 UI 控件。
请记住,在我们长时间运行的进程中,我曾提到我们正在跟踪进度?具体来说,每迭代循环 100 次,我们就调用一次:
backgroundWorker.ReportProgress(i \ iteration)
方法 ReportProgress
被连接起来以调用 BackgroundWorker
的 ProcessChanged
事件。
Private Sub backgroundWorker_ProgressChanged(ByVal sender As Object, _
ByVal e As System.ComponentModel.ProgressChangedEventArgs) _
Handles backgroundWorker.ProgressChanged
Me.wpfCount.Text = _
CStr(e.ProgressPercentage) & "% processed."
End Sub
我们使用此方法将当前迭代计数更新到 TextBlock
。请注意,由于此方法在 Dispatcher 线程上运行,因此我们可以自由地更新 UI 组件。这显然不是使用 ProgressChanged
事件最实用的方法;但是,我只是想演示它的用法。一旦 DoWork
方法中的处理完成,将调用 Dispatcher 线程的 RunWorkerCompleted
方法。这给了我们一个机会来处理从 DoWork
传递过来的 CompletedEventArgs.Result
。
Private Sub backgroundWorker_RunWorkerCompleted(ByVal sender As Object, _
ByVal e As RunWorkerCompletedEventArgs) _
Handles backgroundWorker.RunWorkerCompleted
wpfProgressBarAndText.Visibility = Windows.Visibility.Collapsed
Me.wpfCount.Text = "Processing completed. " & _
CStr(e.Result) & " rows processed."
Me.myStoryboard.Stop(Me.lastStackPanel)
Me.wpfAsynchronousStart.IsEnabled = True
End Sub
在 RunWorkerCompleted
事件中,我们首先隐藏进度条和进度条状态文本,因为我们的长时间运行操作已完成。我们还可以启用“开始”按钮,以便可以再次运行演示。如前所述,我们可以在此处访问这些 UI 元素,因为我们回到了主线程(Dispatcher 线程)。
可下载的代码(有 C# 和 VB 版本)还包含处理 CancelAsync
方法的代码。这演示了如何让用户能够取消长时间运行的进程,如果他们认为不值得等待的话。在大多数应用程序中,一旦用户启动了一个进程,他们就只能等待它完成。然而,由于本文已经很长,我决定不在此文中包含它。