В этой статье рассмотрим, пример реализации архитектуры UI-слоя на Compose, которая основывается на Uni-directional data flow и state hoisting с использованием паттерна «координатор» для навигации. Вдохновением для меня послужила эта публикация, но я решил подробнее развернуть поднятую в ней тему архитектуры Compose и навигации.
Принцип Uni-directional data flow
Uni-directional data flow (однонаправленный поток данных UDF) — это шаблон проектирования, в котором состояние передаётся вниз, а действия — вверх. Следуя UDF, мы можем отделить составные элементы, которые отображают состояние в UI (функции Compose), от частей вашего приложения, которые хранят и изменяют состояние (чаще всего это ViewModel в нашем приложении). Идея в том, чтобы наши компоненты UI использовали состояние и генерировали события. Но поскольку компоненты обрабатывают возникшие вовне события, возникает множество источников истины. А нам нужно, чтобы любое «событие», которое мы вводим, основывалось на состоянии.
Если предположить, что сущность изменения и хранения состояния — это ViewModel, то у неё должна быть одна точка обработка действий и одна точка событий изменения состояния. Пример такого подхода:

Как видите, во ViewModel приходят события на изменение (точка входа) и далее происходит обновление UI, что является точкой выхода. Пример кода:
private val _stateFlow: MutableStateFlow<UserListState> = MutableStateFlow(UserListState(isLoading = true)) val stateFlow: StateFlow<UserListState> = _stateFlow.asStateFlow() fun action(actions: UserListAction) { // some code }
Функция action нужна для обработки действий от пользователя (точка входа), а stateFlow возвращает текущее состояние экрана (точка выхода). Применение этого подхода позволяет легко масштабировать решение и покрывать тестами.
Принцип State Hoisting
State Hoisting — это метод, при котором ответственность за управление и манипулирование состоянием компонента переносится на компонент более высокого уровня. Пример подхода:

Состояние, поднятое таким образом, имеет несколько важных преимуществ:
Единый источник данных: перемещая состояние вместо его дублирования, мы гарантируем наличие только одного источника данных. Это помогает избежать ошибок.
Инкапсулированный: изменять своё состояние может только Compose-функция, содержащая объект состояния.
Возможность совместного использования: поднятое состояние можно использовать совместно с несколькими составными объектами. Если вы хотите прочитать имя в другом компонуемом объекте, подъём позволит вам это сделать.
Перехватываемость: вызывающие объекты без сохранения состояния могут игнорировать или изменять события перед изменением состояния.
Разделение: состояние composable-функций без сохранения состояния может храниться где угодно.
Пример использования:
@Composable fun HelloScreen() { var name by rememberSaveable { mutableStateOf("") } HelloContent(name = name, onNameChange = { name = it }) } @Composable fun HelloContent(name: String, onNameChange: (String) -> Unit) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "Hello, $name", modifier = Modifier.padding(bottom = 8.dp), style = MaterialTheme.typography.bodyMedium ) OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") }) } }
Поле name вынесено за Compose-функцию HelloContent, что позволяет переиспользовать эту функцию где угодно. Подробнее можно почитать здесь.
State
Сущность state обычно описывает состояние экрана. Описывать можно с помощью data class или sealed interface. Пример реализации:
data class UserListState( val isLoading: Boolean = true, val items: List<User> = emptyList() )
Этот пример state описывает два состояния: показ загрузки, отображение данных. В любом случае, состояние — это «статическое» представление вашего компонента или всего UI экрана, который вы можете легко менять.
Screen
Screen — это Compose-функция, которая описывает экран. Чтобы следовать шаблону state hoisting, нам нужно сделать этот компонент независимым от передачи непосредственного viewModel, представить взаимодействия с пользователем как обратные вызовы и не передавать сущности для подписки на данные. Это сделает наш экран доступным для тестирования, предварительного просмотра и повторного использования! Пример:
@Composable fun UserListScreen( state: UserListState, onClickOnUser: (User) -> Unit, onBackClick: () -> Unit ) { Scaffold( topBar = { TopAppBar( title = { Text(text = "User List") }, navigationIcon = { IconButton(onClick = { onBackClick.invoke() }) { Icon(Icons.Filled.ArrowBack, "backIcon") } }, ) }, content = { padding -> if (state.isLoading) { CircleProgress() } else { LazyColumn( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 56.dp) ) { items( count = state.items.size, itemContent = { UserCard(user = state.items[it], modifier = Modifier.fillMaxWidth(), onClick = { user -> onClickOnUser.invoke(user) }) }) } } })
На вход получаем модель данных state, которая отображается на экране, и лямбды обратных вызовов для взаимодействия с пользователем.
Route
Route — это компонент, который будет обрабатывать обратные вызовы, передавать состояние Screen и отправлять его в данном случае контроллёру для навигации. Route является точкой входа в наш flow. Пример:
@Composable fun UserListRoute( navController: NavController, viewModel: UserListViewModel = hiltViewModel(), ) { // ... state collection UserListScreen( state = uiState, onClickOnUser = //.. onBackClick = { // navigate back } } ) }
С каждым новым взаимодействием с пользователем и эффектами, основанными на состоянии, эта функция будет увеличиваться в размерах, что усложнит её понимание и поддержку. Другая проблема — обратные вызовы (лямбды). При каждом новом взаимодействии с пользователем нам придётся добавлять к Screen ещё один обратный вызов, и она также может стать довольно большой.
С другой стороны, давайте подумаем о тестировании. Мы можем легко протестировать Screen и ViewModel, но как насчёт Route? Здесь много всего происходит, и не всё можно легко покрыть тестами.
Введём изменения в текущую реализацию, добавив сущность actions.
Actions
Это сущность, которая объединяет все обратные вызовы (лямбды), что позволяет не изменять сигнатуры Compose-функции и расширять количество вызовов. Пример:
data class UserListActions( val onClickOnUser: (User) -> Unit = {} val onBackClick : () -> Unit = {} )
И соответственно для screen:
@Composable fun UserListScreen( state: UserListState, actions: UserListActions, ) { // actions. onBackClick.invoke() }
На уровне route, чтобы не пересоздавать объект во время рекомпозиций, можно сделать такие изменения:
@Composable fun UserListRoute( navController: NavController, viewModel: UserListViewModel = hiltViewModel(), ) { // ... state collection val uiState by viewModel.stateFlow.collectAsState() val actions = rememberPlacesActions(navController) UserListScreen( state = uiState, actions = actions } ) } @Composable fun rememberUserListActions(navController: NavController): UserListActions { return remember(coordinator) { UserListActions( onClickOnUser = { navController.navigate("OpenDetailUser") } onBackClick = { navController.navigate("Back") } ) } }
Хотя route теперь стал проще, мы лишь перенесли его логику действий в другую функцию, и это не улучшило ни читабельность, ни масштабируемость. Более того, осталась вторая проблема: эффекты, основанные на состоянии. Наша логика UI теперь также разделена, что усложняет читабельность и синхронизацию, да и тестируемость не улучшилась. Пришло время представить последний компонент.
Coordinator
Координатор предназначен для координации различных обработчиков действий и поставщиков состояний. Он наблюдает и реагирует на изменения состояния, обрабатывает действия пользователя. Его можно представить как состояние Сompose нашего потока. Пример координатора:
class UserListCoordinator( val navController: NavController, val viewModel: UserListViewModel ) { val screenStateFlow = viewModel.stateFlow fun openDetail() { navController.navigate("OpenDetailUser") } fun backClick() { navController.navigate("Back") } }
Обратите внимание: поскольку наш координатор теперь не находится внутри функции Compose, мы можем сделать все более простым способом, без необходимости в LaunchedEffect, точно так же, как мы обычно делаем в нашей ViewModel.
Теперь изменим action с использованием координатора:
@Composable fun rememberUserListActions(coordinator: UserListCoordinator): UserListActions { return remember(coordinator) { UserListActions( onClickOnUser = { coordinator. openDetail () } onBackClick = { coordinator.backClick() } ) } }
А route с учётом координатора будет выглядеть вот так:
@Composable fun UserListRoute( coordinator: UserListCoordinator = rememberUserListCoordinator() ) { // State observing and declarations val uiState by coordinator.screenStateFlow.collectAsState() // UI Actions val actions = rememberUserListActions(coordinator) // UI Rendering UserListScreen(uiState, actions) }
В примере координатор теперь отвечает за логику UI, происходящую в наших функциях Compose. Поскольку он знает о различных состояниях, мы можем легко реагировать на их изменения и строить условную логику для каждого взаимодействия с пользователем. Если взаимодействие является простым, мы можем легко делегировать его соответствующему компоненту, например ViewModel.
Диаграмма взаимодействие между компонентами и потоком данных:

Рассмотрим пример по клику на кнопку. Допустим, нужно выполнить запрос, получить данные и сделать переход на другой экран. Чтобы это реализовать в текущем подходе, можно рассмотреть такие варианты:
Реализовать новое состояние, получающее на вход данные, и далее ��ызываем action, который координатор обрабатывает и вызывает соответствующий экран. У этого подхода есть недостаток: фактически новый state не отображает ничего нового на экране, а проксирует вызов action. Также непонятно, как, например, решать задачу логики навигации, связанной с состоянием показа текущего экрана, где его хранить.
Добавить новую точку события, например event во
ViewModel, подписаться на неё и выполнять навигацию. Преимущество в том, что не нужно создавать избыточный state, но он нарушает принцип Uni-direction data flow, так как появляется ещё один источник данных, и остаётся проблема с хранением состояния навигации.
Паттерн «координатор»
«Координатор» — это распространённый в разработке iOS паттерн, введённый Сорушом Ханлоу для помощи в навигации внутри приложения. Идею реализации этого подхода взяли из Application Controller (один из паттернов книги «Архитектура корпоративных приложений» Мартина Фаулера).
Цели этого паттерна:
Избежать так называемых Massive ViewControllers (например, God-Activity), у которых большая ответственность.
Обеспечить логику навигации внутри приложения.
Повторно использовать Activity или Fragments, поскольку они не связаны с навигацией внутри приложения.
Для навигации используем сущность navigator, этот класс просто выполняет навигацию без какой-либо логики. Пример:
class Navigator() { private lateinit var navController: NavHostController var context: Activity? = null fun showUserDetailScreen() { navController.navigate(NavigationState.Detail.name) } fun showUserLists() { user = null navController.navigate(NavigationState.List.name) } fun close() { context?.finish() } }
Как видим по коду, происходит просто переход на экран с помощью различных способов: context для Activity или фрагмента, NavHostController для навигации в Compose.
Рассмотрим сам координатор для навигации. Идея проста: координатор просто знает, к какому экрану перейти дальше, а для непосредственной навигации он использует navigator. Пример координатора:
class UserCoordinator( private val navigator: Navigator ) { private val state: ArrayDeque<NavigationState> = ArrayDeque<NavigationState>().apply { add(NavigationState.List) } fun openUserDetail(user: User) { state.add(NavigationState.Detail) navigator.showUserDetailScreen(user) } fun backClick() { if (state.first() == NavigationState.Detail) { state.removeLast() navigator.showUserLists() } else { navigator.close() } } }
Как видим из приведённого кода, координатор содержит набор экранов. При необходимости смены экрана добавляется или удаляется элемент, после чего у navigator вызывается соответствующий метод для непосредственного показа экрана пользователю.
Диаграмма взаимодействия coordinator и ViewModel с использованием паттерна координатора:

Обработка действий выполняется во ViewModel, далее в зависимости от того, нужна ли навигация, открывается экран или создаётся новый state для screen.
Что ж, внесём изменения в нашу архитектуру. ViewModel теперь выглядит так:
@HiltViewModel class UserListViewModel @Inject constructor( private val coordinator: UserCoordinator ) : ViewModel() { private val _stateFlow: MutableStateFlow<UserListState> = MutableStateFlow(UserListState(isLoading = true)) val stateFlow: StateFlow<UserListState> = _stateFlow.asStateFlow() fun action(actions: UserListAction) { when (actions) { is UserListAction.OpenDetail -> { coordinator.openUserDetail(actions.user) } UserListAction.Back -> { coordinator.backClick() } } } }
Для перехода теперь не нужно создавать отдельный проксирующий state или другую подписку, во ViewModel используется coordinator, и это всё легко покрывается тестами.
Резюме
Наш screen остаётся полностью независимым от состояния. Он отображает только то, что передаётся в качестве параметра функции. Все взаимодействия с пользователем происходят через actions, которые могут обрабатываться другими компонентами.
Route теперь служит простой точкой входа в наш навигационный граф. Он собирает состояние и запоминает наши действия при рекомпозиции.
Coordinator выполняет большую часть тяжёлой работы: реагирует на изменения состояния и делегирует взаимодействие с пользователем другим соответствующим компонентам. Он полностью отделён от нашего screen и route, что позволяет повторно использовать в другом месте, а также легко покрыть тестами.
CoordinatorNavigation выполняет функции навигации и отвечает на вопрос: «Какой экран показывать следующим?» Может применяться с любой библиотекой или механизмом для навигации, которую содержит navigator.
Описанный подход не зависит от сторонних библиотек и легко применим для любого приложения. Пример кода можно посмотреть здесь.
Источники:
