如何实现异步命令

15

虽然我已经在某种程度上理解了使用C#进行异步编程,但我仍然不明白为什么异步void不是更好的解决方案。当我想要改进我的Xamarin Forms代码时,我发现许多MVVM框架使用AsyncCommand来避免void与async的结合,“不同于事件”的写法如下所示:

public class AsyncCommand : Command {
    public AsyncCommand(Func<Task> execute) : base(() => execute()) { }
    public AsyncCommand(Func<object, Task> execute) : base((arg) => execute(arg)) { }
}

但我不知道为什么异步命令本身不是异步的,如果使用异步命令并运行类似这样的任务会怎样:

public AsyncCommand(Action execute) : this(() => Task.Run(execute))
public AsyncCommand(Action<object> execute) : this((arg) => Task.Run(() => execute(arg)))

请注意,ICommandSource 在操作一个 ICommand 上,而 ICommand 只定义了同步的 ICommand.Execute 方法。除非你在自己的代码中执行 AsyncCommand,否则 ICommandSource(例如一个 Button)不会等待任何东西,它会同步调用 ICommand.Execute。这与将异步 lambda 分配给 Action 委托具有相同的效果:lambda 也不会被等待。在这两种情况下,等待链都被打断了。因此,从 Button 的角度来看,你的命令处理程序是 async void 还是 async Task 都无关紧要。 - user21970328
除非Microsoft引入官方的IAsyncCommandSource并重构他们的ICommandSource实现以支持IAsyncCommandSource(例如,使Button调用await this.Command.ExecuteAsync),否则只有自定义的ICommandSource实现和能够显式调用和等待IAsyncCommand.ExecuteAsync的自定义代码才会受益。 - user21970328
4个回答

14
这是我为此NuGet包创建的AsyncCommand实现:AsyncAwaitBestPractices.MVVM
这个实现受到@John Thiriet的博客文章“使用AsyncCommand进行MVVM异步操作”的启发。
using System;
using System.Threading.Tasks;
using System.Windows.Input;

namespace AsyncAwaitBestPractices.MVVM
{
    /// <summary>
    /// An implmentation of IAsyncCommand. Allows Commands to safely be used asynchronously with Task.
    /// </summary>
    public sealed class AsyncCommand<T> : IAsyncCommand<T>
    {
        #region Constant Fields
        readonly Func<T, Task> _execute;
        readonly Func<object, bool> _canExecute;
        readonly Action<Exception> _onException;
        readonly bool _continueOnCapturedContext;
        readonly WeakEventManager _weakEventManager = new WeakEventManager();
        #endregion

        #region Constructors
        /// <summary>
        /// Initializes a new instance of the <see cref="T:TaskExtensions.MVVM.AsyncCommand`1"/> class.
        /// </summary>
        /// <param name="execute">The Function executed when Execute or ExecuteAysnc is called. This does not check canExecute before executing and will execute even if canExecute is false</param>
        /// <param name="canExecute">The Function that verifies whether or not AsyncCommand should execute.</param>
        /// <param name="onException">If an exception is thrown in the Task, <c>onException</c> will execute. If onException is null, the exception will be re-thrown</param>
        /// <param name="continueOnCapturedContext">If set to <c>true</c> continue on captured context; this will ensure that the Synchronization Context returns to the calling thread. If set to <c>false</c> continue on a different context; this will allow the Synchronization Context to continue on a different thread</param>
        public AsyncCommand(Func<T, Task> execute,
                            Func<object, bool> canExecute = null,
                            Action<Exception> onException = null,
                            bool continueOnCapturedContext = true)
        {
            _execute = execute ?? throw new ArgumentNullException(nameof(execute), $"{nameof(execute)} cannot be null");
            _canExecute = canExecute ?? (_ => true);
            _onException = onException;
            _continueOnCapturedContext = continueOnCapturedContext;
        }
        #endregion

        #region Events
        /// <summary>
        /// Occurs when changes occur that affect whether or not the command should execute
        /// </summary>
        public event EventHandler CanExecuteChanged
        {
            add => _weakEventManager.AddEventHandler(value);
            remove => _weakEventManager.RemoveEventHandler(value);
        }
        #endregion

        #region Methods
        /// <summary>
        /// Determines whether the command can execute in its current state
        /// </summary>
        /// <returns><c>true</c>, if this command can be executed; otherwise, <c>false</c>.</returns>
        /// <param name="parameter">Data used by the command. If the command does not require data to be passed, this object can be set to null.</param>
        public bool CanExecute(object parameter) => _canExecute(parameter);

        /// <summary>
        /// Raises the CanExecuteChanged event.
        /// </summary>
        public void RaiseCanExecuteChanged() => _weakEventManager.HandleEvent(this, EventArgs.Empty, nameof(CanExecuteChanged));

        /// <summary>
        /// Executes the Command as a Task
        /// </summary>
        /// <returns>The executed Task</returns>
        /// <param name="parameter">Data used by the command. If the command does not require data to be passed, this object can be set to null.</param>
        public Task ExecuteAsync(T parameter) => _execute(parameter);

        void ICommand.Execute(object parameter)
        {
            if (parameter is T validParameter)
                ExecuteAsync(validParameter).SafeFireAndForget(_continueOnCapturedContext, _onException);
            else if (parameter is null && !typeof(T).IsValueType)
                ExecuteAsync((T)parameter).SafeFireAndForget(_continueOnCapturedContext, _onException);
            else
                throw new InvalidCommandParameterException(typeof(T), parameter.GetType());
        }
        #endregion
    }

    /// <summary>
    /// An implmentation of IAsyncCommand. Allows Commands to safely be used asynchronously with Task.
    /// </summary>
    public sealed class AsyncCommand : IAsyncCommand
    {
        #region Constant Fields
        readonly Func<Task> _execute;
        readonly Func<object, bool> _canExecute;
        readonly Action<Exception> _onException;
        readonly bool _continueOnCapturedContext;
        readonly WeakEventManager _weakEventManager = new WeakEventManager();
        #endregion

        #region Constructors
        /// <summary>
        /// Initializes a new instance of the <see cref="T:TaskExtensions.MVVM.AsyncCommand`1"/> class.
        /// </summary>
        /// <param name="execute">The Function executed when Execute or ExecuteAysnc is called. This does not check canExecute before executing and will execute even if canExecute is false</param>
        /// <param name="canExecute">The Function that verifies whether or not AsyncCommand should execute.</param>
        /// <param name="onException">If an exception is thrown in the Task, <c>onException</c> will execute. If onException is null, the exception will be re-thrown</param>
        /// <param name="continueOnCapturedContext">If set to <c>true</c> continue on captured context; this will ensure that the Synchronization Context returns to the calling thread. If set to <c>false</c> continue on a different context; this will allow the Synchronization Context to continue on a different thread</param>
        public AsyncCommand(Func<Task> execute,
                            Func<object, bool> canExecute = null,
                            Action<Exception> onException = null,
                            bool continueOnCapturedContext = true)
        {
            _execute = execute ?? throw new ArgumentNullException(nameof(execute), $"{nameof(execute)} cannot be null");
            _canExecute = canExecute ?? (_ => true);
            _onException = onException;
            _continueOnCapturedContext = continueOnCapturedContext;
        }
        #endregion

        #region Events
        /// <summary>
        /// Occurs when changes occur that affect whether or not the command should execute
        /// </summary>
        public event EventHandler CanExecuteChanged
        {
            add => _weakEventManager.AddEventHandler(value);
            remove => _weakEventManager.RemoveEventHandler(value);
        }
        #endregion

        #region Methods
        /// <summary>
        /// Determines whether the command can execute in its current state
        /// </summary>
        /// <returns><c>true</c>, if this command can be executed; otherwise, <c>false</c>.</returns>
        /// <param name="parameter">Data used by the command. If the command does not require data to be passed, this object can be set to null.</param>
        public bool CanExecute(object parameter) => _canExecute(parameter);

        /// <summary>
        /// Raises the CanExecuteChanged event.
        /// </summary>
        public void RaiseCanExecuteChanged() => _weakEventManager.HandleEvent(this, EventArgs.Empty, nameof(CanExecuteChanged));

        /// <summary>
        /// Executes the Command as a Task
        /// </summary>
        /// <returns>The executed Task</returns>
        public Task ExecuteAsync() => _execute();

        void ICommand.Execute(object parameter) => _execute().SafeFireAndForget(_continueOnCapturedContext, _onException);
        #endregion
    }

    /// <summary>
    /// Extension methods for System.Threading.Tasks.Task
    /// </summary>
    public static class TaskExtensions
    {
        /// <summary>
        /// Safely execute the Task without waiting for it to complete before moving to the next line of code; commonly known as "Fire And Forget". Inspired by John Thiriet's blog post, "Removing Async Void": https://johnthiriet.com/removing-async-void/.
        /// </summary>
        /// <param name="task">Task.</param>
        /// <param name="continueOnCapturedContext">If set to <c>true</c> continue on captured context; this will ensure that the Synchronization Context returns to the calling thread. If set to <c>false</c> continue on a different context; this will allow the Synchronization Context to continue on a different thread</param>
        /// <param name="onException">If an exception is thrown in the Task, <c>onException</c> will execute. If onException is null, the exception will be re-thrown</param>
        #pragma warning disable RECS0165 // Asynchronous methods should return a Task instead of void
        public static async void SafeFireAndForget(this System.Threading.Tasks.Task task, bool continueOnCapturedContext = true, System.Action<System.Exception> onException = null)
        #pragma warning restore RECS0165 // Asynchronous methods should return a Task instead of void
        {
            try
            {
                await task.ConfigureAwait(continueOnCapturedContext);
            }
            catch (System.Exception ex) when (onException != null)
            {
                onException?.Invoke(ex);
            }
        }
    }

    /// <summary>
    /// Weak event manager that allows for garbage collection when the EventHandler is still subscribed
    /// </summary>
    public class WeakEventManager
    {
        readonly Dictionary<string, List<Subscription>> _eventHandlers = new Dictionary<string, List<Subscription>>();

        /// <summary>
        /// Adds the event handler
        /// </summary>
        /// <param name="handler">Handler</param>
        /// <param name="eventName">Event name</param>
        public void AddEventHandler(Delegate handler, [CallerMemberName] string eventName = "")
    {
            if (IsNullOrWhiteSpace(eventName))
                throw new ArgumentNullException(nameof(eventName));

            if (handler is null)
                throw new ArgumentNullException(nameof(handler));

            EventManagerService.AddEventHandler(eventName, handler.Target, handler.GetMethodInfo(), _eventHandlers);
        }

        /// <summary>
        /// Removes the event handler.
        /// </summary>
        /// <param name="handler">Handler</param>
        /// <param name="eventName">Event name</param>
        public void RemoveEventHandler(Delegate handler, [CallerMemberName] string eventName = "")
        {
            if (IsNullOrWhiteSpace(eventName))
                throw new ArgumentNullException(nameof(eventName));

            if (handler is null)
                throw new ArgumentNullException(nameof(handler));

            EventManagerService.RemoveEventHandler(eventName, handler.Target, handler.GetMethodInfo(), _eventHandlers);
        }

        /// <summary>
        /// Executes the event
        /// </summary>
        /// <param name="sender">Sender</param>
        /// <param name="eventArgs">Event arguments</param>
        /// <param name="eventName">Event name</param>
        public void HandleEvent(object sender, object eventArgs, string eventName) => EventManagerService.HandleEvent(eventName, sender, eventArgs, _eventHandlers);
    }

    /// <summary>
    /// An Async implmentation of ICommand
    /// </summary>
    public interface IAsyncCommand<T> : System.Windows.Input.ICommand
    {
        /// <summary>
        /// Executes the Command as a Task
        /// </summary>
        /// <returns>The executed Task</returns>
        /// <param name="parameter">Data used by the command. If the command does not require data to be passed, this object can be set to null.</param>
        System.Threading.Tasks.Task ExecuteAsync(T parameter);
    }

    /// <summary>
    /// An Async implmentation of ICommand
    /// </summary>
    public interface IAsyncCommand : System.Windows.Input.ICommand
    {
        /// <summary>
        /// Executes the Command as a Task
        /// </summary>
        /// <returns>The executed Task</returns>
        System.Threading.Tasks.Task ExecuteAsync();
    }
}

1
你好Brandon。我想在我的应用程序中使用它,但我无法与CanExecute一起工作-当条件设置时,按钮不会变为启用。只有初始化时才有效,但更改不起作用。您能发布一个带有视图的示例来使其正常工作吗? - Andreas_k
1
@Andreas_k 每当CanExecute的值发生变化时,您需要调用AsyncCommand.RaiseCanExecuteChanged()。我已将此作为示例添加到v4.0.0的Readme底部:https://github.com/brminnick/AsyncAwaitBestPractices/blob/Release-v4.0.0/README.md#asynccommand - Brandon Minnick
这是我在一个示例应用程序中使用它的示例:https://github.com/brminnick/SimpleXamarinGraphQL/blob/e22ba2dc4666860bb54987f1ae6edc14bf9c9d62/SimpleXamarinGraphQL/ViewModels/GraphQLViewModel.cs#L23 - Brandon Minnick
1
那么我需要将 AsyncAwaitBestPractices.MVVM nuget 包添加到我的项目中才能使用这段代码吗?我缺少对 EventManagerServiceSubscription 的引用。 - Stanley G.
SafeFireAndForget方法中有一个catch when(onException != null)的语句。所以如果我没有提供一个处理程序,异常将不会被捕获,而是会传递给调用async void的方法。但这难道不正是SafeFireAndForget方法旨在防止的吗 - 防止未处理的异常传递到UI线程,导致应用程序崩溃? - undefined
显示剩余3条评论

8

在命令执行处理程序中使用asyncvoid并正确处理异常是没有问题的。

那么,AsyncCommand提供了什么呢?可能包括以下内容:

  • 错误通道,用于传递任何未处理的异常

  • 无需编写async voidasync lamdas

  • IsBusy框架,可以防止双重点击等问题,任你想象


3
“总效益?几乎为零",你确定吗? - Muhammad Abu Mandor
1
在你列出了三个可能的好处之后,我对你说总体好处“接近于零”感到困惑。为了给异常处理提供一个集中的位置而定义一个自定义类——而不会使每个调用点变得混乱——对我来说似乎非常有用。你的观点是没有框架内置类能够提供显著的好处吗?这是一种需要每个程序员决定是否值得编写自定义类来处理他们的要求的情况吗? - ToolmakerSteve
@ToolmakerSteve 是的,我应该真的删除最后一部分。在某些领域有好处。 - TheGeneral
使用WPF库的ICommandSourceICommand一起使用时,并没有太大的好处。重点在于ICommand仅定义了一个同步的Execute方法,而ICommandSource期望处理一个ICommand。这意味着无论您的IAsyncCommand.ExecuteAsync实现如何,如ButtonICommandSource都不会等待您的命令。对ICommand.Execute的调用始终是同步的。AsyncCommand通常从ICommand.Execute方法中调用ExecuteAsync。这与将异步lambda分配给Action委托时的情况相同。await链被中断。 - user21970328
所以,除非你在自己的代码中明确调用IAsyncCommand.ExecuteAsync,否则在分配一个async void命令处理程序时没有任何区别。 - user21970328

4

对于任何感兴趣的人:Brandons上述解决方案不会自动重新查询CanExecute并需要RaiseCanExecuteChanged()。要更改此设置,您可以交换

    public event EventHandler CanExecuteChanged
    {
        add => _weakEventManager.AddEventHandler(value);
        remove => _weakEventManager.RemoveEventHandler(value);
    }

使用

    public event EventHandler CanExecuteChanged {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

并删除

public void RaiseCanExecuteChanged() => _weakEventManager.HandleEvent(this, EventArgs.Empty, nameof(CanExecuteChanged));

这对我解决了问题。

CommandManager是什么,它与原始实现有何不同之处? - t3chb0t

0

在编写代码时(事件处理程序的异常除外),您应始终避免使用异步 void。

请参考Stephen的博客以获取更多详细信息。

异步 void 方法具有不同的错误处理语义。当从异步任务或异步任务方法中抛出异常时,该异常会被捕获并放置在任务对象上。对于异步 void 方法,没有任务对象,因此从异步 void 方法中抛出的任何异常都将直接在启动异步 void 方法时处于活动状态的同步上下文上引发。

enter image description here


“exceptions of event handlers”和“ICommand\IAsyncCommand”也是特殊情况,它们是WPF中MVVM模式的事件处理程序。 - isxaker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接