Обзор архитектуры Compose с использованием паттерна «координатор»

—

от автора

В этой статье рассмотрим, пример реализации архитектуры 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.

Диаграмма взаимодействие между компонентами и потоком данных:

Рассмотрим пример по клику на кнопку. Допустим, нужно выполнить запрос, получить данные и сделать переход на другой экран. Чтобы это реализовать в текущем подходе, можно рассмотреть такие варианты:

  1. Реализовать новое состояние, получающее на вход данные, и далее вызываем action, который координатор обрабатывает и вызывает соответствующий экран. У этого подхода есть недостаток: фактически новый state не отображает ничего нового на экране, а проксирует вызов action. Также непонятно, как, например, решать задачу логики навигации, связанной с состоянием показа текущего экрана, где его хранить.

  2. Добавить новую точку события, например event во ViewModel, подписаться на неё и выполнять навигацию. Преимущество в том, что не нужно создавать избыточный state, но он нарушает принцип Uni-direction data flow, так как появляется ещё один источник данных, и остаётся проблема с хранением состояния навигации.

Паттерн «координатор»

«Координатор» — это распространённый в разработке iOS паттерн, введённый Сорушом Ханлоу для помощи в навигации внутри приложения. Идею реализации этого подхода взяли из Application Controller (один из паттернов книги «Архитектура корпоративных приложений» Мартина Фаулера).

Цели этого паттерна:

  1. Избежать так называемых Massive ViewControllers (например, God-Activity), у которых большая ответственность.

  2. Обеспечить логику навигации внутри приложения.

  3. Повторно использовать 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.

Описанный подход не зависит от сторонних библиотек и легко применим для любого приложения. Пример кода можно посмотреть здесь.

Источники:

  1. https://developer.android.com/develop/ui/compose/state

  2. https://khanlou.com/2015/01/the-coordinator/


ссылка на оригинал статьи https://habr.com/ru/articles/809683/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *