Охота на toggle: Как простую фичу сделать максимально сложно

— Хей, Катя, у нас там багуля небольшая завелась. Посмотри, плиз.

— Не вопрос, бро. В чем проблема?

— Toggle сбрасывается при возврате на экран. Изи, ваще.

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

Всем привет, меня зовут Катя, я — Android-разработчик компании SimbirSoft, и я помогаю улучшать продукт в hh.ru. В статье расскажу историю о том, как разработчики сразу двух компаний, техлид Android и даже Head of Mobile писали минимальную фичу на MVI с тоглом, и всё равно упустили баг после долгих часов проектирования. Разберемся, на что идут программисты ради хорошего UX, почему первоначальное решение было неверным, и как это можно исправить.

Это текстовая расшифровка выпуска нашего влога, поэтому если вам удобнее смотреть, а не читать, добро пожаловать на наш Youtube-канал.

ac823ee144ebc6edbe73900f4c0956f3.jpeg

Поиск проблемы на дне океана

Поскольку экран был реализован другим разработчиком, первое, что я сделала — внимательно его рассмотрела. У экрана было три состояния: загрузка, ошибка и контент. 

Состояния экрана

Состояния экрана - загрузка, ошибка и контентСостояния экрана — загрузка, ошибка и контент

И контентом был тот самый злосчастный переключатель. При нажатии на переключатель происходил запрос на сервер — мы посылали новое значение флага настроек.

Важное условие: мы должны позволить пользователю сколько угодно раз переключать этот toggle. И при этом не показывать никаких прелоадеров. 

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

Как это выглядело?

Сбрасывание toggle при возврате на экранСбрасывание 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-состояние, чтобы показать его пользователю. Именно в этом и таилась проблема.

d8f2d7335dde36f918d360c2940f3f0a.jpeg

Варианты решения проблемы

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

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

Переключаем toggle много-много раз!

Переключение 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()
      }
    })
}

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

Если версии совпадают, значит можно спокойно обрабатывать результаты, если нет –просто пропускаем обработку. 

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

А что не так-то?

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

Во-вторых, хранение в репозитории дополнительного флага, что идет загрузка. Появляется лишний источник правды, который нуждается в согласовании. 

В-третьих, хранение кэша и в репозитории, и в фиче. Опять же — несколько источников правды, рано или поздно это приведёт к их рассинхрону. 

И, наконец, в-четвертых, лишние запросы всё равно будут отправляться, несмотря на использование варианта с шиной событий или хранением номера версии списка. 

e7c0bcdda88e72f709ab0c8a3005f1b5.jpeg

Исправление ошибки

Как обычно, решение оказалось довольно простым. Пусть фича сама управляет своим кэшем! Это позволит нам избавиться от кэша в репозитории и оставить его только в фиче, следовательно, получим единственный источник правды о данных для экрана. Получается, что в данной реализации state-а у нас теперь три поля: закэшированное состояние, UI-состояние и флаг прогресса.

Обновленная схема реализации нашего экранаОбновленная схема реализации нашего экрана

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

Схема загрузки данных

Схема загрузки данныхСхема загрузки данных

В момент переключения toggle юзером, мы сохраняем его значение в State и проверяем, идет ли отправка запроса. Если на текущий момент запрос не отправляется, мы отправляем запрос с новым значением toggle-а. 

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

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

Реализация


Так мы определились с общей схемой. Настало время реализации. Мы использовали библиотеку MVICore от Badoo. MVICore — это библиотека для реализации паттерна MVI в Android-приложениях. 

Схема работы featureСхема работы 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Упс : D

Всё можно было сделать проще =)

© Habrahabr.ru