AsyncCommand в MVVM и обработка исключений

Изучаю асинхронное программирование и готовлю класс асинхронной команды для внедрения в свои проекты.

Поставил себе требования:

  • Реализация для WPF на базе .NET Core 3.1 с использованием Binding в кнопках или всякого рода меню
  • Решение, не требующее сильно изменять существующий код проекта при внедрении класса в код при условии, что сейчас используется обычный синхронный RelayCommand
  • Должен быть кастомный обработчик исключений, умеющий по-разному реагировать на разные типы исключений (как это обычно бывает в коде здорового человека)
  • CanExecute должен возвращать false во время выполнения команды, и контрол должен немедленно на его изменения реагировать. Запретить команде одновременно выполняться несколько раз.
  • Всё это должно работать, даже если я вызываю команду императивно, из любого (не UI) потока.

Использовал референсы:

И вот что получилось:

AsyncRelayCommand.cs

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

public class AsyncRelayCommand : IAsyncCommand
{
    private bool _isExecuting;
    private readonly Func<object, Task> _execute;
    private readonly Func<object, bool> _canExecute;
    private readonly Action<Exception> _exceptionCallback;
    private readonly Dispatcher _dispatcher;

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

    // делаю обязательным добавление коллбэка обработчика исключений
    public AsyncRelayCommand(Func<object, Task> execute, Action<Exception> exceptionCallback) : this(execute, null, exceptionCallback) { }
    public AsyncRelayCommand(Func<object, Task> execute, Func<object, bool> canExecute, Action<Exception> exceptionCallback)
    {
        _execute = execute;
        _canExecute = canExecute;
        _exceptionCallback = exceptionCallback;
        _dispatcher = Application.Current.Dispatcher;
    }

    private void InvalidateRequerySuggested()
    {
        // говорят, инвок в диспетчер "дорогой", и лучше его делать только когда надо
        if (_dispatcher.CheckAccess()) 
            CommandManager.InvalidateRequerySuggested();
        else
            _dispatcher.Invoke(CommandManager.InvalidateRequerySuggested);
    }

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

    public async Task ExecuteAsync(object param)
    {
        if (CanExecute(param))
        {
            try
            {
                _isExecuting = true;
                InvalidateRequerySuggested();
                await _execute(param);
            }
            catch (Exception ex)
            {
                // хочу вызывать обработчик исключений в UI потоке
                // быть может здесь будет лучше делать обычный синхронный инвок?
                await _dispatcher.BeginInvoke(_exceptionCallback, ex);
            }
            finally
            {
                _isExecuting = false;
                InvalidateRequerySuggested();
            }
        }
    }

    public void Execute(object param) => _ = ExecuteAsync(param); // fire-and-forget
}

Писал я это все в рамках тестового MVVM проека, который выглядит вот так:

MainWindow.xaml

<Window.DataContext>
    <local:MainViewModel/>
</Window.DataContext>
<Grid>
    <StackPanel Orientation="Vertical">
        <TextBlock Margin="5" Text="{Binding Text}"/>
        <Button Margin="5" Content="Execute" Command="{Binding MyAsyncCommand}"/>
    </StackPanel>
</Grid>

NotifyPropertyChanged.cs

public class NotifyPropertyChanged : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

MainViewModel.cs

public class MainViewModel : NotifyPropertyChanged
{
    private string _text = "some text";
    private IAsyncCommand _myAsyncCommand;

    // обработчик исключений
    private readonly Action<Exception> _exceptionCallback = new Action<Exception>(e => {
        try
        {
            throw e;
        }
        catch (DivideByZeroException ex)
        {
            // обрабатываю сгенерированное исключение
            MessageBox.Show(ex.Message);
        }
        catch (Exception ex)
        {
            // это должно ронять программу, и не теряться глубоко в асинхронных тасках
            // и оно работает (но это не точно)
            throw ex; 
        }
    });

    public string Text
    {
        get => _text;
        set
        {
            _text = value;
            OnPropertyChanged();
        }
    }

    public IAsyncCommand MyAsyncCommand => _myAsyncCommand ?? (_myAsyncCommand = new AsyncRelayCommand(async param => 
    {
        for (int i = 0; i < 500; i++) // типа долго выполняемая асинхронная команда...
        {
            Text = "Count: " + i.ToString(); // ...которая модифицирует какие-то свойства VM
            await Task.Delay(10);
        }
        int x = 0;
        x = 1 / x; // генерирую DivideByZeroException для теста обработчика исключений
    }, _exceptionCallback));

    public MainViewModel()
    {  
    }

    public void Start() // вызывается из переопределенного OnStartup()
    {
        Task.Run(async () =>
        {
            await Task.Delay(5000);
            await MyAsyncCommand.ExecuteAsync(null); // вызов команды из "любого" потока
        });
    }

    public void ShutDown() // OnExit()
    {
    }
}

Всё выше показанное работает исправно, но я прошу помочь с пониманием того, что я понаписал и ответить на следующие вопросы:

  • Не ломаю ли я MVVM?
  • Правильно ли я обрабатываю исключения, или меня надо бить палкой и всё переписывать?
  • Правильно ли в классе команды я взаимодействую с CommandManager через диспетчер?
  • Быть может есть еще что улучшить?

Ответы (2 шт):

Автор решения: return
  1. Вы этим физически не можете сломать MVVM, т. к. MVVM не указывает на синхронность/асинхронность команд.
  2. Нет. Я предпочёл бы как-то так:
if (ex is SomeException someEx)
{
    // handle someEx
}
else if (ex is OtherException)
{
    // ...
}
  1. Я бы не советовал вызывать любую задачу в UI-потоке. Дайте возможность выбора "метода создания" задачи, например, планировщик задач (вы можете создать свой планировщик для UI-потока).

  2. Тут есть несколько ошибок проектирования:

    1. Вы можете создать private св-во Dispatcher вместо того, чтобы создавать отдельное поле _dispatcher.

    2. Вместо callback'а _execute типа Func<object, Task>, возьмите callback Action<object> и просто сделайте его асинхронным (это хорошо комбинируется с пунктом №3).

    3. Уберите этот _exceptionCallback. Если вы захотите обработать ошибку, просто используйте ContinueWith (для подобного он и был создан).

    4. ShutDown -> Shutdown (ссылка).

    5. Пишите имена параметров полностью (param -> parameter). Это повысит читаемость кода, а IntelliSence всё равно подскажет вам нужное имя.

    6. Func<object, bool> -> Predicate<object>


Код Task'а:

public class AsyncCommand : IAsyncCommand
{
    readonly Action<object> callback;
    readonly Predicate<object> validator;
    readonly CancellationToken token;
    readonly TaskCreationOptions options;

    public AsyncCommand(/*...*/)
    {
        // ...
    }

    public bool CanExecute(object parameter) => validator(parameter);
    public async Task ExecuteAsync(object parameter)
    {
        if (!CanExecute(parameter))
            throw new ArgumentException("This command can not be executed", "parameter");

        return await new Task(s => callback(s), parameter, token, options);
    }
}
→ Ссылка
Автор решения: aepot

Для истории оставлю здесь то, что получилось в итоге.

  • Я отказался от обработчика исключений, при синхронных тасках он был не нужен, и здесь, хорошо подумав, я решил, что это излишество.
  • Теперь использую синхронный делегат для ExecuteAsync, при этом сам делегат работает внутри Task.
  • Необработанные исключения теперь выскакивают в коде там, где они случились, что намного упростит отладку. Но у меня и не бывает особо команд, где могут непредсказуемо выскакивать исключения.

В общем, оттолкнулся от первоначальной задачи - сделать так, чтобы код комманд не подмораживал интерфейс и нельзя было запустить комманду повторно, пока она исполняется.

Финальное решение

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();
                Task task = new Task(s => _execute(s), parameter);
                task.Start();
                await task;
            }
            finally
            {
                _isExecuting = false;
                InvalidateRequerySuggested();
            }
        }
    }

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

Использование

private IAsyncCommand _myAsyncCommand;

public IAsyncCommand MyAsyncCommand => _myAsyncCommand ?? (_myAsyncCommand = new AsyncRelayCommand(parameter =>
{
    for (int i = 0; i <= 500; i ++)
    {
        Text = "Count: " + i.ToString();
        Thread.Sleep(10);
    }
}));

Да, обычный Thread.Sleep() теперь не вызывает никаких лагов.

Главное достоинство такого подхода в том, что в моих проектах код командных делегатов VM не придется переписывать вообще. Так же, решение получилось расширяемым, при желании к конструктору таска можно прикрутить CTS и TaskScheduler.

Спасибо всем за помощь!

→ Ссылка