В этой статье рассмотрим, пример реализации архитектуры 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
.
Описанный подход не зависит от сторонних библиотек и легко применим для любого приложения. Пример кода можно посмотреть здесь.
Источники:
alelam
Уважаемый автор, если вы имеете хоть какое-то отношение к android-приложению Сбера, не могли вы попросить людей отвечающих за него избавиться от двух моментов, которые уже не первый год делают его UX в моих пользовательских глазах довольно убогим. Особенно простотой своего фикса.
Уберите уже показ попапа об обновлении в процесса ввода юезром пин-кода. Обновлять банковское приложение очень важно, но ведь никто не мешает демонстрировать его либо до начала ввода либо после его окончания, а не тупо прерывать пользовательское действие.
Есть у вас на экране Перевести -> Перевод поле "По телефону, карте или счёту". Которое вроде не предполагает ввод любых символов за исключением цифр. И поэтому каждый раз, когда приходится им пользоваться, дико раздражает, что вы за каким-то упорно открывайте по клику на нём стандартную ТЕКСТОВУЮ клавиатуру. С мелкими циферками в верхнем ряду, зато с возможностью ввести в поле например эмодзи.
Хотел еще месяца 3 назад написать вам в какой-нибудь раздел с отзывами, но обнаружил, что либо команда разработчиков мобильного Сбера слишком крута, чтобы держать канал для пользовательского фидбэка, либо я оказался слишком туп, чтобы обнаружить таковой в приложении или на сайте.