Приветствую Android-комьюнити! Меня зовут Арсений Шпилевой, я Core-разработчик в команде WB Partners. В этой небольшой статье я расскажу, как мы в проекте решили обеспечить типобезопасность при передаче результатов между экранами с применением библиотеки Compose Navigation. Мы рассмотрим механизм, который помогает избежать типичных ошибок и делает код более поддерживаемым.
Представленное решение никак не завязано на библиотеке и может использоваться и в других фреймворках, где есть подобные ограничения по передаче результатов между экранами. И хоть о самой библиотеке речь идти не будет, чтобы быть в контексте, предлагаю кратко ознакомиться с документацией.
Немного о проекте
Приложение для продавцов маркетплейса Wildberries существует уже около двух лет. При запуске проекта решили остановиться на классическом стеке из популярных библиотек (обширное сообщество, низкий порог входа в проект):
Kotlin
Compose
Jetpack Navigation
Koin
MVVM+
UDF на Flow
Room
Retrofit
Проблематика
Jetpack Navigation в Compose спроектирован в большей степени для однонаправленного перемещения между экранами (форвард-навигации) с передачей аргументов от родителя к потомкам. Однако иногда возникает необходимость передать результат работы экрана назад родительскому экрану, например, чтобы обновить данные после изменений.
На текущий момент Google не предоставляет встроенного механизма для этого, и эта тема вообще никак не освещается в документации. Если такой сценарий все же нужен, единственный штатный способ — передача данных черезSavedStateHandle
родительского экрана, доступный в previousBackStackEntry
:
navController.previousBackStackEntry?.savedStateHandle?.set("key", value)
Родительский экран может получить эти данные следующим образом:
val result = navController.currentBackStackEntry?.savedStateHandle?.get<Boolean>("key")
Для упрощения работы с SavedStateHandle
мы в проекте использовали обертки:
// Сохранение результата в NavigationEvents
when (event) {
is NavigationEvents.OnBack -> {
if (event.argument != null) {
previousBackStackEntry?.savedStateHandle
?.set(KEY_UPDATE_PREVIOUS_SCREEN, event.argument)
}
}
...
}
Пример использования в коде:
// ChildViewModel
fun onBackClick() {
navigateToScreen(NavigationEvents.OnBack("result"))
}
navigateToScreen
— это наша функция внутри ViewModel
, которая отправляет событие в шину обработчика, но в новых фичах мы выбрали подход с явной передачей методов навигации снаружи (подробнее об этом будет в следующей статье).
// ParentScreen
@Composable
internal fun ParentScreenRoute(
navController: NavController,
vm: ParentViewModel = koinViewModel()
) {
navController.CollectPopBackStackArgument<String?> {
it?.let(vm::doSomethingWithResult)
}
ParentScreen(vm)
}
Проблемы подхода
Отсутствие типобезопасности:
Передача данных через один ключ вSavedStateHandle
не гарантирует, что тип возвращаемого значения соответствует ожиданиям. Становится невозможным обрабатывать разные типы данных с нескольких дочерних экранов (в каком-то из коллектов будем ловить ошибку). Также невозможно обработать ситуацию, когда два дочерних экрана отправляют один тип данных, но в обоих случаях он несет разный смысл, например, дочерний экран №1 возвращаетBoolean
как результат какой-то операции, а дочерний экран №2 возвращаетBoolean
как маркер для родителя выполнить какую-то подгрузку.Обработка и передача аргумента на уровне Compose:
Взятие результата на уровне Compose усложняет поддержку архитектурного паттерна. Такое решение смешивает логику UI с логикой передачи данных, что затрудняет рефакторинг и модульное тестирование.Ненадежный контракт взаимодействия:
Родительский экран подписывается на какой-то аргумент, взятый из воздуха. Если дочерний экран поменяет тип результата или вовсе перестанет его отправлять, мы никак не узнаем об этом в родителе.
Конечно, от всего этого можно избавиться, используя общие шины и репозитории между экранами, но такой подход требует правильной настройки скоупов в Koin (что не всегда возможно в связке с Compose Navigation, но об этом также в следующей статье), а иначе все сведется к огромному количеству синглтонов, которые используются 1-2 раза за время жизни приложения. В нашу архитектуру мечты такое решение не вписывается.
Итак, мы пришли к выводу, что пора менять способ передачи результатов от дочернего экрана к родительскому. Составили список требований:
Compile-time проверка типов
Наличие результата прописано в контракте дочернего экрана
Отсутствие коллизий между разными результатами с одинаковым типом
Обработка значений в VM, а не в Compose
Решение
Вспоминаем, что SavedStateHandle
— единственный рабочий вариант с передачей значений между экранами, поэтому будем работать с ним. Мы можем внедрить его через конструктор в VM, но мне лично не очень нравится пробрасывать платформенные зависимости в VM напрямую, поэтому предлагаю сразу обернуть в некий класс BackArgumentHolder
.
class BackArgumentHolder(internal val savedStateHandle: SavedStateHandle)
Это решение еще хорошо потому, что Koin из коробки предлагает внедрение SavedStateHandle
в ViewModel
, но использует не тот же экземпляр, что лежит в previousBackStackEntry
, и явная передача через параметры не поможет (кстати, эта проблема не касается форвард-аргументов экрана — их вы можете брать из любого инстанса):
composable<ParentRoute> { entry ->
println(entry.savedStateHandle) //этот экземпляр доступен в стеке навигации
val vm: ParentViewModel = koinViewModel(parameters = { parametersOf(entry.savedStateHandle) }) //бессмысленно
...
}
internal class ParentViewModel(
private val savedStateHandle: SavedStateHandle, //Новый экземпляр
): ViewModel()
Решение с BackArgumentHolder
в коде будет выглядеть примерно так:
val vm = koinViewModel<ParentViewModel>(parameters = { parametersOf(BackArgumentHolder(entry.savedStateHandle)) })
Сейчас BackArgumentHolder
не содержит никаких методов по работе со значениями. Давайте подумаем, как бы мы хотели работать с результатами в идеале на примере родительской VM, которая должна что-то обновить по результату дочернего экрана:
internal class ParentViewModel(
private val backArgumentHolder: BackArgumentHolder,
) : ViewModel() {
fun onLaunch() {
if (backArgumentHolder.updateRequired) {
updateSomething()
}
}
}
Использование свойства выглядит очень удобно, но как нам реализовать такое, не меняя каждый раз описание BackArgumentHolder? В Kotlin есть прекрасный механизм extension-методов и свойств, который и решит эту проблему. Благодаря нему мы также можем закрыть требование с контрактом экрана. Вот как примерно может выглядеть api-модуль экрана настроек, который возвращает результат по изменению профиля:
@Serializable
data object SettingsScreenRoute //контракт открытия экрана через Compose Navigation
val BackArgumentHolder.profileChanged: Boolean //контракт закрытия
get()= TODO()
Теперь когда какой-то экран захочет открывать SettingsScreen
, он добавляет зависимость от api-модуля, чтобы получить SettingsScreenRoute
для NavController
, и параллельно с этим в BackArgumentHolder
станет доступно новое свойство, с которым можно работать в рамках родительского модуля.
Разобравшись с этим, можно перейти к реализации геттера. Давайте на секунду представим, как бы выглядел BackArgumentHolder
, если бы поля описывались прямо в нем. Вся работа со свойствами по сути сводится к тому, чтобы положить и взять поле по какому-то ключу из SavedStateHandle
— простое делегирование.
class BackArgumentHolder(internal val savedStateHandle: SavedStateHandle) {
val someField: Boolean
get() = savedStateHandle.get("some_key") ?: default
var anotherField: String
get() = savedStateHandle.get("another_key") ?: default2
set(value) { savedStateHandle["another_key"] = value }
}
Чтобы вынести это описание в api конечных модулей, нужен некий механизм, который будет гарантировать привязку свойства к значению с конкретным ключем из SavedStateHandle
(а еще бы предоставлять дефолт и применяться в одну строчку). Давайте сразу посмотрим, что у нас получилось:
//api-модуль дочернего экрана
@Serializable
data object SettingsScreenRoute
val profileChangedArgument = createBackArgument(key = "profileHasBeenChanged", defaultValue = false)
val BackArgumentHolder.profileChanged: Boolean by profileChangedArgument(null)
//SettingsScreen
@Composable
fun SettingsScreen(controller: NavController) {
val vm = koinViewModel<SettingsViewModel>()
DisposableEffect(controller) {
vm.router = object : Router {
override fun goBack(hasChanges: Boolean) {
controller.with(profileChangedArgument(hasChanges)).popBackStack()
}
}
onDispose { vm.router = null }
}
}
//SettingsViewModel
internal class SettingsViewModel : ViewModel() {
var router: Router? = null
fun onProfileChanged() {
router?.goBack(hasChanges = true)
}
}
Ничего непонятно? Давайте разбираться. Для начала нам нужен класс, олицетворяющий результат, а точнее его связку ключ-значение и дефолт:
data class BackArgument<T>(
val key: String,
val defaultValue: T,
val value: T? = null,
)
Далее нам нужна фабричная функция, которая будет инкапсулировать ключ и создавать этот аргумент с переданным значением (в нашем случае это profileChangedArgument(false)
). Для каждого BackArgument
такая функция должна быть своя (ведь ключи и значения разные), но все такие функции работают по одному принципу, поэтому создадим универсальную функцию, создающую эти функции, и в пару к ней добавим расширение для NavController
, которое будет прятать аргумент в SavedStateHandle
родителя:
fun<T> createBackArgument(key: String, defaultValue: T): (T?) -> BackArgument<T> {
return { value ->
BackArgument(
key = key,
defaultValue = defaultValue,
value = value,
)
}
}
infix fun NavController.with(argument: BackArgument<*>): NavController = apply {
previousBackStackEntry?.savedStateHandle?.set(argument.key, argument.value)
}
Класть мы научились, осталось научиться брать. Для этого нам обязательно нужно знать ключ и тип значения. К счастью у нас уже есть класс BackArgument
, который обладает всеми этими свойствами, поэтому все что нам нужно, это сделать его делегатом для работы с SavedStateHandle
:
data class BackArgument<T>(
val key: String,
val defaultValue: T,
val value: T? = null,
) : ReadWriteProperty<BackArgumentHolder, T> {
override operator fun getValue(thisRef: BackArgumentHolder, property: KProperty<*>): T {
//здесь обрабатываем nullable-значение
return thisRef.savedStateHandle.get<T>(key) ?: defaultValue
}
override operator fun setValue(thisRef: BackArgumentHolder, property: KProperty<*>, value: T) {
thisRef.savedStateHandle[key] = value
}
}
Это позволяет нам проинициализировать свойство результата в BackArgumentHolder
через by, вызвав фабричную функцию с дефолтным или нулевым аргументом (null
всегда будет приводить к дефолту):
val BackArgumentHolder.profileChanged: Boolean by profileChangedArgument(false)
Свойство может быть и var, чтобы была возможность менять его в родительском экране тоже, например, после вызова операции явно перевести флаг в false
, чтобы код не отрабатывал при последующих вызовах экрана.
Промежуточный результат
Файл BackArgumentHolder
:
class BackArgumentHolder(internal val savedStateHandle: SavedStateHandle)
data class BackArgument<T>(
val key: String,
val defaultValue: T,
val value: T? = null,
) : ReadWriteProperty<BackArgumentHolder, T> {
override operator fun getValue(thisRef: BackArgumentHolder, property: KProperty<*>): T {
return thisRef.savedStateHandle.get<T>(key) ?: defaultValue
}
override operator fun setValue(thisRef: BackArgumentHolder, property: KProperty<*>, value: T) {
thisRef.savedStateHandle[key] = value
}
}
fun<T> createBackArgument(key: String, defaultValue: T): (T?) -> BackArgument<T> {
return { value ->
BackArgument(
key = key,
defaultValue = defaultValue,
value = value,
)
}
}
infix fun NavController.with(argument: BackArgument<*>): NavController = apply {
previousBackStackEntry?.savedStateHandle?.set(argument.key, argument.value)
}
Это уже рабочее решение, но пока что оно не очень подходит для мультимодульных проектов, а если быть точнее, для проектов, в которых модули делятся на api и impl. Результат работы экрана является частью внешнего контракта, а значит должен описываться непосредственно в api, но завязка на savedStateHandle
потянет за собой лишние зависимости, от чего хочется избавиться.
Выделим минимум для работы в api и перенесем в отдельный легковесный модуль, который будет содержать только один файл:
interface BackArgumentHolder {
operator fun <T> get(key: String): T?
operator fun <T> set(key: String, value: T)
}
data class BackArgument<T>(
val key: String,
val defaultValue: T,
val value: T? = null,
) : ReadWriteProperty<BackArgumentHolder, T> {
override operator fun getValue(thisRef: BackArgumentHolder, property: KProperty<*>): T {
return thisRef[key] ?: defaultValue
}
override operator fun setValue(thisRef: BackArgumentHolder, property: KProperty<*>, value: T) {
thisRef[key] = value
}
}
fun <T> createBackArgument(key: String, defaultValue: T): (T?) -> BackArgument<T> {
return { value ->
BackArgument(
key = key,
defaultValue = defaultValue,
value = value,
)
}
}
В модуле навигации оставим конкретные реализации и функции для работы с фреймворком:
class SavedStateBackArgumentHolder(private val savedStateHandle: SavedStateHandle) : BackArgumentHolder {
override fun <T> get(key: String): T? {
return savedStateHandle.get<T>(key)
}
override fun <T> set(key: String, value: T) {
savedStateHandle[key] = value
}
}
infix fun <T : BackArgument<*>> NavController.with(argument: T): NavController = apply {
previousBackStackEntry?.savedStateHandle?.set(argument.key, argument.value)
}
Эта часть кода полностью зависит от стека навигации в вашем проекте и используется только в impl-модулях, поэтому вы без проблем можете поменять функцию with
на любую другую или в реализации сделать, например, сохранение значений в какой-нибудь файл (если у вас на проекте принято передавать результаты таким образом).
Шаги для реализации в коде фичи
Подключить легковесный модуль с интерфейсом
BackArgumentHolder
в api-модуль дочернего экранаПри помощи
createBackArgument
создать в api-модуле описание возвращаемого аргумента, как контракт закрытия фичиПеред вызовом
popBackStack
илиnavigateUp
передать вnavController
результат через функциюwith
В родительском модуле обернуть
entry.savedStateHandle
вSavedStateBackArgumentHolder
и передать в VMПодключить api-модуль дочернего экрана в impl-модуль родительского
Пример — экран профиля и дочерний экран настроек
api-модуль Settings
@Serializable
data object SettingsScreenRoute
val profileChangedArgument = createBackArgument("profileHasBeenChanged", false)
var BackArgumentHolder.profileChanged: Boolean by profileChangedArgument(null)
impl-модуль Settings
@Composable
fun SettingsScreen(controller: NavController) {
val vm = koinViewModel<SettingsViewModel>()
DisposableEffect(controller) {
vm.router = object : Router {
override fun goBack(hasChanges: Boolean) {
controller.with(profileChangedArgument(hasChanges)).popBackStack()
}
}
onDispose { vm.router = null }
}
}
Использование в экране профиля
@Composable
fun ProfileScreen(controller: NavController, entry: NavBackStackEntry) {
val vm = koinViewModel<ProfileViewModel>(parameters = { parametersOf(SavedStateBackArgumentHolder(entry.savedStateHandle)) })
LaunchedEffect(Unit) {
vm.onLaunch()
}
...
}
internal class ProfileViewModel(
private val backArgumentHolder: SavedStateBackArgumentHolder,
) : ViewModel() {
fun onLaunch() {
if (backArgumentHolder.hasChanges) {
updateSomething()
backArgumentHolder.hasChanges = false
}
}
}
Преимущества подхода
Безопасность: исключение ошибок, связанных с неверными типами. Всегда есть дефолтное значение. Ключ достаточно указать один раз.
Удобство разработки: делегированные свойства делают работу с данными проще и понятнее.
Недостатки подхода
-
God object: корневой модуль app, который получает в зависимостях все модули проекта, знает обо всех свойствах
BackArgumentHolder
, но они все будут дефолтными.P.S. Пока не очень понятен сценарий работы с
BackArgumentHolder
в корневом модуле, но тем не менее такой недостаток есть.
Заключение
Мы рассмотрели, как типобезопасная передача результатов помогает сделать навигацию в Jetpack Compose более надежной и удобной. Использование BackArgumentHolder
и делегированных свойств позволяет избежать распространенных ошибок и улучшает качество кода. Демонстрационный репозиторий GitHub.
Если у вас возникнут вопросы или идеи для улучшения, делитесь ими в комментариях!
pie0tv
Я достаточно юн в разработке и не участвовал в больших коммерческих проектах, но как у Вас в этом случае работает правило единого источника правды?
Не правильнее было бы использовать потоки?
Mortd3kay Автор
Понятие единого источника правды находится немного в другой плоскости.
Представим, что у тебя есть экран выбора контактов (не системный, а самописный) и тебе нужно, чтобы этот экран можно было открыть из разных частей приложения, чтобы получить номер телефона. В реализации экрана выбора контакта источником правды является системный провайдер контактов. В фиче, которая использует твой экран выбора контакта, может быть свой источник правды. Но в рамках перехода между экранами как будто не совсем корректно применять это понятие.
Если абстрагироваться от экранов и библиотек, то выбор контакта можно было бы реализовать так:
или так:
В таких упрощенных примерах если и можно применить правило единого источника, то как минимум оно не будет нарушаться. На этих же примерах понятно, почему не используется поток – в нем просто нет необходимости для разового получения.
Надеюсь, ответил на вопрос