– Хей, Катя, у нас там багуля небольшая завелась. Посмотри, плиз.
– Не вопрос, бро. В чем проблема?
– Toggle сбрасывается при возврате на экран. Изи, ваще.
С этой безобидной фразы началось мое недельное приключение в мир безумной архитектуры, сумасшедших фиксов и красноглазия. И это была ловушка.
Всем привет, меня зовут Катя, я – Android-разработчик компании SimbirSoft, и я помогаю улучшать продукт в hh.ru. В статье расскажу историю о том, как разработчики сразу двух компаний, техлид Android и даже Head of Mobile писали минимальную фичу на MVI с тоглом, и всё равно упустили баг после долгих часов проектирования. Разберемся, на что идут программисты ради хорошего UX, почему первоначальное решение было неверным, и как это можно исправить.
Это текстовая расшифровка выпуска нашего влога, поэтому если вам удобнее смотреть, а не читать, добро пожаловать на наш Youtube-канал.
Поиск проблемы на дне океана
Поскольку экран был реализован другим разработчиком, первое, что я сделала – внимательно его рассмотрела. У экрана было три состояния: загрузка, ошибка и контент.
Состояния экрана
И контентом был тот самый злосчастный переключатель. При нажатии на переключатель происходил запрос на сервер – мы посылали новое значение флага настроек.
Важное условие: мы должны позволить пользователю сколько угодно раз переключать этот toggle. И при этом не показывать никаких прелоадеров.
Изначально передо мной стояла лишь одна небольшая задача: необходимо было поправить сохранение состояния переключателя. При медленном интернете юзер мог успеть выйти с экрана до окончания запроса. Из-за этого состояние переключателя сбрасывалось к первоначальному значению.
Как это выглядело?
В чём был баг? Если пользователь переключал toggle при медленном интернете, то могло случиться следующее. Юзер заходит на экран, нажимает на toggle, переключает его в состояние “unchecked”. После этого он выходит с экрана, возвращается и видит, что toggle в состоянии “checked”. Непорядок.
Схема реализации экрана
Наша фича – это черный ящик, скрывающий внутри себя основную логику экрана. Он общается с репозиторием, который отправляет запросы на сервер. Репозиторий еще и хранит кэш.
По результату общения нашей фичи с репозиторием, мы получаем State. Изначально он разделялся на два поля. Первое поле представляло собой закэшированное состояние. Оно соответствовало состоянию флага на сервере. Второе поле – UI-состояние, отвечало за то, что мы отобразим пользователю.
Важно! Все примеры кода – только примеры, не тащите это в прод, оно ненастоящее!
data class State(
val uiState: SettingsState,
val cachedState: SettingsState
)
data class SettingsState(
val isEnabled: Boolean,
)
В нашем случае оба состояния были сильно связаны между собой: оба хранились в рамках одной модели, оба загружались при заходе на этот экран. И единственным их отличием было условие, по которому мы меняем значение. UI-состояние меняется всегда, когда юзер нажимает на toggle. А вот закэшированное состояние изменяется только тогда, когда мы получили успешный ответ от сервера. И, так как при старте этого экрана оба значения в фиче затирались, мы не могли восстановить UI-состояние, чтобы показать его пользователю. Именно в этом и таилась проблема.
Варианты решения проблемы
Первое решение, которое пришло нам в голову – максимально разделить эти два состояния. В таком случае мы могли бы спокойно обновлять закэшированное состояние при заходе на экран, а UI-состояние хранить столько, сколько потребуется.
Но при проверке выяснилось, что баг-то у нас не один, ведь юзер может переключать toggle сколько угодно раз. И при этом мы не можем гарантировать, что запросы выполнятся последовательно. Таким образом результаты запросов могут прийти в неожиданном порядке. Из-за этого мы можем отобразить пользователю не ту информацию. Это неправильно.
Переключаем toggle много-много раз!
Мы попробовали исправить это остановкой получения результатов от предыдущего запроса, когда мы уже отправляем новый. Я могу предложить вам несколько вариантов реализации такой остановки.
Шина событий
Первый – использовать шину событий. Мы отправляем в шину событие о том, что мы хотим прервать обработку данных, и ловим это событие внутри нашей цепочки обработки запроса. В таком случае события дальше обрабатываться не будут.
fun loadSomeRequest() {
interruptSignal.onNext(Unit)
someRequest
.takeUntil(interruptSignal)
.subscribe({
handleResult()
}, {
handleError()
})
}
В рамках этого решения мы использовали Rx-овый Subject и оператор takeUntil. В таком случае перед отправкой нового запроса мы посылаем в Subject события о том, что мы хотим прервать обработку предыдущего запроса. Внутри цепочки обработки запроса используем takeUntil, оператор, прерывающий дальнейшее выполнение Rx-цепочки, если из указанного источника приходят данные. Таким образом, если мы получили события от Subject, то все последующие шаги вызываться не будут. Это рабочее решение, мы использовали его в нашей фиче для пагинации.
Хранение номера версии списка
Суть метода заключается в следующем: давайте введём специальный AtomicInteger, который будет хранить глобальную версию данных списка, и локальную переменную версии, которая будет сравниваться с глобальной после выполнения очередного запроса.
fun reloadSomeRequest() {
val listVersion = currentListVersion + 1
currentListVersion = listVersion
someRequest
.subscribe({
if (currentListVersion == listVersion) {
handleResult()
}
}, {
if (currentListVersion == listVersion) {
handleError()
}
})
}
Когда мы осуществляем перезагрузку списка, мы инкрементируем глобальный номер версии данных в списке и запоминаем обновлённое значение этой версии. После того, как мы получили данные от запроса, мы можем сверить глобальную версию с сохранённой локальной версией после запроса, результаты от которого мы получили.
Если версии совпадают, значит можно спокойно обрабатывать результаты, если нет –просто пропускаем обработку.
Оба предложенных варианта по-своему хороши. В случае с шиной событий мы не будем обрабатывать дальнейшую цепочку данных от запроса. А в ситуации с сохранением номера версии списка мы будем точно знать, что произошла перезагрузка, и какая она была по счету. Но ни одно из этих решений нам не подошло.
А что не так-то?
Во-первых, сложность понимания происходящего. Человека, который не сталкивался с проблемой “лишних запросов” или же впервые видит экран, такая реализация может поставить в тупик.
Во-вторых, хранение в репозитории дополнительного флага, что идет загрузка. Появляется лишний источник правды, который нуждается в согласовании.
В-третьих, хранение кэша и в репозитории, и в фиче. Опять же – несколько источников правды, рано или поздно это приведёт к их рассинхрону.
И, наконец, в-четвертых, лишние запросы всё равно будут отправляться, несмотря на использование варианта с шиной событий или хранением номера версии списка.
Исправление ошибки
Как обычно, решение оказалось довольно простым. Пусть фича сама управляет своим кэшем! Это позволит нам избавиться от кэша в репозитории и оставить его только в фиче, следовательно, получим единственный источник правды о данных для экрана. Получается, что в данной реализации state-а у нас теперь три поля: закэшированное состояние, UI-состояние и флаг прогресса.
Итак, пользователь пришел на экран, нам нужно что-то для него отобразить. Загрузка данных с сервера произойдет только тогда, когда данных в кэше нет или же данные уже не валидны. Валидация данных может быть устроена как угодно, мы используем валидацию кэша по дате.
Схема загрузки данных
В момент переключения toggle юзером, мы сохраняем его значение в State и проверяем, идет ли отправка запроса. Если на текущий момент запрос не отправляется, мы отправляем запрос с новым значением toggle-а.
Когда запрос был успешно завершен, мы сохраняем данные в State в поле закэшированного состояния флага. После этого надо проверить, соответствует ли UIState закэшированному состоянию. Если они отличаются, мы снова отправляем запрос на сервер.
При такой схеме у нас и кэш остается консистентным, и количество запросов остается минимальным. Профит.
Реализация
Так мы определились с общей схемой. Настало время реализации. Мы использовали библиотеку MVICore от Badoo. MVICore – это библиотека для реализации паттерна MVI в Android-приложениях.
Юзер изменил значение переключателя, мы записываем новое значение в UIState. После этого проверяем, можем ли мы отправить запрос. Это можно сделать в сущности, которая называется Actor.
Actor скрывает в себе основную логику работы фичи. Например, он может отправить запросы на сервер или делегировать события об изменении State.
class ActorImpl : Actor<State, Wish, Effect> {
override fun invoke(state: State, wish: Wish): Observable<out Effect> {
return when (wish) {
is Wish.Toggle -> {
if (canSendRequest) {
updateValue()
}
else {
saveValue()
}
}
}
}
}
Поняли, что можем отправить запрос, и отправляем его. После его успешного завершения, мы сохраняем новое значение флага в State.
После обновления State-а, мы можем отследить его изменение в такой сущности, которая называется PostProcessor. Внутри него мы проверяем State, UIState и закэшированный State. Если они не равны, мы повторяем всю цепочку проверок-запросов-обновления заново.
class ProcessorImpl : PostProcessor<Wish, Effect, State> {
override fun invoke(action: Wish, effect: Effect, state: State): Wish? {
return when (effect) {
is Effect.Success -> {
if (needSendAnotherRequest) {
Wish.Toggle(newValue)
} else {
null
}
}
}
}
}
По итогу такой схемы пользователь спокойно переключает toggle, а мы минимизируем количество запросов. Вот и всё.
Впрочем, не совсем
Но есть и проколы в этой схеме. Вскоре после реализации мы поймали еще один небольшой баг: при разлогине пользователя мы продолжали пытаться отправить запрос на сервер, хотя неавторизованный юзер не может получить данный флаг. В закешированном состоянии пропадало значение флага, и фича начинала бесконечно “стучаться” на сервер за обновлениями.
Но исправить это было довольно просто: необходимо было сохранить в закэшированное состояние значение «по умолчанию».
Охота удалась
Ура, мы пофиксили баг, но какой ценой! Было потрачено огромное количество времени, но этот опыт кое-чему нас научил:
Во-первых, перед тем как браться за разработку какой-либо фичи, нужно очень внимательно посмотреть на нее и составить примерную схему дальнейших действий. В таком случае мы сможем правильно, например, спроектировать State или избежать каких-либо других проблем.
Во-вторых, чтобы наши коллеги не тратили много времени на похожие кейсы, мы решили создать специальный архитектурный cookbook по MVI, первый кейс из которого мы только что разобрали с вами. Имея под рукой шаблонные решения, гораздо проще не допустить подобных багов.
Пишите в комментариях, как вам предложенное решение и как бы эту проблему решили вы. В будущем мы собираемся разбирать еще истории из нашей практики.
Всем продуктивной разработки!
P.S.
И, казалось бы, всё, история рассказана, влог отснят, статья на Хабр написана, но…
Всё можно было сделать проще =)