– Хей, Катя, у нас там багуля небольшая завелась. Посмотри, плиз.
– Не вопрос, бро. В чем проблема?
– Toggle сбрасывается при возврате на экран. Изи, ваще.
С этой безобидной фразы началось мое недельное приключение в мир безумной архитектуры, сумасшедших фиксов и красноглазия. И это была ловушка.
Всем привет, меня зовут Катя, я – Android-разработчик компании SimbirSoft, и я помогаю улучшать продукт в hh.ru. В статье расскажу историю о том, как разработчики сразу двух компаний, техлид Android и даже Head of Mobile писали минимальную фичу на MVI с тоглом, и всё равно упустили баг после долгих часов проектирования. Разберемся, на что идут программисты ради хорошего UX, почему первоначальное решение было неверным, и как это можно исправить.
Это текстовая расшифровка выпуска нашего влога, поэтому если вам удобнее смотреть, а не читать, добро пожаловать на наш Youtube-канал.
![](https://habrastorage.org/getpro/habr/upload_files/ac8/23e/e14/ac823ee144ebc6edbe73900f4c0956f3.jpeg)
Поиск проблемы на дне океана
Поскольку экран был реализован другим разработчиком, первое, что я сделала – внимательно его рассмотрела. У экрана было три состояния: загрузка, ошибка и контент.
Состояния экрана
![Состояния экрана - загрузка, ошибка и контент Состояния экрана - загрузка, ошибка и контент](https://habrastorage.org/getpro/habr/upload_files/879/76d/751/87976d751599cd1edc134992a15389cd.png)
И контентом был тот самый злосчастный переключатель. При нажатии на переключатель происходил запрос на сервер – мы посылали новое значение флага настроек.
Важное условие: мы должны позволить пользователю сколько угодно раз переключать этот toggle. И при этом не показывать никаких прелоадеров.
Изначально передо мной стояла лишь одна небольшая задача: необходимо было поправить сохранение состояния переключателя. При медленном интернете юзер мог успеть выйти с экрана до окончания запроса. Из-за этого состояние переключателя сбрасывалось к первоначальному значению.
Как это выглядело?
![Сбрасывание toggle при возврате на экран Сбрасывание toggle при возврате на экран](https://habrastorage.org/getpro/habr/upload_files/dde/95f/6b9/dde95f6b9a3a01aeb22798eb90f9c58a.gif)
В чём был баг? Если пользователь переключал toggle при медленном интернете, то могло случиться следующее. Юзер заходит на экран, нажимает на toggle, переключает его в состояние “unchecked”. После этого он выходит с экрана, возвращается и видит, что toggle в состоянии “checked”. Непорядок.
Схема реализации экрана
Наша фича – это черный ящик, скрывающий внутри себя основную логику экрана. Он общается с репозиторием, который отправляет запросы на сервер. Репозиторий еще и хранит кэш.
![Схема реализации экрана Схема реализации экрана](https://habrastorage.org/getpro/habr/upload_files/7c7/80f/789/7c780f789221a5731b282420196b5587.png)
По результату общения нашей фичи с репозиторием, мы получаем State. Изначально он разделялся на два поля. Первое поле представляло собой закэшированное состояние. Оно соответствовало состоянию флага на сервере. Второе поле – UI-состояние, отвечало за то, что мы отобразим пользователю.
Важно! Все примеры кода – только примеры, не тащите это в прод, оно ненастоящее!
data class State(
val uiState: SettingsState,
val cachedState: SettingsState
)
data class SettingsState(
val isEnabled: Boolean,
)
В нашем случае оба состояния были сильно связаны между собой: оба хранились в рамках одной модели, оба загружались при заходе на этот экран. И единственным их отличием было условие, по которому мы меняем значение. UI-состояние меняется всегда, когда юзер нажимает на toggle. А вот закэшированное состояние изменяется только тогда, когда мы получили успешный ответ от сервера. И, так как при старте этого экрана оба значения в фиче затирались, мы не могли восстановить UI-состояние, чтобы показать его пользователю. Именно в этом и таилась проблема.
![](https://habrastorage.org/getpro/habr/upload_files/d8f/2d7/335/d8f2d7335dde36f918d360c2940f3f0a.jpeg)
Варианты решения проблемы
Первое решение, которое пришло нам в голову – максимально разделить эти два состояния. В таком случае мы могли бы спокойно обновлять закэшированное состояние при заходе на экран, а UI-состояние хранить столько, сколько потребуется.
Но при проверке выяснилось, что баг-то у нас не один, ведь юзер может переключать toggle сколько угодно раз. И при этом мы не можем гарантировать, что запросы выполнятся последовательно. Таким образом результаты запросов могут прийти в неожиданном порядке. Из-за этого мы можем отобразить пользователю не ту информацию. Это неправильно.
Переключаем toggle много-много раз!
![Переключение toggle-а Переключение toggle-а](https://habrastorage.org/getpro/habr/upload_files/a60/532/0f5/a605320f53ed15bf8f5f5e69de9ef5e7.gif)
Мы попробовали исправить это остановкой получения результатов от предыдущего запроса, когда мы уже отправляем новый. Я могу предложить вам несколько вариантов реализации такой остановки.
Шина событий
Первый – использовать шину событий. Мы отправляем в шину событие о том, что мы хотим прервать обработку данных, и ловим это событие внутри нашей цепочки обработки запроса. В таком случае события дальше обрабатываться не будут.
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()
}
})
}
Когда мы осуществляем перезагрузку списка, мы инкрементируем глобальный номер версии данных в списке и запоминаем обновлённое значение этой версии. После того, как мы получили данные от запроса, мы можем сверить глобальную версию с сохранённой локальной версией после запроса, результаты от которого мы получили.
Если версии совпадают, значит можно спокойно обрабатывать результаты, если нет –просто пропускаем обработку.
Оба предложенных варианта по-своему хороши. В случае с шиной событий мы не будем обрабатывать дальнейшую цепочку данных от запроса. А в ситуации с сохранением номера версии списка мы будем точно знать, что произошла перезагрузка, и какая она была по счету. Но ни одно из этих решений нам не подошло.
А что не так-то?
Во-первых, сложность понимания происходящего. Человека, который не сталкивался с проблемой “лишних запросов” или же впервые видит экран, такая реализация может поставить в тупик.
Во-вторых, хранение в репозитории дополнительного флага, что идет загрузка. Появляется лишний источник правды, который нуждается в согласовании.
В-третьих, хранение кэша и в репозитории, и в фиче. Опять же – несколько источников правды, рано или поздно это приведёт к их рассинхрону.
И, наконец, в-четвертых, лишние запросы всё равно будут отправляться, несмотря на использование варианта с шиной событий или хранением номера версии списка.
![](https://habrastorage.org/getpro/habr/upload_files/e7c/0bc/dda/e7c0bcdda88e72f709ab0c8a3005f1b5.jpeg)
Исправление ошибки
Как обычно, решение оказалось довольно простым. Пусть фича сама управляет своим кэшем! Это позволит нам избавиться от кэша в репозитории и оставить его только в фиче, следовательно, получим единственный источник правды о данных для экрана. Получается, что в данной реализации state-а у нас теперь три поля: закэшированное состояние, UI-состояние и флаг прогресса.
![Обновленная схема реализации нашего экрана Обновленная схема реализации нашего экрана](https://habrastorage.org/getpro/habr/upload_files/de8/4ba/e8e/de84bae8ef8ec80a24bbaeaf25d73c98.png)
Итак, пользователь пришел на экран, нам нужно что-то для него отобразить. Загрузка данных с сервера произойдет только тогда, когда данных в кэше нет или же данные уже не валидны. Валидация данных может быть устроена как угодно, мы используем валидацию кэша по дате.
Схема загрузки данных
![Схема загрузки данных Схема загрузки данных](https://habrastorage.org/getpro/habr/upload_files/b82/728/8d1/b827288d129c2647460a7cbf549e87e0.png)
В момент переключения toggle юзером, мы сохраняем его значение в State и проверяем, идет ли отправка запроса. Если на текущий момент запрос не отправляется, мы отправляем запрос с новым значением toggle-а.
Когда запрос был успешно завершен, мы сохраняем данные в State в поле закэшированного состояния флага. После этого надо проверить, соответствует ли UIState закэшированному состоянию. Если они отличаются, мы снова отправляем запрос на сервер.
При такой схеме у нас и кэш остается консистентным, и количество запросов остается минимальным. Профит.
Реализация
Так мы определились с общей схемой. Настало время реализации. Мы использовали библиотеку MVICore от Badoo. MVICore – это библиотека для реализации паттерна MVI в Android-приложениях.
![Схема работы feature Схема работы feature](https://habrastorage.org/getpro/habr/upload_files/bfc/ae8/40a/bfcae840a7a89869b046411ac106c6db.png)
Юзер изменил значение переключателя, мы записываем новое значение в 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.
И, казалось бы, всё, история рассказана, влог отснят, статья на Хабр написана, но…
![Упс :D Упс :D](https://habrastorage.org/getpro/habr/upload_files/1f6/868/f75/1f6868f750451b0fc85c686b57e28957.png)
Всё можно было сделать проще =)