在 .NET 框架中使用 Task 处理事件






4.64/5 (10投票s)
本文表明,通过从事件处理程序返回 Task 而非普通的 void,可以更结构化、更灵活地管理事件。
引言
当事件处理程序返回 void
时,您能做的不多,但如果您能获得一个 Task
,您就可以访问 .NET 框架中管理异步事件的最强大类型之一。本文展示了如何将事件封装在 Tasks
中,以及如何使用这些 Tasks
以一种合乎逻辑且易于维护的方式控制程序流。
从控件返回 Tasks
从基于控件的事件返回 Tasks
的技术非常直接。在此 WPF
示例中,将一个扩展方法添加到 ButtonControl
。
public static async Task ClickRaisedAsync(this Button button)
{
var tcs = new TaskCompletionSource<bool>();
RoutedEventHandler clickHandler = (s, e) => tcs.SetResult(true);
button.Click += clickHandler;
await tcs.Task;
button.Click -= clickHandler;
}
该扩展方法实例化一个 TaskCompletionSource
并向事件添加一个处理程序。该处理程序仅设置 TaskCompletionSource
的结果。然后 TaskCompletionSource
的 Task
被 await。最后,await
语句之后的延续会移除处理程序。
在 ViewModel 中管理基于 Task 的事件
上述安排对于处理仅与视觉呈现相关的事件(如 Storyboard
事件)来说是可以的,但对于其他事件,最好将事件从控件中抽象出来并在 ViewModel
中进行管理。 Prism Framework 有一个 DelegateCommand,用于将视图中的事件绑定到 ViewModel
中的处理程序方法。但是,当引发事件时调用的方法返回 void
。为了能够 await
事件,返回的对象需要是一个 Task
。
AwaitableCommand
下面的 AwaitableCommand
类有一个 CommandRaisedAsync()
方法,该方法返回一个 Task
并且可以被 await。该类有一个 private
DelegateCommand
实例,并且类的大多数方法只是调用该实例的相应方法。
public class AwaitableCommand<T> : ICommand
{
private readonly DelegateCommand<T> delegateCommand;
public AwaitableCommand(Func<T, bool> canExecuteMethod)
{
delegateCommand =
new DelegateCommand<T>((t) => Execute(t), canExecuteMethod);
}
public event EventHandler<T> CommandRaisedEvent;
public event EventHandler CanExecuteChanged
{
add { delegateCommand.CanExecuteChanged += value; }
remove { delegateCommand.CanExecuteChanged -= value; }
}
public bool CanExecute(object parameter)
{
return delegateCommand.CanExecute((T)parameter);
}
public void Execute(object parameter)
{
CommandRaisedEvent?.Invoke(this, (T)parameter);
}
public void RaiseCanExecuteChanged()
{
delegateCommand.RaiseCanExecuteChanged();
}
public async Task<T> CommandRaisedAsync()
{
var tcs = new TaskCompletionSource<T>();
EventHandler<T> handler = (s, p) => tcs.SetResult(p);
CommandRaisedEvent += handler;
var parameter = await tcs.Task;
CommandRaisedEvent -= handler;
return parameter;
}
}
取消从事件返回的任务
从事件返回 Task
的一个优点是 Tasks
可以被取消。从 CommandRaisedAsync
返回一个可取消的 Task
将允许事件超时,并且 Task
可以与 Task.WhenAny
调用中的其他 Tasks
有效地一起使用。 Task.WhenAny
方法返回一系列 Tasks
中第一个完成的任务,并且在使用此方法时,有时需要取消任何未完成的 Tasks
。取消 Task
的技术是实例化一个 CancellationTokenSource
并将它生成的 CancellationToken
传递给返回 Task
的 async
方法。 通过调用 CancellationTokenSource.Cancel
来实现取消。取消不是自动的,它需要在 async
方法内部实现,通过监视 CancellationToken
的状态并在令牌的 IsCancellationRequested
属性变为 true
时采取行动取消方法。 这是一个允许方法被取消的 CommandRaisedAsync
方法的实现。
public async Task<T> CommandRaisedAsync(CancellationToken token)
{
var commandRaisedTcs = new TaskCompletionSource<T>();
//create linked CancellationTokenSource so 'delayUntilCancelled'
//can be cancelled from inside this method.
//With linked sources, the parent cts.Cancel() will cancel
//its own token and the token generated by a child cts. But a child cts
//cannot cancel the parent's token.
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(token))
{
var localToken = cts.Token;
EventHandler<T> handler = (s, p) => commandRaisedTcs.SetResult(p);
CommandRaisedEvent += handler;
var commandRaisedTask = commandRaisedTcs.Task;
var delayUntilCancelledTask = Task.Delay(-1, localToken);
var awaitedTask = await Task.WhenAny
(commandRaisedTask, delayUntilCancelledTask);
CommandRaisedEvent -= handler;
if (awaitedTask.Status == TaskStatus.Canceled)
{
//it's the delayUntilCancelledTask
//that has completed in Canceled state
//so cancel the commandRaisedTask
commandRaisedTcs.SetCanceled();
throw new OperationCanceledException("The Event was cancelled");
}
//commandRaisedTask has completed so
//cancel the delayUntilCancelledTask
cts.Cancel();
return commandRaisedTask.Result;
}
}
这里的技巧是使用调用 Task.Delay(-1,localToken)
返回的 Task
来监视 CancellationToken
的状态,并使用第二个 Task
来表示命令引发的事件。调用 await Task.WhenAny(commandRaisedTask, delayUntilCancelledTask)
会异步等待两个 Task
中第一个完成的。如果 delayUntilCancelled Task
先完成,则会引发 OperationCanceledException
并取消 commandRasedTask
。如果 commandRaisedTask
先完成,则会取消 delayUntilCancelledTask
并返回 commandRaisedTask.Result
。
使用链接的 CancellationTokenSources 进行取消
在上面的示例中,传递给 Task.Delay
方法的 CancellationToken
。与传递到 CommandRaisedAsync
的令牌链接。这种链接的效果是,当传递到 CommandRaisedAsync
的令牌被取消时,链接源生成的令牌将被设置为已取消。因此 delayUntilCancelledTask
可以被两个源取消,本地源和一个外部源。 using
语句确保在 class
离开作用域时调用链接的 CompletionTokenSource's Dispose
方法。对于未链接的 CompletionTokenSources
,此预防措施不是必需的。
示例应用程序
在这个有点牵强的 WPF
示例中,实现了一个行为,当鼠标按钮在 Rectangle
内被按下并保持时,计数器会更新并记录按钮保持在该位置的时间段。释放按钮或将鼠标移出 Rectangle
会停止计数。该行为在 6 秒后超时,但可以在此之前通过单击取消 Button
来取消。除了开始和取消按钮事件外,还需要管理另外四个事件。它们是 MouseButtonDown
、MouseButtonUp
、MouseLeave
事件以及用于更新计数器的计时器事件。在代码中,ICommand
实例在 ViewModel
的构造函数中初始化,并在 XAML 中绑定到它们的事件。
public TestCounterVM()
{
MouseDownCommand = new AwaitableCommand<object>(o => true);
MouseUpCommand = new AwaitableCommand<object>(o => true);
MouseLeaveCommand = new AwaitableCommand<object>(o => true);
CancelCommand = new DelegateCommand<object>(OnCancel, o => IsStarted);
StartCommand = new DelegateCommand<object>(OnStartAsync, o => !IsStarted);
}
<Rectangle Width="60" Height="60" Fill="Green" Margin="5" >
<i:Interaction.Triggers>
<i:EventTrigger EventName="MouseDown">
<cmd:InvokeCommandAction Command="{Binding MouseDownCommand}"/>
</i:EventTrigger>
<i:EventTrigger EventName="MouseUp">
<i:InvokeCommandAction Command="{Binding MouseUpCommand}"/>
</i:EventTrigger>
<i:EventTrigger EventName="MouseLeave">
<i:InvokeCommandAction Command="{Binding MouseLeaveCommand}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
</Rectangle>
ViewModel
有一个 ManageCounterAsync
方法,该方法 await 从 MouseDownCommand.CommandRaisedAsync
返回的 Task
。如果 Task
在取消状态下完成,则会引发 OperationCanceledException
并退出方法。如果 Task
已成功完成,则运行延续并调用 StartCountingAsync
。此方法使用 Task.Delay
定期更新计数器。然后 await Task.WhenAny
的实例。它返回由调用 MouseUpCommand.CommandRaisedAsync, MouseLeaveCommand.CommandRaisedAsync
和 StartCountingAsync
生成的 Tasks
中第一个完成的任务。此 await
语句的延续通过调用 localCts.Cancel()
来取消其他两个 Tasks
。 while
循环的下一个迭代会重新启动该过程。
private async Task ManageCounterAsync(CancellationToken cancelDemoToken)
{
while (true)
{
using (var localCts =
CancellationTokenSource.CreateLinkedTokenSource(cancelDemoToken))
{
var token = localCts.Token;
await MouseDownCommand.CommandRaisedAsync(cancelDemoToken);
Task startCountingTask = StartCountingAsync(token);
var whenAnyTask = await Task.WhenAny
(MouseUpCommand.CommandRaisedAsync(token),
MouseLeaveCommand.CommandRaisedAsync(token), startCountingTask);
localCts.Cancel();
}
}
}
private async Task StartCountingAsync(CancellationToken token)
{
while (true)
{
//Delay 300millisecs
await Task.Delay(300, token);
Counter += 1;
}
}
当开始 Button
被点击时,会调用 OnStartAsync
。 catch
块会捕获取消 ManageCounterAsync
方法时引发的异常,并更新开始和取消 Buttons
的 enabled 属性。通过指示 CancellationTokenSource
在 6 秒后取消令牌来激活超时。
private async void OnStartAsync(object arg)
{
Counter = 0;
IsStarted = true;
cts = new CancellationTokenSource();
cts.CancelAfter(6000);
var token = cts.Token;
try
{
await ManageCounterAsync(token);
}
catch (OperationCanceledException)
{
IsStarted = false;
}
}
OnCancel
方法仅取消 ManageCounterAsync
方法。
private void OnCancel(object arg)
{
cts.Cancel();
}
结论
以传统方式管理事件可能是一个繁琐的过程,涉及在 ViewModel
中散布的众多事件处理程序的“连接”,并通过多个 if then
语句进行连接。通过让事件返回一个 Task
,可以大大简化它们的管理。在上面的示例中,四个事件在一个方法中得到有效管理,几乎没有连接,也没有 if
语句。
参考文献
历史
- 2018 年 6 月 5 日:初始版本