要实现我们讨论的行为,显然需要实现 Controller 类。为了使此架构能够在多数方案中应用,我们还会定义一些正式接口,可以由 Controller 在与 UI(或客户端)和辅助线程交互时使用。
通过为客户端和辅助线程定义正式接口,我们可以在不同的情况下使用相同的 Controller 对象,还可以根据需要使用不同的 UI 要求和不同的 Worker 对象。
下面的 UML 类图表显示了 Controller 类以及 IClient 和 IWorker 接口。它还显示了 IController 接口,辅助代码将通过它与 Controller 对象交互。
图 5:Controller 和相关接口的类图表
IClient 接口定义的方法将由 Controller 对象调用,用于向客户端 UI 通报 Worker 的开始时间、结束时间和任何中间状态消息。它还包含一个指示辅助代码失败的方法。
多数情况下,我们可以将这些方法作为由 Controller 对象发出而由 UI 处理的事件来实现。但是,从辅助线程发出事件然后由 UI 线程正确处理并非易事,因而我们将其作为一组方法来进行实现。
使控制器代码(在辅助代码上运行)调用 UI 中的这些方法并由 UI 线程进行处理,这样相对要简单得多。
同样,IWorker 接口定义了由 Controller 对象调用的、使其可以与辅助代码交互的方法。使用 Initialize 方法可以为辅助代码提供对 Controller 对象的引用,而使用 Start 方法可以启动后台线程上的操作。
由于线程的工作方式,Start 方法无法包含任何参数。启动新线程时,必须将不接受任何参数的方法的地址传递给线程。
请注意,IWorker 接口中不存在 Cancel 或 Stop 方法。我们不能强制辅助代码停止,同时也没有这个必要;但是辅助代码可以使用 IController 接口询问 Controller 对象是否存在取消请求。
IController 接口定义了辅助代码可以在 Controller 对象上调用的方法。它允许辅助代码检查 Running 标志。如果存在取消请求,Running 标志即为 False。它还允许辅助代码在工作完成或无法完成时告诉 Controller,并允许使用状态消息和完成百分比值(0 到 100 之间的 Integer)更新 Controller。
最后我们定义了 Controller 对象。该对象中包含一些可以被 UI 代码调用的方法。其中包括 Start 方法,该方法可以通过为 Controller 对象提供对 Worker 对象的引用来启动后台操作。还包括 Cancel 方法,该方法用于请求取消操作。UI 也可以检查 Running 属性,查看是否存在取消请求;还可以检查 Percent 属性,查看任务完成的百分比。
Controller 类中包含的 constructor 方法接受 IClient 作为参数,还允许 UI 为 Controller 提供对窗体(用于处理 Worker 中的显示消息)的引用。
为了实现一系列动画点来显示线程的活动,我们将创建一个简单 Windows 窗体控件,该控件使用计时器以更改一系列 PictureBox 控件中的颜色。
实现方案我们将在 Class Library(类库)项目中实现此架构,使其可用于需要运行后台进程的应用程序。
打开 Visual Studio .NET,然后创建一个名为 Background 的新 Class Library(类库)应用程序。由于此库将包含 Windows 窗体控件和窗体,因此需要使用 Add References(添加引用)对话框引用 System.Windows.Forms.dll 和 System.Windows.Drawing.dll。此外,我们可以使用项目的属性对话框在这些项目范围内导入命名空间,如图 6 所示。
图 6:使用项目属性添加项目范围内的命名空间 Imports
此操作完成后,就可以开始编码了。让我们先从创建接口开始。
定义接口在名为 IClient 的项目中添加一个类,并用以下代码替换其代码:
Public Interface IClient Sub Start(ByVal Controller As Controller) Sub Display(ByVal Text As String) Sub Failed(ByVal e As Exception) Sub Completed(ByVal Cancelled As Boolean)End Interface然后添加名为 IWorker 的类,并用以下代码替换其代码:
Public Interface IWorker Sub Initialize(ByVal Controller As IController) Sub Start()End Interface最后添加名为 IController 的类,代码如下:
Public Interface IController ReadOnly Property Running() As Boolean Sub Display(ByVal Text As String) Sub SetPercent(ByVal Percent As Integer) Sub Failed(ByVal e As Exception) Sub Completed(ByVal Cancelled As Boolean)End Interface至此,我们已定义了本文前面所述的所有类图表中的接口。现在可以实现 Controller 类了。
Controller 类现在,我们可以实现架构的核心,Controller 类。此类中包含的代码可用于启动辅助线程,以及在辅助线程完成之前充当 UI 线程和辅助线程之间的媒介。
在名为 Controller 的项目中添加一个新类。首先添加 Imports,并声明一些变量:
Imports System.ThreadingPublic Class Controller Implements IController Private mWorker As IWorker Private mClient As Form Private mRunning As Boolean Private mPercent As Integer然后需要声明一些委托。委托是方法的正式指针,而且方法的委托必须具有与方法本身相同的方法签名(参数类型等)。
委托的用途很广。在我们的示例中,委托非常重要,因为委托使我们可以让一个线程调用窗体上的方法,使其在该窗体的 UI 线程上运行。正如 IClient 所定义的那样,要在窗体上调用的三个方法都需要委托:
' 此委托签名与 IClient.Completed ' 中的签名相匹配,并用于安全地 ' 调用 UI 线程上的方法 Private Delegate Sub CompletedDelegate(ByVal Cancelled As Boolean) ' 此委托签名与 IClient.Display ' 中的签名相匹配,并用于安全地 ' 调用 UI 线程上的方法 Private Delegate Sub DisplayDelegate(ByVal Text As String) ' 此委托签名与 IClient.Failed ' 中的签名相匹配,并用于安全地 ' 调用 UI 线程上的方法 Private Delegate Sub FailedDelegate(ByVal e As Exception)IClient 还定义了 Start 方法,但是该方法可以从 UI 线程调用,因此不需要委托。
下面编写将从 UI 线程调用的代码。代码中包括 constructor 方法、Start 和 Cancel 方法以及 Percent 属性。我将这些内容放入 Region 中,便于大家清楚地了解它们是从 UI 线程调用的。
#Region " 从 UI 线程调用的代码 " ' 使用客户端初始化 Controller Public Sub New(ByVal Client As IClient) mClient = CType(Client, Form) End Sub ' 此方法由 UI 调用,因此在 ' UI 线程上运行。此处我们将 ' 启动辅助线程 Public Sub Start(Optional ByVal Worker As IWorker = Nothing) ' 如果辅助线程已经启动,将产生错误 If mRunning Then Throw New Exception("Background process already running") End If mRunning = True ' 存储对辅助对象的引用,并 ' 初始化辅助对象,使其包含 ' 对 Controller 的引用 mWorker = Worker mWorker.Initialize(Me) ' 创建后台线程 ' 以进行后台操作 Dim backThread As New Thread(AddressOf mWorker.Start) ' 开始后台工作 backThread.Start() ' 告诉客户端后台工作已开始 CType(mClient, IClient).Start(Me) End Sub ' 此代码由 UI 调用,因此在 UI ' 线程上运行。它只设置了请求 ' 取消的标志 Public Sub Cancel() mRunning = False End Sub ' 返回完成百分比值,并且 ' 只被 UI 线程调用 Public ReadOnly Property Percent() As Integer Get Return mPercent End Get End Property#End Region此处唯一比较特殊的代码位于 Start 方法中,我们可以在该方法中创建辅助线程然后启动该线程:
Dim backThread As New Thread(AddressOf mWorker.Start) backThread.Start()要创建线程,需要在 Worker 对象的 IWorker 接口上传递 Start 方法的地址。然后,只需调用线程对象的 Start 方法即可开始操作。此时我们要特别注意,UI 不应直接与 Worker 交互,Worker 也不应直接与 UI 交互。
请注意,Cancel 方法只设置一个标志,表明我们不希望继续运行。辅助代码应定期查看此标志,以确定是否应该停止运行。
现在,我们可以实现 Worker 对象运行时将由辅助线程调用的代码。此代码比较有趣,因为它必须将 Display 和 Completed 从辅助线程中转至 UI 线程,同时还要在 UI 线程上完成此操作。
要完成此操作,我们可以使用 Form 对象的 Invoke 方法。此方法接受窗体应该调用的方法的委托指针,以及包含该方法的参数的 Object 类型数组。
Invoke 方法不直接调用窗体上的方法,而是请求窗体返回并使用窗体的 UI 线程调用该方法。此操作可通过向窗体发送 Windows 消息在后台完成。这说明窗体获得这些方法调用的方式与从操作系统中获得 click 或 keypress 事件的方式基本相同。
通常,这些细节不会影响大局。结果由 Invoke 方法触发一个进程,通过该进程窗体将终止其 UI 线程上运行的方法,这就是我们要实现的目标。
再次重申,此代码位于 Region 内,目的是为了明确它将在辅助线程上调用:
#Region " 从辅助线程调用的代码 " ' 从辅助线程调用,以更新显示 ' 这将触发对包含状态文本的 UI 的 ' 方法调用 - 该调用是在 UI 线程上 ' 进行的 Private Sub Display(ByVal Text As String) _ Implements IController.Display Dim disp As New DisplayDelegate( _ AddressOf CType(mClient, IClient).Display) Dim ar() As Object = {Text} ' 调用 UI 线程上的客户端窗体 ' 以更新显示 mClient.BeginInvoke(disp, ar) End Sub ' 从辅助线程调用,以表明出现故障 ' 这将触发对包含异常对象的 UI 的 ' 方法调用 - 该调用是在 UI 线程上 ' 进行的 Private Sub Failed(ByVal e As Exception) _ Implements IController.Failed Dim disp As New FailedDelegate(_ AddressOf CType(mClient, IClient).Failed) Dim ar() As Object = {e} ' 在 UI 线程上调用客户端窗体 ' 以表明出现故障 mClient.Invoke(disp, ar) End Sub ' 从辅助线程上调用,以指出完成的百分比 ' 值将转到 Controller,由 UI 在需要时读取 Private Sub SetPercent(ByVal Percent As Integer) _ Implements IController.SetPercent mPercent = Percent End Sub ' 从辅助线程调用,以表明已完成 ' 我们还传递参数,以表明是否真正完成, ' 以及是否取消在 UI 线程上进行的对 UI ' 的调用 Private Sub Completed(ByVal Cancelled As Boolean) _ Implements IController.Completed mRunning = False Dim comp As New CompletedDelegate( _ AddressOf CType(mClient, IClient).Completed) Dim ar() As Object = {Cancelled} ' 调用 UI 线程上的客户端窗体 ' 以表明已完成 mClient.Invoke(comp, ar) End Sub ' 表明是否仍在运行或是否已请求取消 ' 这将在辅助线程上进行调用,因此 ' 辅助代码可以查看它是否应该正常 ' 退出 Private ReadOnly Property Running() As Boolean _ Implements IController.Running Get Return mRunning End Get End Property#End RegionFailed 和 Completed 方法利用窗体的 Invoke 方法。例如,Failed 方法可以执行以下操作:
Dim disp As New FailedDelegate(_ AddressOf CType(mClient, IClient).Failed) Dim ar() As Object = {e} ' 调用 UI 线程上的客户端窗体 ' 以表明出现故障 mClient.Invoke(disp, ar)首先创建一个委托,从 IClient 接口指向客户端窗体的 Failed 方法。然后声明包含向方法传递参数值的 Object 类型数组。最后调用客户端窗体的 Invoke 方法,将委托指针和参数数组传递给窗体。
窗体将在 UI 线程(窗体在这里可以安全运行以更新显示)上使用这些参数调用此方法。
整个进程是同步进行的,即对窗体进行调用时辅助线程将停止。尽管可以在显示错误消息或完成消息时停止辅助线程,但我们并不希望显示每个小状态时都停止辅助线程。
为了避免显示状态时停止辅助线程,Display 方法将使用 BeginInvoke,而不使用 Invoke。BeginInvoke 使窗体上的方法调用异步进行,这样辅助线程可以一直保持运行状态,不需要等待窗体上的显示方法完成:
Dim disp As New DisplayDelegate( _ AddressOf CType(mClient, IClient).Display) Dim ar() As Object = {Text} ' 调用 UI 线程上的客户端窗体 ' 以更新显示 mClient.BeginInvoke(disp, ar)以这种方式使用 BeginInvoke 可以防止辅助线程停止,使辅助线程具有尽可能高的性能。
ActivityBar 控件最后,我们来创建显示动画点的 ActivityBar 控件。
在名为 ActivityBar 的项目中添加一个用户控件。
将该控件的宽度调整为约 110,高度调整为约 20。可以通过拖动边界进行调整,也可以通过在 Properties(属性)窗口中设置 Size 属性进行调整。
其余的操作将通过代码完成。要创建一系列在显示时不停闪烁的动画“灯”,可以使用带有 Timer 控件的一系列 PictureBox 控件。每次 Timer 控件关闭时,我们将使下一个 PictureBox 呈绿色显示,并将已经呈绿色显示的 PictureBox 更改为窗体的背景色。
将 Windows Forms(Windows 窗体)选项卡中的 Timer 控件放入窗体中,然后将其名称更改为 tmAnim。同时将 Interval 属性设置为 300,以获得较好的动画速度。
顺便说一句,Components(组件)选项卡中有一个不同的 Timer 控件。它是一个多线程计时器。也就是说,该计时器将在后台线程中引发 Elapsed 事件,而不是象 Windows 窗体计时器那样在 UI 线程上引发 Elapsed 事件。建立 UI 时这种方法通常会产生相反的效果,因为 Elapsed 事件中的代码显然不能直接与我们的 UI 进行交互。
现在,在控件中添加以下代码:
Private mBoxes As New ArrayList() Private mCount As Integer Private Sub ActivityBar_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Dim index As Integer If mBoxes.Count = 0 Then For index = 0 To 6 mBoxes.Add(CreateBox(index)) Next End If mCount = 0 End Sub Private Function CreateBox(ByVal index As Integer) As PictureBox Dim box As New PictureBox() With box SetPosition(box, index) .BorderStyle = BorderStyle.Fixed3D .Parent = Me .Visible = True End With Return box End Function Private Sub GrayDisplay() Dim index As Integer For index = 0 To 6 CType(mBoxes(index), PictureBox).BackColor = Me.BackColor Next End Sub Private Sub SetPosition(ByVal Box As PictureBox, ByVal Index As Integer) Dim left As Integer = CInt(Me.Width / 2 - 7 * 14 / 2) Dim top As Integer = CInt(Me.Height / 2 - 5) With Box .Height = 10 .Width = 10 .Top = top .Left = left + Index * 14 End With End Sub Private Sub tmAnim_Tick(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles tmAnim.Tick CType(mBoxes((mCount + 1) Mod 7), PictureBox).BackColor = _ Color.LightGreen CType(mBoxes(mCount Mod 7), PictureBox).BackColor = Me.BackColor mCount += 1 If mCount > 6 Then mCount = 0 End Sub Public Sub Start() CType(mBoxes(0), PictureBox).BackColor = Color.LightGreen tmAnim.Enabled = True End Sub Public Sub [Stop]() tmAnim.Enabled = False GrayDisplay() End Sub Private Sub ActivityBar_Resize(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles MyBase.Resize Dim index As Integer For index = 0 To mBoxes.Count - 1 SetPosition(CType(mBoxes(index), PictureBox), index) Next End Sub窗体的 Load 事件创建 PictureBox 控件并将它们放入数组,这样便于我们在它们之间循环。Timer 控件的 Tick 事件循环显示,使各个控件依次呈绿色。
所有操作由 Start 方法开始,由 Stop 事件结束。由于 Stop 是一个保留字,因此把这个方法名放在方括号内:[Stop]。Stop 方法不仅可以停止计时器,还可以灰显所有框,告诉用户这些框中当前没有活动。
创建 Worker 类本文前面已简单介绍了 Worker 类。因为我们已经定义了 IWorker 接口,所以可以增强该类,以利用我们创建的 Controller。
首先创建 Background.dll 文件。此步骤很重要,因为如果不完成此步骤,ActivityBar 控件将无法在我们建立测试窗体时显示在工具箱上。
在解决方案中添加名为 bgTest 的 Windows Forms Application(Windows 窗体应用程序)。在 Solution Explorer(解决方案资源浏览器)中用右键单击该项目并选择相应的菜单项,将该程序设置为启动项目。
然后使用 Add References(添加引用)对话框中的 Projects(项目)选项卡,添加对 Background 项目的引用。
现在,在名为 Worker 的项目中添加一个类。其中部分代码与前面所述的代码相同,但还包含一些不同的代码,用以实现 IWorker 接口(此处突出显示的部分):
Imports BackgroundPublic Class Worker Implements IWorker Private mController As IController Private mInner As Integer Private mOuter As Integer Public Sub New(ByVal InnerSize As Integer, ByVal OuterSize As Integer) mInner = InnerSize mOuter = OuterSize End Sub ' 由 Controller 调用,以便获取 ' Controller 的引用 Private Sub Init(ByVal Controller As IController) _ Implements IWorker.Initialize mController = Controller End Sub Private Sub Work() Implements IWorker.Start Dim innerIndex As Integer Dim outerIndex As Integer Dim value As Double Try For outerIndex = 0 To mOuter If mController.Running Then mController.Display("Outer loop " & outerIndex & " starting") mController.SetPercent(CInt(outerIndex / mOuter * 100)) Else ' 它们请求取消 mController.Completed(True) Exit Sub End If For innerIndex = 0 To mInner ' 此处进行一些有意思的计算 value = Math.Sqrt(CDbl(innerIndex - outerIndex)) Next Next mController.SetPercent(100) mController.Completed(False) Catch e As Exception mController.Failed(e) End Try End SubEnd Class我们添加了能够实现 IWorker.Initialize 的 Init 方法。Controller 将调用此方法,因此以后我们可以引用 Controller 对象。
我们还将 Work 方法更改为 Private,只是为了实现 IWorker.Start 方法。此方法将在辅助线程上运行。
我们增强了 Work 方法,使其可以使用 Try..Catch 块。这样我们可以使用 Controller 上的 Failed 方法捕捉任何错误并将其返回给 UI。
假设代码正在运行,我们调用 Controller 对象的 Display 和 SetPercent 方法,使它们随着代码的运行更新其状态和完成的百分比。
我们还定期检查 Controller 对象的 Running 属性,查看是否存在取消请求。如果存在取消请求,则停止进程,并指示由于取消请求而停止操作。