За последние два года 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 без участия пользователя.


Схема может выглядеть просто:


или включать в себя все перечисленные выше дополнительные компоненты:


Сама же 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. Чтобы получить приглашение, пройдите отборочный тест по ссылке. Удачи!

Комментарии (12)


  1. KevlarBeaver
    13.11.2018 18:01
    +1

    Наймите дизайнера, пожалуйста :'(


    1. alinaleena
      13.11.2018 18:11

      Мы как раз в поисках :)


  1. nomadmoon
    13.11.2018 22:13
    +2

    Напишите пожалуйста статью с написанием небольшого приложения с использованием вашей библиотеки.


    1. ANublo
      14.11.2018 01:22

      Вы можете посмотреть репозиторий на Github, в нём есть пример небольшого приложения:
      github.com/badoo/MVICore/blob/master/documentation/demoproject.md


  1. vyndor
    13.11.2018 22:47

    Спасибо за труды. Хотелось бы понять как вы решаете такие кейсы:

    1. Процесс убился и мы хотим что-то восстановить из Bundle'а при пересоздании Activity/Fragment. Как это может быть реализовано?
    2. Предположим у нас есть какой-нибудь большой список, как часть State'а. Выглядит так, что каждый раз, когда нам приходит новый State, придётся прогнать список через DiffUtil или что-то подобное. Получим значительный оверхед на любое изменение состояния. Или такие вещи не включаются в State вовсе?
    3. Обычно считается, что Clean и MV* — это ортогональные вещи (Clean — разделение бизнес-логики от фреймворков и системы, MV* — разделение presentation слоя). Мне показалось, что у вас часть бизнес-логики попадает в Feature и пр. классы фреймворка. Можете прокомментировать?


    1. ANublo
      14.11.2018 01:55

      1. Мы можем инициализировать фичу с уже необходимым нам состоянием. При уничтожении фичи вы кладетё в Bundle её текущее состояние, а потом создаёте с уже готовым состоянием.
      На данный момент мы используем TimeCapsule, однако есть некоторые концептуальные проблемы связанные с ним и возможно в будущем будет выработан другой подход.

      2. Держать списки в State это нормальная практика. Ребята из чата встретились с этой проблемой когда переписывали его на MVICore. Для решения они дописали небольшое расширение, чтобы view получало одновременно пару из (viewModel, previousViewModel?). В таком случае список обновлялся только в том случае, если они отличались в моделях. Ну и собственно если список меняется, то его в любом случае придётся прогонять через DiffUtil, чтобы показать пользователю.
      Если сформулировать кратко: мы хотим обновлять UI только тогда, когда он действительно меняется. Этот вопрос вынесен на обсуждения, чтобы найти подход, который покроет все необходимые кейсы, как только он будет найден — решение появится в библиотеке.

      3. Вы верно поняли. В Feature и находится абсолютно вся бизнес-логика.


      1. MihailovJava
        14.11.2018 19:40

        Правильно ли я понимаю, что при копировании списков, вы копируете и каждый его эллемент, иначе не получится пропустить его через логику DiffUtils? В этом случае не возникает ли проблем с большим списком на этапе копирования?

        В догонку, списки могут обновится в 3 «направлениях», подгрузка «поздних»\«ранних» эллементов (как правило пачка) и реакция на уведомления от сервера, создавать ли копию State на каждый одиночный Event от сервера?


        1. ShikaSD Автор
          15.11.2018 00:10

          При копировании списков обычно используется обычная логика из стандартной либы Котлина: элементы старого списка просто перегоняются в новый, что не влечет с собой полного копирования каждого элемента. Если же список не поменялся, мы просто кладем весь старый инстанс в новый State (в Котлине это просто copy для data class). Эти списки и их элементы обычно immutable, что позволяет легко избежать возможных проблем из-за изменений данных снаружи Reducer.

          Насчет второго вопроса: ваше предположение почти верно, мы создаем новую копию State на каждый ивент, который его меняет. Если ивент ничего не поменял (например он только триггерит News или PostProcessor), то обычно State остается прежним.
          Если же State изменяется слишком часто, в теории можно аггрегировать эти ивенты (например через window для RxJava), и применять их изменения пачками.


          1. Link20
            15.11.2018 00:31

            элементы старого списка просто перегоняются в новый, что не влечет с собой полного копирования каждого элемента.

            Переносятся ссылки, но при мапинге во ViewModel будут созданы новые объекты ViewModel для каждого элемента списка. Так что если хочется иметь ViewModel, то нужно придумывать обходные пути, либо на каждое обновление списка создавать порядка N объектов.

            Если же список не поменялся, мы просто кладем весь старый инстанс в новый State (в Котлине это просто copy для data class)

            Тогда нужно сохранять viewModel, чтобы каждый раз список не мапить в новые viewModel объекты.


    1. ShikaSD Автор
      14.11.2018 02:33

      В дополнение к комментарию ANublo насчет пункта про восстановление из Bundle. Если мы держим фичу в Android компоненте (Activity/Fragment), то восстановление State возможно при создании Feature (один из параметров — initialState). Вопрос в том, что часто имеет смысл держать фичу в более глобальном контексте, привязываясь к view и иным компонентам через Binder, который умеет в Lifecycle (об этом больше во второй статье).

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


    1. arkivanov
      15.11.2018 01:10

      Как разработчик чата в Badoo и первопроходец MVICore могу добавить:
      1. Есть возможность сохранять/восстанавливать стейты. Как отметили выше, на данный момент мы используем TimeCapsule и её Андроид реализацию AndroidTimeCapsule. Нужно ей передать в фичу и тогда фича регистрирует себя в капсуле как поставщик стейта, передавая туда Supplier текущего стейта. Далее из onSaveInstanceState надо сказать капсуле сохранить состояние и она опросит все зарегистрированные фичи, соберёт с них состояния и положит в Bundle. А позже фича сможет взять сохранённое состояние из капсулы. При этом перед выдачей стейта на сохранение, его можно изменить (во всё том же Supplier'е), например можно сбросить флаг загрузки, чтобы после пересоздания фича не оказалась в этом состоянии.
      2. Списки храняться в тех же стейтах и они иммутабельные (хотя никто не мешает делать мутабельные списки для каких-либо хитрых случаев). Далее при маппинге стейтов во view-модели, можно постараться использовать исходные объекты: например просто перенести List как он есть из стейта во view-модель. Однако это не всегда возможно. В этом случае можно прибегнуть к хитростям. Например можно сделать LruCache из ChatMessage->ViewChatMessage и пробовать брать сначала оттуда, а если там нет, тогда мапить. Это позволит избежать лишних аллокаций.
      3. В MVICore ничего особо строго не регламентировано, у разработчика довольно большая свобода. Лично я страюсь всю бизнес-логику складывать в фичи и писать на них хорошие и очень подробные юнит-тесты. А например источники данных (data source) и view делать совершенно без логики (например источники данных — это совершенно пассивные компоненты, выполняющие только дай-положи) и обязательно stateless.


  1. Lebedevsd
    14.11.2018 12:02
    +1

    Ребят очень классно.

    Есть предложение немного переименовать News State Feature Wish (NSFW)