История начинается с этой статьи Мануэля Виво:
ViewModel: One-off event antipatterns
Я рекомендую прочитать статью и особенно комментарии, чтобы полностью понять ситуацию, но я кратко перескажу статью:
Мануэль Виво утверждает, что одноразовые события, которые происходят внутри UI / UI-логики приложения, должны быть представлены как переменные состояния, а не как потоки объектов, которые могут быть обработаны UI-компонентами.
Я оставил комментарий под той статьей, критикуя подход, который предлагает автор, и я перейду к аргументам ниже. С тех пор оригинальная статья получила более 1.5К аплодисментов, а мой комментарий получил более 300 аплодисментов.
Этой статьёй я не хочу унизить Мануэля каким-либо образом или сказать, что он некомпетентен. Вместо этого я хотел бы поговорить о недостатках подхода, предложенного в его посте, чтобы вы, читатели, могли, надеюсь, лучше понять плюсы и минусы каждого подхода и сделать осознанный выбор.
Разбор аргументов
Давайте начнём с пошагового разбора аргументов, представленных в статье.
Exposing events that haven’t been reduced to state from a ViewModel means the ViewModel is not the source of truth for the state derived from those events; Unidirectional Data Flow (UDF) describes the advantages of sending events only to consumers that outlive their producers.
Во-первых, по моему скромному мнению, несколько утверждений здесь можно поправить:
-
Отдавать события из VM не означает, что VM не является источником истины. Я не уверен, что понимаю логику этого утверждения, потому что события всё ещё создаются строго во вьюмодели и не могут быть отправлены извне, при условии, что VM не отдаёт наружу изменяемый channel подписчикам, конечно. Это была бы просто дырявая абстракция в этом случае, и это легко исправить.
-
UDF не упоминает преимущества отправки событий подписчикам, которые переживают производителей. Я не совсем уверен, как был сделан такой вывод. Я думаю, что подписчики, которые переживают производителей, создали бы дырявую абстракцию, потому что теперь UI-компонент (composable, подписчик) каким-то образом должен пережить
ViewModelили singleton MVI-контейнер. Например, делая производителя осведомлённым о существовании жизненного цикла какой-то внешней сущности, зависимость по сути переворачивается, и это приводит к утечке ЖЦ UI в базовый слой бизнес-логики. В приложениях слой бизнес-логики в идеале должен работать независимо от UI и его ЖЦ. Поэтому этот случай, даже если он якобы валидный, не относится к нашему обсуждению, поскольку мы говорим о противоположном направлении потока событий.
Продолжим.
You should handle
ViewModelevents immediately, causing a UI state update. Trying to expose events as an object using other reactive solutions such as Channel or SharedFlow doesn’t guarantee the delivery and processing of the events.
Я согласен, что оба этих API (SharedFlow и Channel) не гарантируют получение событий подписчиком. Однако давайте немного поговорим о том, почему это нормальное и задуманное поведение и почему эту проблему можно решить по-другому и, по моему мнению, более эффективно.
Проблема SharedFlow
Использование SharedFlow как держателя событий состояния неправильно в более чем 95% случаев. Причина в том, что SharedFlow будет игнорировать все события, которые эмитятся, когда нет подписчиков (по умолчанию). Это намеренное поведение API shared flow. Поэтому использование SharedFlow для событий - рискованное дело. Рассмотрим это объяснение того, как работает SharedFlow:
“Если что-то произошло, и есть подписчики, которые хотят знать, я дам им всем знать об этом. Если их нет, то некого уведомлять, и я дропну событие”.
Видите, как эта логика не подходит для одноразовых событий?
-
Подписчики (Composables / UI-компоненты, но по сути просто пользователи) могут уходить и возвращаться в любой момент, и они будут ожидать, что когда они вернутся, событие будет ждать их, чтобы его можно было обработать. Это не имеет смысла,
SharedFlowа приори не может дать такое поведение одноразовых событий. -
Когда есть несколько подписчиков, они все будут ожидать, что только один из них должен получить событие, потому что если событие обработано несколькими подписчиками, то по определению “события” ожидается, что оно будет обработано точно таким же образом во всех подписчиках. Это означает, что, например, не имеет смысла показывать два снекбара с одним и тем же текстом сразу, не имеет смысла отправлять два уведомления с одним и тем же содержимым сразу, и не имеет смысла навигироваться на другой экран в двух местах сразу.
Однако есть валидный юзкейс для этого, когда несколько подписчиков могут обрабатывать одно и то же событие по-разному. Я считаю этот юзкейс нежелательным, однако, поскольку поведение обработки события теперь будет размазано по нескольким местам в коде.
В большинстве случаев есть лучший подход, чем дублировать события. Даже для валидных случаев производитель захочет убедиться, что все подписчики будут присутствовать (чтобы правильно выполнить ожидания), и это условие требует отдельный API, в идеале предоставляемый архитектурным фреймворком, а не клиентским кодом.
Когда события делятся, в большинстве случаев делятся и другие вещи. Дублировать только события за всю свою карьеру разработки приложений мне ни разу в реальности не нужно было. Всякий раз, когда я думал, что столкнулся с таким, мне позже приходилось переписывать этот код, потому что я понимал, что это была архитектурная ошибка по другим причинам, чем пропущенные события.
Проблема отмены Channel
Во-вторых, давайте поговорим о якобы “опасном” случае, когда событие теряется с Channel. Даже каналы, которые гарантируют, что событие будет отправлено, сохранено, а затем эмитнуто подписчикам, не гарантируют, однако, что весь код, который обрабатывал событие, действительно сможет полностью выполниться, когда событие обрабатывается. Это также дизайнерское решение, сделанное командой Kotlin, которое называется “Prompt Cancellation Guarantee”.
Это означает, что хотя корутина, которая обрабатывает событие, всегда будет запущена (гарантируется Channel), нет гарантии, что она не будет отменена из-за принципа Cooperative Cancellation Kotlin Coroutines.
На практике это означает, что примерно в 0.01% запусков выполнения (это простая догадка с моей стороны, просто число, чтобы дать вам общее представление), код, который обрабатывает полученное событие, будет отменён “на полпути”, прежде чем правильно обработать событие. Причина, по которой это число так мало, заключается в том, что для возникновения этой проблемы событие должно быть отправлено в окне примерно 20-50 мс до изменения конфигурации или другого события, которое вызывает отмену подписок на flow. Итак, вот мои аргументы, почему это совершенно нормально:
-
Примерно 0.01% - это настолько невероятно маленькое число, что большинство пользовательских приложений могут просто игнорировать эту проблему и быть в порядке.
-
Эта проблема очень легко решается простым переключением контекста внутри кода обработчика. Что показалось мне странным, так это то, что Мануэль сам создал issue, на которую я ссылался, но затем написал статью, несмотря на то, что получил реально работающее решение в одном из комментариев под issue, не упоминая все другие работающие решения. Я не знаю, почему Мануэль решил просто игнорировать решения, которые были ему даны. Несмотря на то, что на первый взгляд это подвержено ошибкам, хороший архитектурный фреймворк просто сделает переключение контекста за вас.
Со всем этим мы по сути смогли решить проблему, вылечив болезнь, а не симптомы и не меняя весь наш способ мышления для крайне редкого исключения
Опровержение антипаттерна #1: Состояние может быть потеряно
Следующий аргумент, который делает автор:
This is an antipattern because the payment result state modeled in the UI layer isn’tdurableoratomicif we think about it in terms of anACID transaction. The payment may have succeeded as far as the repository is concerned, but we never moved to the proper next screen.
По моему мнению, use-case вроде этого - это архитектурная проблема. Это утверждение предполагает, что полагаться на UI в ответственности за целостность данных - это каким-то образом правильный подход. В настоящем банковском приложении результат транзакции не будет простым делом навигации на страницу.
Результат платежа меняет состояние бэкенд-сервиса и/или базы данных, что приводит к стойкому изменению состояния, которое затем может наблюдаться UI-логическим компонентом (например, ViewModel). В этом случае реактивное приложение, которое следует вышеупомянутым принципам UDF, всегда будет реагировать на изменения в состоянии источника истины (не Viewmodel, а настоящего источника, как база данных бэкенд-сервиса). Когда пользователь возвращается к потоку платежа, должен быть сделан запрос или установлено вебсокет-соединение (например), чтобы проверить, что пользователь не собирается выполнить ту же транзакцию дважды. Например, transaction id вернёт состояние PURCHASED или что-то подобное. В случае, если вам интересно, как это можно реализовать, я снова буду ссылаться на архитектурные фреймворки, чьи обязанности должны включать такие случаи, как наблюдение изменений состояния при повторной подписке.
Я рад, что Мануэль добавил постпродакшн-заметку о переключении диспетчера, которая подчеркнула подход, который я описал выше. В дополнение к этому я хочу сказать, что я не считаю, что…
However, if that’s not enforced by a lint check, this solution could be error-prone as devs could easily forget it.
…по той причине, что эта настройка обычно делается либо архитектурным фреймворком, либо простой функцией, которая может быть переиспользована во всём приложении. Корневая причина проблемы “забывания” снова решается абстрагированием деталей и переиспользованием кода. У нас уже так много вещей, о которых нужно помнить во время разработки, попытка держать архитектурную настройку в одном месте, как библиотека или модуль, - хорошая идея, по моему мнению. Но менять нашу парадигму того, как мы работаем с сайд-эффектами полностью, а затем помнить о том, чтобы их чистить, не масштабируется и не может быть абстрагировано (я пробовал).
Опровержение антипаттерна #2: Говорить UI, какое действие совершить
The ViewModel should tell the UI what the app state is and the UI should determine how to reflect that. The ViewModel shouldn’t tell the UI which actions it should take.
Я согласен, что определение того, как изменить UI приложения, не является ответственностью ViewModel (Container и т.д.). Попытка добавить код, который решает, какое действие предпринять на основе конфигурации привязанной к фреймворку, во вьюмодели - вот где обычно кроется настоящая проблема среди разработчиков.
Например, проблема, изложенная в статье…
For an app that supports multiple screen sizes, the UI action to perform given a ViewModel event might be different depending on the screen size.
…легко решается перемещением кода, ответственного за UI логику, в сам UI, а не его хранением в VM. ViewModel не знает о UI, которым она управляет, и если размер экрана требуется для определения следующего действия, предпринятого UI в ответ на событие, то этот код должен находиться на слое UI самом. Например, я предполагаю, что кто-то мог бы написать код, который навигируется на экран завершения платежа, вот так:
fun performPayment() {
api.completePurchase()
val isTablet = TODO("how to get this here?")
if (isTablet) sideEffect(GoToPaymentCompletion) else sideEffect(ShowSnackbar(R.string.payment_completed))
}
Этот код проблематичен, потому что UI-логика утекла через абстракцию на слой VM. Правильный подход так же прост:
fun performPayment() {
api.completePurchase()
sideEffect(ShowPaymentCompletionResult)
}
@Composable
fun PerformPaymentScreen(nav: Navigator) {
val isTablet = windowSizeClass.isWideScreen
val viewModel by viewModel<PerformPaymentViewModel>()
val snackbarHostState = rememberSnackbarHostState()
viewModel.subscribe { event ->
when(event) {
is ShowPaymentCompletionResult -> if (isTablet) snackbarHostState.showSnackbar() else nav.toPaymentCompletion()
}
}
}
Я знаю. Я сам допускал эту ошибку сначала. Но когда у тебя появляется опыт работы с KMP и MVI, ты вынужден научиться избегать таких проблем естественным образом.
Опровержение антипаттерна #3: Не обрабатывать одноразовое событие сразу
State is , events happen . The longer an event isn’t handled, the harder the problem becomes.For ViewModel events, process the event as soon as possible and generate a new UI state from it.
Так и есть :) - Состояние существует, события происходят.
Но давайте сначала поговорим о второй части. Это утверждение предполагает, что по какой-то причине будет значительная задержка между отправкой события и его обработкой. Я бы поспорил - не часто. Дело в том, что мы делаем большую часть нашей работы уже на Dispatchers.Main.immediate. Это диспетчер по умолчанию, который использует класс ViewModel, и это диспетчер, который мы используем для сбора событий сейчас. Поэтому внутрянка корутин умно распознает, что переключение контекста не требуется, и, согласно документации Dispatchers.Main.immediate, просто отправит, обработает, соберёт, а затем обработает событие немедленно. Это буквально то, что означает слово “immediate”. Этот диспетчер был сделан специально для этой цели - быть немедленным.
В любом случае, даже если я неправ здесь, я не считаю аргумент о выжимании нескольких миллисекунд, необходимых для создания и передачи объекта, чтобы отправить его в UI, стоящим. Всё это произойдёт менее чем за один кадр (8 мс) на большинстве устройств. Даже если нет, пользователь никогда не заметит задержку, пока не пройдёт от 50 до 70 мс. Это пример преждевременной оптимизации, по моему мнению. Если что-то работает не так быстро, как должно (что редко для простых UI-событий), я обычно сначала начинаю смотреть на клиентский код, а не на детали реализации фреймворка.
Заключительные мысли
В заключение статьи давайте посмотрим на эту малюсенькую заметочку в конце статьи (автор старался чтобы не заметили?):
Note: If in your use case the Activity doesn’t
finish()and is kept in the backstack, your ViewModel would need to expose a function to clear thepaymentResultfrom the UiState (i.e. setting the field to_null) that will be called after the Activity starts the other one. An example of this can be found in the Consuming events can trigger state updates section of the docs.
По моему мнению, ЭТО - самая большая проблема с подходом. Помните, как мы говорили, что события “происходят”? Когда события представлены через состояния, они теперь каким-то образом “существуют” (состояние). Я категорически не согласен.
Для меня лично важнее, чем следить за редкими проблемами - то, что код, который мы пишем, должен быть понятным, интуитивным и простым. Рассмотрим реальный прикладной пример того, как вы как человек и как ваше устройство работают.
Допустим, вы получаете уведомление. Но является ли уведомление “состоянием”? В этом случае, когда мы отправляем уведомление, какое “состояние” было изменено? Какое состояние имеет теперь уведомление? Должны ли мы реагировать на что-то, что уже произошло несколько раз? Оно было отправлено в операционную систему, его нигде не найти в коде нашего приложения. И так и должно быть. Потому что наш мозг предполагает, что так работает реальный мир и как мы предполагаем, что наш код должен работать. Я думаю, что большинство людей согласятся, что в реальном мире есть вещи, которые происходят, и есть вещи, которые “существуют”. Тогда почему в коде программного обеспечения не должно быть знакомых и узнаваемых, естественных концепций, представляющих определённые вещи в реальном мире? Почему мы обменяли бремя изменения диспетчера один раз на бремя запоминания о том, чтобы чистить состояние везде ?
На протяжении моего опыта практика представления событий как состояния часто играла против нашей команды. В командах, с которыми я работаю, когда люди пытаются представить события как состояние, происходят две вещи:
-
Количество свойств состояния раздувается, захламляясь десятками полей, которые по сути ничего не значат и являются простыми флагами, чтобы показать, что что-то произошло, которые нужно чистить снова и снова для каждого флага, который есть. Состояние становится всё труднее и труднее понять, поддерживать, и в конце концов целостность состояния разваливается. Это происходит просто потому, что члены команды с трудом понимают, как событие может быть представлено как “состояние”, и что это состояние теперь нужно отслеживать. Человеческий мозг способен естественно различать события и состояния, и противоречить этому понятию - значит позволить разработчикам делать очевидные ошибки в логике приложения.
-
Рано или поздно начинают появляться баги. Десятки функций, которые устанавливают boolean в true, а затем в false, выходят из-под контроля, и затем один из разработчиков просто забывает вызвать функцию, чтобы “очистить результат платежа”, что приводит, например, к ужасному багу с бесконечным циклом навигации, который отправляет пользователя обратно на страницу успеха транзакции снова и снова, блокируя его в приложении навсегда. Это отражается в не в крашлитике, а в отзывах на одну звезду, что является худшим сценарием для приложения, по моему мнению.
Всё вышеперечисленное было одними из причин, по которым наша команда мигрировала с подхода “события как состояния”, и не могла нарадоваться стабильности, поддерживаемости и производительности нашего приложения с тех пор.