– Хей, Катя, у нас там багуля небольшая завелась. Посмотри, плиз. – Не вопрос, бро. В чем проблема? – Toggle сбрасывается при возврате на экран. Изи, ваще. С этой безобидной фразы началось мое недельное приключение в мир безумной архитектуры, сумасшедших фиксов и красноглазия. И это была ловушка. Всем привет, меня зовут Катя, я – Android-разработчик компании SimbirSoft, и я помогаю улучшать продукт в hh.ru. В статье расскажу историю о том, как разработчики сразу двух компаний, техлид Android и даже Head of Mobile писали минимальную фичу на MVI с тоглом, и всё равно упустили баг после долгих часов проектирования. Разберемся, на что идут программисты ради хорошего UX, почему первоначальное решение было неверным, и как это можно исправить. Это текстовая расшифровка выпуска нашего влога, поэтому если вам удобнее смотреть, а не читать, добро пожаловать на наш Youtube-канал. Поскольку экран был реализован другим разработчиком, первое, что я сделала – внимательно его рассмотрела. У экрана было три состояния: загрузка, ошибка и контент.  И контентом был тот самый злосчастный переключатель. При нажатии на переключатель происходил запрос на сервер – мы посылали новое значение флага настроек. Важное условие: мы должны позволить пользователю сколько угодно раз переключать этот toggle. И при этом не показывать никаких прелоадеров.  Изначально передо мной стояла лишь одна небольшая задача: необходимо было поправить сохранение состояния переключателя. При медленном интернете юзер мог успеть выйти с экрана до окончания запроса. Из-за этого состояние переключателя сбрасывалось к первоначальному значению. В чём был баг? Если пользователь переключал toggle при медленном интернете, то могло случиться следующее. Юзер заходит на экран, нажимает на toggle, переключает его в состояние “unchecked”. После этого он выходит с экрана, возвращается и видит, что toggle в состоянии “checked”. Непорядок.  Наша фича – это черный ящик, скрывающий внутри себя основную логику экрана. Он общается с репозиторием, который отправляет запросы на сервер. Репозиторий еще и хранит кэш.  По результату общения нашей фичи с репозиторием, мы получаем State. Изначально он разделялся на два поля. Первое поле представляло собой закэшированное состояние. Оно соответствовало состоянию флага на сервере. Второе поле – UI-состояние, отвечало за то, что мы отобразим пользователю.  Важно! Все примеры кода – только примеры, не тащите это в прод, оно ненастоящее!  В нашем случае оба состояния были сильно связаны между собой: оба хранились в рамках одной модели, оба загружались при заходе на этот экран. И единственным их отличием было условие, по которому мы меняем значение. UI-состояние меняется всегда, когда юзер нажимает на toggle. А вот закэшированное состояние изменяется только тогда, когда мы получили успешный ответ от сервера. И, так как при старте этого экрана оба значения в фиче затирались, мы не могли восстановить UI-состояние, чтобы показать его пользователю. Именно в этом и таилась проблема. Первое решение, которое пришло нам в голову – максимально разделить эти два состояния. В таком случае мы могли бы спокойно обновлять закэшированное состояние при заходе на экран, а UI-состояние хранить столько, сколько потребуется.  Но при проверке выяснилось, что баг-то у нас не один, ведь юзер может переключать toggle сколько угодно раз. И при этом мы не можем гарантировать, что запросы выполнятся последовательно. Таким образом результаты запросов могут прийти в неожиданном порядке. Из-за этого мы можем отобразить пользователю не ту информацию. Это неправильно. Мы попробовали исправить это остановкой получения результатов от предыдущего запроса, когда мы уже отправляем новый. Я могу предложить вам несколько вариантов реализации такой остановки. Первый – использовать шину событий. Мы отправляем в шину событие о том, что мы хотим прервать обработку данных, и ловим это событие внутри нашей цепочки обработки запроса. В таком случае события дальше обрабатываться не будут.  В рамках этого решения мы использовали Rx-овый Subject и оператор takeUntil. В таком случае перед отправкой нового запроса мы посылаем в Subject события о том, что мы хотим прервать обработку предыдущего запроса. Внутри цепочки обработки запроса используем takeUntil, оператор, прерывающий дальнейшее выполнение Rx-цепочки, если из указанного источника приходят данные. Таким образом, если мы получили события от Subject, то все последующие шаги вызываться не будут. Это рабочее решение, мы использовали его в нашей фиче для пагинации. Суть метода заключается в следующем: давайте введём специальный AtomicInteger, который будет хранить глобальную версию данных списка, и локальную переменную версии, которая будет сравниваться с глобальной после выполнения очередного запроса.  Когда мы осуществляем перезагрузку списка, мы инкрементируем глобальный номер версии данных в списке и запоминаем обновлённое значение этой версии. После того, как мы получили данные от запроса, мы можем сверить глобальную версию с сохранённой локальной версией после запроса, результаты от которого мы получили. Если версии совпадают, значит можно спокойно обрабатывать результаты, если нет –просто пропускаем обработку.  Оба предложенных варианта по-своему хороши. В случае с шиной событий мы не будем обрабатывать дальнейшую цепочку данных от запроса. А в ситуации с сохранением номера версии списка мы будем точно знать, что произошла перезагрузка, и какая она была по счету. Но ни одно из этих решений нам не подошло.  Во-первых, сложность понимания происходящего. Человека, который не сталкивался с проблемой “лишних запросов” или же впервые видит экран, такая реализация может поставить в тупик.  Во-вторых, хранение в репозитории дополнительного флага, что идет загрузка. Появляется лишний источник правды, который нуждается в согласовании.  В-третьих, хранение кэша и в репозитории, и в фиче. Опять же – несколько источников правды, рано или поздно это приведёт к их рассинхрону.  И, наконец, в-четвертых, лишние запросы всё равно будут отправляться, несмотря на использование варианта с шиной событий или хранением номера версии списка.  Как обычно, решение оказалось довольно простым. Пусть фича сама управляет своим кэшем! Это позволит нам избавиться от кэша в репозитории и оставить его только в фиче, следовательно, получим единственный источник правды о данных для экрана. Получается, что в данной реализации state-а у нас теперь три поля: закэшированное состояние, UI-состояние и флаг прогресса. Итак, пользователь пришел на экран, нам нужно что-то для него отобразить. Загрузка данных с сервера произойдет только тогда, когда данных в кэше нет или же данные уже не валидны. Валидация данных может быть устроена как угодно, мы используем валидацию кэша по дате.  В момент переключения toggle юзером, мы сохраняем его значение в State и проверяем, идет ли отправка запроса. Если на текущий момент запрос не отправляется, мы отправляем запрос с новым значением toggle-а.  Когда запрос был успешно завершен, мы сохраняем данные в State в поле закэшированного состояния флага. После этого надо проверить, соответствует ли UIState закэшированному состоянию. Если они отличаются, мы снова отправляем запрос на сервер.  При такой схеме у нас и кэш остается консистентным, и количество запросов остается минимальным. Профит. Так мы определились с общей схемой. Настало время реализации. Мы использовали библиотеку MVICore от Badoo. MVICore – это библиотека для реализации паттерна MVI в Android-приложениях.  Юзер изменил значение переключателя, мы записываем новое значение в UIState. После этого проверяем, можем ли мы отправить запрос. Это можно сделать в сущности, которая называется Actor.  Actor скрывает в себе основную логику работы фичи. Например, он может отправить запросы на сервер или делегировать события об изменении State.  Поняли, что можем отправить запрос, и отправляем его. После его успешного завершения, мы сохраняем новое значение флага в State.  После обновления State-а, мы можем отследить его изменение в такой сущности, которая называется PostProcessor. Внутри него мы проверяем State, UIState и закэшированный State. Если они не равны, мы повторяем всю цепочку проверок-запросов-обновления заново.  По итогу такой схемы пользователь спокойно переключает toggle, а мы минимизируем количество запросов. Вот и всё.  Но есть и проколы в этой схеме. Вскоре после реализации мы поймали еще один небольшой баг: при разлогине пользователя мы продолжали пытаться отправить запрос на сервер, хотя неавторизованный юзер не может получить данный флаг. В закешированном состоянии пропадало значение флага, и фича начинала бесконечно “стучаться” на сервер за обновлениями.  Но исправить это было довольно просто: необходимо было сохранить в закэшированное состояние значение «по умолчанию».  Ура, мы пофиксили баг, но какой ценой! Было потрачено огромное количество времени, но этот опыт кое-чему нас научил:  Во-первых, перед тем как браться за разработку какой-либо фичи, нужно очень внимательно посмотреть на нее и составить примерную схему дальнейших действий. В таком случае мы сможем правильно, например, спроектировать State или избежать каких-либо других проблем.  Во-вторых, чтобы наши коллеги не тратили много времени на похожие кейсы, мы решили создать специальный архитектурный cookbook по MVI, первый кейс из которого мы только что разобрали с вами. Имея под рукой шаблонные решения, гораздо проще не допустить подобных багов.  Пишите в комментариях, как вам предложенное решение и как бы эту проблему решили вы. В будущем мы собираемся разбирать еще истории из нашей практики.  Всем продуктивной разработки! И, казалось бы, всё, история рассказана, влог отснят, статья на Хабр написана, но… Всё можно было сделать проще =)