Как часто в разработке функциональности мобильного приложения вы сталкиваетесь со слонами? Большими и неповоротливыми, но настолько умными и полезными для пользователей, что игнорировать их просто невозможно. Хотя бы из-за их размера.  Казалось бы, еще месяц-другой назад это был такой простой в обращении питомец, но теперь приходится быть особенно осторожным, чтобы не превратить кодовую базу в груду раздавленных осколков. В этой статье я предлагаю вам отправиться в продуктово-техническое сафари. Мы возьмем одного такого слона из соискательского приложения hh и попробуем обучить его балетной гибкости. Шаг за шагом, фрагмент за фрагментом.   Цель статьи: поделиться нашим текущим опытом на тернистом пути к безболезненному росту и переиспользованию фич приложения, а так же рассмотреть и опробовать на реальном кейсе общий принцип к проектированию архитектуры, описанный в докладе The immense benefits of not thinking in screens.  Под словом “фича” будем понимать неделимую единицу функциональности приложения.
Делить функциональность на фичи мы хотели бы так, чтобы сохранить общий язык с бизнесом и конечными пользователями. Так мы сможем развивать техническую сторону приложения, исходя из логики развития продукта.  В hh мы крепко подсели на MVICore для управления состоянием наших фич. По именованию сущностей в этой статье может показаться, что статья имеет смысл только в контексте MVI, но на самом деле мы не будем затрагивать внутренние особенности этого паттерна. Вместо этого мы воспользуемся общим принципом построения архитектуры на основе black box компонентов.  Перед тем, как перейти к практическому кейсу, разберем, что такое black box компоненты. Основы этой концепции отлично описаны в статьях от коллег из Badoo (особенно в этой).  В общем виде black box — это система, внутреннее устройство которой неизвестно за ее пределами. Все, что мы знаем о black box, это то, что с ним можно взаимодействовать через некоторый известный Input и наблюдать его реакции через Output. Рассмотрим более частный вариант black box, который мы будем называть Feature, и при помощи которого будем моделировать функциональность приложения.  Wish — это внешние команды для фичи. Можно думать о них как о “просьбах” сделать фичу то, для чего она предназначена. Wish может вызвать у фичи ответную реакцию двух типов: Приведем пример: представьте фичу “форма заявки от пользователя” в виде black box. Форма может содержать правила валидации, автозамены, предзаполнения etc., но всё это является внутренним устройством фичи, которое не имеет значения в контексте интеграции с другими компонентами приложениями. Для пользователей формы Wish-ами станут события ввода данных, State — текущее содержимое полей формы, а News — ошибки заполнения формы, которые, например, будут нарисованы на UI одноразовыми сообщениями в Snackbar.  В терминах реактивных фреймворков (RxJava, Reaktive) Feature реализует интерфейсы Consumer<Wish>, ObservableSource<State> и ObservableSource<News>. Мы можем подписывать Consumer<B> на ObservableSource<A>, если нам известно преобразование между типами (A) -> B. Это позволяет нам научить наши фичи общаться друг с другом. Так мы получаем инструмент для создания систем реактивных компонентов, который попробуем применить для описания фич приложения и их взаимодействия друг с другом.  Перейдем к конкретному примеру. В приложении hh есть экран со списками откликов на вакансии (negotiations). На этом экране пользователь должен видеть различные списки сделанных им откликов, разбитых по статусам. Каждый список поддерживает пагинацию, обновление по свайпу, показ дополнительных сообщений и многое другое. 
Скриншоты специально подобраны так, чтобы затронуть как можно больше функциональных особенностей этого экрана. Давайте попробуем подумать об этом экране, как об изолированном black box компоненте.  Чтобы однозначно описать фичу в виде black box, достаточно описать 3 типа — Wish, State и News. Если попробовать описать типы "в лоб" и проектировать экран при помощи подхода "фича == экран" (как мы и сделали изначально), то получится нечто подобное: Вот несколько наблюдений, которые можно сделать по этим сниппетам: Кроме того: Но что будет, если мы попробуем разбить один большой "чёрный ящик" на несколько маленьких, разделяя их по функциональности? Давайте пройдемся по всем функциональным особенностям экрана откликов, которые мы можем увидеть на скриншотах “сверху вниз”. Сначала попробуем выделить те функциональные черты, которые присущи экрану на уровне самого верхнего UI-контейнера, а затем будем спускаться вниз по иерархии визуальных компонентов. Несмотря на то, что мы сейчас пытаемся декомпозировать в первую очередь бизнес-логику экрана, а не его UI, бизнес-правила зачастую проецируются на конкретные визуальные компоненты. Далее по пунктам рассмотрим какую функциональность мы выделили в отдельные фичи.   Экран списка откликов (и некоторые другие экраны приложения) доступны как авторизованным, так и неавторизованным пользователям. Если пользователь не авторизован, логика экрана предельно простая: мы показываем заглушку, которая предлагает пройти авторизацию.  Мы не хотим, чтобы остальные фичи экрана знали о том, в авторизованной они зоне, или нет. Мы хотим вообще избавить их от необходимости подписываться на состояние авторизации. Более того, схожая логика по контролю авторизации присутствует и на других экранах. Если мы сможем выделить переиспользуемый юнит для этой логики и в контексте других фич не зависеть от него напрямую, то почему бы этого не сделать? Получился black box авторизации с простейшим контрактом:  У AuthFeature нет ни одного Wish, так как эта фича самостоятельно подписывается на источник информации о смене активного пользователя. Если нам потребуется знать больше информации о текущем пользователе, помимо индикатора авторизации, мы расширим состояние и внутреннюю логику именно этой фичи.   Статусы откликов визуально представлены как панель с табами. Помимо отображения счётчиков непросмотренных изменений, каждый таб является фильтром по статусу откликов. Мы смоделировали эту фичу следующим образом: Фича описывает выбранный пользователем статус (NegotiationStatusPage) и значения счётчиков для каждого статуса. При изменении значений счётчиков или выборе нового статуса мы должны сообщать об этом наружу, чтобы узнать о необходимости обновления соответствующего списка откликов. Мы описали это через News, так как снаружи нас интересует единоразовая реакция на событие перехода между конкретными состояниями.   Баннер "Откликайтесь чаще" по бизнесовым правилам должен отображаться только если среди статусов откликов выбран фильтр "Все". При этом, если пользователь скроет баннер нажатием на крестик, данное сообщение больше не должно всплывать.  Wish CheckVisibility и ClearVisibility нужны для проверки состояния и сброса флага о показе баннера при смене состояния авторизации.  Помните я говорил, что не хотелось бы, чтобы другие фичи экрана что-то знали про авторизацию? На самом деле мы можем избавиться от этих двух Wish, если не создавать экземпляр фичи до логина пользователя и уничтожать её сразу после логаута. Но к этой теме мы вернёмся позже, когда закончим декомпозицию фич и перейдем к их связыванию, заодно обсудим, причём тут декларативный UI.   То, ради чего и создавался экран — отображение списка откликов. Это типичный списочный компонент с пагинацией, завязанной на скролл. Пагинация — не самая тривиальная фича, поэтому реализовывать её на каждом конкретном экране с нуля не очень хочется, учитывая количество возможных краевых случаев. Поэтому мы решили выделить её в отдельный black box, параметризованный типом элементов списка и функцией для подгрузки очередной страницы:  Каждый элемент списка откликов содержит индикатор индекса вежливости работодателя (отображает процент просмотренных работодателем откликов). По клику на этот индикатор нужно развернуть более подробное описание статистики.  Соискатель может удалить свой отклик, и опционально, он может сделать это с отказом. Для изоляции логики действий с откликами мы решили завести отдельную фичу, которая через News может сообщить о результатах удаления. В случае успеха мы обновим список с этим откликом, в случае ошибки — покажем Snackbar.  Некоторые фичи (например, статистика работодателя — EmployerStatsFeature), получились очень простыми и могут показаться излишними, ведь они управляют всего лишь UI-логикой отображения элементов списка. Однако, сформулировать чёткие критерии и границы, где логика перестаёт быть UI-only и превращается в чистую бизнес-логику, довольно сложно. На выходе мы хотели получить набор однородных компонентов, которые будут отделены друг от друга не по границам искусственно введённых слоёв, а по границам той функциональности, за которую они отвечают.  Итак, мы получили семейство "чёрных ящиков" (фич) экрана, которые пока что никак не связаны друг с другом. Прежде чем начать связывать их в коде, давайте построим диаграмму связей между фичами, которые диктуются нам бизнес-требованиями:  Черные прямоугольники — это фичи экрана. Стрелками отмечено влияние на другие фичи. Красным описано, что произошло, а зеленым — как отразится на другой фиче. Обратите внимание, что диаграмма отражает естественную сложность нашего экрана и не содержит искусственных терминов, навязанных кодом.  Я уже упоминал выше, что связи между событиями авторизации и реакциями других фич в стиле "Сбрасываем состояние" выглядят не очень красиво, и их можно избежать, если создавать фичи только в авторизованной зоне, гарантированно уничтожая их при логауте. Чтобы лучше раскрыть эту тему, следует взглянуть еще разок на экран откликов и оценить его уже с точки зрения иерархии фрагментов.   Корень иерархии — фрагмент NegotiationsContainerFragment, который нужен для показа содержимого вкладки нижней навигации, а заодно в нем мы можем показать bottom sheet-диалог для действий с откликами. В контейнер кладётся фрагмент NegotiationPagerFragment, отображающий состояние экрана списка откликов в авторизованной и неавторизованной зоне, а заодно содержащий ViewPager для списков откликов. Списки откликов, разбитые по статусам, находятся в отдельных StatusPageFragment. За каждым фрагментом мы закрепляем автоматически создаваемый DI-скоуп, в котором поддерживаем экземпляры наших фич. В идеале нужно было вынести экран-заглушку (который показывается в случае отсутствия авторизации) в отдельный фрагмент и не прикреплять фрагмент для авторизованной зоны (NegotiationsPagerFragment) до тех пор, пока фича авторизации не сообщит нам, что пользователь авторизовался. Тогда мы бы получили автоматическое пересоздание всех нужных фич при логине/логауте пользователя. Однако в реалиях работы с фрагментами синхронизация состояния FragmentManager с состоянием бизнес-логики приложения — не самая приятная задача и потенциальный источник багов, связанных с неконсистентностью отображения экрана. В рамках рефакторинга логики экрана меньше всего хотелось трогать уже существующий код отображения состояний на View. Поэтому пока что мы решили оставить уже имеющуюся иерархию фрагментов, что заставило нас добавить специальные Wish для сброса состояния фич из авторизованной зоны. Фреймворки для декларативного UI должны решить проблему гибкого управления жизненным циклом экземпляров фич, так как изменив флажок в состоянии фичи, мы сможем автоматически перестроить иерархию UI-компонентов, а значит и создать/уничтожить привязанные к ним фичи.  Наша цель — прозрачно описать граф связей между фичами, чтобы он соответствовал построенной диаграмме бизнес-связей. Каждая фича реализует интерфейсы Consumer<Wish>, ObservableSource<State>, ObservableSource<News>. И по сути, связывание фич — это реактивные подписки с маппингом State/News одних фич в Wish для других фич. Важное наблюдение. Black box — очень общая концепция, которой можно описывать не только stateful бизнес-логику фич. Следовательно, и связывание можно делать не только между фичами, а между произвольными ObservableSource<A> и Consumer<B>. Например, UI тоже можно рассматривать как black box с контрактами Consumer<UiState> и ObservableSource<UiEvent>, а любые внешние события, которые происходят за рамками данного фрагмента, как ObservableSource<ExternalEvent>.  Для создания таких подписок мы использовали Binder из MVICore, но концептуально подход применим и для других фреймворков: можно использовать корутины, подписывать Rx-потоки напрямую или использовать обычные коллбэки. Главная идея — описать связи между фичами в явном виде под нужды конкретного контекста использования, вытащив эти связи из внутренней реализации фич наружу. По рекомендации от Badoo связывание фич удобно вынести в отдельный класс Bindings. Мы решили разделить связи между фичами экрана по структурным компонентам (в нашем случае это обычные фрагменты). В DI-скоупе самого верхнего уровня (который привязан к жизненному циклу NegotiationsPagerFragment) создается экземпляр фичи авторизации (AuthFeature), действий с откликами (NegotiationActionsFeature) и фича статусов откликов (StatusFeature). Также на этом уровне мы слушаем внешние события, которые могут произойти на других экранах приложения и повлиять на состояния этих фич.  Binder автоматически выполнит отписку связей между фичами, когда переданный ему жизненный цикл (объект Lifecycle) перейдет в уничтоженное состояние. Отмечу, что здесь мы используем два lifecycle. featureLifecycle соответствует жизненному циклу DI-скоупа, связанного с фрагментом, а viewLifecycle соответствует жизненному циклу View этого же фрагмента. Трансформации между типами событий описываются несложными чистыми функциями, вот пример:  Трансформации событий между фичами могут описывать бизнес-правила их взаимодействия. При необходимости эти правила можно легко протестировать.  На уровень ниже находится StatusPageFragment, в котором создается экземпляр фичи пагинации (PaginationFeature) и показа баннера (RecommendationFeature). При этом для каждой страницы ViewPager создаются свои экземпляры этих фич. Примеры трансформаций для этого уровня: Еще раз отмечу, что наши фичи не имеют ни одной прямой зависимости друг от друга и связываются только на уровне реактивных потоков извне.  
"Look around — we live in a perfect world where everything fits together and no one gets hurt."
— Homer Jay Simpson Один из вариантов решения проблемы — делать иерархию структурных компонентов менее плоской. Под структурными компонентами здесь мы понимаем сущности типа Fragments, RIBs, Controller из Conductor и т.п., в контексте которых мы связываем фичи.  Можно ввести иерархию потоков данных между структурными компонентами и стараться упрощать их внутреннее устройство. Это не избавляет нас от большого количества связей, но помогает в них ориентироваться. Подробнее про эту идею можно почитать в одном из туториалов по RIBs.  Важно всегда помнить, что разделяя одно состояние фичи на множество мелких, мы можем потерять идеальную целостность инвариантов состояния. Под инвариантом будем понимать некоторое логическое выражение от нескольких состояний, истинность которого нам хотелось бы обеспечить при любом раскладе. Приведу пример. Допустим, у нас есть фича, которая управляет состоянием вакансии, и отдельная фича для управления процессом отклика на вакансию (отклик это самостоятельная процедура, включающая несколько этапов). Один из инвариантов, который мы хотим поддержать между этими фичами, заключается в том, что если процесс отклика перешёл в состояние "отклик сделан", то вакансия должна иметь состояние "текущий пользователь откликался". Фиче вакансии необходимо знание о наличии отклика, чтобы открывать пользователю дополнительные действия.  Таким образом, разделив состояние связанных друг с другом фич на отдельные сущности, мы создали возможность нарушения инварианта в нашей реактивной системе, пусть и на очень малый отрезок времени. Когда процесс отклика перейдет в конечное состояние, фича вакансии может еще не успеть среагировать на этот факт в силу чисто технических причин (вроде затрат времени на переключение потоков). И не исключено, что Consumer состояния вакансии успеет сделать что-то невалидное, предполагая, что отклика на вакансию всё ещё нет.  На практике это свойство систем с распределенным состоянием не всегда является критичным. Например, если "очень малый отрезок времени" несогласованности данных не ломает дальнейшую бизнес логику. Или если он влияет только на UI, но происходит в пределах отрисовки одного кадра и не вызывает резких мерцаний. Тем не менее, всегда стоит иметь это в виду и ознакомиться с опытом коллег из других областей разработки. Один из способов частично избежать проблем с несогласованностью данных при их расщеплении — проектировать распределенные состояния так, как если бы мы проектировали реляционную базу данных, стараясь избегать дублирования и противоречивости в модели данных. Однако если два состояния должны поддерживать между собой большое количество инвариантов, это признак того, что будет лучше контролировать их в рамках одной фичи. В таких случаях могут помочь подходы к композиции фич в рамках одного состояния. В этой статье я рассказал про наш боевой тест-драйв подхода к изоляции фич в реактивные black box компоненты. Неизбежным последствием стало расщепление состояния между компонентами и их связывание с использованием реактивного фреймворка. Основное преимущество, которое мы получили — облегчили переиспользование и тестирование фич. Также мы получили возможность масштабировать сложные фичи, конструируя их из простых компонентов. В нашем случае мы больше всего были рады тому, что теперь у нас есть универсальный и хорошо протестированный компонент для работы со списочной пагинацией, для подключения которого не нужно затрагивать реализацию других компонентов экрана. Будем продолжать экспериментировать с этим подходом и для других фич приложения.  Как я отметил в предыдущем разделе, фичи можно композировать в рамках одного состояния. Например, при использовании подходов с централизованным управлением состоянием (в духе TEA или Redux) композиция логики может быть реализована на уровне чистых функций. Главный плюс подхода к композиции на уровне единого хранилища состояния — сохранение возможности более консистентного контроля состояния и отсутствие сильной зависимости от реактивных фреймворков. Но для подробного описания композиции с единым состоянием, кажется, потребуется отдельная большая статья.  Дабы избежать холиваров, сразу отмечу, что целью статьи является не сравнение различных подходов в стиле “лучше/хуже”, а лишь практическая демонстрация особенностей подхода к декомпозиции составных фич на реактивную систему black box компонентов. Выбор подходящего инструмента под нужды конкретно вашего проекта is your own. Однако нам всегда интересно поделиться с вами нашим опытом. Отдельное спасибо за помощь при подготовке текста статьи: Ztrel, alaershov, Xanderblinov