В этой статье рассмотрим, пример реализации архитектуры 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://engineering.monstar-lab.com/en/post/2023/07/14/Jetpack-Compose-UI-Architecture

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

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

Комментарии (1)


  1. alelam
    24.04.2024 05:46
    +2

    Уважаемый автор, если вы имеете хоть какое-то отношение к android-приложению Сбера, не могли вы попросить людей отвечающих за него избавиться от двух моментов, которые уже не первый год делают его UX в моих пользовательских глазах довольно убогим. Особенно простотой своего фикса.

    1. Уберите уже показ попапа об обновлении в процесса ввода юезром пин-кода. Обновлять банковское приложение очень важно, но ведь никто не мешает демонстрировать его либо до начала ввода либо после его окончания, а не тупо прерывать пользовательское действие.

    2. Есть у вас на экране Перевести -> Перевод поле "По телефону, карте или счёту". Которое вроде не предполагает ввод любых символов за исключением цифр. И поэтому каждый раз, когда приходится им пользоваться, дико раздражает, что вы за каким-то упорно открывайте по клику на нём стандартную ТЕКСТОВУЮ клавиатуру. С мелкими циферками в верхнем ряду, зато с возможностью ввести в поле например эмодзи.

    Хотел еще месяца 3 назад написать вам в какой-нибудь раздел с отзывами, но обнаружил, что либо команда разработчиков мобильного Сбера слишком крута, чтобы держать канал для пользовательского фидбэка, либо я оказался слишком туп, чтобы обнаружить таковой в приложении или на сайте.