Представим типичный пользовательский интерфейс. Есть несколько элементов управления, которые запускают некоторые повторяемые (за время жизни приложения) действия разной сложности. Чтобы сложные действия, такие как обращение к различным носителям, обращение к сети или сложное вычисление, не снижали отзывчивость интерфейса, они должны быть асинхронными. Дополнительно могут быть элементы управления, отменяющие асинхронно запущенное действие. Действие имеет свойство состояния (неактивно, запущено, завершено успешно, завершено с ошибкой, отменено), которое тем или иным образом отображается пользователю. Принятый в WPF, Silverlight и WinPhone шаблон проектирования MVVM диктует, чтобы такое «действие» было частью модели представления, давая возможность вызывать сервисы модели из пользовательского интерфейса без создания между ними жёсткой связи. К сожалению, такое «действие» в базовой библиотеке классов не реализовано. Ближайшие имеющиеся в библиотеке сущности, такие как задачи System.Threading.Tasks.Task, команды System.Windows.Input.ICommand и делегаты System.Delegate, не подходят: задачи всегда одноразовые и не могут представлять повторяемое действие, делегаты и команды не поддерживают отмену и не содержат свойств состояния, а команды вообще не могут быть асинхронными. Далее я предлагаю решение в виде небольшой библиотеки классов, дающей возможность легко использовать описанные «действия» в ваших приложениях.
Для начала, суммируем наши требования.
Требования пользователя:
- Никакое запущенное действие (а также отмена действия) не блокирует интерфейс пользователя.
- Длительно работающее действие можно отменять.
- Действия могут быть взаимоисключающими (например: загрузка и сохранение). Для таких действий запущенным может быть только одно из группы.
- Для группы действий может использоваться единый элемент интерфейса пользователя для их отмены, который будет отменять всё что запущено из группы.
- Элементы интерфейса пользователя для запуска и отмены действий отображают доступность соответствующих операций, предотвращая повторный запуск запущенного или отмену не запущенного действия.
Требования разработчика приложений:
- Запуск и отмена действий должны быть представлены командами ICommand для связывания с элементами интерфейса пользователя.
- Контекстное действие должно позволять привязку к многим элементам интерфейса пользователя (например, одно контекстное меню вызывается для многих элементов списка). Для этого действия должны принимать параметр контекста, задаваемый в привязке к элементам интерфейса пользователя.
- Действия должны получать параметром токен запроса отмены (CancellationToken), позволяющий реагировать на запросы отмены действия.
Обычные требования шаблона проектирования MVVM:
- Представление состоит только из XAML и не содержит код.
- В модели представления нет ссылок на представление и классы, специфичные для представлений.
Ни одно из найденных в Интернете решений не удовлетворяет всем перечисленным требованиям. Посмотреть можно Commands in MVVM, RelayCommand Yes Another One!, Asynchronous WPF Command, WPF Commands and Async Commands и самое близкое по требованиям Шаблоны для асинхронных MVVM-приложений: команды. Интересно, что все найденные решения имеют одну нелогичность: они сосредоточены вокруг команд, добавляя к интерфейсу ICommand совершенно чуждую ему асинхронность. С моей точки зрения, гораздо логичнее оттолкнуться от задач (Task), сделав их повторяемыми и добавив к ним нужные команды, такие как «запуск», «отмена» или «пауза».
В моей библиотеке реализованы следующие сущности. Класс RepeatableTask представляет собой повторяемую задачу, которая является самодостаточной и никак не связанной ни с шаблоном MVVM, ни вообще с пользовательским интерфейсом. В конструкторе RepeatableTask указывается синхронный или асинхронный метод, который будет выполняться при запуске действия. Назначение методов Start() и Cancel() — очевидное. События TaskStarting, TaskStarted и OnTaskStarted позволяют устанавливать обработчики событий жизненного цикла действия. В наследованном от RepeatableTask классе CommandedRepeatableTask добавлены команды запуска и отмены, которые можно использовать для привязки к пользовательскому интерфейсу. Команды ChainedRelayCommand созданы на основе широко распространённой RelayCommand и дополнены поддержкой объединения в цепь чтобы формировать группы взаимосвязанных (с точки зрения интерфейса пользователя) действий. Класс CommandChain содержит список объединённых в цепь команд и параметры их совместного исполнения.
Использовать описанные сущности легко, особенно для тех, кто работает по шаблону MVVM и уже знаком с RelayCommand. Для создания простых действий, которые нет необходимости выполнять асинхронно (например, сортировка списка по клику на его заголовке), используйте ChainedRelayCommand также, как как RelayCommand. Создаёте команду, указывая метод модели, который производит сортировку. Команду выставляете в виде свойства модели представления. В представлении для нужного элемента управления для свойства Command указываете привязку к созданному свойству модели представления. Для упомянутого случая сортировки по клику на заголовке списка, имеет смысл для каждого столбца указать одну и ту же команду, но кроме Command указать свойство CommandParameter со значением по которому делать сортировку. Для действия, которое требует асинхронного исполнения, создавайте повторяемую задачу CommandedRepeatableTask, указывая метод модели. Задачу выставляете в виде свойства модели представления, а в представлении привязываете свойства задачи StartCommand и StopCommand к соответствующим элементам управления. Если действия образуют взаимоисключающую группу, то после создания первого действия, второе и последующие действия создавайте не через конструктор, а через вызов метода CreateLinked первого действия. Действия в группе (цепи) используйте также, как одиночные. При этом доступность команды пуска (StartCommand) каждого действия будет зависеть от состояния других действий группы. А команда отмены (StopCommand) любого из них будет вести себя одинаково (отменять любое из запущенных действий группы).
Подведём итог. Созданный класс CommandedRepeatableTask удовлетворяет всем перечисленным требованиям для «действий» и дополнительно предоставляет следующие удобства:
- Конструируется на основе методов как в простом синхронном синтаксисе, так и в синтаксисе async/await.
- При асинхронном запуске синхронных методов они могут выполняться в требуемом потоке исполнения или контексте синхронизации. Например, действия с оболочкой (Shell) или буфером обмена (Clipboard) обычно требуют выполнения в потоке с ApartmentState.STA.
- Токены запроса отмены для каждого запуска каждого действия создаются автоматически.
- Предоставляемая команда отмены инициирует запрос отмены для того токена, с которым запущено действие.
- Предоставляемые команды запуска и отмены отражают состояние действия в методе ICommand.CanExecute() и автоматически генерируются события ICommand.CanExecuteChanged когда меняется состояние действия.
- Позволяет назначать предупредительные мероприятия, которые могут отменить запуск действия или предоставить для него дополнительный параметр (например, запросить подтверждение перед ответственным действием или запросить имя файла с которым будет работать действие). Чтобы иметь возможность создавать новые представления, предупредительное мероприятие выполняется в том же потоке (контексте), из которого инициирован запуск действия.
- Позволяет назначать подготовительные мероприятия, которые будут запущены перед стартом действия. Чтобы иметь возможность создавать новые представления (например, окно прогресса), подготовительное мероприятие выполняется в том же потоке (контексте), из которого инициирован запуск действия.
- Позволяет назначать регистрирующие мероприятия, которые будут запущены после завершения действия, даже если оно отменено или вызвало исключение. Регистрирующее мероприятие получает аргументом статус действия и исключение. Чтобы иметь возможность создавать новые представления (например, окно с сообщением об ошибке действия), регистрирующее мероприятие выполняется в том же потоке (контексте), из которого инициирован запуск действия.
Библиотека создана в виде Portable Class Library Profile259 (.NET Framework 4.5, Windows 8, Windows Phone 8.1, Windows Phone Silverlight 8). Для сборки под .NET Framework 4 добавлен отдельный проект, который состоит только из ссылок на исходники основного. В проекте-примере показаны различные способы использования библиотеки в WPF-приложении: в кнопках панели инструментов, в элементах главного меню, в элементах контекстного меню записей списка и в заголовках списка (для его сортировки). Решение со всеми перечисленными проектами выложено в публичный репозиторий на github. Готовая для использования сборка выложена в виде nuget-пакета. Удачного программирования!
Автор: novar