Поделиться через


Асинхронное программирование

Шаблоны для асинхронных MVVM-приложений: команды

Стивен Клири

Продукты и технологии:

Асинхронное программирование, шаблон MVVM

В статье рассматриваются:

  • интерфейс ICommand;
  • обработка завершения асинхронных команд через связывание с данными;
  • добавление возможности отмены команды;
  • создание простой очереди рабочих элементов.

Исходный код можно скачать по ссылке

Это вторая статья в серии по комбинированному применению async и await с устоявшимся шаблоном Model-View-ViewModel (MVVM). В прошлый раз я показал, как связать данные с асинхронной операцией, и создал ключевой тип NotifyTaskCompletion<TResult>, который действует как дружественный к связыванию с данными Task<TResult> (msdn.microsoft.com/magazine/dn605875). Теперь я переключусь на ICommand — .NET-интерфейс, используемый в MVVM-приложениях для определения пользовательской операции (которая часто связана с кнопкой через механизм привязки данных), и рассмотрю трудности, связанные с созданием асинхронного ICommand.

Предлагаемые здесь шаблоны могут оказаться не идеальными для каждого сценария, поэтому вы можете свободно подстраивать их под свои потребности. По сути, вся эта статья построена как набор последовательных улучшений асинхронного типа команд. В конце этих итераций вы получите приложение, подобное показанному на рис. 1. Оно похоже на приложение, которое разрабатывалось в моей прошлой статье, но на этот раз я предоставлю пользователю возможность выполнять реальную команду. Когда пользователь щелкает кнопку Go, из текстового поля считывается URL и приложение подсчитывает количество байтов в этом URL (после искусственной задержки). Пока операция выполняется, пользователь не может начать другую операцию, но может отменить текущую.

Приложение, способное выполнять одну команду
Приложение, способное выполнять одну команду
Приложение, способное выполнять одну команду
Приложение, способное выполнять одну команду
Приложение, способное выполнять одну команду
Рис. 1. Приложение, способное выполнять одну команду

Предлагаемые здесь шаблоны могут оказаться не идеальными для каждого сценария, поэтому вы можете свободно подстраивать их под свои потребности.

Затем я покажу, как применить очень похожий подход для создания любого числа операций. Рис. 2 иллюстрирует приложение, модифицированное так, что кнопка Go добавляла операцию в набор операций.

Приложение, выполняющее несколько команд
Рис. 2. Приложение, выполняющее несколько команд

При разработке этого приложения я намерен внести пару упрощений, чтобы сосредоточиться на асинхронных командах, а не на деталях реализации. Во-первых, я не буду использовать параметры выполнения команд. Эти параметры и в реальных приложения практически не нужны, но, если они вам нужны, шаблоны в этой статье легко расширить для их включения. Во-вторых, я не реализую ICommand.CanExecuteChanged самостоятельно. Стандартное событие, которое можно использовать как поле (field-like event), будет давать утечки памяти на некоторых платформах MVVM (см. bit.ly/1bROnVj). Чтобы не усложнять код, я реализую CanExecuteChanged, применяя встроенный в WPF CommandManager.

Я также использую упрощенный «уровень сервисов», который на данный момент представляет собой всего один статический метод (рис. 3). Фактически это тот же сервис, что и в моей предыдущей статье, но расширенный для поддержки отмены команд. В следующей статье мы займемся должным уровнем асинхронных сервисов, а пока обойдемся этим упрощенным сервисом.

Рис. 3. Уровень сервисов

public static class MyService
{
  // bit.ly/1fCnbJ2
  public static async Task<int> DownloadAndCountBytesAsync(string url,
    CancellationToken token = new CancellationToken())
  {
    await Task.Delay(TimeSpan.FromSeconds(3), token).ConfigureAwait(false);
    var client = new HttpClient();
    using (var response = await client.GetAsync(url, token).ConfigureAwait(false))
    {
      var data = await
        response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
      return data.Length;
    }
  }
}

Асинхронные команды

Прежде чем начать, кратко рассмотрим интерфейс ICommand:

public interface ICommand
{
  event EventHandler CanExecuteChanged;
  bool CanExecute(object parameter);
  void Execute(object parameter);
}

Игнорируйте CanExecuteChanged и параметры и немного поразмыслите о том, как асинхронная команда работала бы с этим интерфейсом. Метод CanExecute должен быть синхронным; единственный член, который может быть асинхронным, — это Execute. Метод Execute предназначен для синхронных реализаций, поэтому он возвращает void. Как упоминалось в прошлой статье «Best Practices in Asynchronous Programming» (msdn.microsoft.com/magazine/jj991977), асинхронных void-методов следует избегать, если только это не обработчики событий (или логические эквиваленты таковых). Реализации ICommand.Execute являются логическими эквивалентами обработчиков событий и поэтому могут быть асинхронными void-методами.

Однако лучше всего свести к минимуму код в асинхронных void-методах и предоставлять вместо них асинхронный Task-метод, который и содержит саму логику. Такая практика делает код более тестируемым. С учетом этого я предлагаю следующий интерфейс асинхронных команд и код на рис. 4 в качестве базового класса:

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

Рис. 4. Базовый тип для асинхронных команд

public abstract class AsyncCommandBase : IAsyncCommand
{
  public abstract bool CanExecute(object parameter);
  public abstract Task ExecuteAsync(object parameter);
  public async void Execute(object parameter)
  {
    await ExecuteAsync(parameter);
  }
  public event EventHandler CanExecuteChanged
  {
    add { CommandManager.RequerySuggested += value; }
    remove { CommandManager.RequerySuggested -= value; }
  }
  protected void RaiseCanExecuteChanged()
  {
    CommandManager.InvalidateRequerySuggested();
  }
}

Базовый класс заботится о двух вещах: он оставляет реализацию CanExecuteChanged на класс CommandManager и реализует асинхронный void-метод ICommand.Execute, вызывая метод IAsyncCommand.ExecuteAsync. Он ожидает результат, гарантируя, что любые исключения в логике асинхронной команды будут должным образом сгенерированы в основном цикле UI-потока.

Здесь много сложностей, но каждый из этих типов имеет свое предназначение. IAsyncCommand можно использовать для любой асинхронной реализации ICommand; он рассчитан на то, что вы предоставляете его из ViewModels и применяете в View и модульных тестах. AsyncCommandBase обрабатывает некоторую часть стереотипного кода, общего для всех асинхронных ICommand.

Построив этот фундамент, я готов начать разработку эффективной асинхронной команды. Стандартный тип делегата для синхронной операции без возвращаемого значения — Action. Асинхронный эквивалент — Func<Task>. На рис. 5 показана моя первая итерация AsyncCommand на основе делегата.

Рис. 5. Первая попытка создания асинхронной команды

public class AsyncCommand : AsyncCommandBase
{
  private readonly Func<Task> _command;
  public AsyncCommand(Func<Task> command)
  {
    _command = command;
  }
  public override bool CanExecute(object parameter)
  {
    return true;
  }
  public override Task ExecuteAsync(object parameter)
  {
    return _command();
  }
}

К этому моменту в UI есть лишь текстовое поле для URL, кнопка для запуска HTTP-запроса и метка, где выводятся результаты. XAML и основные части ViewModel просты. Вот как выглядит MainWindow.xaml (атрибуты позиционирования вроде Margin опущены):

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" 
      Content="Go" />
  <TextBlock Text="{Binding ByteCount}" />
</Grid>

MainWindowViewModel.cs показан на рис. 6.

Рис. 6. Первый MainWindowViewModel

public sealed class MainWindowViewModel : INotifyPropertyChanged
{
  public MainWindowViewModel()
  {
    Url = "http://www.example.com/";
    CountUrlBytesCommand = new AsyncCommand(async () =>
    {
      ByteCount = await MyService.DownloadAndCountBytesAsync(Url);
    });
  }
  public string Url { get; set; } // вызывает PropertyChanged
  public IAsyncCommand CountUrlBytesCommand { get; private set; }
  public int ByteCount { get; private set; } // вызывает PropertyChanged
}

Запустив приложение (AsyncCommands1 в пакете сопутствующего кода), вы заметите четыре случая неэлегантного поведения. Во-первых, метка всегда показывает результат, даже до щелчка кнопки. Во-вторых, после щелчка кнопки не появляется индикатора занятости (busy indicator), который подсказывал бы, что операция выполняется. В-третьих, если HTTP-запрос терпит неудачу, исключение передается в основной цикл UI, вызывая крах приложения. В-четвертых, если пользователь выдает несколько запросов, он не может различить результаты; вполне возможно, что результаты более раннего запроса будут перезаписаны результатами более позднего запроса из-за вариаций во времени ответа сервера.

Проблем немало! Но, прежде чем приступить к итерациям проекта, поразмышляем о возникающих разного рода вопросах. Когда UI становится асинхронным, это заставляет думать о дополнительных состояниях в UI. Советую вам задать себе хотя бы следующие вопросы.

  1. Как UI будет отображать ошибки? (Надеюсь, что в вашем синхронном UI ответ на этот вопрос уже есть!)
  2. Как должен выглядеть UI, пока операция выполняется? (Например, будет ли он обеспечивать немедленную обратную связь через индикаторы прогресса?)
  3. Как ограничивается пользователь в процессе выполнения операции? (Скажем, отключаются ли какие-то кнопки?)
  4. Сможет ли пользователь выдавать любые дополнительные команды, пока одна операция выполняется? (Например, можно ли будет отменять операцию?)
  5. Если пользователь может запускать несколько операций, то как UI будет предоставлять информацию о завершении или ошибках каждой операции? (Например, будет UI использовать стиль «очереди команд» или всплывающие уведомления?)

Обработка завершения асинхронной команды через связывание с данными

Большинство проблем на первой итерации AsyncCommand относится к тому, как обрабатываются результаты. Что на самом деле нужно, так это некий тип, который обертывал бы Task<T> и предоставлял бы какие-то средства связывания с данными, чтобы приложение могло элегантнее реагировать на завершение команды. Как оказалось, тип NotifyTaskCompletion<T>, разработанный в моей прошлой статье, почти идеально подходит к этим требованиям. Я добавлю в него один член, упрощающий часть логики AsyncCommand: свойство TaskCompletion, которое представляет завершение операции, но не распространяет исключения (или результат). Вот какие изменения я внес в NotifyTaskCompletion<T>:

public NotifyTaskCompletion(Task<TResult> task)
{
  Task = task;
  if (!task.IsCompleted)
    TaskCompletion = WatchTaskAsync(task);
}
public Task TaskCompletion { get; private set; }

Следующая итерация AsyncCommand использует NotifyTaskCompletion для представления реальной операции. Благодаря этому XAML можно напрямую связывать через данные с результатом и сообщением об ошибке операции, а также использовать связывание с данными для отображения подходящего сообщения, пока операция выполняется. В новом AsyncCommand теперь есть свойство, которое представляет операцию (рис. 7).

Рис. 7. Вторая попытка создания асинхронной команды

public class AsyncCommand<TResult> : AsyncCommandBase, INotifyPropertyChanged
{
  private readonly Func<Task<TResult>> _command;
  private NotifyTaskCompletion<TResult> _execution;
  public AsyncCommand(Func<Task<TResult>> command)
  {
    _command = command;
  }
  public override bool CanExecute(object parameter)
  {
    return true;
  }
  public override Task ExecuteAsync(object parameter)
  {
    Execution = new NotifyTaskCompletion<TResult>(_command());
    return Execution.TaskCompletion;
  }
  // Генерируем PropertyChanged
  public NotifyTaskCompletion<TResult> Execution { get; private set; }
}

Заметьте, что AsyncCommand.ExecuteAsync использует TaskCompletion, а не Task. Я не хочу распространять исключения в основной цикл UI (что произошло бы при ожидании свойства Task); вместо этого я возвращаю TaskCompletion и обрабатываю исключения с помощью механизма связывания с данными. Кроме того, я добавил в проект простой NullToVisibilityConverter, чтобы индикатор занятости, результаты и сообщение об ошибке скрывались, пока не будет нажата кнопка. Обновленный код ViewModel приведен на рис. 8.

Рис. 8. Второй вариант MainWindowViewModel

public sealed class MainWindowViewModel : INotifyPropertyChanged
{
  public MainWindowViewModel()
  {
    Url = "http://www.example.com/";
    CountUrlBytesCommand = new AsyncCommand<int>(() => 
      MyService.DownloadAndCountBytesAsync(Url));
  }
  // Генерируем PropertyChanged
  public string Url { get; set; }
  public IAsyncCommand CountUrlBytesCommand { get; private set; }
}

И новый XAML-код показан на рис. 9.

Рис. 9. Второй вариант MainWindow XAML

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" Content="Go" />
  <Grid Visibility="{Binding CountUrlBytesCommand.Execution,
    Converter={StaticResource NullToVisibilityConverter}}">
    <!-- Индикатор занятости -->
    <Label Visibility="{Binding CountUrlBytesCommand.Execution.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"
      Content="Loading..." />
    <!-- Результаты -->
    <Label Content="{Binding CountUrlBytesCommand.Execution.Result}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}" />
    <!-- Детальная информация об ошибке -->
    <Label Content="{Binding CountUrlBytesCommand.Execution.ErrorMessage}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}" Foreground="Red" />
  </Grid>
</Grid>

Сама по себе отмена всегда является синхронной операцией — запрос отмены выполняется немедленно.

Этот код теперь совпадает с проектом AsyncCommands2 в сопутствующем коде. Он снимает все озабоченности, о которых я упоминал: метки скрываются, пока не начнется первая операция, есть индикатор занятости, обеспечивающий немедленную обратную связь для пользователя, исключения захватываются и обновляют UI через связыванием с данными, несколько запросов больше не мешают друг другу. Каждый запрос создает новую оболочку NotifyTaskCompletion, которая имеет собственное независимое Result и другие свойства. NotifyTaskCompletion действует как связываемая с данными абстракция асинхронной операции. Это позволяет выдавать несколько запросов, и при этом UI всегда связывается с самым последним запросом. Однако во многих сценариях на практике подходящее решение — запрещать выдачу нескольких запросов. То есть нужно, чтобы команда возвращала false из CanExecute, пока выполняется какая-то операция. Сделать это достаточно легко, внеся небольшие изменения в AsyncCommand, как показано на рис. 10.

Рис. 10. Отключение возможности выдачи нескольких запросов

public class AsyncCommand<TResult> : AsyncCommandBase, INotifyPropertyChanged
{
  public override bool CanExecute(object parameter)
  {
    return Execution == null || Execution.IsCompleted;
  }
  public override async Task ExecuteAsync(object parameter)
  {
    Execution = new NotifyTaskCompletion<TResult>(_command());
    RaiseCanExecuteChanged();
    await Execution.TaskCompletion;
    RaiseCanExecuteChanged();
  }
}

Теперь код соответствует проекту AsyncCommands3 в сопутствующем коде. Кнопка отключается, пока продолжается операция.

Добавление поддержки отмены

Время выполнения многих асинхронных операций может варьироваться. Так, HTTP-запрос обычно дает очень быстрый ответ — еще до того, как пользователь успеет на него отреагировать. Однако, если сеть медленная или сервер занят, тот же HTTP-запрос может вызвать значительную задержку. При проектировании асинхронного UI следует ожидать этого и рассчитывать на такой сценарий. В текущем решении уже есть индикатор занятости (busy indicator). Кроме того, проектируя асинхронный UI, вы можете предоставить пользователю больше вариантов, и отмена — один из самых распространенных.

Сама по себе отмена всегда является синхронной операцией — запрос отмены выполняется немедленно. Крайне сложная часть отмены — определить, когда ее можно осуществить; она должна выполняться, только если асинхронная операция еще работает. AsyncCommand, измененный на рис. 11, предоставляет вложенную команду отмены (nested cancellation command) и уведомляет эту команду, когда начинается и заканчивается асинхронная команда.

Рис. 11. Добавление отмены

public class AsyncCommand<TResult> : AsyncCommandBase, INotifyPropertyChanged
{
  private readonly Func<CancellationToken, Task<TResult>> _command;
  private readonly CancelAsyncCommand _cancelCommand;
  private NotifyTaskCompletion<TResult> _execution;
  public AsyncCommand(Func<CancellationToken, Task<TResult>> command)
  {
    _command = command;
    _cancelCommand = new CancelAsyncCommand();
  }
  public override async Task ExecuteAsync(object parameter)
  {
    _cancelCommand.NotifyCommandStarting();
    Execution = new NotifyTaskCompletion<TResult>(_command(_cancelCommand.Token));
    RaiseCanExecuteChanged();
    await Execution.TaskCompletion;
    _cancelCommand.NotifyCommandFinished();
    RaiseCanExecuteChanged();
  }
  public ICommand CancelCommand
  {
    get { return _cancelCommand; }
  }
  private sealed class CancelAsyncCommand : ICommand
  {
    private CancellationTokenSource _cts = new CancellationTokenSource();
    private bool _commandExecuting;
    public CancellationToken Token { get { return _cts.Token; } }
    public void NotifyCommandStarting()
    {
      _commandExecuting = true;
      if (!_cts.IsCancellationRequested)
        return;
      _cts = new CancellationTokenSource();
      RaiseCanExecuteChanged();
    }
    public void NotifyCommandFinished()
    {
      _commandExecuting = false;
      RaiseCanExecuteChanged();
    }
    bool ICommand.CanExecute(object parameter)
    {
      return _commandExecuting && !_cts.IsCancellationRequested;
    }
    void ICommand.Execute(object parameter)
    {
      _cts.Cancel();
      RaiseCanExecuteChanged();
    }
  }
}

Добавить кнопку Cancel (и соответствующую метку) в UI несложно (рис. 12).

Рис. 12. Добавление кнопки Cancel

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" Content="Go" />
  <Button Command="{Binding CountUrlBytesCommand.CancelCommand}" Content="Cancel" />
  <Grid Visibility="{Binding CountUrlBytesCommand.Execution,
    Converter={StaticResource NullToVisibilityConverter}}">
    <!-- Индикатор занятости -->
    <Label Content="Loading..."
      Visibility="{Binding CountUrlBytesCommand.Execution.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}" />
    <!-- Результаты -->
    <Label Content="{Binding CountUrlBytesCommand.Execution.Result}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}" />
    <!-- Детальная информация об ошибке -->
    <Label Content="{Binding CountUrlBytesCommand.Execution.ErrorMessage}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}" Foreground="Red" />
    <!-- Отмена -->
    <Label Content="Canceled"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsCanceled,
      Converter={StaticResource BooleanToVisibilityConverter}}" Foreground="Blue" />
  </Grid>
</Grid>

По моему мнению, сообщество пока не предложило по-настоящему хорошую UX для обработки нескольких асинхронных операций.

Теперь, запустив приложение (проект AsyncCommands4 в сопутствующем коде), вы увидите, что изначально кнопка отмены отключена. Она включается, когда вы щелкаете кнопку Go и остается доступной до тех пор, пока не завершится операция (успешно, неудачно или из-за отмены). В данный момент у нас с вами более-менее законченный UI для асинхронной операции.

Простая очередь рабочих элементов

До этого момента я фокусировался на UI, где единовременно возможна только одна операция. Это все, что нужно во многих ситуациях, но иногда требуется возможность запуска сразу нескольких асинхронных операций. По моему мнению, сообщество пока не предложило по-настоящему хорошую UX для обработки нескольких асинхронных операций. Два распространенных подхода основаны на очереди рабочих элементов и на системе уведомления, ни один из которых не идеален.

Крайне сложная часть отмены — определить, когда ее можно осуществить; она должна выполняться, только если асинхронная операция еще работает.

Очередь рабочих элементов (work queue) отображает все асинхронные операции в наборе; это дает пользователю максимум визуализации и контроля, но типичному конечному пользователю обычно слишком сложно управляться с ней. Система уведомления скрывает операции, пока они выполняются, и выводит всплывающее уведомление, если любая из них проваливается (и, возможно, если они завершаются успешно). Система уведомления более дружественна к пользователю, но не обеспечивает полную визуализацию и мощь очереди рабочих элементов (например, в систему на основе уведомлений трудно встроить отмену). Мне еще предстоит найти идеальную UX для нескольких асинхронных операций.

С учетом сказанного, пример кода к этому моменту можно без особых проблем расширить для поддержки сценария с несколькими операциями. В существующем коде кнопки Go и Cancel концептуально относятся к одной асинхронной операции. В новом UI смысл кнопки Go будет изменен на «запуск новой асинхронной операции и ее добавление в список операций». Это означает, что кнопка Go теперь на самом деле становится синхронной. Я добавил в решение простой (синхронный) DelegateCommand, и теперь ViewModel и XAML можно обновить, как показано на рис. 13 и 14.

Рис. 13. ViewModel для нескольких команд

public sealed class CountUrlBytesViewModel
{
  public CountUrlBytesViewModel(MainWindowViewModel parent, string url,
    IAsyncCommand command)
  {
    LoadingMessage = "Loading (" + url + ")...";
    Command = command;
    RemoveCommand = new DelegateCommand(() => parent.Operations.Remove(this));
  }
  public string LoadingMessage { get; private set; }
  public IAsyncCommand Command { get; private set; }
  public ICommand RemoveCommand { get; private set; }
}
public sealed class MainWindowViewModel : INotifyPropertyChanged
{
  public MainWindowViewModel()
  {
    Url = "http://www.example.com/";
    Operations = new ObservableCollection<CountUrlBytesViewModel>();
    CountUrlBytesCommand = new DelegateCommand(() =>
    {
      var countBytes = new AsyncCommand<int>(token =>
        MyService.DownloadAndCountBytesAsync(
        Url, token));
      countBytes.Execute(null);
      Operations.Add(new CountUrlBytesViewModel(this, Url, countBytes));
    });
  }
  public string Url { get; set; } // генерирует PropertyChanged
  public ObservableCollection<CountUrlBytesViewModel> Operations
    { get; private set; }
  public ICommand CountUrlBytesCommand { get; private set; }
}

Рис. 14. XAML для нескольких команд

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" Content="Go" />
  <ItemsControl ItemsSource="{Binding Operations}">
    <ItemsControl.ItemTemplate>
      <DataTemplate>
        <Grid>
          <!-- Индикатор занятости -->
          <Label Content="{Binding LoadingMessage}"
            Visibility="{Binding Command.Execution.IsNotCompleted,
            Converter={StaticResource BooleanToVisibilityConverter}}" />
          <!-- Результаты -->
          <Label Content="{Binding Command.Execution.Result}"
            Visibility="{Binding Command.Execution.IsSuccessfullyCompleted,
            Converter={StaticResource BooleanToVisibilityConverter}}" />
          <!-- Детальная информация об ошибке -->
          <Label Content="{Binding Command.Execution.ErrorMessage}"
            Visibility="{Binding Command.Execution.IsFaulted,
            Converter={StaticResource BooleanToVisibilityConverter}}"
            Foreground="Red" />
          <!-- Отмена -->
          <Label Content="Canceled"
            Visibility="{Binding Command.Execution.IsCanceled,
            Converter={StaticResource BooleanToVisibilityConverter}}"
            Foreground="Blue" />
          <Button Command="{Binding Command.CancelCommand}" Content="Cancel" />
          <Button Command="{Binding RemoveCommand}" Content="X" />
        </Grid>
      </DataTemplate>
    </ItemsControl.ItemTemplate>
  </ItemsControl>
</Grid>

Универсального решения асинхронной команды, которое соответствовало бы потребностям каждого, пока нет.

Этот код эквивалентен проекту AsyncCommandsWithQueue в сопутствующем коде. Когда пользователь щелкает кнопку Go, создается новый AsyncCommand и обертывается в дочерний ViewModel (CountUrlBytesViewModel). Затем этот экземпляр дочернего ViewModel добавляется в список операций. Все, что связано с этой конкретной операцией (различные метки и кнопка Cancel), отображается в шаблоне данных для очереди рабочих элементов. Я также добавил кнопку «X», которая удаляет элемент из очереди.

Это самая базовая очередь рабочих элементов, и я сделал несколько допущений по поводу дизайна. Например, когда операция удаляется из очереди, она не отменяется автоматически. Когда вы начинаете работать с несколькими асинхронными операциями, я советую вам задать себе, как минимум, следующие дополнительные вопросы.

  1. Как пользователь будет узнавать, какое уведомление или рабочий элемент относится к той или иной операции? (Скажем, индикатор занятости в этом примере очереди рабочих элементов содержит URL, по которому выполняется скачивание.)
  2. Нужно ли пользователю знать каждый результат? (Например, может оказаться вполне приемлемым уведомлять пользователя только об ошибках или автоматически удалять успешные операции из очереди рабочих элементов.)

Заключение

Универсального решения асинхронной команды, которое соответствовало бы потребностям каждого, пока нет. Сообщество разработчиков все еще исследует шаблоны асинхронных UI. Моя цель в этой статье — показать, как интерпретировать асинхронные команды в контексте MVVM-приложения, особенно учитывая проблемы с UX, которые нужно решать, когда UI становится асинхронным. Но помните, что шаблоны в этой статье и примеры кода — не более чем шаблоны, и их нужно адаптировать под требования конкретного приложения.

В частности, для нескольких асинхронных решений идеала пока нет. Недостатки есть в обоих подходах (как в очередях рабочих элементов, так и в уведомлениях), и, как мне кажется, универсальную UX еще только предстоит разработать. Чем больше UI становится асинхронными, тем больший круг разработчиков будет думать об этой проблеме, и вполне возможно, что вскоре нас ждет революционный прорыв. Поразмыслите над этой задачей и вы, уважаемый читатель. Вдруг вам удастся изобрести новую UX.

В этой статье я начал с самых базовых асинхронных реализаций ICommand и постепенно добавлял функционал, пока не получил в итоге нечто более-менее подходящее для большинства современных приложений. Результат также является полностью тестируемым с помощью модульных тестов; поскольку асинхронный void-метод ICommand.Execute вызывает только метод IAsyncCommand.ExecuteAsync, возвращающий Task, вы можете использовать ExecuteAsync непосредственно в своих модульных тестах.

В своей прошлой статье я создал NotifyTaskCompletion<T>, связанную с данными оболочку Task<T>. В этой статье я показал, как разработать один из видов AsyncCommand<T>, асинхронную реализацию ICommand. В следующей статье мы займемся асинхронными сервисами. Учитывайте, что асинхронные шаблоны MVVM все еще сравнительно новые, поэтому не бойтесь отклоняться от них и изобретать свои решения.


Стивен Клири (Stephen Cleary) — отец, муж и программист, живет в северной части Мичигана. Имеет 16-летний опыт работы в области многопоточного и асинхронного программирования и использует поддержку асинхронности в Microsoft .NET Framework с момента ее первой CTP-версии. Его сайт и блог можно найти по ссылке stephencleary.com.

Выражаю благодарность за рецензирование статьи экспертам Microsoft Джеймсу Маккафри (James McCaffrey) и Стефену Таубу (Stephen Toub).