Как часто в разработке функциональности мобильного приложения вы сталкиваетесь со слонами? Большими и неповоротливыми, но настолько умными и полезными для пользователей, что игнорировать их просто невозможно. Хотя бы из-за их размера.


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



Цель статьи: поделиться нашим текущим опытом на тернистом пути к безболезненному росту и переиспользованию фич приложения, а так же рассмотреть и опробовать на реальном кейсе общий принцип к проектированию архитектуры, описанный в докладе The immense benefits of not thinking in screens.


Под словом “фича” будем понимать неделимую единицу функциональности приложения.
Делить функциональность на фичи мы хотели бы так, чтобы сохранить общий язык с бизнесом и конечными пользователями. Так мы сможем развивать техническую сторону приложения, исходя из логики развития продукта.


В hh мы крепко подсели на MVICore для управления состоянием наших фич. По именованию сущностей в этой статье может показаться, что статья имеет смысл только в контексте MVI, но на самом деле мы не будем затрагивать внутренние особенности этого паттерна. Вместо этого мы воспользуемся общим принципом построения архитектуры на основе black box компонентов.


Black box компоненты


Перед тем, как перейти к практическому кейсу, разберем, что такое black box компоненты. Основы этой концепции отлично описаны в статьях от коллег из Badoo (особенно в этой).


В общем виде black box — это система, внутреннее устройство которой неизвестно за ее пределами. Все, что мы знаем о black box, это то, что с ним можно взаимодействовать через некоторый известный Input и наблюдать его реакции через Output.


Рассмотрим более частный вариант black box, который мы будем называть Feature, и при помощи которого будем моделировать функциональность приложения.


Схема Feature


Wish — это внешние команды для фичи. Можно думать о них как о “просьбах” сделать фичу то, для чего она предназначена. Wish может вызвать у фичи ответную реакцию двух типов:


  • Изменить свое текущее состояние (State)
  • Отправить одно или несколько событий (News)

Приведем пример: представьте фичу “форма заявки от пользователя” в виде black box. Форма может содержать правила валидации, автозамены, предзаполнения etc., но всё это является внутренним устройством фичи, которое не имеет значения в контексте интеграции с другими компонентами приложениями. Для пользователей формы Wish-ами станут события ввода данных, State — текущее содержимое полей формы, а News — ошибки заполнения формы, которые, например, будут нарисованы на UI одноразовыми сообщениями в Snackbar.


Схема разницы между State и News


В терминах реактивных фреймворков (RxJava, Reaktive) Feature реализует интерфейсы Consumer<Wish>, ObservableSource<State> и ObservableSource<News>.


class Feature : Consumer<Wish>, ObservableSource<State> {

    override fun accept(wish: Wish) { ... }

    override fun subscribe(observer: Observer<State>) { ... }

    val news = ObservableSource<News> { ... }

}

Мы можем подписывать Consumer<B> на ObservableSource<A>, если нам известно преобразование между типами (A) -> B. Это позволяет нам научить наши фичи общаться друг с другом. Так мы получаем инструмент для создания систем реактивных компонентов, который попробуем применить для описания фич приложения и их взаимодействия друг с другом.


Толстофича и ее проблемы


Перейдем к конкретному примеру. В приложении hh есть экран со списками откликов на вакансии (negotiations). На этом экране пользователь должен видеть различные списки сделанных им откликов, разбитых по статусам. Каждый список поддерживает пагинацию, обновление по свайпу, показ дополнительных сообщений и многое другое.



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


Давайте попробуем подумать об этом экране, как об изолированном black box компоненте.


Чтобы однозначно описать фичу в виде black box, достаточно описать 3 типа — Wish, State и News. Если попробовать описать типы "в лоб" и проектировать экран при помощи подхода "фича == экран" (как мы и сделали изначально), то получится нечто подобное:


Black box экрана откликов
State экрана
data class NegotiationListState(
    val isRespondMoreOftenBannerClosed: Boolean,
    val isUserLoggedIn: Boolean,
    val currentOpenedTab: NegotiationStatusPage,
    val negotiationsByStatus: Map<NegotiationStatusPage, NegotiationPagesState>,
    val newItemsCounter: Map<NegotiationStatusPage, Int?>
)

data class NegotiationsPageState(
    val list: List<Negotiation>,
    val currentPage: Int,
    val pageSize: Int,
    val pages: Int,
    val foundedCount: Int,
    val isRefreshingBySwipe: Boolean,
    val isSkeletonItemsVisible: Boolean,
    val loadingPageIndex: Int?,
    val error: Throwable?,
    val employerStatsOpenedIds: Set<String>
)

Wish экрана
sealed class NegotiationListWish

object CloseRespondMoreOftenBannerWish : NegotiationListWish()

object CheckRespondMoreOftenBannerWish : NegotiationListWish()

data class ChangeUserLoggedInWish(
    val isLoggedIn: Boolean
) : NegotiationListWish()

data class DeleteNegotiationWish(
    val id: String, 
    val withDecline: Boolean
) : NegotiationListWish()

data class SwitchNegotiationEmployerStatsWish(
    val negotiationId: String,
    val statusPage: NegotiationStatusPage
) : NegotiationListWish()

object UpdateCountersWish : NegotiationListWish()

object ReloadAllAndWaitingNegotiationsPagesWish : NegotiationListWish()

data class LoadNegotiationsForCurrentPageWish(
    val isFirstLoadingPage: Boolean
) : NegotiationListWish()

data class OpenTabWish(
    val tab: NegotiationStatusPage
) : NegotiationListWish()

data class ReadNegotiationWish(
    val id: String
) : NegotiationListWish()

data class NegotiationDeletedWish(
    val id: String
) : NegotiationListWish()

object CurrentTabEmptyErrorActionWish : NegotiationListWish()

object SwipeToRefreshOnCurrentTabWish : NegotiationListWish()

data class CountersChangedOnTabsTabsWish(
    val tabs: List<NegotiationStatusPage>
) : NegotiationListWish()

News экрана
sealed class NegotiationListNews

data class NegotiationDeleteFailedNews(
    val negotiationId: String,
    val withDecline: Boolean
) : NegotiationListNews()

data class PageLoadingFinishedNews(
    val page: NegotiationStatusPage, 
    val showSnackError: Boolean
) : NegotiationListNews()

object OpenVacancySearchNews : NegotiationListNews()

Вот несколько наблюдений, которые можно сделать по этим сниппетам:


  • Фича имеет довольно много "внешних ручек" (Wish). При обработке такого количества вариантов Wish в when-выражении мы регулярно сталкиваемся с ворнингами статического анализатора о том, что метод слишком сложный. У фичи слишком сложный внешний интерфейс.
  • Как на уровне состояния фичи, так и на уровне ее Wish и News можно выделить отдельные ответственности экрана. И есть предположение, что разные Wish независимо друг от друга влияют на разные части состояния. Явный намек на то, что плоская иерархия Wish и полей в State может быть как-то сгруппирована.
  • Из-за настолько объёмного контракта фичи практически невозможно отделить специфичные функциональные особенности экрана откликов от более общих, которые мы бы хотели использовать на других экранах. Например, логику показа баннера с рекомендацией ("Откликайтесь чаще!") можно добавить и в другие списки внутри приложения, но она уже привязана к логике экрана откликов. Низкая переиспользуемость.
  • Если приоткрыть ящик Пандоры дверцу black box-а и посмотреть на детали внутренней реализации фичи, то мы найдём там ещё одну sealed-иерархию из множества data-классов, предназначенных для изменения состояния фичи (Effect-ы в терминах MVICore), и набор сущностей, специфичных для библиотеки MVICore: Actor, Reducer, Bootstraper, PostProcessor, реализация которых может насчитывать несколько сотен строк. Я не буду вдаваться в подробности о том, что именно происходит внутри этих сущностей, просто отмечу сложность и масштабы содержимого black box, который мы получили с таким подходом. Сложная реализация.

Кроме того:


  • В контексте интеграции фичи с остальным приложением неважно, насколько сложно фича устроена внутри, если рассматривать её как "чёрный ящик", потому что есть чёткий контракт входов и выходов. С другой стороны, если вся логика работы экрана сосредоточена внутри одного black box, то при добавлении новой функциональности придётся заново изучать все детали его реализации. Только так мы сможем гарантировать, что новый код будет правильно дружить с написанным ранее. Сложная поддержка.
  • Ещё отмечу, что хоть зачастую мы и можем поделить экран на набор обособленных кусочков функциональности, не всегда получается сделать их полностью независимыми друг от друга. Например, на приведенном выше экране откликов есть панель с табами-статусами. У каждого таба есть счетчик непрочитанных откликов, который периодически обновляется. По бизнес-правилам экрана если счётчик при обновлении получит новое значение, необходимо выполнить обновление соответствующего списка откликов. При использовании подхода "фича = экран" такие бизнес-правила (которые описывают связи между разными кусочками функциональности) будут растворяться во внутренней реализации фичи, и порой тяжело узнать об их существовании, не говоря о том, чтобы чётко сформулировать. Неочевидно как одна функциональность может затрагивать другую.

Декомпозиция фичи списка откликов


Но что будет, если мы попробуем разбить один большой "чёрный ящик" на несколько маленьких, разделяя их по функциональности?


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


Авторизация



Экран списка откликов (и некоторые другие экраны приложения) доступны как авторизованным, так и неавторизованным пользователям. Если пользователь не авторизован, логика экрана предельно простая: мы показываем заглушку, которая предлагает пройти авторизацию.


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


Получился black box авторизации с простейшим контрактом:


AuthFeature
class AuthFeature {

    data class State(
        val isUserLoggedIn: Boolean
    )

}

У AuthFeature нет ни одного Wish, так как эта фича самостоятельно подписывается на источник информации о смене активного пользователя. Если нам потребуется знать больше информации о текущем пользователе, помимо индикатора авторизации, мы расширим состояние и внутреннюю логику именно этой фичи.


Статусы откликов



Статусы откликов визуально представлены как панель с табами. Помимо отображения счётчиков непросмотренных изменений, каждый таб является фильтром по статусу откликов. Мы смоделировали эту фичу следующим образом:


StatusFeature
class StatusFeature {

    sealed class Wish {
        object UpdateCounters : Wish()

        data class SetActiveStatusPage(
            val statusPage: NegotiationStatusPage
        ) : Wish()

        object Clear : Wish()
    }

    data class State(
        val activeStatusPage: NegotiationStatusPage,
        val counters: Map<NegotiationStatusPage, Int>,
        val isCountersInUpdate: Boolean
    )

    sealed class News {
        data class CountersChanged(
            val changedStatusPages: Set<NegotiationStatusPage>
        ) : News()

        data class ActiveStatusPageChanged(
            val newValue: NegotiationStatusPage
        ) : News()
    }

}

Фича описывает выбранный пользователем статус (NegotiationStatusPage) и значения счётчиков для каждого статуса. При изменении значений счётчиков или выборе нового статуса мы должны сообщать об этом наружу, чтобы узнать о необходимости обновления соответствующего списка откликов. Мы описали это через News, так как снаружи нас интересует единоразовая реакция на событие перехода между конкретными состояниями.


Рекомендация “Откликайтесь чаще”



Баннер "Откликайтесь чаще" по бизнесовым правилам должен отображаться только если среди статусов откликов выбран фильтр "Все". При этом, если пользователь скроет баннер нажатием на крестик, данное сообщение больше не должно всплывать.


RecommendationFeature
class RecommendationFeature {

    sealed class Wish {
        object Close : Wish()

        object CheckVisibility : Wish()

        object ClearVisibility : Wish()
    }

    data class State(
        val shouldShowRecommendationBanner: Boolean
    )

}

Wish CheckVisibility и ClearVisibility нужны для проверки состояния и сброса флага о показе баннера при смене состояния авторизации.


Помните я говорил, что не хотелось бы, чтобы другие фичи экрана что-то знали про авторизацию? На самом деле мы можем избавиться от этих двух Wish, если не создавать экземпляр фичи до логина пользователя и уничтожать её сразу после логаута. Но к этой теме мы вернёмся позже, когда закончим декомпозицию фич и перейдем к их связыванию, заодно обсудим, причём тут декларативный UI.


Список откликов с пагинацией



То, ради чего и создавался экран — отображение списка откликов. Это типичный списочный компонент с пагинацией, завязанной на скролл. Пагинация — не самая тривиальная фича, поэтому реализовывать её на каждом конкретном экране с нуля не очень хочется, учитывая количество возможных краевых случаев. Поэтому мы решили выделить её в отдельный black box, параметризованный типом элементов списка и функцией для подгрузки очередной страницы:


PaginationFeature
class PaginationFeature<Entity>(
    val pageFetcher: (params: PageParams) -> Single<PageData<Entity>>
) {

    sealed class State<out Entity> {
        object Initial : State<Nothing>()

        object InitialProgress : State<Nothing>()

        data class InitialError(
            val error: Throwable
        ) : State<Nothing>()

        data class Data<Entity>(
            val dataList: List<Entity>,
            val isInReloading: Boolean,
            val isNextPageLoading: Boolean,
            val lastLoadingError: Throwable?,
            val lastLoadedPage: Int,
            val isAllPagesLoaded: Boolean,
            val listVersion: Long
        ) : State<Entity>()
    }

    sealed class Wish<out Entity> {
        object LoadNextPage : Wish<Nothing>()

        object Reload : Wish<Nothing>()

        object Clear : Wish<Nothing>()

        data class DeleteEntities<Entity>(
            val predicate: (Entity) -> Boolean
        ) : Wish<Entity>()

        data class UpdateEntities<Entity>(
            val predicate: (Entity) -> Boolean,
            val update: (Entity) -> Entity
        ) : Wish<Entity>()
    }

    sealed class News {
        data class PageLoadingError(
            val error: Throwable
        ) : News()
    }

}

Статистика работодателя



Каждый элемент списка откликов содержит индикатор индекса вежливости работодателя (отображает процент просмотренных работодателем откликов). По клику на этот индикатор нужно развернуть более подробное описание статистики.


EmployerStatsFeature
class EmployerStatsFeature {

    data class State(
        val activeEmployerStats: Set<String>
    )

    sealed class Wish {
        data class ToggleEmployerStats(
            val negotiationId: String
        ) : Wish()
    }

}

Действия с откликами



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


NegotiationActionsFeature
class NegotiationActionsFeature {

    data class State(
        val deletionInProgress: Set<String>
    )

    sealed class Wish {
        data class Delete(
            val id: String, 
            val withDecline: Boolean
        ) : Wish()
    }

    sealed class News {
        data class Deleted(
            val id: String
        ) : News()

        data class DeletionError(
            val id: String, 
            val withDecline: Boolean
        ) : News()
    }

}



Некоторые фичи (например, статистика работодателя — 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). Также на этом уровне мы слушаем внешние события, которые могут произойти на других экранах приложения и повлиять на состояния этих фич.


NegotiationsPagerBindings
class NegotiationsPagerBindings(
    private val featureLifecycle: Lifecycle,
    private val authFeature: AuthFeature,
    private val statusFeature: StatusFeature,
    private val negotiationActionsFeature: NegotiationActionsFeature,
    private val externalEventsSource: ExternalEventsSource
) {

    init {
        val featureBinder = Binder(featureLifecycle)

        // переносим часть диаграммы бизнес-связей на наши Feature
        with(featureBinder) {
            bind(authFeature 
                to statusFeature using authToStatus)

            bind(negotiationActionsFeature.news 
                to statusFeature using negotiationActionsToStatus)

            bind(externalEventsSource 
                to statusFeature using externalEventsToStatus)
        }
    }

    fun connectView(view: NegotiationsPagerView) {
        val viewBinder = Binder(view.lifecycle)

        // подписываем View на Feature
        with(viewBinder) {
            bind(authFeature to view.zeroScreenView using authToUi)
            bind(statusFeature to view.statusTabs using statusToUi)
            bind(view.statusTabs to statusFeature using uiEventsToStatus)
        }
    }

}

Binder автоматически выполнит отписку связей между фичами, когда переданный ему жизненный цикл (объект Lifecycle) перейдет в уничтоженное состояние. Отмечу, что здесь мы используем два lifecycle. featureLifecycle соответствует жизненному циклу DI-скоупа, связанного с фрагментом, а viewLifecycle соответствует жизненному циклу View этого же фрагмента.


Трансформации между типами событий описываются несложными чистыми функциями, вот пример:


authToStatus, actionToStatus
// реализуем правило 
// "если состояние авторизации изменилось, то нужно обновить или сбросить счетчики"
fun authToStatus(state: AuthFeature.State): StatusFeature.Wish {
    return if (state.isUserLoggedIn) {
        StatusFeature.Wish.UpdateCounters
    } else {
        StatusFeature.Wish.Clear
    }
}

// реализуем правило "если отклик был удален, то нужно обновить счетчики"
fun actionToStatus(
    news: NegotiationActionsFeature.News
): StatusFeature.Wish? {
    return when (news) {
        is NegotiationActionsFeature.News.Deleted -> {
            StatusFeature.Wish.UpdateCounters
        }

        // если возвращаем null, то Consumer (StatusFeature) никак на это не среагирует
        is NegotiationActionsFeature.News.DeletionError -> {
            null
        }
    }
}

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


На уровень ниже находится StatusPageFragment, в котором создается экземпляр фичи пагинации (PaginationFeature) и показа баннера (RecommendationFeature). При этом для каждой страницы ViewPager создаются свои экземпляры этих фич.


NegotiationsStatusPageBindings
class NegotiationsStatusPageBindings(
    private val featureLifecycle: Lifecycle,
    private val authFeature: AuthFeature,
    private val recommendationFeature: RecommendationFeature,
    private val statusFeature: StatusFeature,
    private val employerStatsFeature: EmployerStatsFeature,
    private val paginationFeature: PaginationFeature<Negotiation>,
    private val externalEventsSource: ExternalEventsSource
) {

    init {
        val featureBinder = Binder(featureLifecycle)

        // переносим диаграмму бизнес-связей на наши Feature
        with(featureBinder) {
            bind(authFeature.news 
                to recommendationFeature using authToRecommendation)

            bind(authFeature.news 
                to paginationFeature using authToNegotiationsList)

            bind(negotiationActionsFeature.news 
                to paginationFeature using negotiationActionsToNegotiationsList)

            bind(statusFeature.news 
                to paginationFeature using statusToNegotiationsList)

            bind(externalEventsSource 
                 to paginationFeature using externalEventsToNegotiationsList)
        }
    }

    fun connectView(view: StatusPageView) {
        val viewBinder = Binder(view.lifecycle)

        // комбинируем состояния фич для их конвертации в единый список на UI
        val boundStateSource = Observables.combineLatest(
            Observable.wrap(recommendationFeature),
            Observable.wrap(employerStatsFeature),
            Observable.wrap(paginationFeature),
            ::StatusPageBoundState
        )

        // подписываем View на Feature
        with(viewBinder) {
            bind(boundStateSource 
                to view using boundStateToUi)

            bind(view.recommendationEvents 
                to recommendationFeature using uiEventsToRecommendation)

            bind(view.paginationEvents 
                to paginationFeature using uiEventsToPagination)

            bind(view.employerStatsEvents 
                to employerStatsFeature using uiEventsToEmployerStats)
        }
    }

}

Примеры трансформаций для этого уровня:


statusToNegotiationsList, externalEventsToNegotiationsList
// реализуем правило "обновить список откликов при изменении счетчика обновлений"
fun statusToNegotiationsList(
    news: StatusFeature.News
): PaginationWish<Negotiation>? {
    return when (news) {
        is StatusFeature.News.CountersChanged -> { 
            PaginationWish.Reload()
        }

        is StatusFeature.News.ActiveStatusPageChanged -> {
            null
        }
    }
}

// реализуем правило 
// "обновить элементы списка, если происходит внешнее событие прочтения или удаления отклика"
fun externalEventsToNegotiationsList(
    event: NegotiationsExterrnalEvent
): PaginationWish<Negotiation> {
    return when (result) {
        is NegotiationsExterrnalEvent.ReadResult -> {
            PaginationWish.UpdateEntities(
                predicate = { it.id == result.id },
                update = { it.copy(isRead = true) }
            )
        }

        is NegotiationsExterrnalEvent.DeletionResult -> {
            PaginationWish.DeleteEntities { it.id == result.id }
        }
    }
}

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


Преимущества подхода



"Look around — we live in a perfect world where everything fits together and no one gets hurt."
— Homer Jay Simpson


  • Легко писать и тестировать небольшие изолированные фичи, в которых не смешаны разные ответственности. Тесты на фичи похожи на декларативную спецификацию бизнес-требований.
  • Возможность переиспользования фич на разных экранах в считанные минуты за счет отсутствия прямых зависимостей между ними. Достаточно создать экземпляр уже готовой фичи и “подключить” его входы и выходы к контексту структурного компонента, в котором она должна использоваться.
  • Связи между фичами перестают быть неявными, сосредотачиваются в одном месте, а само по себе связывание становится отдельной ответственностью, которую можно контролировать.

Пример теста на фичу для списков с пагинацией
@Test
fun `first page loads with error and then retry is successful`() {
    PaginationFeature(
        pagesSource = SinglePageWithFirstPageErrorSource
    ).assert(
        givenWishes = arrayOf(
            PaginationWish.LoadNextPage,
            PaginationWish.LoadNextPage
        ),
        expectedStates = arrayOf(
            PaginationState.Initial,
            PaginationState.InitialProgress,
            PaginationState.InitialError(TestPagingException),
            PaginationState.InitialProgress,
            DefaultDataState.copy(
                dataList = SinglePageWithFirstPageErrorSource.page0Items,
                isAllPagesLoaded = true
            )
        ),
        expectedNews = arrayOf(
            PaginationNews.PageLoadingError(TestPagingException)
        )
    )
}

Проблемы подхода


Binding hell: связи между фичами могут выходить из-под контроля


Один из вариантов решения проблемы — делать иерархию структурных компонентов менее плоской. Под структурными компонентами здесь мы понимаем сущности типа Fragments, RIBs, Controller из Conductor и т.п., в контексте которых мы связываем фичи.


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



Нарушение целостности системы


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


Приведу пример. Допустим, у нас есть фича, которая управляет состоянием вакансии, и отдельная фича для управления процессом отклика на вакансию (отклик это самостоятельная процедура, включающая несколько этапов). Один из инвариантов, который мы хотим поддержать между этими фичами, заключается в том, что если процесс отклика перешёл в состояние "отклик сделан", то вакансия должна иметь состояние "текущий пользователь откликался". Фиче вакансии необходимо знание о наличии отклика, чтобы открывать пользователю дополнительные действия.



Таким образом, разделив состояние связанных друг с другом фич на отдельные сущности, мы создали возможность нарушения инварианта в нашей реактивной системе, пусть и на очень малый отрезок времени. Когда процесс отклика перейдет в конечное состояние, фича вакансии может еще не успеть среагировать на этот факт в силу чисто технических причин (вроде затрат времени на переключение потоков). И не исключено, что Consumer состояния вакансии успеет сделать что-то невалидное, предполагая, что отклика на вакансию всё ещё нет.


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


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


Заключение и альтернативные способы композиции фич


В этой статье я рассказал про наш боевой тест-драйв подхода к изоляции фич в реактивные black box компоненты. Неизбежным последствием стало расщепление состояния между компонентами и их связывание с использованием реактивного фреймворка. Основное преимущество, которое мы получили — облегчили переиспользование и тестирование фич. Также мы получили возможность масштабировать сложные фичи, конструируя их из простых компонентов. В нашем случае мы больше всего были рады тому, что теперь у нас есть универсальный и хорошо протестированный компонент для работы со списочной пагинацией, для подключения которого не нужно затрагивать реализацию других компонентов экрана. Будем продолжать экспериментировать с этим подходом и для других фич приложения.


Как я отметил в предыдущем разделе, фичи можно композировать в рамках одного состояния. Например, при использовании подходов с централизованным управлением состоянием (в духе TEA или Redux) композиция логики может быть реализована на уровне чистых функций. Главный плюс подхода к композиции на уровне единого хранилища состояния — сохранение возможности более консистентного контроля состояния и отсутствие сильной зависимости от реактивных фреймворков. Но для подробного описания композиции с единым состоянием, кажется, потребуется отдельная большая статья.


Дабы избежать холиваров, сразу отмечу, что целью статьи является не сравнение различных подходов в стиле “лучше/хуже”, а лишь практическая демонстрация особенностей подхода к декомпозиции составных фич на реактивную систему black box компонентов. Выбор подходящего инструмента под нужды конкретно вашего проекта is your own. Однако нам всегда интересно поделиться с вами нашим опытом.


Отдельное спасибо за помощь при подготовке текста статьи: Ztrel, alaershov, Xanderblinov