Главная » Хабрахабр » [Перевод] Современная MVI-архитектура на базе Kotlin. Часть 1

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

Мы с ANublo хотим поделиться переводом статьи нашего коллеги Zsolt Kocsi, описывающую проблемы, с которыми мы столкнулись, и их решение. За последние два года Android-разработчики в Badoo прошли длинный тернистый путь от MVP к совершенно иному подходу к архитектуре приложений.

Это первая из нескольких статей, посвящённых разработке современной MVI-архитектуры на Kotlin.

Начнём с начала: проблемы состояний

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

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

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

Чем сложнее система, тем больше шансов столкнуться с каким-нибудь непредвиденным состоянием, отладка которого ещё долго будет сниться в ночных кошмарах. Но так везёт далеко не всегда.

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

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

Даже после того как мы переписали чат-модуль, A/B-тесты выявляли небольшие, но значимые несоответствия в количестве сообщений пользователей, использовавших новый и старый модули. Clean Architecture (чистая архитектура) тоже не смогла нам помочь. Несоответствие сохранялось и после проверки всех остальных факторов. Мы решили, что это связано с трудновоспроизводимостью багов и состоянием гонки. Интересы компании страдали, разработчикам было тяжело поддерживать код.

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

Откуда же начинать поиски?

В конечном итоге мы, конечно, исправили эти баги, но потратили на это много времени и сил. Спойлер: это не вина Clean Architecture — виноват, как всегда, человеческий фактор. Тогда мы задумались: а нет ли более простого способа избежать возникновения этих проблем?

Свет в конце туннеля…

Модные термины вроде Model-View-Intent и «однонаправленный поток данных» нам хорошо знакомы. Если в вашем случае это не так, советую их загуглить — в Интернете полно статей на эти темы. Android-разработчикам особенно рекомендую материал Ханнеса Дорфмана в восьми частях.

Подходы наподобие Flux и Redux оказались очень полезны — они помогали нам справиться со многими проблемами. Мы начали играть с этими взятыми из веб-разработки идеями ещё в начале 2017 года.

Когда всё хранится в одном месте, лучше видна общая картина. Прежде всего, очень полезно содержать все элементы состояния (переменные, влияющие на 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 определяется тремя параметрами: interface Feature<Wish, State, News> Feature — центральный элемент фреймворка, содержащий всю бизнес-логику компонента.

 Wish — это точка входа для Feature. Wish соответствует Intent из Model-View-Intent — это те изменения, которые мы хотим видеть в модели (поскольку термин Intent имеет своё значение в среде Android-разработчиков, нам пришлось найти другое название).

State не изменяем (immutable): мы не можем менять его внутренние значения, но можем создавать новые States. State — это, как вы уже поняли, состояние компонента. Это и выходные данные: всякий раз, создавая новое состояние, мы передаём его в Rx-стрим.

Использование News необязательно (в сигнатуре Feature можно использовать Nothing из Kotlin). News — компонент для обработки сигналов, которых не должно быть в State; News используется один раз при создании (проблема SingleLiveEvent).

Также в 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 (о нём пойдёт речь в следующей статье).

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Из песочницы] Контроллер, полегче! Выносим код в UIView

У вас большой UIViewController? У многих да. С одной стороны, в нём работа с данными, с другой — с интерфейсом. Они решают проблему потока данных, но не отвечают на вопрос как работать с интерфейсом: в одном месте остается создание элементов, лейаут, ...

Нужно больше разных Blur-ов

Размытие изображение посредством фильтра Gaussian Blur широко используется в самых разных задачах. Но иногда хочется чуть большего разнообразия, чем просто один фильтр на все случаи жизни, в котором регулировке поддаётся только один параметр — его размер. В этой статье мы ...