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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.64/5 (10投票s)

2018年6月5日

CPOL

5分钟阅读

viewsIcon

14952

downloadIcon

157

本文表明,通过从事件处理程序返回 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 的结果。然后 TaskCompletionSourceTask 被 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 传递给返回 Taskasync 方法。 通过调用 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 来取消。除了开始和取消按钮事件外,还需要管理另外四个事件。它们是 MouseButtonDownMouseButtonUpMouseLeave 事件以及用于更新计数器的计时器事件。在代码中,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.CommandRaisedAsyncStartCountingAsync 生成的 Tasks 中第一个完成的任务。此 await 语句的延续通过调用 localCts.Cancel() 来取消其他两个 Taskswhile 循环的下一个迭代会重新启动该过程。

 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 被点击时,会调用 OnStartAsynccatch 块会捕获取消 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 日:初始版本
© . All rights reserved.