Охота на toggle: Как простую фичу сделать максимально сложно
— Хей, Катя, у нас там багуля небольшая завелась. Посмотри, плиз.
— Не вопрос, бро. В чем проблема?
— 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-приложениях.
Схема работы feature
Юзер изменил значение переключателя, мы записываем новое значение в UIState. После этого проверяем, можем ли мы отправить запрос. Это можно сделать в сущности, которая называется Actor.
Actor скрывает в себе основную логику работы фичи. Например, он может отправить запросы на сервер или делегировать события об изменении State.
class ActorImpl : Actor {
override fun invoke(state: State, wish: Wish): Observable {
return when (wish) {
is Wish.Toggle -> {
if (canSendRequest) {
updateValue()
}
else {
saveValue()
}
}
}
}
}
Поняли, что можем отправить запрос, и отправляем его. После его успешного завершения, мы сохраняем новое значение флага в State.
После обновления State-а, мы можем отследить его изменение в такой сущности, которая называется PostProcessor. Внутри него мы проверяем State, UIState и закэшированный State. Если они не равны, мы повторяем всю цепочку проверок-запросов-обновления заново.
class ProcessorImpl : PostProcessor {
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
Всё можно было сделать проще =)