65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.64/5 (20投票s)

2008年10月5日

CPOL

16分钟阅读

viewsIcon

119060

downloadIcon

4007

演示了如何通过异步编程在运行长时间进程时保持 WPF UI 的响应性。

引言

目标:演示如何在运行长时间进程时通过异步方式保持 WPF UI 的响应性。

方法:通过一个示例应用程序,我将首先演示什么是无响应的 UI 以及如何产生无响应的 UI。接下来,我将演示如何通过异步代码使 UI 响应。最后,我将演示如何通过一种更简单、基于事件的异步方法使 UI 响应。我还会通过更新 TextBlockProgressBar 来展示如何在处理过程中让用户了解进度。

什么是无响应的 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 版本。

不要这样做

lockedUpUI.jpg

正如我之前提到的,您不希望做的是在单个线程上运行所有后台和 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。这就是我们同步调用委托附加的方法的方式。您可能还注意到了 BeginInvokeEndInvoke 方法(如果您使用了 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 请求。

asynchronousDemo.jpg

此演示使用了与上一个示例相同的 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 也这样做;但是,传递了两个额外参数,这两个参数在委托或委托方法签名中都看不到。这两个额外参数是类型为 AsyncCallbackDelegateCallback 和类型为 ObjectDelegateAsyncState。同样,您不会将这两个额外参数添加到您的委托声明或委托实例指向的方法中;但是,您必须在 BeginInvoke 调用中同时处理它们。

本质上,有多种方法可以使用 BeginInvoke 来处理异步执行。这些参数的值取决于使用哪种技术。其中一些技术包括:

  • 调用 BeginInvoke,执行一些处理,然后调用 EndInvoke
  • 使用 BeginInvoke 返回的类型为 IAsyncResultWaitHandle
  • 使用 BeginInvoke 返回的 IAsyncResultIsCompleted 属性进行轮询。
  • 在异步调用完成后执行回调方法。

我们将使用最后一种技术,即在异步调用完成后执行回调方法。我们可以使用此方法,因为启动异步调用的主线程不需要处理该调用的结果。本质上,这使我们能够调用 BeginInvoke 在新线程上启动长时间运行的方法。BeginInvoke 会立即返回给调用者(在本例中是主线程),这样 UI 处理就可以继续进行而不会死锁。一旦长时间运行的方法完成,就会调用回调方法,并将长时间运行的方法的结果作为类型 IAsyncResult 传递。我们可以到此结束;但是,在我们的演示中,我们希望获取传递给回调方法的结果并用它们更新 UI。

您可以看到,我们对 BeginInvoke 的调用传递了一个整数,这是委托和委托方法作为第一个参数所必需的。第二个参数是指向回调方法的指针。最后一个值是“Nothing”,因为在我们的方法中不需要使用 DelegateAsyncState。另外,请注意,我们在此处设置了 visualIndicator TextBlockTextVisibility 属性。我们可以访问此控件,因为此方法是在主线程上调用的,而这些控件也是在该主线程上创建的。

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 应用程序。我们通过使用委托、BeginInvokeEndInvoke、回调方法和 WPF Dispatcher 来实现这一点。工作量不算太大,但比一点点要多。然而,这种传统的实现多线程的方法现在可以使用一种更简单的 WPF 异步方法来完成。

基于事件的异步模型

asynchronousEventBasedDemo.jpg

编写异步代码的方法有很多。我们已经看过其中一种方法,如果需要,它非常灵活。但是,从 .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 调用的事件是 DoWorkDoWork 在新线程上运行,为我们提供了一个调用长时间运行方法的场所。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 被连接起来以调用 BackgroundWorkerProcessChanged 事件。

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 方法的代码。这演示了如何让用户能够取消长时间运行的进程,如果他们认为不值得等待的话。在大多数应用程序中,一旦用户启动了一个进程,他们就只能等待它完成。然而,由于本文已经很长,我决定不在此文中包含它。

© . All rights reserved.