Что такое mvi android

Современная MVI-архитектура на базе Kotlin

За последние два года 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

Продолжая предыдущий пример с загрузкой данных, мы получаем следующие действия:

Тогда можно создать 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 и использовать интерфейс для запуска намерений и выведения состояний.
Читайте также:  Apple earpod on android

…может быть поездом, несущимся на вас

Одного Reducer явно недостаточно. Как быть с асинхронными задачами с различными результатами? Как реагировать на пуши с сервера? Как быть с запуском дополнительных задач (например, очистки кеша или загрузки данных из локальной базы) после изменения состояния? Выходит, что либо мы не включаем всю эту логику в Reducer (то есть добрая половина бизнес-логики окажется не охвачена, и о ней придётся позаботиться тем, кто решит воспользоваться нашим компонентом), либо заставляем Reducer заниматься всем сразу.

Требования к MVI-фреймворку

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

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

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

И все же, текущее положение вещей устраивает всех. Рады представить вам MVICore! Исходный код библиотеки открыт и доступен на GitHub.

Чем хорош MVICore

  • Лёгкий способ реализации бизнес-фич в стиле реактивного программирования с однонаправленным потоком данных.
  • Масштабирование: базовая реализация включает только Reducer, а в более сложных случаях можно задействовать дополнительные компоненты.
  • Решение для работы с событиями, которые вы не хотите включать в состояние (проблема SingleLiveEvent).
  • Простой API для привязки фич (и других реактивных компонентов вашей системы) к UI и друг к другу с поддержкой жизненного цикла Android (и не только).
  • Поддержка Middleware (об этом ниже) для каждого компонента системы.
  • Готовый логгер и возможность time travel дебага для каждого компонента.

Краткое введение в Feature

Поскольку на GitHub уже выложена пошаговая инструкция, я опущу подробные примеры и остановлюсь на основных составляющих фреймворка.

Feature — центральный элемент фреймворка, содержащий всю бизнес-логику компонента. Feature определяется тремя параметрами: interface Feature

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

Источник

Читайте также:  Режим передачи файлов андроид как включить

Реактивные приложения с Model-View-Intent. Часть 1: Модель

При работе с платформой Android я столкнулся со множеством проблем, потому что проектировал свои Модели неправильно. Мои приложения были недостаточно реактивными. Теперь используя RxJava и Model-View-Intent (MVI) я, наконец, добился нужного уровня реактивности. Об этом я пишу цикл статей. В первой части расскажу о модели и объясню, чем она важна.

Что я имел в виду, когда сказал, что проектировал модели неправильно? Многие архитектурные шаблоны разделяют «View» от «Model». Самые популярные в разработке Android — Model-View-Controller (MVC), Model-View-Presenter (MVP) и Model-View-ViewModel (MVVM). По названиям видно, что все они используют «Model». Я понял, что большую часть времени у меня вообще не было модели.

Пример. Задача — загрузить список людей с сервера. «Традиционная» имплементация MVP выглядит примерно так:

Что такое модель и где она? Модель — это не бэкенд и не List, который мы получаем. Это сущность, которую отображает View вместе с другими индикаторами загрузки или сообщениями об ошибке. С моей точки зрения, Модель должна выглядеть так:

И тогда Presenter может быть реализован следующим образом:

Теперь у View есть модель, которая отобразится на экране.

Первое определение MVC предложил Трюгве Реенскауг в 1979 году. Оно отражает похожую идею: View наблюдает, как меняется модель. Исследователи описывали термином MVC большое количество шаблонов, которые не попадают под формулировку Реенскауга. Например, серверные разработчики используют фреймворки MVC, у iOS есть ViewController. Возникают вопросы:

  • Что означает MVC в Android?
  • Activity — контроллер?
  • Что такое ClickListener?

Сегодня термин MVC употребляют и толкуют неправильно, поэтому мы приостановим дискуссию, чтобы это не вышло из-под контроля. Вернемся к моему начальному утверждению. В разработке Android мы сталкиваемся с рядом проблем, которые можно избежать с помощью модели:

  1. Проблема состояния.
  2. Изменения ориентации экрана.
  3. Навигация по бэкстеку.
  4. Смерть процесса.
  5. Неизменяемость и однонаправленный поток данных.
  6. Отлаживание и воспроизведение состояний.
  7. Тестируемость.

Посмотрим, как «традиционная» реализация MVP и MVVM справляется с проблемами, и как модель избавляет от распространенных ошибок.

Реактивные приложения — модное определение приложений с UI, которые реагируют на изменения состояния. Состояние — то, что мы видим на экране. Например, состояние загрузки, когда View отображает ProgressBar. Как фронтэнд-разработчики мы склонны фокусироваться на UI: хороший UI определяет, насколько успешно приложение, как много людей будут им пользоваться.

Еще раз взгляните на код Presenter выше — не тот, который использует PersonsModel. Presenter определяет состояние UI, именно он говорит View, что отображать. То же относится и к MVVM. В этой статье я выделяю две реализации MVVM: одна использует Android Data Binding, другая — RxJava. В MVVM с Data Binding состояние находится во ViewModel:

В MVVM с RxJava мы не используем механизм Data Binding, но связываем Observable с виджетами UI во View:

Примеры кода не идеальны, ваша реализация может выглядеть по-другому. Главное, что в MVP и MVVM состоянием управляет Presenter или ViewModel. К чему это приводит:

1. У бизнес-логики есть собственное состояние, так же, как у Presenter или ViewModel. Вы пытаетесь синхронизировать состояния бизнес-логики и Presenter, чтобы они были одинаковыми. Устанавливаете видимость какого-то виджета прямо во View, или Android сам восстанавливает состояние из bundle во время пересоздания.

2. Presenter и ViewModel имеют произвольное количество входных точек. View запускает событие, которое обрабатывает Presenter. Но и Presenter имеет много каналов вывода — как view.showLoading() или view.showError() в MVP. А ViewModel предлагает множественные Observables. Это приводит к конфликтующим состояниям View, Presenter и бизнес-логики, особенно при работе с несколькими потоками.

В лучшем случае появятся визуальные ошибки, например, отображение индикатора загрузки (состояние загрузки) и индикатора ошибки (состояние ошибки) одновременно.

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

Рассмотрим ситуацию, когда у нас единый источник состояния, передаваемый снизу вверх — от бизнес-логики к View. Мы уже видели похожую идею в начале статьи, когда говорили о модели.

Модель отражает состояние — если это понять, вы избежите множество связанных с состоянием проблем. У Presenter будет только один источник вывода: getView().render(PersonsModel). Это отражает простую математическую функцию f(x) = y. Может быть несколько входных значений, например f(a,b,c), но выход всегда будет один.

Математика нравится не всем, но математики, в отличие от программистов, не знают, что такое баг. Понимание сущности модели и того, как правильно ее сформировать, очень важно: оно решает проблему состояния.

Изменения ориентации экрана

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

Однако, лично мне не нравится видеть индикатор загрузки даже несколько миллисекунд, потому что, по моему мнению, это не похоже на плавный интерфейс.. Поэтому разработчики используют MVP c retain презентером. Во время поворота экрана View может быть отделено (и удалено), тогда как Presenter продолжает существовать в памяти, и View снова прикрепляется к нему.

Та же идея возможна при использовании MVVM с RxJava. Но как только View отпишется от своей ViewModel, observable stream — наблюдаемый поток — уничтожится. Этого можно избежать, используя Subjects. В MVVM с Data Binding ViewModel напрямую связана со View механизмом Data Binding. Чтобы избежать утечек памяти, нужно уничтожать ViewModel, когда меняется ориентация экрана.

Читайте также:  Как настроить push уведомления андроид

Главная проблема с retain презентером или ViewModel — как вернуть состояние View к тому, что было до поворота экрана, чтобы View и Presenter снова были в одном состоянии? Я написал MVP библиотеку Mosby, она включает компонент ViewState, который синхронизирует состояние вашей бизнес-логики и View. Moxy, еще одна библиотека MVP, использует команды и воспроизводит состояние View после того, как изменилась ориентации экрана:

Есть и другие решения проблемы состояния View. Сделаем шаг назад и обобщим: все эти библиотеки пытаются решить проблему состояния.

Итак, еще раз: если у нас одна «Модель», отражающая текущее «Состояние», и один метод для обработки «Модели», мы решаем проблему состояния просто вызовом getView().render(PersonsModel) (с последней Моделью, когда повторно присоединяем View к Presenter).

Навигация по бэкстеку

Сохранять ли Presenter или ViewModel, если мы больше не используем View? Например, Fragment (View) был заменен другим Fragment, и пользователь перешел на другой экран. View больше не прикреплена к Presenter — он не сможет обновить View с последними данными от бизнес-логики. Что, если пользователь вернется назад, например, нажав кнопку «Назад» и удалив последнюю транзакцию в бэк-стеке? Нужно ли перезагрузить данные или использовать существующий Presenter?

Пользователь, вернувшись на предыдущий экран, хочет продолжить работу с приложением там же, где остановился. Эту проблему мы обсуждали, говоря об изменении ориентации экрана. Решение очевидно: как только пользователь возвращается из бэк-стека, вызываем getView().render(PersonsModel) с моделью, которая отражает состояние.

Существует общее недопонимание в разработке под Android, что смерть процесса — это плохо, и нам нужны библиотеки, которые помогают восстанавливать состояние после смерти процесса. Процесс умирает в двух случаях: если операционная система Android нуждается в ресурсах для других приложений или для экономии батареи. Этого не случится, когда ваше приложение на переднем плане и его активно используют. Не стоит спорить с платформой. Если у вас есть долгие операции в фоновом режиме, применяйте Service — так система поймет, что приложение используется.

Если процесс умер, Android предоставит некоторые методы жизненного цикла, например onSaveInstanceState(), чтобы сохранить состояние. И снова мы задаем вопросы:

  • Нужно ли сохранять информацию из View в bundle?
  • Есть ли у презентера свое состояние, которое мы также должны сохранить в bundle?
  • Что насчет состояния бизнес-логики?

Сделаем вывод. Как описано в предыдущих пунктах, нам нужна модель, которая отражает полное состояние. Тогда мы просто сохраним модель в bundle и восстановим позже. Но часто лучшее решение — это не сохранение состояния, а перезагрузка всего экрана, как при первом запуске приложения. Представьте приложение, которое отображает список новостей. Наше приложение убито, мы сохраняем состояние. Через 6 часов, когда пользователь откроет приложение и состояние восстановится, приложение отобразит устаревший контент. В этом сценарии лучше перезагружать данные, а не сохранять состояние.

Неизменяемость и однонаправленный поток данных

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

Представьте, что мы пишем простое приложение-счётчик, которое отображает текущее значение в TextView и имеет всего две кнопки — «Увеличить» и «Уменьшить». В этом случае наша модель — значение счетчика. Если это неизменяемая модель, как нам изменить счетчик? Мы не будем изменять TextView сразу после каждого нажатия кнопки.

  1. Наша модель должна использовать только view.render(…) метод.
  2. Прямые изменения модели невозможны.
  3. У нас должен быть единственный источник достоверных данных — бизнес-логика.

В бизнес-логике есть приватное поле с текущей моделью, и она создает новую модель с большим или меньшим значением относительно старой модели.

Мы устанавливаем однонаправленный поток данных, где бизнес-логика — единый источник данных, который создает неизменяемые экземпляры модели. Это выглядит технически усложненным для простого счетчика, ведь счетчик — это простое приложение. Множество приложений разработчики начинают с простых вещей, но затем усложняют. Однонаправленный поток данных и неизменяемая модель важны даже для простых приложений — для разработчиков они останутся простыми, даже когда сложность возрастет.

Отлаживаемые и воспроизводимые состояния

Однонаправленный поток данных обеспечивает простую отладку в нашем приложении. Было бы неплохо получать полный отчет о сбое из Crashlytics, чтобы воспроизвести и быстро устранить этот сбой. Вся нужная нам информация — это текущая модель и действие, которое хотел совершить пользователь в момент сбоя. Например, нажатие кнопки «Уменьшить» в счётчике. Этого достаточно, чтобы воспроизвести сбой, эта информация просто логируется и прикрепляется к отчету о сбое. Без однонаправленного потока данных было трудно узнать, например, что кто-то неправильно использовал EventBus и запустил нашу CounterModel непонятно куда. Без неизменяемости мы не могли бы вычислить, кто и где именно изменил нашу Модель.

«Традиционное» использование MVP или MVVM улучшает тестируемость приложения. MVC тоже можно тестировать: не обязательно размещать всю бизнес-логику в Activity. С моделью, которая отражает состояние, мы можем упростить наши юнит-тесты, просто написав assertEquals(expectedModel, model).
Это избавит нас от создания множества «Mock» объектов.

Мы избавимся от тестов, подтверждающих, что конкретный метод был вызван, т.е.
Mockito.verify(view, times(1)).showFoo()). Нам будет проще читать, понимать и поддерживать код юнит-тестов — не придется работать с деталями реализации настоящего кода.

В первой части из серии статей о MVI мы больше говорили о теоретических вещах. Зачем нужна отдельная статья про модель?

Модель помогает избежать ряда проблем:

  1. Проблемы состояния.
  2. Изменений ориентации экрана.
  3. Навигации по бэкстеку.
  4. Смерти процесса.
  5. Неизменяемости и однонаправленного потока данных.
  6. Отлаживания и воспроизведения состояний.
  7. Тестируемости.

В своих проектах разработчики по-разному называют бизнес-логику — Interactor, Usecase, Repository. Модель — это не бизнес-логика. Бизнес-логика производит модель.

В следующей статье мы посмотрим на всю эту теоретическую часть о Модели в работе. Для примера создадим реактивное приложение, где используем Model-View-Intent. Мы собираемся создать демо-приложение для вымышленного онлайн-магазина.

Источник

Оцените статью