Архитектура, с которой мы работаем изо дня в день, оставляет желать лучшего. Иначе как объяснить тот хаос и стресс, который каждый из нас испытывает, приходя на новое место работы? В большинстве случаев самым сложным слоем является UI, и эта сложность зачастую обусловлена не разнообразием элементов интерфейса, а неправильным подходом к архитектуре этого слоя. Отсюда можно сделать вывод, что нельзя дробить приложение в привычной форме: на UI, domain и data. Необходимо добавить еще один дочерний слой — ui/viewModel, и на этом слое, одном из самых важных, я хочу акцентировать ваше внимание.
Меня зовут Илья и мы начинаем!
Вопрос остается открытым: почему UI слой является таким сложным? Ведь его задача — просто получить и передать запрашиваемые данные. Возможно, это зависит от специфики приложения, но, увы, в вашем случае это не так. ViewModel — вот истинный источник наших ночных кошмаров, но, как ни странно, на эту проблему мало кто обращает внимание. И Google, который рекомендует использовать одно поле как public, а другое как private, что плохо не только с точки зрения поддержки кода, но и с точки зрения здравого смысла. А если открыть публичные репозитории на GitHub, посвященные Android-приложениям, то разобраться без крепкого напитка бывает сложно — почти все пишут код, который сложно поддерживать, а иногда он просто ужасен. Но, как ни странно, за это нам платят, и, по крайней мере, из эгоистических соображений мы должны упростить себе работу.
Здесь приходит на помощь мое решение — Apex (Aligned Protocol and Execution). Этот подход является производным от архитектуры UDF (Unidirectional Data Flow). Его ключевой особенностью является комьюнити: каждый может предложить свою идею, и, если она действительно стоящая, ей будет дан шанс на развитие.
Что такое Apex?
На диаграмме представлена архитектура взаимодействия между пользовательским интерфейсом (UI) и ViewModel, которая помогает структурировать обработку данных и управление состоянием в приложении. Главным компонентом в этой системе является State — объект, содержащий данные и текущее состояние UI, которые динамически меняются в зависимости от действий пользователя и внутренней логики приложения. State позволяет контролировать, какие данные и в каком виде отображаются на экране, обеспечивая синхронизацию с пользовательским интерфейсом. UI слой отслеживает изменения State и обновляет компоненты интерфейса, чтобы отображаемая информация всегда была актуальной и согласованной с данными.
При взаимодействии пользователя с интерфейсом, например, при нажатии кнопки, UI слой отправляет команду EX (Execute Command), которую перехватывает компонент Executor. Этот компонент играет ключевую роль в архитектуре, так как он отвечает за выполнение логики, обновление State и обработку команд. Executor работает в UI-потоке, поэтому его задачи ограничиваются легкими операциями, такими как обновление данных или переключение состояния элементов. Это позволяет поддерживать высокую отзывчивость интерфейса, избегая блокировок и задержек.
В дополнение к изменению состояния, Executor может инициировать два типа дополнительных процессов: Effect и Event.
-
Effect — это механизм для выполнения сложных или долгосрочных операций, которые выходят за рамки простых изменений состояния и требуют работы в фоновом потоке. Эти операции могут включать запросы к серверу, выполнение сложных расчетов, построение графиков или работу с базой данных. Задача Executor — делегировать выполнение Effect вспомогательным компонентам, чтобы они могли работать параллельно и не блокировать основной UI-поток. После завершения Effect может отправить одну или несколько команд EX обратно в Executor, который обновит State с учетом результатов завершенной операции. Это позволяет организовать асинхронное взаимодействие, при котором длительные операции не мешают основной работе приложения и не влияют на его производительность.
-
Event — это сообщения, отправляемые для UI слоя, предназначенные для инициирования определенных действий в интерфейсе, таких как показ уведомлений, открытие диалогов или отображение всплывающих окон. Event используется для передачи немедленных указаний от Executor, требующих визуального отклика интерфейса. В отличие от State, который отслеживается на постоянной основе, Event является одноразовым сигналом, который не сохраняется. Например, по завершении операции Executor может отправить Event, чтобы UI отобразил сообщение об успешном выполнении задачи или показал ошибку, если что-то пошло не так.
Эта архитектура позволяет отделить логику обработки данных и событий от пользовательского интерфейса, делая код более структурированным, модульным и удобным для тестирования. Каждый элемент выполняет строго определенную роль: State хранит данные и состояние, Executor выполняет простую логику и отвечает за обновление State, Effect позволяет делегировать длительные процессы, а Event — управлять реакцией UI на события
Довольно теории, идем к практике! 😤
-
Люди произошли от Адама и Евы, а наша архитектура — от Contract. Этот компонент является основным, в нем задекларированы State, Executor, Event и Effect.
-
State — представляет собой совокупность всех данных, которые потребуются UI для корректной работы.
-
Executor — катализатор действий нашей ViewModel, в нем определяются публичные API для прямого взаимодействия с UI и обновления State.
-
Event — сообщения для UI-слоя с целью управления этим слоем. Events контролируются методом: Channel.onEvent.
-
Effect — единственная прослойка между ViewModel и domain-слоем. Именно здесь следует использовать UseCase и вычисления сложных операций, так как он выполняется в IO-потоке.
-
Пример Contract который взят из учебного проекта:
package com.example object NumbersListContract { data class State( val isLoading: Boolean = false, //... ): APEX.State sealed interface Executor: APEX.Executor { data object Init: Executor data class Error(val throwable: Throwable): Executor data class ReplaceFacts(val factsAboutNumber: List<FactAboutNumber>): Executor //... } sealed interface Event: APEX.Event { data class ShowError(val error: String): Event //... } sealed interface Effect: APEX.Effect { data object SubscribeOnNumbers: Effect //... } }
-
-
Наследование от
APEXViewModel
:package com.example import com.example.NumbersListContract.State import com.example.Effect import com.example.Event import com.example.Executor //com.example - your path to the Contract class NumbersListViewModel: APEXViewModel<State, Executor, Effect, Event>(State())
-
Имплементация абстрактных методов APEXViewModel(далее следует пример синтаксиса имплементации, которого следует придерживаться)
NumbersListViewModel
:init { dispatch(Executor.Init) } override suspend fun ExecutorScope<Effect, Event>.execute(ex: Executor): State = when (ex) { Executor.Init -> { sendEffect(Effect.SubscribeOnNumbers) state.copy( isLoading = true ) } is Executor.ReplaceFacts -> { state.copy( isLoading = false, isObtainingFact = false, facts = ex.factsAboutNumber ) } is Executor.Error -> { sendEvent( Event.ShowError( error = ex.throwable.message ?: "Unknown error" ) ) state.copy( isLoading = false, isObtainingFact = false ) } } override suspend fun EffectorScope<Executor>.affect(ef: Effect) = when (ef) { Effect.SubscribeOnNumbers -> { getCachedFactsUseCase().collect { when (it) { is Response.Success -> { dispatch( Executor.ReplaceFacts( factsAboutNumber = it.data ) ) } is Response.Error -> { dispatch(Executor.Error(throwable = it.error)) } } } } }
-
Отслеживание
Events
вNumbersListScreen
:@Composable fun NumbersListScreen( modifier: Modifier = Modifier, state: NumbersListContract.State, events: Channel<NumbersListContract.Event>, navigateToNumberDetails: (NumberDetailsRoute.Arg) -> Unit, dispatch: (NumbersListContract.Executor) -> Unit ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() events.onEvent(block = { when (it) { is NumbersListContract.Event.NavigateToFactDetails -> { navigateToNumberDetails( NumberDetailsRoute.Arg( number = it.factAboutNumber.number.toString(), description = it.factAboutNumber.fact ) ) } is NumbersListContract.Event.ShowError -> { scope.launch { snackbarHostState.showSnackbar( message = it.error, duration = SnackbarDuration.Long ) } } } }, scope = scope) /* * You can use dispatch(NumbersListContract.Executor.Ex) * to interact with Executor */ }
-
Как использовать
NumbersListScreen
:```kotlin /* * You can use this component, for example, in MainActivity.kt * scope setContent {} */ @Composable fun NumbersListDestination( modifier: Modifier = Modifier ) { val viewModel: NumbersListViewModel = hiltViewModel() NumbersListScreen( modifier = modifier, state = viewModel.state, navigateToNumberDetails = { // For example you can use Jetpack Navigation }, events = viewModel.events, dispatch = { viewModel.dispatch(it) } ) } ```
Великолепно, осталось лишь подключить архитектуру:
-
Перейдите в build.gradle.kts (:app) либо в build.gradle (:app) далее в блоке dependencies добавьте данную строку:
dependencies { //... val apexVersion = "1.0.2" // or newer implementation("com.safronov.apex:architecture:$apexVersion") //... }
-
После перейдите в settings.gradle.kts или settings.gradle и добавьте эти строки:
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { url = uri("https://maven.pkg.github.com/i-safronov/apex") } } }
-
Синхронизируйте проект нажав на кнопку Sync Now
Ссылки
-
Основной репозиторий Apex: https://github.com/i-safronov/apex
-
Учебный проект для знакомства с архитектурой: https://github.com/i-safronov/apex/tree/main/app
-
Мой ТГ: @i_safronov
Готово! 😎
На этой странице вы познакомились с архитектурой Apex, в которую я искренне верю и уверен, что она завоюет свою известность, а в этом вы можете принять прямое участие начав использовать ее и рекомендовать коллегам и помните, все возможно если вы в это поверите
Автор: isfr