Современная MVI-архитектура на базе Kotlin. Часть 1

в 13:01, , рубрики: android, badoo, kotlin, mvi, mvicore, архитектура приложений, Блог компании Badoo, Программирование, разработка мобильных приложений, Разработка под android

Современная MVI-архитектура на базе Kotlin. Часть 1 - 1

За последние два года 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 со следующими правилами:

  1. В случае StartedLoading создать новый объект State, скопировав старый, и установить значение isLoading как true.
  2. В случае FinishedWithSuccess создать новый объект State, скопировав старый, в котором значение isLoading будет установлено как false, а значение payload будет
    соответствовать загруженному.

Если мы выведем получившуюся серию State в лог, мы увидим следующее:

  1. State (payload = null, isLoading = false) — изначальное состояние.
  2. State (payload = null, isLoading = true) — после StartedLoading.
  3. 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 без участия пользователя.

Схема может выглядеть просто:
Современная MVI-архитектура на базе Kotlin. Часть 1 - 2

или включать в себя все перечисленные выше дополнительные компоненты:
Современная MVI-архитектура на базе Kotlin. Часть 1 - 3

Сама же Feature, содержащая всю бизнес-логику и готовая к использованию, выглядит проще некуда:

Современная MVI-архитектура на базе Kotlin. Часть 1 - 4

Что ещё?

Feature, краеугольный камень фреймворка, работает на концептуальном уровне. Но библиотека может предложить гораздо больше.

  • Поскольку все компоненты Feature детерминированы (за исключением Actor, который не полностью детерминирован, поскольку взаимодействует с внешними источниками данных, но даже при этом выполняемая им ветвь определяется вводными данными, а не внешними условиями), каждый из них можно обернуть в Middleware. При этом в библиотеке уже содержатся готовые решения для логгинга и time travel дебага.
  • Middleware применимо не только к Feature, но и к любым другим объектам, реализующим интерфейс Consumer<Т>, что делает его незаменимым инструментом отладки.
  • При использовании дебаггера для отладки при движении в обратном направлении можно внедрить модуль DebugDrawer.
  • Библиотека включает в себя плагин IDEA, который можно использовать для добавления шаблонов самых распространённых реализаций Feature, что позволяет сэкономить кучу времени.
  • Имеются вспомогательные классы для поддержки Android, но сама библиотека к Android не привязана.
  • Есть готовое решение для привязки компонентов к UI и друг к другу через элементарный API (о нём пойдёт речь в следующей статье).

Надеемся, вы попробуете нашу библиотеку и её использование доставит вам столько же радости, сколько нам — её создание!

24 и 25 ноября можно познакомиться с нашей командой мобильной разработки поближе. Мы проведём hiring event: за один день можно будет пройти все этапы отбора и получить оффер. Общаться с кандидатами в Москву приедут мои коллеги из iOS- и Android-команд. Если вы из другого города, расходы на проезд берёт на себя Badoo. Чтобы получить приглашение, пройдите отборочный тест по ссылке. Удачи!

Автор: Андрей Шиков

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js