通用 InvocationHelper
一个泛型类,用于提供线程安全的委托调用。可用于(但不限于)从另一个线程更新 GUI 元素。
目录
引言
最近,我参与编写了一个实用程序,用于解释来自各种来源的 GPS 数据,包括串行端口。该库/程序的主要结构涉及三个层次
- 数据提供者
- 解析器
- 演示
数据提供者
数据提供者负责从不同来源读取数据。例如,一个从文件读取数据,而另一个应该从 USB GPS 接收器读取数据。在接收到一些数据后,提供者将触发一个事件,表示数据已准备好进行解析。
解析器
这些类解析来自提供者的数据,并触发一些事件通知“父”类数据已准备就绪。一个例子是将 NMEA 数据解析为某些结构;当位置、方位等发生变化时,会触发各种事件。
演示
最后一层监听来自解析器触发的事件,并以各种形式显示数据,例如文本框、图表、绘图等等。
问题
事情进展顺利,直到演示层。这时调试器告诉我我正在尝试从另一个线程更新 UI。我有些困惑,Google 和 MSDN 帮助了我。似乎 SerialPort.DataReceived
事件是从不同的线程触发的。回想起来,现在就说得通了,因为 SerialPort
类只是某些 API 的包装器。我似乎记得这些可能涉及多个线程。
所以,去 Google 寻找解决方案,或者不找。我一开始找到的唯一例子说你应该使用 Control.Invoke
从另一个线程更新 UI。这没什么用,因为我特别不想将解析器与任何 Windows Forms 控件绑定。
最终,经过一番深入挖掘——甚至通过 Reflector 查看框架代码以了解 Control.Invoke
中如何实现——我发现了 Alexey Popov 的这篇文章。它完全符合我的要求,除了一个问题。让我望而却步的是目前需要将代码复制粘贴到我所有的 OnEventHandler
例程中。不用说,NMEA 解析器有很多这样的例程。
Generics
最初,第一个版本使用了泛型,直到 Code Project 上的一些好心人指出这些代码实际上不需要泛型。因此,我对其进行了修改,使其接受简单的委托类型而不是泛型。有必要检查它是否是委托类型,因为这不能通过泛型约束来强制执行。
代码
此代码基于 Alexey Popov 的文章。他的文章很好地解释了正在发生的事情,所以我没有包括该讨论。
using System;
using System.ComponentModel;
namespace PooreDesign
{
public static class InvocationHelper
{
public static void Invoke(Delegate handler, params object[] arguments)
{
int requiredParameters = handler.Method.GetParameters().Length;
// Check that the correct number of arguments have been supplied
if (requiredParameters != arguments.Length)
{
throw new ArgumentException(string.Format(
"{0} arguments provided when {1} {2} required.",
arguments.Length, requiredParameters,
((requiredParameters == 1) ? "is" : "are")));
}
// Get a local copy of the invocation list in case it changes
Delegate[] invocationList = handler.GetInvocationList();
// Check that it's not null
if (invocationList == null)
return;
// Loop through delegates and check for ISynchronizeInvoke
foreach (Delegate singleCastDelegate in invocationList)
{
ISynchronizeInvoke synchronizeTarget =
singleCastDelegate.Target as ISynchronizeInvoke;
// Check to see if the interface was supported
if (synchronizeTarget == null)
{
// Invoke delegate normally
singleCastDelegate.DynamicInvoke(arguments);
}
else
{
// Invoke through synchronization interface
synchronizeTarget.BeginInvoke(
singleCastDelegate, arguments);
}
}
}
}
}
Invoke (Delegate eventHandler, params object[] Arguments)
参数检查
接下来进行的检查是验证是否提供了适当数量的参数。请注意,它不检查参数的类型是否正确。这有几个原因,主要原因是始终检查所有参数的成本会很高。对参数数量进行检查的原因是,抛出的默认异常非常模糊。因此,至少这可以缩小开发人员的问题范围。
int requiredParameters = handler.Method.GetParameters().Length;
// Check that the correct number of arguments have been supplied
if (requiredParameters != arguments.Length)
{
throw new ArgumentException(
string.Format("{0} arguments provided when {1} {2} required.",
arguments.Length, requiredParameters,
((requiredParameters == 1) ? "is" : "are")));
}
只需向 delegate
订阅更多处理程序,一个 delegate
就可以轻松地成为 MultiCastDelegate
。为了使此解决方案正常工作,必须独立地查询每个 delegate
。GetInvocationList()
返回一个当前已订阅到委托的处理程序数组。之后会进行一个简单的检查,以确保存在一些已订阅的委托。
// Get a local copy of the invocation list in case it changes
Delegate[] invocationList = handler.GetInvocationList();
// Check that it's not null
if (invocationList == null)
{
return;
}
调用
下一段代码处理的是查找每个目标对象是否实现了 ISynchronizeTarget
,所有 Windows Forms 控件都实现了该接口。
ISynchronizeInvoke synchronizeTarget =
singleCastDelegate.Target as ISynchronizeInvoke;
上面一行将 Target
转换为所需的接口。如果无法转换,则转换操作将返回 null
实例。如果目标对象不支持该接口,则可以进行正常的委托调用。Delegate.DynamicInvoke
方法处理此问题,接受要传递给方法的正确数量的参数。如果目标对象确实支持该接口,则调用 ISynchronizeInvoke.BeginInvoke
。调用 BeginInvoke
而不是 Invoke
是因为它不会阻塞调用线程。在这种情况下,调用线程在完成时是否收到通知无关紧要。如果需要,这可以很容易地更改。
如何使用
让我们举一个导致本文的类似例子。假设我们想将 SerialPort
接收到的数据从 DataReceived
事件输出到 TextBox
。表单本身设置起来非常简单。只需将 SerialPort
组件和 TextBox
拖放到设计器上并适当地命名它们。还可以包含一个 Button
来打开 SerialPort
。所以,我们最终会得到一些类似于这样的代码
private void btnOpenport_Click(object sender, EventArgs e)
{
this.serialPort.Open();
}
private void serialPort_DataReceived(object sender,
SerialDataReceivedEventArgs e)
{
//To Do:
}
现在是时候将数据写入输出文本框了。您可以在 DataReceived
事件处理程序中尝试此代码
this.outputTextBox.Text = this.serialPort.ReadExisting();
然而,如果您在调试器中运行此代码,它将中断并显示您正在进行跨线程调用。一种常见的替代方法是使用 Control.Invoke
方法。在这个简单的例子中,这已经足够了。但是,在我的应用程序中,我没有对正在更新的控件的引用,所以它行不通。此示例的解决方案类似于 Control.Invoke
方法
InvocationHelper.Invoke(new EventHandler(delegate (object sender, EventArgs e)
{
this.outputTextBox.Text = this.serialPort.ReadExisting();
}));
好的,我同意这个方法并不能真正节省多少,但它的关键在于以下几点。
更复杂的例子
这是 NMEA 解析器的一个非常基本的概要,它应该演示这个类的用处。首先,有一个 Parse
例程,用于解析 NMEA 语句(GPS string
)。同时还有一个事件声明,用于指示错误数据。这个方法内部有一段代码会触发一个事件,例如,指示错误的校验和。
public event EventHandler<InvalidDataEventArgs> InvalidData;
public void Parse(string sentence)
{
if (!this.IsValidChecksum(sentence))
{
this.OnInvalidData(new InvalidDataEventArgs(
"Checksum failed", sentence));
}
}
InvalidDataEventArgs
类仅存储有关导致错误数据的原因和理由的一些信息。OnInvalidData
是一个例程,类似于 Microsoft 在其类中实现的例程。如果您查看 Control
对象,您会注意到所有“事件处理程序”都可以被覆盖以提供自定义功能。这实现了同样的目的。
现在,奇迹发生了。如果您还记得背景部分,Parse
例程是由 SerialPort.DataReceived
事件调用的。所以,事情正在那个上下文中执行。
protected virtual void OnInvalidData(InvalidDataEventArgs e)
{
InvokeHelper.Invoke(this.InvalidData, this, e);
}
这段代码现在——如果与事件关联的处理程序实现了 ISynchronizeInvoke
——将在适当的线程中执行此代码。因此,在更新 GUI 时不会抛出异常。希望使用泛型实现的优点是显而易见的,特别是当您需要多次执行此操作时。
致谢
- Alexey Popov 的从工作线程调用 UI 的另一种方式文章
- Jon Pearson 的编写您自己的 GPS 应用程序系列
历史
- 2006年9月27日:发布原始版本
- 2007年6月8日:文章和下载更新