ViewModel. События как состояние  -  это антипаттерн

Обсудим актуальную тему сообщества разработчиков Kotlin  —  антипаттерны одноразовых событий.

Все началось с этой статьи Мануэля Виво.

Прочитайте ее с комментариями, чтобы полностью понять ситуацию. Вот вкратце суть статьи:

Мануэль Виво утверждает, что одноразовые события внутри слоя пользовательского интерфейса (далее  —  ПИ) приложения/слоя логики ПИ приложения должны выражаться переменными состояния, а не потоками объектов, получаемых компонентами ПИ.

Я оставил комментарий с критикой и позже приведу свои аргументы. С тех пор у статьи набралось более 1700 хлопков, у комментария  —  более 300.

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

Сначала разберем аргументацию статьи.

Предоставление событий, которые не сведены к состоянию из ViewModel, означает, что ViewModel  —  не источник истины для состояния, полученного из этих событий. Однонаправленным потоком данных описываются преимущества отправки событий только получателям, которые «переживают» отправителей.

По моему скромному мнению, кое-что здесь стоит поправить:

  1. Предоставление событий из виртуальной машины не означает, что виртуальная машина  —  не источник истины. Не уверен, что вообще следую логике этого утверждения, ведь события все равно отправляются строго из ViewModel, а не извне. Если, конечно, виртуальной машиной получателям не предоставляется базового изменяемого канала. В этом случае была бы простая дырявая абстракция с простым решением.
  2. Однонаправленный поток данных не касается преимуществ отправки событий получателям, которые «переживают» отправителей. Не совсем уверен, как к этому пришли. Думаю, что получателями, которые «переживают» отправителей, создается дырявая абстракция, потому что теперь компонент ПИ  —  composable, получатель  —  как-то должен «пережить» ViewModel или MVI-контейнер синглтона. Например, сообщая отправителю о существовании жизненного цикла некой внешней сущности. Соотношение фактически перевернулось, что чревато утечкой жизненного цикла ПИ в базовый слой бизнес-логики. В приложениях слой бизнес-логики в идеале независим от ПИ и его жизненного цикла. Таким образом, этот случай  —  даже если и допустимый  —  не связан с нашим обсуждением, поскольку мы говорим о противоположном направлении потока событий.

Идем дальше.

Чтобы обновлять состояние ПИ, события ViewModel следует обрабатывать немедленно.Попыткой предоставить события в виде объекта, используя другие реактивные решения вроде Channel или SharedFlow, доставка и обработка событий не гарантируется.

Согласен, что этими API  —  SharedFlow и Channel  —  не гарантируется получение событий получателем. Но почему это поведение нормальное и желаемое и почему эту проблему можно решить по-другому и другими способами?

Во-первых, использование SharedFlow как хранилища события-состояния некорректно, по моему опыту, более чем в 95 % случаев. Причина: в SharedFlow игнорируются все события, выдаваемые по умолчанию при отсутствии подписчиков. Это целенаправленное поведение API общего потока. Поэтому применять SharedFlow для событий  —  рискованно.

Рассмотрим такое объяснение функционирования SharedFlow:

«Если что-то произошло и имеются получатели, которые желают знать, я сообщу им всем об этом. Если их нет, то уведомлять некого, я удалю событие».

Но эта логика не подходит для одноразовых событий:

  1. Получатели  —  Composables/компоненты ПИ, а, по сути, просто пользователи  —  могут уйти и в любой момент вернуться, надеясь по возвращении увидеть готовое произойти событие. Это не имеет смысла как допустимый вариант применения SharedFlow для одноразовых событий.
  2. Когда подписчиков несколько, все они ожидают, что только один из них получит событие. Ведь если событие получается несколькими получателями, то, по определению «события», ожидается точно такая же его обработка у всех подписчиков. То есть, например, нет смысла показывать сразу два виждета snackbar с одинаковым текстом, отправлять сразу два уведомления с одинаковым содержимым и переходить на другой экран сразу в двух местах.

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

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

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

Во-вторых, имеется предположительно «опасный» случай, когда событие теряется вместе с Channel. Даже теми каналами, которыми гарантируются отправка, сохранение и затем передача события подписчикам, не гарантируется полного выполнения всего кода при обработке события, которое этим кодом получалось. Это тоже проектное решение команды Kotlin, так называемая «гарантия быстрой отмены».

Его суть: хотя корутина, которой обрабатывается событие, всегда запустится  —  это гарантируется каналом Channel  —  нет гарантии, что она не отменится из-за принципа совместной отмены корутин Kotlin.

На практике это означает, что в 0,0001% прогонов выполнения  —  число взято из головы просто для понимания, о каком порядке цифр идет речь  —  код, не добравшись до корректной обработки полученного события, «на полпути» отменится.

Число такое маленькое из-за того, что для возникновения этой проблемы событие должно отправляться в интервале примерно 20–50 мс перед изменением конфигурации или другим событием, чреватым отменой подписок на поток.

Это совершенно нормально. И вот мои аргументы:

  1. Число 0,0001% настолько мало, что большинством приложений получателей эта проблема просто проигнорируется.
  2. Устраняется она простым переключением контекста внутри кода обработчика. Странно, что Мануэль сам создал тот вопрос, ссылка на который приведена выше, а затем написал статью, хотя в одном из комментариев получил фактически рабочее решение. Проигнорировал он и все остальные рабочие решения. Несмотря на кажущуюся подверженность ошибкам, переключение контекста легко выполняется хорошим архитектурным фреймворком.

Так нам, по сути, удалось смягчить последствия проблемы, устранив ее первопричину и не прибегая к обходным вариантам.

Разоблачение антипаттерна № 1: состояние завершения платежа теряется

Вот следующий аргумент автора статьи:

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

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

В реальном банковском приложении результат транзакции  —  не простой переход на страницу. Результатом платежа изменяется состояние серверной службы и/или базы данных, что чревато постоянным изменением состояния, наблюдаемым затем в логическом компоненте ПИ, например ViewModel.

В этом случае реактивное приложение, в котором соблюдаются принципы вышеупомянутого однонаправленного потока данных, всегда реагирует на изменения состояния источника истины  —  не Viewmodel, а настоящего источника вроде базы данных серверной службы.

Чтобы при возвращении пользователя к потоку платежей одна и та же транзакция не выполнялась дважды, необходимо выполнить запрос или подключение к веб-сокету. Например, идентификатором транзакции вернется состояние PURCHASED или что-то подобное.

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

Рад, что Мануэль добавил постпродакшн-заметку о диспетчерах переключения контекста с использованием описанного мной выше подхода. А еще я не согласен с тем, что…

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

…потому что эта настройка, опять же, выполняется обычно архитектурным фреймворком либо простой функцией, используемой во всем приложении.

Первопричина проблемы «забывания» снова устраняется абстрагированием деталей и переиспользованием кода. Во время разработки столько всего нужно учитывать! Неплохо было бы хранить архитектурную настройку в одном месте, например в библиотеке или модуле.

Разоблачение антипаттерна № 2: указание на выполнение действия в ПИ

Из ViewModel в ПИ сообщается о состоянии приложения, а в ПИ определяется, как это отразить. Из ViewModel не сообщается, какие действия должны выполняться в ПИ.

Согласен, что определение того, как изменить ПИ приложения, не является задачей ViewModel, контейнера и т. д. Попытка добавить во ViewModel код и определить им выполняемое на основе привязанной к ПИ конфигурации действие  —  вот где обычно подстерегает разработчиков реальная проблема.

Например, проблема, обозначенная в статье…

Для приложения с поддержкой нескольких размеров экрана действие, выполняемое в ПИ с учетом события ViewModel, в зависимости от размера экрана может отличаться.

…легко решается помещением кода, ответственного за привязанную к ПИ логику, в сам ПИ вместо сохранения его в виртуальной машине. ViewModel не «знает» об управляемом им ПИ, и если для определения следующего выполняемого в ПИ в ответ на событие действия требуется размер экрана, то этот код перемещается в сам слой ПИ.

Например, напишем такой код для перехода к экрану завершения платежа:

// ViewModel
fun performPayment() {
api.completePurchase()
val isTablet = TODO("how to get this here?")
if (isTablet) sideEffect(GoToPaymentCompletion) else sideEfect(ShowSnackbar(R.string.payment_completed))
}

Это проблематичный код, поскольку логика ПИ просачивается через абстракцию в слой виртуальной машины.

Корректный подход очень прост:

// ViewModel
fun performPayment() {
api.completePurchase()
sideEffect(ShowPaymentCompletionResult)
}
// Composable
@Composable
fun PerformPaymentScreen(nav: Navigator) {
val isTablet = windowSizeClass.isWideScreen
val viewModel by viewModel<PerformPaymentViewModel>()
val snackbarHostState = rememberSnackbarHostState()
viewModel.subscribe { event -> // где-то здесь выполнено абстрагирование переключения «Dispatchers.Main.immediate» ✅
when(event) {
is ShowPaymentCompletionResult -> if (isTablet) snackbarHostState.showSnackbar(/* ... */) else nav.toPaymentCompletion()
}
}
}

Знаю, раньше я сам совершал эту ошибку. Но с опытом работы в KMP и MVI волей-неволей научаешься естественным образом избегать подобных проблем.

Разоблачение антипаттерна № 3: отсутствие немедленной обработки одноразового события

Состояние существует, события происходят. Чем дольше событие не обрабатывается, тем сложнее становится проблема. Обрабатывайте событие ViewModel как можно скорее и генерируйте из него новое состояние ПИ.

Да, совершенно верно. Состояние существует, события происходят.

Сначала поговорим о второй части. В этом утверждении предполагается, что по какой-то причине между отправкой и получением события происходит значительная задержка. Я бы поспорил  —  происходит не часто.

Дело в том, что большая часть работы уже выполнена в Dispatchers.Main.immediate. Это диспетчер по умолчанию класса ViewModel, используемый сейчас нами для сбора событий. Фреймворком корутин быстро распознается, что переключение контекста не требуется, и согласно документации Dispatchers.Main.immediate событие просто отправляется, подвергается манипуляциям, собирается и немедленно обрабатывается. Именно так  —  немедленно. Диспетчер специально предназначен для этой цели  —  быть немедленным.

В любом случае, даже если я здесь некорректен, не думаю, что аргумент о выжимании нескольких миллисекунд, необходимых для создания, передачи и отправки объекта в ПИ, является стоящим. На большинстве устройств все это происходит менее чем за один кадр, или 8 мс. А даже если и больше, пользователь не заметит задержки, пока не пройдет 50–70 мс.

Это пример преждевременной оптимизации. Если что-то происходит не так быстро, как должно  —  что редкость для простых событий ПИ,  —  я обычно сразу смотрю на клиентский код, а не детали реализации фреймворка.

Заключение

Обращу ваше внимание также на небольшое примечание в конце приведенной выше статьи:

Если в вашем случае применением finish() Activity не завершается и сохраняется в стеке, нужно указать во ViewModel функцию для очистки paymentResult из UIState, то есть задать полю значение null, она вызовется после запуска этим Activity другого paymentResult. Пример этого содержится в разделе документации Consuming events can trigger state updates («Получение событий чревато обновлениями состояния»).

Это, на мой взгляд, самая большая проблема такого подхода. Помните: события происходят. События, представляемые состояниями, уже каким-то образом «являются» состоянием.

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

Рассмотрим реальный пример взаимодействия с устройством  —  получение уведомления. Является ли уведомление «состоянием»? И если да, какое «состояние» при его отправке было изменено? Каким состояние уведомления стало сейчас? Следует ли реагировать на то, что уже происходило несколько раз? Оно отправлено в операционную систему, нигде не найдено в коде приложения.

Так должно оставаться и дальше. Потому что именно так мозгом воспринимается реальный мир, так воспринимается работа кода. Думаю, большинство людей согласятся с тем, что в реальном мире что-то происходит, а что-то «существует». Тогда почему в программном коде не должно быть знакомых и узнаваемых естественных концепций, представляющих определенные вещи реального мира?

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

  1. Свойства состояния неестественно разрастаются, накапливаются десятки фактически ничего не значащих свойств  —  простых флагов для обозначения чего-то, что произошло и что нужно снова и снова очищать, и так для каждого имеющегося флага. Состояние становится все труднее понимать, сопровождать, в итоге оно утрачивает согласованность. Это происходит просто потому, что команда не понимает, как представить событие «состоянием» и что теперь это состояние нужно отслеживать. Человеческий мозг способен естественным образом различать события и состояния. Идти против этого  —  значит позволять разработчикам допускать очевидные ошибки в логике приложения.
  2. Рано или поздно появляются ошибки. Десятки функций, логическому значению которых задается true, а затем false, выходят из-под контроля, и вскоре один из разработчиков просто забывает вызвать функцию для «очистки результата платежа», что приводит, например, к очень серьезному багу с циклом с бесконечными переходами, которым пользователь снова и снова отправляется на страницу успеха транзакции, причем доступ к приложению для него навсегда блокируется. Этим ошибкам место не на Crashlytics или аналитических сервисах, а скорее в пользовательских отзывах с одной звездой  —  наихудшем сценарии для приложения.

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи ProAndroidDev: ViewModel: Events as State are an Antipattern

Предыдущая статьяОбзор итераторов в Go
Следующая статьяРаскройте возможности генераторов PHP