Введение в ReactiveUI: изучаем команды
Часть 2: Введение в ReactiveUI: коллекции
Мы уже обсудили возможности ReactiveUI, связанные с работой со свойствами, выстраиванием зависимостей между ними, а также с работой с коллекциями. Это одни из основных примитивов, на базе которых строится разработка с применением ReactiveUI. Еще одним таким примитивом являются команды, которые мы и рассмотрим в этой части. Команды инкапсулируют действия, которые производятся в ответ на некоторое событие: обычно это запрос пользователя или какие-то отслеживаемые изменения. Мы узнаем, что можно сделать с помощью команд в ReactiveUI, обсудим особенности их работы и выясним, чем команды в ReactiveUI отличаются от команд, с которыми мы знакомы по WPF и его родственникам.
Но прежде чем перейти к командам, рассмотрим более широкие темы, касающиеся реактивного программирования в целом: связь между Task
Task vs. IObservable
Итак, проведем параллель между Task
Звучит как-то подозрительно, не правда ли? Давайте разбираться. Сразу посмотрим пример:
Task 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
IObservable 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
Посмотрим еще один известный прием с запуском действия после завершения задачи:
//Task
task.ContinueWith(t => Console.WriteLine(DateTime.Now.ToLongTimeString() + " Полученный результат: " + t.Result));
//IObservable
task.Subscribe(t => Console.WriteLine(DateTime.Now.ToLongTimeString() + " Полученный результат: " + t));
Разницы, опять же, практически нет.
Получается, Task
Горячие и холодные последовательности
Обсудим еще один вопрос, касающийся работы с наблюдаемыми последовательностями (observable). Они могут быть двух типов: холодные (cold) и горячие (hot). Холодные последовательности пассивны и начинают генерировать уведомления по запросу, в момент подписки на них. Горячие же последовательности активны и не зависят от того, подписан ли на них кто-то: уведомления все равно генерируются, просто иногда они уходят в пустоту.
Тики таймера, события движения мыши, приходящие по сети запросы — это горячие последовательности. Подписавшись на них в некоторый момент, мы начнем получать актуальные уведомления. Подпишутся 10 наблюдателей — уведомления будут доставляться каждому. Вот пример с таймером:
Холодная же последовательность — это, например, запрос в базу данных или чтение файла построчно. Запрос или чтение запускается при подписке, и с каждой новой полученной строкой вызывается OnNext (). Когда строки закончатся, вызовется OnComplete (). При повторной подписке все повторяется снова: новый запрос в БД или открытие файла, возврат всех полученных результатов, сигнал завершения:
Классические команды…
Теперь перейдем к нашей сегодняшней теме — к командам. Команды, как я уже упомянул, инкапсулируют действия, совершаемые в ответ на некоторые события. Таким событием может быть нажатие пользователем кнопки «Сохранить»; тогда инкапсулируемым действием станет операция сохранения чего-то. Но команда может быть исполнена не только в ответ на явное действие пользователя или связанные с ним косвенные события. Сигнал от таймера, срабатывающего раз в 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 () возвращает управление только после завершения выполнения команды. Когда команда выполняется долго — пользователь расстраивается, потому что он сталкивается с зависшим интерфейсом.
Прогресс-бар останавливается, лог обновляется только по завершении команды, интерфейс зависает. Нехорошо получилось…
Как спасти ситуацию? Конечно, можно реализовать команду так, чтобы она только инициировала выполнение нужных действий в другом потоке и возвращала управление. Но тогда возникает другая проблема: пользователь может нажать на кнопку еще раз и запустить команду снова, еще до завершения предыдущей. Усложним реализацию: сделаем так, чтобы CanExecute возвращал false, пока задача выполняется. Интерфейс не зависнет, команда не запустится параллельно несколько раз, мы добились своего. Но все это нужно делать своими руками. А в ReactiveUI команды уже умеют все это и многое другое.
Реактивные команды
Познакомимся с ReactiveCommand
Сразу попробуем создать и запустить команду, опустив для начала все, что связано с 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
Комментарии (2)
11 июля 2016 в 22:39
+1↑
↓
Наконец-то дождался статьи на тему команд в ReactiveUI, а то не так уж и много информации о них можно найти в сети (особенно учитывая куцую документацию). У синхронных Reactive-команд есть особенность, о которой в статье упомянуто, но не слишком широко. Речь про «глотание» ошибок, выброшенных в подписчике. В такой ситуации в приложении не происходит ровным счетом ничего, только кнопка перестает реагировать на команду, так как команда завершилась с ошибкой. Максимум что можно увидеть — скромную строчку Exception thrown: 'System.Exception' in ReactiveUI.Test.exe в логе при запуске с дебагом.
В итоге в подписчике синхронных команд крайне опасно вызывать методы, которые могут выкинуть исключения. Любое проскочившее необработанное исключение просто «убьет» команду, не выдав никакого уведомления пользователю, а именно не скрашив приложение в соответствии с принципом «Fail fast».
А ведь исключение может быть выброшено где угодно, в том числе и в методе, который был вызван в методе, который был вызван в методе →… → который был вызван в подписчике. Получается надо либо как параноику обертывать код подписчика в try-catch с самим Exception в catch-блоке чтобы иметь возможность хотя бы уведомить об ошибке, либо использовать синхронные ReactiveCommand для самых-самых простых вешей, которые уж точно не выкинут исключений.
Иногда из-за этой особенности приходилось эмулировать асинхронность при вызове быстрого синхронного кода:CommandDoSomething = ReactiveCommand.CreateAsynctTask(() => { Код, который может выкинуть коленце }); CommandDoSomething.ThrownExceptions.Subscribe(...логирование либо уведомление...); CommandDoSomething.Subscribe();
Иначе все возможные дефолтные проверки, такие как ArgumentNullException, IndexOutOfRangeException и прочие, при вызове методов из подписчика оказываются бесполезными.
12 июля 2016 в 01:13
0↑
↓
Спасибо за развернутый комментарий! А вы не могли бы привести пример кода, в котором в ответ на исключение при вызове команда окончательно подыхает? В том примере, который есть в статье, код подписчика в Subscribe () бросает исключение, но повторный вызов Execute () возможен и CanExecute () возвращает true.Реактивные команды реализованы так, что в команде как в IObservable не предполагается возможность возникновения OnError (), потому что тогда после первой же ошибки команда отваливалась бы и нужно было бы создавать ее снова. Каждый запуск команды внутри должен создавать новый IObservable, результат которого при успехе прилетает через саму команду. Если же что-то упало, то исключение либо проглотится (самый неприятный вариант), либо вылетит при вызове метода запуска команды, либо придет через ThrownExceptions. Сама же команда не должна пострадать.
Как ловить исключения в синхронных командах — хороший вопрос. По идее их просто не должно быть. Вот тут создатель фреймворка говорит, что сложный код и код, который бросает исключения — это не для синхронных команд и Subscribe ().
In ReactiveUI, you should never put Interesting™ code inside the Subscribe block — Subscribe is solely to log the result of operations, or to wire up properties to other properties.
По-хорошему стоит использовать асинхронные команды для всего сколько-нибудь сложного и/или потенциально падающего. Для синхронных команд же можно попробовать сделать обертку над ReactiveCommand и реализовать Execute по-своему, чтобы каждый раз не оборачивать код в обработчик исключений, и команды громко падали в случае чего. Но я думаю, что в первую очередь стоит стремиться к тому, чтобы подписчики вообще не могли упасть.