Приветствую 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.
Если у вас возникнут вопросы или идеи для улучшения, делитесь ими в комментариях!
