【问题标题】:How to implement Async Command如何实现异步命令
【发布时间】:2019-06-11 10:20:03
【问题描述】:

虽然我以某种方式了解了 c# 的异步编程,但仍然不明白为什么 async with void 不是更好的解决方案,然后当我想改进我的 Xamarin Forms 代码时,我发现许多 MVVM 框架使用 AsyncCommand 来避免 void with 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)))

【问题讨论】:

    标签: c# wpf xamarin xamarin.forms


    【解决方案1】:

    这是我为此 NuGet 包创建的AsyncCommand 的实现:AsyncAwaitBestPractices.MVVM

    此实现的灵感来自 @John Thiriet's 博客文章 "Going Async With AsyncCommand"

    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();
        }
    }
    

    【讨论】:

    • 你好 Brandon。我想在我的应用程序中使用它,但我无法使用 CanExecute - 设置条件时,按钮不会更改为启用。只有在初始化时它才有效 - 但更改不起作用。您可以发布一个示例并查看如何使其工作吗?
    • @Andreas_k 每次 CanExecute 的值发生变化时,您都需要调用 AsyncCommand.RaiseCanExecuteChanged()。我在 v4.0.0 的自述文件底部添加了这个作为示例:github.com/brminnick/AsyncAwaitBestPractices/blob/…
    • 下面是我如何在示例应用程序中使用它的示例:github.com/brminnick/SimpleXamarinGraphQL/blob/…
    【解决方案2】:

    如果您处理异常,则在命令执行处理程序上使用 async void 没有任何问题。

    那么AsyncCommand, 提供什么?可能如下

    • 返回任何未处理异常的错误通道

    • 不必编写 async void 或 async lamdas

    • IsBusy 框架,用于阻止诸如双击或任何你能想象到的事情

    【讨论】:

    • “总收益?接近于零”你确定吗?
    • 在列出 3 种可能的好处之后,您声称总收益“接近于零”,这让我感到困惑。定义一个自定义类以提供一个注入异常处理的中心位置 - 而不会使每个调用站点混乱 - 对我来说似乎非常有用。你的观点是没有框架内置类可能会提供显着的好处吗?在这种情况下,需要由每个程序员来决定是否值得编写自定义类来处理他们的需求?
    • @ToolmakerSteve 是的,我真的应该删除最后一部分。在某些领域有好处
    【解决方案3】:

    致任何感兴趣的人:上面的 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));
    

    这解决了我的问题。

    【讨论】:

      【解决方案4】:

      您应该始终避免在代码中出现 async void(事件处理程序例外)。

      请参考this blog from Stephen for more detail

      Async void 方法具有不同的错误处理语义。当一个 异常是从异步任务或异步任务方法中抛出的,即 异常被捕获并放置在 Task 对象上。使用异步无效 方法,没有 Task 对象,所以任何异常都会抛出 async void 方法将直接在 在异步 void 方法时处于活动状态的 SynchronizationContext 开始了。

      【讨论】:

      • 正如您所写 - exceptions of event handlersICommand\IAsyncCommand 也是例外情况,它是 mvvmwpf 的事件处理程序
      猜你喜欢
      • 1970-01-01
      • 2010-09-14
      • 2011-01-23
      • 2015-08-24
      • 2012-07-08
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多