异步的ICommand实现,带有可选的canExecute功能。

4
我有以下的ICommand实现,它很好用,但我想扩展它,让我能够传递外部的canExecute参数。
public class AsyncRelayCommand : ICommand
    {
        private readonly Func<object, Task> callback;
        private readonly Action<Exception> onException;
        private bool isExecuting;

        public bool IsExecuting
        {
            get => isExecuting;
            set
            {
                isExecuting = value;
                CanExecuteChanged?.Invoke(this, new EventArgs());
            }
        }
        public event EventHandler CanExecuteChanged;

        public AsyncRelayCommand(Func<object, Task> callback, Action<Exception> onException = null)
        {
            this.callback = callback;
            this.onException = onException;
        }

        public bool CanExecute(object parameter) => !IsExecuting;

        public async void Execute(object parameter)
        {
            IsExecuting = true;
            try
            {
                await callback(parameter);
            }
            catch (Exception e)
            {
                onException?.Invoke(e);
            }

            IsExecuting = false;
        }
    }

这个实现能否以一种方式扩展,使得当调用者的CanExecute()改变时,Execute1AsyncCommand和Execute2AsyncCommand都能意识到?这是我的调用者类:

public class Caller : ObservableObject
{
public ObservableTask Execute1Task { get; } = new ObservableTask();
public ObservableTask Execute2Task { get; } = new ObservableTask();

public ICommand Execute1AsyncCommand { get; }
public ICommand Execute2AsyncCommand { get; }

public Caller()
{
    Execute1AsyncCommand = new AsyncRelayCommand(Execute1Async);
    Execute2AsyncCommand = new AsyncRelayCommand(Execute2Async);
}

private bool CanExecute(object o)
{
    return Task1?.Running != true && Task2?.Running != true;
}

private async Task Execute1Async(object o)
{
    Task1.Running = true;
            
    try
    {
        await Task.Run(()=>Thread.Sleep(2000)).ConfigureAwait(true);
        Task1.RanToCompletion = true;
    }
    catch (Exception e)
    {
        Task1.Faulted = true;
    }
}
private async Task Execute2Async(object o)
{
    Task2.Running = true;

    try
    {
        await Task.Run(() => Thread.Sleep(2000)).ConfigureAwait(true);
        Task2.RanToCompletion = true;
    }
    catch (Exception e)
    {
        Task2.Faulted = true;
    }
}
}

在其他调用程序中,我仍然希望能够只使用必选的 `callback` 使用 `AsyncRelayCommand()`。在这种情况下,`CanExecute` 应该从 `AsyncRelayCommand` 在内部求值,就像我的原始实现一样。
为了完整起见,以下是我的看法:
<StackPanel>
    <Button Content="Execute Task 1"
            Command="{Binding Execute1AsyncCommand}" />
    <Button Content="Execute Task 2"
            Command="{Binding Execute2AsyncCommand}" />
    <TextBlock Text="Task 1 running:" />
    <TextBlock Text="{Binding Task1.Running}" />
    <TextBlock Text="Task 2 running:" />
    <TextBlock Text="{Binding Task2.Running}" />
</StackPanel>

ObservableTask类:

public class ObservableTask : ObservableObject
{
    private bool running;
    private bool ranToCompletion;
    private bool faulted;

    public Task Task { get; set; }

    public bool WaitingForActivation => !Running && !RanToCompletion && !Faulted;

    public bool Running
    {
        get => running;
        set
        {
            running = value;
            if (running)
            {
                RanToCompletion = false;
                Faulted = false;
            }
        }
    }

    public bool RanToCompletion
    {
        get => ranToCompletion;
        set
        {
            ranToCompletion = value;
            if (ranToCompletion)
            {
                Running = false;
            }
        }
    }

    public bool Faulted
    {
        get => faulted;
        set
        {
            faulted = value;
            if (faulted)
            {
                Running = false;
            }
        }
    }
}

我想要实现的是用户按下一个按钮后,直到所有任务完成前,该按钮和另一个按钮都将变为禁用状态。
解决方案
我最终采用了以下实现方式,目前看来已经按照预期工作:
public class AsyncRelayCommand : ICommand
{
    private bool isExecuting;
    private readonly Func<object, Task> execute;
    private readonly Predicate<object> canExecute;
    private readonly Action<Exception, object> onException;

    private Dispatcher Dispatcher { get; }

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

    public AsyncRelayCommand(Func<object, Task> execute, Predicate<object> canExecute = null, Action<Exception, object> onException = null)
    {
        this.execute = execute;
        this.canExecute = canExecute;
        this.onException = onException;
        Dispatcher = Application.Current.Dispatcher;
    }

    private void InvalidateRequerySuggested()
    {
        if (Dispatcher.CheckAccess())
            CommandManager.InvalidateRequerySuggested();
        else
            Dispatcher.Invoke(CommandManager.InvalidateRequerySuggested);
    }

    public bool CanExecute(object parameter) => !isExecuting && (canExecute == null || canExecute(parameter));

    private async Task ExecuteAsync(object parameter)
    {
        if (CanExecute(parameter))
        {
            try
            {
                isExecuting = true;
                InvalidateRequerySuggested();
                await execute(parameter);
            }
            catch (Exception e)
            {
                onException?.Invoke(e, parameter);
            }
            finally
            {
                isExecuting = false;
                InvalidateRequerySuggested();
            }
        }
    }

    public void Execute(object parameter) => _ = ExecuteAsync(parameter);
}

使用方法:

public class Caller: ObservableObject
{
    public ObservableTask Task1 { get; } = new ObservableTask();
    public ObservableTask Task2 { get; } = new ObservableTask();
    public ObservableTask Task3 { get; } = new ObservableTask();

    public ICommand Execute1AsyncCommand { get; }
    public ICommand Execute2AsyncCommand { get; }
    public ICommand Execute3AsyncCommand { get; }

    public Caller()
    {
        // Command with callers CanExecute method and error handled by callers method.
        Execute1AsyncCommand = new AsyncRelayCommand(Execute1Async, CanExecuteAsMethod, Execute1ErrorHandler);

        // Command with callers CanExecute parameter and error handled inside task therefore not needed.
        Execute2AsyncCommand = new AsyncRelayCommand(Execute2Async, _=>CanExecuteAsParam);

        // Some other, independent command.
        // Minimum example - CanExecute is evaluated inside command, error handled inside task.
        Execute3AsyncCommand = new AsyncRelayCommand(Execute3Async);
    }

    public bool CanExecuteAsParam => !(Task1.Running || Task2.Running);
    private bool CanExecuteAsMethod(object o)
    {
        return !(Task1.Running || Task2.Running);
    }

    private async Task Execute1Async(object o)
    {
        Task1.Running = true;
        await Task.Run(() => { Thread.Sleep(2000); }).ConfigureAwait(true);
        Task1.RanToCompletion = true;
    }
    private void Execute1ErrorHandler(Exception e, object o)
    {
        Task1.Faulted = true;
    }

    private async Task Execute2Async(object o)
    {
        try
        {
            Task2.Running = true;
            await Task.Run(() => { Thread.Sleep(2000); }).ConfigureAwait(true);
            Task2.RanToCompletion = true;
        }
        catch (Exception e)
        {
            Task2.Faulted = true;
        }
    }

    private async Task Execute3Async(object o)
    {
        try
        {
            Task3.Running = true;
            await Task.Run(() => { Thread.Sleep(2000); }).ConfigureAwait(true);
            Task3.RanToCompletion = true;
        }
        catch (Exception e)
        {
            Task3.Faulted = true;
        }
    }
}

谢谢大家提供的非常宝贵的帮助!

AsyncRelayCommand 类如何知道调用者中发生了什么?更新命令状态是调用者的责任。例如,您可以在 SomeConditionOtherCondition 更改时设置 IsExecuting 属性。否则,命令实现必须订阅这些属性的更改。 - mm8
@mm8,这个订阅可以在AsyncRelayCommand中实现吗? - Pawel
在运行任务时,为什么要使用.ConfigureAwait(false);?实际上,这意味着await下面的代码可能会在随机的线程池线程上执行,而不是在UI线程上执行。我不确定,但对我来说似乎可能会发生竞态条件。 - E. Shcherbo
好的,谢谢。已更改为.ConfigureAwait(true)。 - Pawel
我根本不会写 ConfigureAwait,因为它默认会捕获同步上下文。所以我个人认为即使加上 true 后缀,这个调用也更加令人困惑。 - E. Shcherbo
这只是一个习惯,让代码分析器保持愉快。 - Pawel
2个回答

2

我有一些现成的解决方案。

  • 常规同步委托,因此可以替换简单的RelayCommand
  • 在池线程上执行的委托。
  • CanExecute在命令执行时为false,因此它会自动禁用控件。

实现

public interface IAsyncCommand : ICommand
{
    Task ExecuteAsync(object param);
}

public class AsyncRelayCommand : IAsyncCommand
{
    private bool _isExecuting;
    private readonly Action<object> _execute;
    private readonly Predicate<object> _canExecute;

    private Dispatcher Dispatcher { get; }

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

    public AsyncRelayCommand(Action<object> execute, Predicate<object> canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
        Dispatcher = Application.Current.Dispatcher;
    }

    private void InvalidateRequerySuggested()
    {
        if (Dispatcher.CheckAccess())
            CommandManager.InvalidateRequerySuggested();
        else
            Dispatcher.Invoke(CommandManager.InvalidateRequerySuggested);
    }

    public bool CanExecute(object parameter) => !_isExecuting && (_canExecute == null || _canExecute(parameter));

    public async Task ExecuteAsync(object parameter)
    {
        if (CanExecute(parameter))
        {
            try
            {
                _isExecuting = true;
                InvalidateRequerySuggested();
                await Task.Run(() => _execute(parameter));
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.Message);
            }
            finally
            {
                _isExecuting = false;
                InvalidateRequerySuggested();
            }
        }
    }

    public void Execute(object parameter) => _ = ExecuteAsync(parameter);
}

使用

private IAsyncCommand _myAsyncCommand;

public IAsyncCommand MyAsyncCommand => _myAsyncCommand ?? (_myAsyncCommand = new AsyncRelayCommand(parameter =>
{
    Thread.Sleep(2000);
}));
注意:您不能从非UI线程处理ObservableCollection,作为解决方法,我建议使用这个

异步委托版本

public class AsyncRelayCommand : IAsyncCommand
{
    private bool _isExecuting;
    private readonly Func<object, Task> _executeAsync;
    private readonly Predicate<object> _canExecute;

    private Dispatcher Dispatcher { get; }

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

    public AsyncRelayCommand(Func<object, Task> executeAsync, Predicate<object> canExecute = null)
    {
        _executeAsync = executeAsync;
        _canExecute = canExecute;
        Dispatcher = Application.Current.Dispatcher;
    }

    private void InvalidateRequerySuggested()
    {
        if (Dispatcher.CheckAccess())
            CommandManager.InvalidateRequerySuggested();
        else
            Dispatcher.Invoke(CommandManager.InvalidateRequerySuggested);
    }

    public bool CanExecute(object parameter) => !_isExecuting && (_canExecute == null || _canExecute(parameter));

    public async Task ExecuteAsync(object parameter)
    {
        if (CanExecute(parameter))
        {
            try
            {
                _isExecuting = true;
                InvalidateRequerySuggested();
                await _executeAsync(parameter);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.Message);
            }
            finally
            {
                _isExecuting = false;
                InvalidateRequerySuggested();
            }
        }
    }

    public void Execute(object parameter) => _ = ExecuteAsync(parameter);
}

用法

private IAsyncCommand _myAsyncCommand;

public IAsyncCommand MyAsyncCommand => _myAsyncCommand ?? (_myAsyncCommand = new AsyncRelayCommand(async parameter =>
{
    await Task.Delay(2000);
}));

感谢aepot,这几乎就可以了。但是有两个问题。当任务完成时,UI没有自动刷新。我必须点击或移动窗口,让按钮重新启用。另外,如果我选择不提供外部CanExecutenew AsyncRelayCommand2(p => Execute2Async(p)), 我可以在任务已经运行的同时多次按该按钮并启动执行。 - Pawel
好的,有一些进展 :) 我更愿意用 Func<object, Task> execute 替换 Action<object> execute,然后在 ExecuteAsync 中只需 await _execute(parameter);。我仍在测试中,但你的解决方案看起来很有前途。 - Pawel
@Pawel,您确实需要一个异步委托吗?为什么?内部将进行什么类型的作业?一些 I/O 绑定的任务,例如网络请求?具体是什么?如果是这样的话,您可以使用 Func<Task> 并在没有 Task.Run() 的情况下执行它,但我不确定是否真的需要。 - aepot
@Pawel添加了async委托版本。但我仍然认为这是一种冗余。new AsyncRelayCommand2(async p => await Execute2Async(p)) - aepot
@Pawel,你可以实现生产者/消费者编程模式(谷歌上有很多相关信息),而不是在Command中使用async。这只是一个选项。 - aepot
显示剩余3条评论

1
如果你的Caller有一个像这样的CanExecute方法:
 private bool CanExecute()
 {
     return SomeCondition && OtherCondition;
 }

那么您就可以将其作为委托类型 Func<bool> 的实例传递给您的 AsyncRelayCommand,当然,如果您的 AsyncRelayCommand 定义了所需参数的构造函数:

    public AsyncRelayCommand(Func<object, Task> callback, Func<bool> canExecute, Action<Exception> onException = null)
    {
        this.callback = callback;
        this.onException = onException;
        this.canExecute = canExecute;
    }

然后你可以像这样将它传递给构造函数:
MyAsyncCommand = new AsyncRelayCommand(ExecuteAsync, CanExecute, ErrorHandler);

因此,您的AsyncRelayCommand将能够调用canExecute委托并获得实际结果。
或者,您可以将CanExecute保留为属性,但是在创建AsyncRelayCommand时,请像这样将其包装到Lambda表达式中。
MyAsyncCommand = new AsyncRelayCommand(ExecuteAsync, () => CanExecute, ErrorHandler);

要将回退逻辑应用于AsyncRelayCommandCanExecute,您可以按以下方式更改代码:

  • 有一个名为_canExecute的类型为Func<bool>的实例变量。然后在构造函数中使用接受参数Func<bool> canExecute的任何值来分配它,即使它是null。然后在您的public CanExecute(object param)中,只需检查_canExecute是否为null,如果是,就像现在一样返回!IsExecuting,如果不是null,则返回_canExecute返回的内容。

感谢E. Shcherbo。我认为Caller的CanExecute可以是一个方法。然而,我希望AsyncRelayCommand的canExecute是可选的,因此当省略AsyncRelayCommand时,AsyncRelayCommand可以在内部评估自己的CanExecute,就像它目前所做的那样。因此,如果我将签名更改为Func<bool> canExecute = null,如果不为空,我应该怎么处理这个canExecute函数? - Pawel
有几种方法可以解决它。其中一种方法是:你仍然需要一个Func<bool>的实例变量来保存canExecute委托对象,然后你的CanExecute属性可以这样实现:public bool CanExecute => canExecute == null ? !IsExecuting : canExecute(); - E. Shcherbo
你是指 CanExecute() 方法,对吧?我想我已经尝试过了,但没有成功。代码如下:public bool CanExecute(object parameter) => canExecute == null ? !IsExecuting : canExecute(); 这是因为调用者的 ExecuteAsync 在进入时设置了 SomeCondition = false;,并在退出时重新设置为 true。调用者的 CanLoad() 在我点击按钮后被调用,但在 SomeCondition = false; 之前被调用,所以 SomeCondition 总是为真。 - Pawel
感谢E. Shcherbo。我编辑了问题并提供了更多细节。 - Pawel
这个解决方案几乎完美。我可以同时按下两个按钮,如果其中一个任务已经在运行,则不会启动另一个任务,这正是我想要的。然而,我还希望当任一任务正在运行时禁用这两个按钮。目前,当任务正在运行时,两个按钮仍然保持启用状态,但无法操作。 - Pawel

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