Pull to refresh

Инфраструктура команд для вызова пользователем действий в шаблоне MVVM

Reading time 10 min
Views 10K
Представим типичный пользовательский интерфейс. Есть несколько элементов управления, которые запускают некоторые повторяемые (за время жизни приложения) действия разной сложности. Чтобы сложные действия, такие как обращение к различным носителям, обращение к сети или сложное вычисление, не снижали отзывчивость интерфейса, они должны быть асинхронными. Дополнительно могут быть элементы управления, отменяющие асинхронно запущенное действие. Действие имеет свойство состояния (неактивно, запущено, завершено успешно, завершено с ошибкой, отменено), которое тем или иным образом отображается пользователю. Принятый в 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 и TaskEnded позволяют устанавливать обработчики событий жизненного цикла повторяемой задачи.
public class RepeatableTask : IDisposable
{
	// Инициализирует новый экземпляр RepeatableTask на основе
	// указанной фабрики по производству задач.
	public RepeatableTask (Func<object, CancellationToken, Task> taskFactory);
	// Инициализирует новый экземпляр RepeatableTask на основе
	// указанного делегата и планировщика задач.
	public RepeatableTask (Action<object, CancellationToken> taskAction, TaskScheduler taskScheduler = null);
	// Возвращает true если задача в настоящий момент выполняется, иначе false.
	public bool IsRunning { get; }
	// Запускает задачу с указанным объектом состояния.
	// Ранее запущенная задача отменяется.
	public void Start (object state);
	// Отменяет все ранее запущенные задачи.
	public void Cancel ();
	// Освобождает все занятые ресурсы.
	public void Dispose ();
	// Происходит после завершения задачи.
	public event EventHandler<DataEventArgs<CompletedTaskData>> TaskEnded;
	// Происходит после запуска задачи.
	public event EventHandler<DataEventArgs<object>> TaskStarted;
	// Происходит перед тем, как запустится задача.
	public event EventHandler<TaskStartingEventArgs> TaskStarting;
}

В наследованном от RepeatableTask классе CommandedRepeatableTask добавлены команды запуска и отмены, которые можно использовать для привязки к пользовательскому интерфейсу.
public class CommandedRepeatableTask : RepeatableTask, INotifyPropertyChanged
{
	// Инициализирует новый экземпляр CommandedRepeatableTask
	// на основе указанной фабрики по производству задач.
	public CommandedRepeatableTask (Func<object, CancellationToken, Task> taskFactory);
	// Инициализирует новый экземпляр CommandedRepeatableTask
	// на основе указанного делегата и планировщика задач.
	public CommandedRepeatableTask (Action<object, CancellationToken> taskAction, TaskScheduler taskScheduler);
	// Получает команду запуска задачи.
	public ChainedRelayCommand<object> StartCommand { get; }
	// Получает команду остановки задачи.
	public ChainedRelayCommand StopCommand { get; }
	// Происходит после изменения свойства.
	public event PropertyChangedEventHandler PropertyChanged;
	// Создаёт связанную задачу на основе указанной фабрики по производству задач.
	public CommandedRepeatableTask CreateLinked (Func<object, CancellationToken, Task> taskFactory);
	// Создаёт связанную задачу на основе указанного делегата и планировщика задач.
	public CommandedRepeatableTask CreateLinked (Action<object, CancellationToken> taskAction, TaskScheduler taskScheduler);
}

Команды ChainedRelayCommand созданы на основе широко распространённой RelayCommand и дополнены поддержкой объединения в цепь чтобы формировать группы взаимосвязанных (с точки зрения интерфейса пользователя) действий.
public class ChainedRelayCommand : ICommand
{
	// Инициализирует новый экземпляр класса ChainedRelayCommand
	// который будет выполнять указанный делегат и использовать другой
	// указанный делегат для получения статуса готовности команды.
	// Команда не будет являтся частью цепи связанных команд.
	public ChainedRelayCommand (Action execute, Func<bool> canExecute = null);
	// Инициализирует новый экземпляр класса ChainedRelayCommand
	// который будет выполнять указанный делегат, использовать другой
	// указанный делегат для получения статуса готовности команды и
	// будет являться звеном указанной цепи команд.
	public ChainedRelayCommand (CommandChain commandChain, Action execute, Func<bool> canExecute = null);
	// Получает цепь связанных команд, частью которой является команда.
	public CommandChain Chain { get; }
	// Исполняет команду как часть цепи команд.
	public void Execute (object parameter);
	// Определяет готовность команды к исполнению с указанным параметром как часть цепи команд
	public bool CanExecute (object parameter);
	// Вызывает событие CanExecuteChanged для всех команд в цепи.
	public void RaiseCanExecuteChanged ();
	// Происходит когда изменяются факторы, влияющие на готовность команды к исполнению.
	public event EventHandler CanExecuteChanged;
}

Класс CommandChain содержит список объединённых в цепь команд и параметры их совместного исполнения.
public class CommandChain
{
	// Инициализирует новый экземпляр класса RelayCommandChain
	// с указанным поведением в цепи связанных команд.
	public CommandChain (bool executionChained, ExecutionAbilityChainBehavior canExecuteChainBehavior);
	// Получает поведение при запросе готовности
	// выполнения команды связанное с другими командами цепи.
	public ExecutionAbilityChainBehavior ExecutionAbilityChainBehavior { get; }
	// Получает признак выполнения всех команд цепи при выполнении одной.
	public bool ExecutionChained { get; }
	// Получает начальный узел односвязного списка команд цепи.
	public SingleLinkedListNode<ChainedCommandBase> FirstCommand { get; }
	// Добавляет указанную команду в цепь.
	public void Add (ChainedCommandBase command);
	// Очищает цепь.
	public void Clear ();
}

Использовать описанные сущности легко, особенно для тех, кто работает по шаблону MVVM и уже знаком с RelayCommand. Для создания простых действий, которые нет необходимости выполнять асинхронно (например, сортировка списка по клику на его заголовке), используйте ChainedRelayCommand также, как как RelayCommand. Создаёте команду, указывая метод модели, который производит сортировку. Команду выставляете в виде свойства модели представления.
public class AppViewModel
{
	private readonly ChainedRelayCommand<object> _sortListCommand;
	public ChainedRelayCommand<object> SortListCommand { get { return _sortListCommand; } }
	public AppViewModel ()
	{
		_sortListCommand = new ChainedRelayCommand<object> (arg => SortList ((string)arg));
	}
}
private void SortList (string column)
{
	// тут сортируем список по полю column
}

В представлении для нужного элемента управления для свойства Command указываете привязку к созданному свойству модели представления. Для упомянутого случая сортировки по клику на заголовке списка, имеет смысл для каждого столбца указать одну и ту же команду, но кроме Command указать свойство CommandParameter со значением по которому делать сортировку.
<ListView>
	<ListView.View>
		<GridView>
			<GridView.Columns>
				<GridViewColumn>
					<GridViewColumnHeader Command="{Binding SortListCommand}" CommandParameter="ID" Content="ID" />
				</GridViewColumn>
				<GridViewColumn>
					<GridViewColumnHeader Command="{Binding SortListCommand}" CommandParameter="Name" Content="Файл" />
				</GridViewColumn>
			</GridView.Columns>
		</GridView>
	</ListView.View>
</ListView>

Для действия, которое требует асинхронного исполнения, создавайте повторяемую задачу CommandedRepeatableTask, указывая метод модели. Задачу выставляете в виде свойства модели представления. Если действия образуют взаимоисключающую группу, то после создания первого действия, второе и последующие действия создавайте не через конструктор, а через вызов метода CreateLinked первого действия. Действия в группе (цепи) используйте также, как одиночные. При этом доступность команды пуска (StartCommand) каждого действия будет зависеть от состояния других действий группы. А команда отмены (StopCommand) любого из них будет вести себя одинаково (отменять любое из запущенных действий группы).

public class AppViewModel
{
	private readonly CommandedRepeatableTask _workTask1;
	private readonly CommandedRepeatableTask _workTask2;
	private readonly CommandedRepeatableTask _workTask3;
	private readonly BusinessLogicService _businessLogicService;
	public CommandedRepeatableTask WorkTask1 { get { return _workTask1; } }
	public CommandedRepeatableTask WorkTask2 { get { return _workTask2; } }
	public CommandedRepeatableTask WorkTask3 { get { return _workTask3; } }
	public AppViewModel (BusinessLogicService businessLogicService)
	{
		_businessLogicService = businessLogicService;
		_workTask1 = new CommandedRepeatableTask (_businessLogicService.Work1, TaskScheduler.Default);
		_workTask2 = _workTask1.CreateLinked (_businessLogicService.Work2, TaskScheduler.Default);
		_workTask3 = _workTask1.CreateLinked (_businessLogicService.Work3, TaskScheduler.Default);
	}
}
public class BusinessLogicService
{
	public void Work1 (object state, CancellationToken cancellationToken);
	public void Work2 (object state, CancellationToken cancellationToken);
	public void Work3 (object state, CancellationToken cancellationToken);
}


В представлении привязываете свойства задачи StartCommand и StopCommand к соответствующим элементам управления.
<Button Command="{Binding WorkTask1.StartCommand}">Work1</Button>
<Button Command="{Binding WorkTask2.StartCommand}">Work2</Button>
<Button Command="{Binding WorkTask3.StartCommand}">Work3</Button>
<Button Command="{Binding WorkTask1.StopCommand}">Cancel</Button>

Подведём итог. Созданный класс 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-пакета. Удачного программирования!
Tags:
Hubs:
+12
Comments 11
Comments Comments 11

Articles