Введение в ReactiveUI: изучаем команды

в 11:13, , рубрики: .net, C#, commands, reactive extensions, reactive programming, reactiveui, wpf, команды, Программирование, разработка под windows, реактивное программирование

Часть 1: Введение в ReactiveUI: прокачиваем свойства во ViewModel
Часть 2: Введение в ReactiveUI: коллекции

Мы уже обсудили возможности ReactiveUI, связанные с работой со свойствами, выстраиванием зависимостей между ними, а также с работой с коллекциями. Это одни из основных примитивов, на базе которых строится разработка с применением ReactiveUI. Еще одним таким примитивом являются команды, которые мы и рассмотрим в этой части. Команды инкапсулируют действия, которые производятся в ответ на некоторое событие: обычно это запрос пользователя или какие-то отслеживаемые изменения. Мы узнаем, что можно сделать с помощью команд в ReactiveUI, обсудим особенности их работы и выясним, чем команды в ReactiveUI отличаются от команд, с которыми мы знакомы по WPF и его родственникам.
Но прежде чем перейти к командам, рассмотрим более широкие темы, касающиеся реактивного программирования в целом: связь между Task<T> и IObservable<T>, и что такое горячие и холодные последовательности.

Task vs. IObservable

Итак, проведем параллель между Task<T> (+ async, await, вот это все) и IObservable<T>. Нам это будет важно для понимания того, как работать с командами в ReactiveUI, но описываемый подход имеет более широкое применение и о нем не помешает знать. Итак: Task<T> — это IObservable<T>. Но они, безусловно, не эквивалентны: IObservable<T> может решать гораздо больший круг задач.
Звучит как-то подозрительно, не правда ли? Давайте разбираться. Сразу посмотрим пример:

Task<string> task = Task.Run(() =>
	{
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Начинаем долгую задачу");
		Thread.Sleep(1000);
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Завершаем долгую задачу");
		return "Результат долгой задачи";
	});
Console.WriteLine(DateTime.Now.ToLongTimeString() + " Делаем что-то до начала ожидания результата задачи");
string result = task.Result;
Console.WriteLine(DateTime.Now.ToLongTimeString() + " Полученный результат: " + result);

Мы создали задачу, она выполнится асинхронно и не помешает нам делать что-то еще сразу после ее запуска, не дожидаясь завершения. Результат предсказуем:

18:19:47 Делаем что-то до начала ожидания результата задачи
18:19:47 Начинаем долгую задачу
18:19:48 Завершаем долгую задачу
18:19:48 Полученный результат: Результат долгой задачи

Первые 2 строки выведутся сразу и могут иметь разный порядок, как повезет.
А теперь перепишем на IObservable<T>:

IObservable<string> task = Observable.Start(() =>
	{
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Начинаем долгую задачу");
		Thread.Sleep(1000);
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Завершаем долгую задачу");
		return "Результат долгой задачи";
	});
Console.WriteLine(DateTime.Now.ToLongTimeString() + " Делаем что-то до начала ожидания результата задачи");
string result = task.Wait(); // блокирующее ожидание завершения и возврат результата
Console.WriteLine(DateTime.Now.ToLongTimeString() + " Полученный результат: " + result);

Разница в двух строках: IObservable<string> вместо Task<string>, Observable.Start() вместо Task.Run() и task.Wait() вместо task.Result. Результат работы же точно такой же.

Посмотрим еще один известный прием с запуском действия после завершения задачи:

//Task
task.ContinueWith(t => Console.WriteLine(DateTime.Now.ToLongTimeString() + " Полученный результат: " + t.Result));
//IObservable
task.Subscribe(t => Console.WriteLine(DateTime.Now.ToLongTimeString() + " Полученный результат: " + t));

Разницы, опять же, практически нет.

Получается, Task<T> можно представить через IObservable<T>, который выдаст один элемент и сигнал завершения. Большой философской и архитектурной разницы между такими подходами нет, и можно пользоваться любым. Даже async/await доступен в обоих случаях: если мы в асинхронном методе должны получить результат из IObservable, можно не делать блокирующее ожидание через метод Wait(), как в примере, а использовать await. Более того, эти два подхода можно совмещать, трансформировать одно представление в другое и пользоваться плюсами обоих.

Горячие и холодные последовательности

Обсудим еще один вопрос, касающийся работы с наблюдаемыми последовательностями (observable). Они могут быть двух типов: холодные (cold) и горячие (hot). Холодные последовательности пассивны и начинают генерировать уведомления по запросу, в момент подписки на них. Горячие же последовательности активны и не зависят от того, подписан ли на них кто-то: уведомления все равно генерируются, просто иногда они уходят в пустоту.
Тики таймера, события движения мыши, приходящие по сети запросы — это горячие последовательности. Подписавшись на них в некоторый момент, мы начнем получать актуальные уведомления. Подпишутся 10 наблюдателей — уведомления будут доставляться каждому. Вот пример с таймером:
Введение в ReactiveUI: изучаем команды - 1

Холодная же последовательность — это, например, запрос в базу данных или чтение файла построчно. Запрос или чтение запускается при подписке, и с каждой новой полученной строкой вызывается OnNext(). Когда строки закончатся, вызовется OnComplete(). При повторной подписке все повторяется снова: новый запрос в БД или открытие файла, возврат всех полученных результатов, сигнал завершения:
Введение в ReactiveUI: изучаем команды - 2


Классические команды...

Теперь перейдем к нашей сегодняшней теме — к командам. Команды, как я уже упомянул, инкапсулируют действия, совершаемые в ответ на некоторые события. Таким событием может быть нажатие пользователем кнопки «Сохранить»; тогда инкапсулируемым действием станет операция сохранения чего-то. Но команда может быть исполнена не только в ответ на явное действие пользователя или связанные с ним косвенные события. Сигнал от таймера, срабатывающего раз в 5 минут независимо от пользователя, тоже может инициировать ту же самую команду «Сохранить». И хотя обычно команды используются именно для действий, которые так или иначе совершает пользователь, не стоит пренебрегать их использованием и в других случаях.
Также команды позволяют выяснить, доступно ли в данный момент выполнение. Например, мы хотим, чтобы сохранение было доступно не всегда, а только когда заполнены все обязательные поля формы, и от доступности команды зависело бы, активна ли кнопка в интерфейсе.
Посмотрим, что представляет собой интерейс команды ICommand:

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

Execute, очевидно, выполняет команду. Ему можно передать параметр, но этой возможностью не стоит пользоваться, если необходимое значение можно получить внутри самой команды (например, взять из ViewModel). Дальше мы поймем, почему так. Но, конечно, есть и ситуации, когда передача параметра — самый приемлемый вариант.
CanExecute проверяет, доступно ли выполнение команды в данный момент. У него тоже есть параметр, и тут все то же самое, что и с Execute. Важно то, что CanExecute с неким значением параметра разрешает или запрещает выполнение команды только с таким же значением параметра, для других значений результат может отличаться. Стоит также помнить, что Execute перед выполнением действий не проверяет CanExecute на возможность выполнения, это задача вызывающего кода.
Событие CanExecuteChanged возникает, когда статус возможности выполнения меняется и стоит перепроверить CanExecute. Например, когда все поля в форме были заполнены и стало возможным сохранение, нужно инициировать включение кнопки в интерфейсе. Кнопка с привязанной командой узнает об этом именно так.

… и что с ними не так

Первая проблема — это то, что событие CanExecuteChanged не говорит о том, для какого значения параметра статус возможности выполнения изменился. Это та самая причина, по которой использования параметров при вызове Execute/CanExecute стоит избегать: интерфейс ICommand в отношении параметров не особо согласован. С реактивными же командами, как мы увидим, этот подход вообще не ужился.

Вторая проблема — Execute() возвращает управление только после завершения выполнения команды. Когда команда выполняется долго — пользователь расстраивается, потому что он сталкивается с зависшим интерфейсом.

Кому понравится такое?

В программе есть кнопка запуска команды, вывод лога и прогресс-бар, который в нормальной ситуации должен постоянно двигаться. Команда при запуске пишет в лог текущее время, полторы секунды что-то делает (например, загружает данные) и пишет время завершения.
Введение в ReactiveUI: изучаем команды - 3
Прогресс-бар останавливается, лог обновляется только по завершении команды, интерфейс зависает. Нехорошо получилось…

Как спасти ситуацию? Конечно, можно реализовать команду так, чтобы она только инициировала выполнение нужных действий в другом потоке и возвращала управление. Но тогда возникает другая проблема: пользователь может нажать на кнопку еще раз и запустить команду снова, еще до завершения предыдущей. Усложним реализацию: сделаем так, чтобы CanExecute возвращал false, пока задача выполняется. Интерфейс не зависнет, команда не запустится параллельно несколько раз, мы добились своего. Но все это нужно делать своими руками. А в ReactiveUI команды уже умеют все это и многое другое.


Реактивные команды

Познакомимся с ReactiveCommand<T>. Не перепутайте: есть еще non-generic реализация с таким же именем: ReactiveCommand (она находится в пространстве имен ReactiveUI.Legacy, и, очевидно, является устаревшей). Этот generic параметр обозначает не тип парамера, а тип результата, но мы вернемся к этому позже.

Сразу попробуем создать и запустить команду, опустив для начала все, что связано с CanExecute. Заметьте, что обычно мы вообще не создаем команды напрямую через оператор new, а пользуемся статическим классом ReactiveCommand, предоставляющим нужные методы.

var command = ReactiveCommand.Create();
command.Subscribe(_ =>
	{
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Начинаем долгую задачу");
		Thread.Sleep(1000);
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Завершаем долгую задачу");
	});
command.Execute(null);
Console.WriteLine(DateTime.Now.ToLongTimeString() + " После запуска команды");
Console.ReadLine();

Метод ReactiveCommand.Create() создает синхронные задачи, они имеют тип ReactiveCommand<object>. Execute() возвращает управление только после завершения работы:

19:01:07 Начинаем долгую задачу
19:01:08 Завершаем долгую задачу
19:01:08 После запуска команды

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

Возможность выполнения команды

Обсудим CanExecute и связанные с ним особенности. Кроме того, что мы уже видели (метод CanExecute и событие CanExecuteChanged), ReactiveCommand предоставляет последовательности IsExecuting и CanExecuteObservable:

var command = ReactiveCommand.Create();
command.Subscribe(_ => Console.WriteLine("Выполняется команда"));

command.CanExecuteChanged += (o, a) => Console.WriteLine("CanExecuteChanged event: now CanExecute() == {0}", command.CanExecute(null));
command.IsExecuting.Subscribe(isExecuting => Console.WriteLine("IsExecuting: {0}", isExecuting));
command.CanExecuteObservable.Subscribe(canExecute => Console.WriteLine("CanExecuteObservable: {0}", canExecute));

Console.WriteLine("Подписались на все, запускаем команду...");
command.Execute(null);
Console.WriteLine("После запуска команды");

IsExecuting: False
CanExecuteObservable: False
CanExecuteObservable: True
CanExecuteChanged event: now CanExecute() == True
Подписались на все, запускаем команду…
IsExecuting: True
CanExecuteChanged event: now CanExecute() == False
CanExecuteObservable: False
Выполняется команда
IsExecuting: False
CanExecuteChanged event: now CanExecute() == True
CanExecuteObservable: True
После запуска команды

На то, что происходит сразу после подписки и до запуска команды, можно особенно не обращать внимание: это инициализация. Фактически нам сразу при подписке возвращают текущее состояние (получается холодный первый элемент и горячие последующие). Причем CanExecuteObservable изначально установлен в false. Похоже, что при подписке сначала нам дают это значение, а потом команда определяет, что мы не предоставлили механизм определения доступности, и делает команду доступной по-умолчанию.
Судя по выводу программы, команда уже бывает недоступна во время ее выполнения. Это особенно актуально для асинхронных команд: так они не будут запущены параллельно несколько раз. Таким образом, CanExecute, CanExecuteObservable и событие CanExecuteChanged зависят не только от того, что мы предоставим для расчета, но и от того, выполняется ли сейчас команда. IsExecuting же предоставляет именно информацию о том, выполняется ли команда в данный момент, и это можно использовать, например, для показа какого-то индикатора работы.

Давайте теперь предоставим команде информацию о том, когда она может выполняться. Для этого у каждого метода создания команд в классе ReactiveCommand есть перегрузки, принимающие IObservable<bool> canExecute. Команда подпишется на эту последовательность и при получении изменений будет актуализировать свою информацию о доступности выполнения. Смотрим:

var subject = new Subject<bool>();
var command = ReactiveCommand.Create(subject);

command.CanExecuteChanged += (o, a) => Console.WriteLine("CanExecuteChanged event: now CanExecute() == {0}", command.CanExecute(null));
command.CanExecuteObservable.Subscribe(canExecute => Console.WriteLine("CanExecuteObservable: {0}", canExecute));

Console.WriteLine("Делаем выполнение доступным");
subject.OnNext(true);
Console.WriteLine("Делаем выполнение недоступным");
subject.OnNext(false);
Console.WriteLine("Еще раз делаем выполнение недоступным");
subject.OnNext(false);

Subject здесь — это observable, который мы контролируем своими руками, выдавая через него нужные значения команде. Как минимум это очень удобно при тестировании. Мы подписываемся на все, делаем выполнение доступным, а потом два раза недоступным. Какой результат мы получим?

CanExecuteObservable: False
Делаем выполнение доступным
CanExecuteChanged event: now CanExecute() == True
CanExecuteObservable: True
Делаем выполнение недоступным
CanExecuteChanged event: now CanExecute() == False
CanExecuteObservable: False
Еще раз делаем выполнение недоступным

Вроде все ожидаемо. Изначально выполнение недоступно. Потом команда начинает реагировать на изменения, которые мы делаем. Здесь стоит только отметить, что когда мы несколько раз подряд посылаем одно и то же состояние доступности, команда игнорирует повторы. Заметим также, что CanExecuteObservable — это просто последовательность значений типа bool, и здесь возникает несовместимость с тем, что у метода CanExecute есть параметр. В ReactiveCommand он просто игнорируется.

Способы вызвать команду

Мы уже видели вызов команды методом Execute(). Посмотрим на другие способы:

IObservable<T> ExecuteAsync(object parameter)
Тут кроется одна особенность: выполнение команды не начнется, пока на результат ExecuteAsync() не будет совершена подписка. Воспользуемся им:

command.ExecuteAsync().Subscribe();

Однако синхронная команда от этого не становится асинхронной. Конечно, ExecuteAsync() вернет управление сразу, но выполнение еще не стартовало! А Subscribe(), который его стартует, вернет управление только после завершения команды. Фактически сейчас мы написали эквивалент Execute(). Впрочем, это закономерно, ведь ExecuteAsync() возвращает холодную последовательность и подписка на нее инициирует выполнение нашей долгой задачи. А выполняется она в текущем потоке. Хотя это можно исправить, явно указав, где выполнять подписку:

command.ExecuteAsync().SubscribeOn(TaskPoolScheduler.Default).Subscribe();

Теперь планировщик TPL отвечает за выполнение подписки. Соответственно, подписка будет выполнена в чем-то вроде Task.Run(), и все заработает как надо. Но заниматься таким в реальности не стоит, и этот пример лишь показывает одну из возможностей. Всяких же планировщиков много, и однажды мы коснемся и этой темы.

Task<T> ExecuteAsyncTask(object parameter)
В отличие от ExecuteAsync(), этот метод сразу запускает команду. Пробуем:

command.ExecuteAsyncTask();

Нам вернули Task<>, но счастья в жизни все нет. ExecuteAsyncTask() тоже возвращает управление только после завершения работы команды, и дает нам уже завершенную задачу. Какая-то подстава.

InvokeCommand()
Этот способ позволяет удобно настроить вызов команды при появлении сигнала в последовательности (например, изменение свойства). Примерно так:

this.WhenAnyValue(x => x.FullName).Where(x => !string.IsNullOrEmpty(x)).InvokeCommand(this.Search); // указываем конкретный экземпляр команды
this.WhenAnyValue(x => x.FullName).Where(x => !string.IsNullOrEmpty(x)).InvokeCommand(this, x => x.Search); // указываем, где каждый раз брать экземпляр команды

Пока мы так и не нашли способ выполнить команду асинхронно. Конечно, можно пользоваться методом ExecuteAsync() и назначать планировщик для выполнения подписки, но это костыль. Тем более WPF про этот метод не знает и будет по-прежнему вызывать Execute() и вешаться.

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

Синхронные команды имеют смысл, когда действия выполняются быстро и нет смысла усложнять. А для долгих задач нужны асинхронные команды. Здесь нам в помощь два метода: ReactiveCommand.CreateAsyncObservable() и ReactiveCommand.CreateAsyncTask(). Разница между ними только в том, через что выражается выполняемое действие. Возвращаемся к первому разделу статьи и тому, как можно представить асинхронные задачи.

Посмотрим CreateAsyncObservable:

var action = new Action(() =>
	{
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Начинаем долгую задачу");
		Thread.Sleep(1000);
		Console.WriteLine(DateTime.Now.ToLongTimeString() + " Завершаем долгую задачу");
	});
var command = ReactiveCommand.CreateAsyncObservable(_ => Observable.Start(action));

Console.WriteLine(DateTime.Now.ToLongTimeString() + " Запускаем команду...");
command.Execute(42);
Console.WriteLine(DateTime.Now.ToLongTimeString() + " После запуска команды");
Console.ReadLine();

2:33:50 Запускаем команду…
2:33:50 После запуска команды
2:33:50 Начинаем долгую задачу
2:33:51 Завершаем долгую задачу

Ура! Execute уже не блокируется до завершения выполнения команды, и интерфейс не будет зависать. С ExecuteAsync и ExecuteAsyncTask все аналогично: блокировок нет.

Теперь СreateAsyncTask:

var command = ReactiveCommand.CreateAsyncTask(_ => Task.Run(action));
var command = ReactiveCommand.CreateAsyncTask(_ => doSomethingAsync()); // метод возвращает Task<T>
var command = ReactiveCommand.CreateAsyncTask(async _ => await doSomethingAsync());

У обоих описанных методов много перегрузок, поддерживающих, например, передачу CanExecuteObservable или возможность отмены.
Кроме того, из асинхронной команды можно возвращать результат. Generic-параметр T из ReactiveCommand<T> — это как раз тип результата работы команды:

ReactiveCommand<int> command = ReactiveCommand.CreateAsyncTask(_ => Task.Run(() => 42));
var result = await command.ExecuteAsync(); // result == 42

И можно сразу направить его куда-нибудь:

var command = ReactiveCommand.CreateAsyncTask(_ => Task.Run(() => 42));
command.Subscribe(result => _logger.Log(result));
_answer = command.ToProperty(this, this.Answer); // Меняет значение свойства на результат выполнения команды (ObservableToPropertyHelper)

Гарантируется, что результат возвращается в потоке UI. Поэтому, кстати, выполнять в Subscribe какие-то долгие действия как реакцию на выполнение команды противопоказано. В случае синхронных команд собственный результат вернуть нельзя, команда имеет тип ReactiveCommand<object> и возвращаться будут значения, с которыми была запущена команда.

Ловим исключения, возникающие при работе команд

Исключения при работе команд могут возникать постоянно, особенно если речь идет о какой-нибудь загрузке данных. Соответственно, надо научиться их ловить и обрабатывать. Где и как это делать?

Синхронные команды

var command = ReactiveCommand.Create();
command.Subscribe(_ => { throw new InvalidOperationException(); });
command.ThrownExceptions.Subscribe(e => Console.WriteLine(e.Message));
command.Execute(null); //не бросает исключение
command.ExecuteAsync().Subscribe(); //сразу бросит InvalidOperationException
await command.ExecuteAsyncTask(); //сразу бросит InvalidOperationException

Так как вызовы всех методов синхронные, стоило бы ожидать, что они и бросят исключения. Но все не так просто. Execute() на самом деле не бросает исключения. Он реализован так, что все исключения просто глотаются. Другие два метода бросают исключения сразу, как и ожидалось.

С асинхронными командами все гораздо интереснее
ReactiveCommand предоставляет последовательность ThrownExceptions, через которую приходят возникшие при исполнении асинхронных команд исключения. Разницы между командами на базе Observable и Task здесь нет. Создадим команды для нашего эксперимента:

var command = ReactiveCommand.CreateAsyncTask(_ => Task.Run(() => { throw new InvalidOperationException(); })); /* 1 вариант */ 
var command = ReactiveCommand.CreateAsyncObservable(_ => Observable.Start(() => { throw new InvalidOperationException(); })); /* 2 вариант */ 
command.ThrownExceptions.Subscribe(e => Console.WriteLine(e.Message));

И попробуем разные способы вызвать команду:

command.Execute(null); //исключение придет через ThrownExceptions

command.ExecuteAsyncTask(); //исключение InvalidOperationException бросится когда-то в будущем (Task с необработанным исключением)
command.ExecuteAsync().Subscribe(); //исключение InvalidOperationException бросится когда-то в будущем, аналогично Task с необработанным исключением

await command.ExecuteAsync(); //сразу бросит InvalidOperationException И исключение придет через ThrownExceptions
await command.ExecuteAsyncTask(); //сразу бросит InvalidOperationException И исключение придет через ThrownExceptions

var subj = new Subject<Unit>();
subj.InvokeCommand(command);
subj.OnNext(Unit.Default); //исключение придет через ThrownExceptions

Если же мы так или иначе подпишемся на саму команду (например, command.ToProperty(...)), то при возникновении исключения в команде OnError() не отсылается.

В приведенном примере странными кажутся примеры, в которых исключения возникнут «когда-то». В TPL это нужно было, чтобы неперехваченные исключения не исчезали без следа. Тут же можно было передать их через ThrownExceptions и не бросать «в будущем». Но такова реализация, и в следующей версии ReactiveUI вроде как что-то в этом отношении поменяется.

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

Команды, которые могут долго выполняться, хорошо бы уметь отменять. Для этого есть много способов. Создадим асинхронную команду, которая в цикле будет выводить сообщение, пока ее не отменят:

var command = ReactiveCommand.CreateAsyncTask(async (a, t) =>
{
	while (!t.IsCancellationRequested)
	{
		Console.WriteLine("работаем");
		await Task.Delay(300);
	}
	Console.WriteLine("отмена");
});

Первый способ отмены — отмена подписки, созданной на основе ExecuteAsync():

var disposable = command.ExecuteAsync().Subscribe();
Thread.Sleep(1000);
disposable.Dispose();

Второй способ — передача токена через ExecuteAsyncTask():

var source = new CancellationTokenSource();
command.ExecuteAsyncTask(ct: source.Token);
Thread.Sleep(1000);
source.Cancel();

Но что делать, если мы хотим отменять команду, которая запускается методом Execute(), то есть при вызове, например, самим WPF? Это тоже несложно сделать, для этого нам понадобится обернуть Task в IObservable и использовать метод TakeUntil(). Приведу пример с вызовом другой команды для отмены:

Func<CancellationToken, Task> action = async (ct) =>
{
	while (!ct.IsCancellationRequested)
	{
		Console.WriteLine("работаем");
		await Task.Delay(300);
	}
	Console.WriteLine("отмена");
};

IReactiveCommand<object> cancelCommand = null;
var runCommand = ReactiveCommand.CreateAsyncObservable(_ => Observable.StartAsync(action).TakeUntil(cancelCommand));
cancelCommand = ReactiveCommand.Create(runCommand.IsExecuting);

runCommand.Execute(null);

Thread.Sleep(1000);
cancelCommand.Execute(null);

Команда выполняется до появления в последовательности cancelCommand очередного уведомления. По сути на месте cancelCommand может быть даже не команда, а любая наблюдаемая последовательность.

Во всех этих способах есть одна тонкость: когда мы инициировали отмену, команда сразу считается завершенной и доступной для повторного выполнения, но если токен отмены игнорируется задачей, то внутри еще могут продолжаться какие-то действия. Это тоже стоит учитывать, если команда становится отменяемой. Особенно это касается случаев, когда мы отменяем команду, в которой вообще нет Task<T>:

Action action = () => { ... };
var runCommand = ReactiveCommand.CreateAsyncObservable(_ => Observable.Start(action).TakeUntil(cancelCommand));

Здесь action будет выполняться и после того, как мы отменим выполнение и команда снова станет доступной для вызова. Только происходить это будет за занавесом, и может приводить к весьма неожиданным результатам.

Объединение команд

Можно легко создавать команду, вызывающую другие команды:

RefreshAll = ReactiveCommand.CreateCombined(RefreshNotifications, RefreshMessages);

В этом случае команда выполнима, когда выполнимы все команды из переданного набора. Также есть перегрузка, в которую можно передать отдельный canExecute для указания, когда команда может выполняться. В таком случае учитывается и возможность выполнения каждой команды, и переданный параметр.


Пример работы с командами

Давайте напишем небольшой пример, показывающий использование команд.
Мы сделаем поиск. Ну, условный поиск. На самом деле мы будем полторы секунды имитировать деятельность и возвращать в качестве результата коллекцию из нескольких модификаций запроса. Также мы поддержим отмену поиска, очистку старого результата при начале нового поиска и возможность выполнять поиск автоматически при изменении входных данных.
Смотрим вьюмодель:

public class SearchViewModel : ReactiveObject
{
    [Reactive] public string SearchQuery { get; set; }

    [Reactive] public bool AutoSearch { get; set; }

    private readonly ObservableAsPropertyHelper<ICollection<string>> _searchResult;
    public ICollection<string> SearchResult => _searchResult.Value;

    public IReactiveCommand<ICollection<string>> Search { get; }

    public IReactiveCommand<object> CancelSearch { get; }

    public SearchViewModel()
    {
        Search = ReactiveCommand.CreateAsyncObservable(
            this.WhenAnyValue(vm => vm.SearchQuery).Select(q => !string.IsNullOrEmpty(q)),
            _ => Observable.StartAsync(ct => SearchAsync(SearchQuery, ct)).TakeUntil(CancelSearch)
        );

        CancelSearch = ReactiveCommand.Create(Search.IsExecuting);

        Observable.Merge(
                Search,
                Search.IsExecuting.Where(e => e).Select(_ => new List<string>()))
            .ToProperty(this, vm => vm.SearchResult, out _searchResult);

        this.WhenAnyValue(vm => vm.SearchQuery)
            .Where(x => AutoSearch)
            .Throttle(TimeSpan.FromSeconds(0.3), RxApp.MainThreadScheduler)
            .InvokeCommand(Search);
    }

    private async Task<ICollection<string>> SearchAsync(string query, CancellationToken token)
    {
        await Task.Delay(1500, token);
        return new List<string>() { query, query.ToUpper(), new string(query.Reverse().ToArray()) };
    }
}

Конечно, здесь есть недочеты. Один из них в автопоиске. Если во время выполнения поиска пользователь изменит запрос, текущий поиск не будет остановлен и сначала завершится он, а потом уже выполнится поиск по новому запросу. Впрочем, исправить это — дело пары строк. Или, скажем, странно не очищать результаты поиска в случае, когда пользователь стер весь запрос. Но такие комплексные примеры оставим на следующие части, а пока ограничимся существующей логикой. Еще раз обращу внимание на то, что в общем-то не самое примитивное поведение нашего поисковика сосредоточено в одном месте и описывается достаточно кратко и понятно.

Посмотрим на XAML:

<Grid DataContext="{Binding ViewModel, ElementName=Window}">
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>

    <StackPanel>
        <Label>Search query:</Label>
        <TextBox Margin="10, 5" Text="{Binding SearchQuery, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
    </StackPanel>
    
    <ListBox Grid.Row="1" ItemsSource="{Binding SearchResult}"/>

    <Grid Grid.Row="2">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"></ColumnDefinition>
            <ColumnDefinition Width="Auto"></ColumnDefinition>
        </Grid.ColumnDefinitions>

        <ProgressBar Margin="10" x:Name="SearchExecutingProgressBar" />
        <StackPanel Orientation="Horizontal" Grid.Column="1">
            <CheckBox VerticalAlignment="Center" IsChecked="{Binding AutoSearch, Mode=TwoWay}">Auto search</CheckBox>
            <Button Margin="10" Command="{Binding Search}">Run</Button>
            <Button Margin="10" Command="{Binding CancelSearch}">Cancel</Button>
        </StackPanel>
    </Grid>
</Grid>

Вопрос здесь может вызвать ProgressBar. Я хотел, чтобы он включался в процессе поиска. Но в команде Search свойство IsExecuting — не bool, а последовательность, и сделать к ней привязку в XAML не выйдет. Поэтому привязку выполним в конструкторе нашей вьюхи:

public partial class MainWindow : Window
{
    public SearchViewModel ViewModel { get; }
    
    public MainWindow()
    {
        ViewModel = new SearchViewModel();
        InitializeComponent();

        this.WhenAnyObservable(w => w.ViewModel.Search.IsExecuting).BindTo(SearchExecutingProgressBar, pb => pb.IsIndeterminate);
    }
}

Да, в ReactiveUI есть поддержка таких вот биндингов, и по идее я мог сделать их все таким образом. Но о биндингах мы поговорим в другой раз, а пока я ограничился только тем, без чего не обойтись.

Посмотрим на результат

Введение в ReactiveUI: изучаем команды - 4

Ура! Работает как надо, интерфейс не виснет во время поиска, отмена работает, автопоиск работает, заказчик в экстазе и дает тройную премию.


In the next episode

Итак, подведем небольшой итог. В этой части мы выяснили, какая связь есть между Task<T> и IObservable<T>. Сравнили горячие и холодные последовательности. Но нашей основной темой были команды. Мы научились создавать синхронные и асинхронные команды и вызывать их разными способами. Выяснили, как включать и выключать возможность их выполнения, а также как перехватывать ошибки в асинхронных командах. Кроме того, мы разобрались, как отменять асинхронные команды.
Я хотел в этой части затронуть тестирование вьюмодели, но как-то не рассчитал, что команды растянутся в такую простыню. Поэтому в этот раз его не будет. В следующей же части мы рассмотрим либо привязки, либо тестирование и планировщики.
Не переключайтесь!

Автор: INC_R

Источник

Поделиться новостью

* - обязательные к заполнению поля