За последние два года Android-разработчики в Badoo прошли длинный тернистый путь от MVP к совершенно иному подходу к архитектуре приложений. Мы с ANublo хотим поделиться переводом статьи нашего коллеги Zsolt Kocsi, описывающую проблемы, с которыми мы столкнулись, и их решение.
Это первая из нескольких статей, посвящённых разработке современной MVI-архитектуры на Kotlin.
Начнём с начала: проблемы состояний
В каждый момент времени у приложения есть определённое состояние, которое задаёт его поведение и то, что видит пользователь. Если сфокусироваться лишь на паре классов, это состояние включает в себя все значения переменных — от простых флагов до отдельных объектов. Каждая из этих переменных живёт своей жизнью и управляется различными частями кода. Определить текущее состояние приложения можно, лишь проверив их все одну за другой.
Работая над кодом, мы создаём уже существующую у нас в голове модель работы системы. Мы легко реализуем идеальные случаи, когда всё идет по плану, но совершенно неспособны просчитать все возможные проблемы и состояния приложения. И рано или поздно одно из не предусмотренных нами состояний нас настигнет, и мы столкнёмся с багом.
Изначально код пишется в соответствии с нашими представлениями о том, как система должна работать. Но в дальнейшем, проходя через пять стадий отладки, приходится мучительно всё переделывать, попутно меняя сложившуюся в голове модель уже созданной системы. Остаётся надеяться, что рано или поздно к нам придёт понимание того, что же пошло не так, и баг будет исправлен.
Но так везёт далеко не всегда. Чем сложнее система, тем больше шансов столкнуться с каким-нибудь непредвиденным состоянием, отладка которого ещё долго будет сниться в ночных кошмарах.
В Badoo все приложения существенно асинхронны — не только из-за обширного функционала, доступного пользователю через UI, но и из-за возможности односторонней отправки данных сервером. На состояние и поведение приложения оказывает влияние многое — от изменения статуса оплаты до новых совпадений и запросов верификации.
В результате в нашем чат-модуле нам попалось несколько странных и трудновоспроизводимых багов, попортивших всем немало крови. Иногда тестировщикам удавалось их записать, но на устройстве разработчика они не повторялись. Из-за асинхронности кода повторение в полном объёме той или иной цепочки событий было крайне маловероятно. И поскольку приложение не падало, у нас даже не было стек-трейса, который показал бы, откуда начинать поиски.
Clean Architecture (чистая архитектура) тоже не смогла нам помочь. Даже после того как мы переписали чат-модуль, A/B-тесты выявляли небольшие, но значимые несоответствия в количестве сообщений пользователей, использовавших новый и старый модули. Мы решили, что это связано с трудновоспроизводимостью багов и состоянием гонки. Несоответствие сохранялось и после проверки всех остальных факторов. Интересы компании страдали, разработчикам было тяжело поддерживать код.
Нельзя выпускать новый компонент, если он работает хуже существующего, но не выпускать его тоже нельзя — раз уж потребовалось обновление, значит, на то была причина. Итак, необходимо разобраться, почему в системе, которая выглядит совершенно нормально и не вылетает, падает число сообщений.
Откуда же начинать поиски?
Спойлер: это не вина Clean Architecture — виноват, как всегда, человеческий фактор. В конечном итоге мы, конечно, исправили эти баги, но потратили на это много времени и сил. Тогда мы задумались: а нет ли более простого способа избежать возникновения этих проблем?
Свет в конце туннеля…
Модные термины вроде Model-View-Intent и «однонаправленный поток данных» нам хорошо знакомы. Если в вашем случае это не так, советую их загуглить — в Интернете много статей на эти темы. Android-разработчикам особенно рекомендую материал Ханнеса Дорфмана в восьми частях.
Мы начали играть с этими взятыми из веб-разработки идеями ещё в начале 2017 года. Подходы наподобие Flux и Redux оказались очень полезны — они помогали нам справиться со многими проблемами.
Прежде всего, очень полезно содержать все элементы состояния (переменные, влияющие на UI и запускающие различные действия) в одном объекте — State. Когда всё хранится в одном месте, лучше видна общая картина. Например, если вы хотите представить загрузку данных с использованием такого подхода, то вам потребуются поля payload и isLoading. Взглянув на них, вы увидите, когда данные получены (payload) и показывается ли при этом пользователю анимация (isLoading).
Далее, если мы отойдём от параллельного выполнения кода с колбеками и выразим изменения состояния приложения в виде серии транзакций, мы получим единую точку входа. Представляем вам Reducer, прибывший к нам из функционального программирования. Он берёт текущее состояние и данные о дальнейших действиях (Intent) и создаёт из них новое состояние:
Reducer = (State, Intent) -> State
Продолжая предыдущий пример с загрузкой данных, мы получаем следующие действия:
- StartedLoading
- FinishedWithSuccess
Тогда можно создать Reducer со следующими правилами:
- В случае StartedLoading создать новый объект State, скопировав старый, и установить значение isLoading как true.
- В случае FinishedWithSuccess создать новый объект State, скопировав старый, в котором значение isLoading будет установлено как false, а значение payload будет
соответствовать загруженному.
Если мы выведем получившуюся серию State в лог, мы увидим следующее:
- State (payload = null, isLoading = false) — изначальное состояние.
- State (payload = null, isLoading = true) — после StartedLoading.
- State (payload = данные, isLoading = false) — после FinishedWithSuccess.
Подключив эти состояния к UI, вы увидите все стадии процесса: сначала пустой экран, затем экран загрузки и, наконец, нужные данные.
У такого подхода есть множество плюсов.
- Во-первых, централизованно изменяя состояние при помощи серии транзакций, мы не допускаем состояния гонки и множества незаметных раздражающих багов.
- Во-вторых, изучив серию транзакций, мы можем понять, что случилось, почему это случилось и как это повлияло на состояние приложения. Кроме того, с Reducer намного проще представить все изменения состояния ещё до первого запуска приложения на девайсе.
- Наконец, мы имеем возможность создать простой интерфейс. Раз уж все состояния хранятся в одном месте (Store), которое учитывает намерения (Intents), вносит изменения при помощи Reducer и наглядно демонстрирует цепочку состояний, значит, можно поместить всю бизнес-логику в Store и использовать интерфейс для запуска намерений и выведения состояний.
Или нельзя?
…может быть поездом, несущимся на вас
Одного Reducer явно недостаточно. Как быть с асинхронными задачами с различными результатами? Как реагировать на пуши с сервера? Как быть с запуском дополнительных задач (например, очистки кеша или загрузки данных из локальной базы) после изменения состояния? Выходит, что либо мы не включаем всю эту логику в Reducer (то есть добрая половина бизнес-логики окажется не охвачена, и о ней придётся позаботиться тем, кто решит воспользоваться нашим компонентом), либо заставляем Reducer заниматься всем сразу.
Требования к MVI-фреймворку
Нам, конечно, хотелось бы заключить всю бизнес-логику отдельной фичи в самостоятельный компонент, с которым разработчики из других команд могли бы легко работать, просто создав его экземпляр и подписавшись на его состояние.
Кроме того:
- он должен легко взаимодействовать с другими компонентами системы;
- в его внутренней структуре должно быть чёткое разделение обязанностей;
- все внутренние части компонента должны быть полностью детерминированными;
- базовая реализация такого компонента должна быть простой и усложняться только при необходимости подключения дополнительных элементов.
Мы не сразу перешли от Reducer к решению, которое используем сегодня. Каждая команда сталкивалась с проблемами при использовании различных подходов, и разработка универсального решения, которое устроило бы всех, казалась маловероятной.
И все же, текущее положение вещей устраивает всех. Рады представить вам MVICore! Исходный код библиотеки открыт и доступен на GitHub.
Чем хорош MVICore
- Лёгкий способ реализации бизнес-фич в стиле реактивного программирования с однонаправленным потоком данных.
- Масштабирование: базовая реализация включает только Reducer, а в более сложных случаях можно задействовать дополнительные компоненты.
- Решение для работы с событиями, которые вы не хотите включать в состояние (проблема SingleLiveEvent).
- Простой API для привязки фич (и других реактивных компонентов вашей системы) к UI и друг к другу с поддержкой жизненного цикла Android (и не только).
- Поддержка Middleware (об этом ниже) для каждого компонента системы.
- Готовый логгер и возможность time travel дебага для каждого компонента.
Краткое введение в Feature
Поскольку на GitHub уже выложена пошаговая инструкция, я опущу подробные примеры и остановлюсь на основных составляющих фреймворка.
Feature — центральный элемент фреймворка, содержащий всю бизнес-логику компонента. Feature определяется тремя параметрами: interface Feature<Wish, State, News>
Wish соответствует Intent из Model-View-Intent — это те изменения, которые мы хотим видеть в модели (поскольку термин Intent имеет своё значение в среде Android-разработчиков, нам пришлось найти другое название). Wish — это точка входа для Feature.
State — это, как вы уже поняли, состояние компонента. State не изменяем (immutable): мы не можем менять его внутренние значения, но можем создавать новые States. Это и выходные данные: всякий раз, создавая новое состояние, мы передаём его в Rx-стрим.
News — компонент для обработки сигналов, которых не должно быть в State; News используется один раз при создании (проблема SingleLiveEvent). Использование News необязательно (в сигнатуре Feature можно использовать Nothing из Kotlin).
Также в Feature обязательно должен присутствовать Reducer.
Feature может содержать следующие компоненты:
- Actor — выполняет асинхронные задачи и/или условные модификации состояния, основанные на текущем состоянии (например, валидация формы). Actor привязывает Wish к определённому числу Effect, а затем передаёт его Reducer (в случае отсутствия Actor Reducer получает Wish напрямую).
- NewsPublisher — вызывается, когда Wish становится любым Effect, который даёт результат в виде нового State. По этим данным он решает, создавать ли News.
- PostProcessor — тоже вызывается после создания нового State и тоже знает, какой эффект привёл к его созданию. Он запускает те или иные дополнительные действия (Actions). Action — это «внутренние Wishes» (например, очистка кеша), которые нельзя запустить извне. Они выполняются в Actor, что приводит к новой цепочке Effects и States.
- Bootstrapper — компонент, который может запускать действия самостоятельно. Его главная функция — инициализация Feature и/или соотнесение внешних источников с Action. Этими внешними источниками могут быть News из другой Feature или данные сервера, которые должны модифицировать State без участия пользователя.
Схема может выглядеть просто:
или включать в себя все перечисленные выше дополнительные компоненты:
Сама же Feature, содержащая всю бизнес-логику и готовая к использованию, выглядит проще некуда:
Что ещё?
Feature, краеугольный камень фреймворка, работает на концептуальном уровне. Но библиотека может предложить гораздо больше.
- Поскольку все компоненты Feature детерминированы (за исключением Actor, который не полностью детерминирован, поскольку взаимодействует с внешними источниками данных, но даже при этом выполняемая им ветвь определяется вводными данными, а не внешними условиями), каждый из них можно обернуть в Middleware. При этом в библиотеке уже содержатся готовые решения для логгинга и time travel дебага.
- Middleware применимо не только к Feature, но и к любым другим объектам, реализующим интерфейс Consumer<Т>, что делает его незаменимым инструментом отладки.
- При использовании дебаггера для отладки при движении в обратном направлении можно внедрить модуль DebugDrawer.
- Библиотека включает в себя плагин IDEA, который можно использовать для добавления шаблонов самых распространённых реализаций Feature, что позволяет сэкономить кучу времени.
- Имеются вспомогательные классы для поддержки Android, но сама библиотека к Android не привязана.
- Есть готовое решение для привязки компонентов к UI и друг к другу через элементарный API (о нём пойдёт речь в следующей статье).
Надеемся, вы попробуете нашу библиотеку и её использование доставит вам столько же радости, сколько нам — её создание!
24 и 25 ноября можно попробовать свои силы и присоединиться к нам! Мы проведём mobile hiring event: за один день можно будет пройти все этапы отбора и получить оффер. Общаться с кандидатами в Москву приедут мои коллеги из iOS- и Android-команд. Если вы из другого города, расходы на проезд берёт на себя Badoo. Чтобы получить приглашение, пройдите отборочный тест по ссылке. Удачи!