Привет, Хабр! Я, Алексей, ведущий разработчик в платформенной команде Альфа-Бизнес Мобайл. В этой статье речь пойдет про приложение Альфа-Бизнес и об одной из архитектурных проблем, с которой сталкиваются на любом среднем/крупном проекте, рассмотрим несколько способов, которыми можно такие проблемы решать, и расскажу почему мы выбрали именно шарить презентационный слой.
Немного контекста. Альфа-Бизнес Мобайл — классический многомодульный Андроид проект. У нас есть главный модуль app — это модуль-медиатор, который знает про все другие модули проекта. Его главная задача: собрать весь граф зависимостей и предоставить их в другие модули проекта.
Также у нас есть базовые модули, в которых находятся общие утилиты, и базовые классы, которые могут понадобиться в любом модуле проекта. Например, это может быть код, который необходим для работы с сетью, или базовые классы для mvi.
Для фичей мы заводим два модуля, с приставками -api и -impl с интерфейсом и реализацией фичи. На схеме это будет выглядеть так:
Стандартные кейсы с модулями решаются просто
Например, когда нам нужно запустить фичу2 из фичи1, при этом фича2 может иметь несколько экранов внутри, то:
Мы создаем медиатор, который запускает новый флоу навигации.
Интерфейс медиатора находится в модуле -api.
Фича1 может, вызвав метод startFlow, запустить Feature2FragmentFlow. Feature2FragmentFlow, в нашем случае, хостит все фрагменты фичи2.
Дальше все переключения происходят внутри модуля -impl запущенной фичи через Feature2Navigator.
Но что делать, когда кейс нестандартный?
Допустим, у нас есть экран какой-то сложной фичи. Для примера рассмотрим упрощенную версию экрана профиля пользователя.
На рисунке видно несколько фичей: смена номера телефона, смена/верификация email, смена паспортных данных. Каждая фича имеет свое состояние.
Если бы каждая фича была просто кнопкой, которая ведёт на следующий экран, задача бы упростилась: нужно просто подключить кнопку к навигатору.
В нашем же случае фича представлена отдельным виджетом.
У виджетов появляется свое состояние.
Виджет с электронной почтой отображает смену почты и статус верификации. Получение этого статуса может приводить к специфичным для верификации почты ошибкам, которые тоже нужно отобразить в UI. При нажатии на виджет открывается боттомшит с подтверждением почты, который тоже имеет свое состояние, ошибки статусы загрузки и т.д.
У виджета со сменой паспортных паспортных данных могут отображаться информационные баннеры, в зависимости от статуса верификации, а сам виджет отображает информацию, серию и номер паспорта, дату рождения.
Со сменой номера телефона похожая история. А с развитием приложения сюда могут добавляться новые виджеты.
Как сделать так, чтобы код, специфичный для определенного бизнес-контекста, хранился в отдельном модуле (фича-модуле)?
Разделение доменного слоя фичи
Подход, который приходит на ум в первую очередь — это шарить domain слои фичи.
В api-модуле расположим интерфейс репозитория или usecase (в случае, если есть какая-то логика) и необходимые сущности.
Реализации будут храниться в impl-модуле. Там же расположим data-слой фичи — получение и, в случае необходимости, кэширование данных.
В профиль (ProfileViewModel) предоставим ссылку на интерфейс репозитория (ChangeEmailRepository).
Родительская фича будет получать данные, при необходимости мапить их, и складывать в свой стейт. После этого в UI будет отрисовано нужное состояние виджета.
В таком подходе есть определенные плюсы:
Код, хоть и частично, разбит на модули по фичам.
Низкая связанность. Когда меняем что-то в дата-слое смены email, то не нужно менять код профиля (или хуже — код смены номера телефона).
Инкрементальная сборка.
Но тут же начинаешь задумываться над недостатками такого подхода.
Часто банковское приложение почти не хранит какой-то доменной логики. Логика слоя данных тоже не выходит за пределы получить, смапить, передать на другой слой. Основную логику содержат презентационный слой и слой view (не будем ударяться в чистую архитектуру).
Презентер (вьюмодель, экзекутор) фичи-контейнера, в данном случае профиля, начинает сильно раздуваться и в нём становится сложно ориентироваться.
Тесты на такую вью модель можно писать бесконечно.
Все-таки от связанности мы полностью не избавились: код одной фичи может повлиять на код другой.
Разделение фичи на view слое
Второе, что можно тут придумать — разбить экран на отдельные виджеты с помощью фрагментов, вью или компоузабл функций, каждая из которых будет лежать в отдельном модуле, а фича-контейнер будет объединять их вместе.
На схеме я показал один из вариантов того, как это могло бы выглядеть.
ProfileFragment, в данном случае, состоит из нескольких фрагментов.
Каждый из этих фрагментов мы получаем через провайдер (на схеме ChangeEmailFragmentProvider).
Его реализация находится в модуле impl дочерней фичи.
Плюсы тут очевидны.
Мы полностью разделили большую фичу на несколько небольших. У каждой мини-фичи свой стейт и свое отображение.
Можно частично переезжать на новые технологии. Например, виджет смены email можно сделать на вью, а новый виджет смены паспорта может быть написан на compose, и при этом не будет твориться вакханалия во фрагменте-контейнере.
Плюсы из первого подхода переедут тоже сюда.
Что может пойти не так.
Мы ушли от концепции mvi и единого стейта экрана. Теперь у каждой фичи есть свой отдельный стейт, который будет меняться независимо от стейта другой фичи. Тут могут вернуться те же проблемы, которые и привели сообщество к подходу с единым стейтом.
Разбитый стейт может привести ещё к одной проблеме. Например, нам нужно отображать прогрессбар поверх всех фичей пока не загрузятся все данные на экране. Или отобразить полноэкранную дефолтную ошибку, если хотя бы одна из загрузок упала или не получили данных. В этом случае логика переедет во фрагмент, а этого бы не хотелось.
Мы же решили пойти другим путем и разделить презентационный слой фичи.
Разделение презентационного слоя фичи
Создадим интерфейс с api фичи в api модуле.
interface ChangeEmailFeatureApi {
val state: StateFlow<ChangeEmailFeatureState>
fun updateStatus()
fun onEditClicked()
fun closeError()
}
State фичи похож на mvi стейт и его стоит организовывать по тем же правилам. Например, в случае загрузки данных будет меняться флаг isLoading, а в случае ошибки — флаг wasError.
data class ChangeEmailFeatureState(
val emailStatusEntity: EmailStatusEntity,
val email: String,
val isLoading: Boolean,
val wasError: Boolean
)
Реализация с логикой получения данных, показом прогрессбаров, ошибок, должна находиться в impl-модуле фичи. После чего интерфейс фичи можно заинжектить в конструктор вьюмодели фичи-контейнера.
Теперь осталось в родительской вьюмодели подписаться на стейт дочерней фичи.
class ProfileViewModel(
private val changeEmailFeatureApi: ChangeEmailFeatureApi,
private val changePhoneNumberApi: ChangePhoneNumberFeatureApi,
private val changePassportFeatureApi: ChangePassportFeatureApi
) : ViewModel() {
val state: StateFlow<ProfileState>
get() = mutableState.asStateFlow()
private val mutableState by lazy { MutableStateFlow(ProfileState.default()) }
init {
changeEmailFeatureApi.state
.onEach(::updateState)
.launchIn(viewModelScope)
changePhoneNumberApi.state
.onEach(::updateState)
.launchIn(viewModelScope)
changePassportFeatureApi.state
.onEach(::updateState)
.launchIn(viewModelScope)
}
fun onAction(action: ProfileAction) {
when(action) {
ProfileAction.UpdateChangeEmailStatus -> changeEmailFeatureApi.updateStatus()
}
}
private fun updateState(state: ChangeEmailFeatureState) {
mutableState.update { it.copy(changeEmailFeatureState = state) }
}
private fun updateState(state: ChangePhoneNumberFeatureState) {
mutableState.update { it.copy(changePhoneNumberFeatureState = state) }
}
private fun updateState(state: ChangePassportFeatureState) {
mutableState.update { it.copy(changePassportFeatureState = state) }
}
}
Стейт дочерних фичи расположим внутри стейта родительской. Чтобы показывать во view один прелоадер на все фичи можно добавить переменную isLoading, которая будет вычисляться в зависимости от стейтов дочерних фич.
data class ProfileState(
val changeEmailFeatureState: ChangeEmailFeatureState,
val changePhoneNumberFeatureState: ChangePhoneNumberFeatureState,
val changePassportFeatureState: ChangePassportFeatureState
) {
val isLoading = changeEmailFeatureState.isLoading ||
changePassportFeatureState.isLoading ||
changePhoneNumberFeatureState.isLoading
val wasError = changeEmailFeatureState.wasError ||
changePassportFeatureState.wasError ||
changePhoneNumberFeatureState.wasError
}
Получается такая история:
Вьюмодель фичи-контейнера (ProfileViewModel) обрабатывает сигналы от вью (метод onAction) и делегирует вызовы в дочерние-фичи.
При этом подписывается на изменение этого стейта.
Стейты всех мини фичей объединяются одним общим стейтом.
Вью (ProfileFragment) рендерит уже этот общий стейт.
На схеме это будет выглядеть так:
Что в итоге получилось.
Мы разделили большую фичу на несколько разных.
Разнесли код по модулям, в том числе, большую часть логики и unit тестов.
Получаем плюсы инкрементальной сборки.
Стейт остался единым для одного экрана.
Есть также минусы подхода.
Логика рендеринга осталась в модуле фичи-контейнера.
UI тесты тоже останутся в фиче-контейнере.
В этой статье я описал три способа, как разделить логику сложного экрана на несколько фичемодулей. Каждый из этих способов имеет место быть. Так как мы хотели сохранить единый стейт для экрана, то выбрали разделить презентационный слой фичи.
Но выбор остается за вами. Напишите в коментариях, если есть идеи, как можно еще решить проблему или улучшить текущий подход.
Рекомендуем почитать:
Также подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.
audiserg
Спасибо интересное решение, еще можно на decompose посмотреть