AsyncCommand в MVVM и обработка исключений
Изучаю асинхронное программирование и готовлю класс асинхронной команды для внедрения в свои проекты.
Поставил себе требования:
- Реализация для
WPFна базе.NET Core 3.1с использованиемBindingв кнопках или всякого рода меню - Решение, не требующее сильно изменять существующий код проекта при внедрении класса в код при условии, что сейчас используется обычный синхронный
RelayCommand - Должен быть кастомный обработчик исключений, умеющий по-разному реагировать на разные типы исключений (как это обычно бывает в коде здорового человека)
CanExecuteдолжен возвращатьfalseво время выполнения команды, и контрол должен немедленно на его изменения реагировать. Запретить команде одновременно выполняться несколько раз.- Всё это должно работать, даже если я вызываю команду императивно, из любого (не UI) потока.
Использовал референсы:
- класс
RelayCommandиз гайда - вот этот блог пост для
ASP.NET, где очень не любятasync void(и наверное правильно делают) - вот этот очень сложный гайд, во всяком случае для меня
И вот что получилось:
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 шт):
- Вы этим физически не можете сломать MVVM, т. к. MVVM не указывает на синхронность/асинхронность команд.
- Нет. Я предпочёл бы как-то так:
if (ex is SomeException someEx)
{
// handle someEx
}
else if (ex is OtherException)
{
// ...
}
Я бы не советовал вызывать любую задачу в UI-потоке. Дайте возможность выбора "метода создания" задачи, например, планировщик задач (вы можете создать свой планировщик для UI-потока).
Тут есть несколько ошибок проектирования:
Вы можете создать
privateсв-воDispatcherвместо того, чтобы создавать отдельное поле_dispatcher.Вместо callback'а
_executeтипаFunc<object, Task>, возьмите callbackAction<object>и просто сделайте его асинхронным (это хорошо комбинируется с пунктом №3).Уберите этот
_exceptionCallback. Если вы захотите обработать ошибку, просто используйтеContinueWith(для подобного он и был создан).ShutDown->Shutdown(ссылка).Пишите имена параметров полностью (
param->parameter). Это повысит читаемость кода, а IntelliSence всё равно подскажет вам нужное имя.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);
}
}
Для истории оставлю здесь то, что получилось в итоге.
- Я отказался от обработчика исключений, при синхронных тасках он был не нужен, и здесь, хорошо подумав, я решил, что это излишество.
- Теперь использую синхронный делегат для
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.
Спасибо всем за помощь!