Приветствую 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 на любую другую или в реализации сделать, например, сохранение значений в какой-нибудь файл (если у вас на проекте принято передавать результаты таким образом).

Шаги для реализации в коде фичи

  1. Подключить легковесный модуль с интерфейсом BackArgumentHolder в api-модуль дочернего экрана

  2. При помощи createBackArgument создать в api-модуле описание возвращаемого аргумента, как контракт закрытия фичи

  3. Перед вызовом popBackStack или navigateUp передать в navController результат через функцию with

  4. В родительском модуле обернуть entry.savedStateHandle в SavedStateBackArgumentHolder и передать в VM

  5. Подключить 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.

Если у вас возникнут вопросы или идеи для улучшения, делитесь ими в комментариях!

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


  1. pie0tv
    15.05.2025 08:53

    Я достаточно юн в разработке и не участвовал в больших коммерческих проектах, но как у Вас в этом случае работает правило единого источника правды?

    Не правильнее было бы использовать потоки?


    1. Mortd3kay Автор
      15.05.2025 08:53

      Понятие единого источника правды находится немного в другой плоскости.
      Представим, что у тебя есть экран выбора контактов (не системный, а самописный) и тебе нужно, чтобы этот экран можно было открыть из разных частей приложения, чтобы получить номер телефона. В реализации экрана выбора контакта источником правды является системный провайдер контактов. В фиче, которая использует твой экран выбора контакта, может быть свой источник правды. Но в рамках перехода между экранами как будто не совсем корректно применять это понятие.
      Если абстрагироваться от экранов и библиотек, то выбор контакта можно было бы реализовать так:

      openContactSelector { selectedContact -> doSomething(selectedContact) }

      или так:

      val selectedContact = selectContact()

      В таких упрощенных примерах если и можно применить правило единого источника, то как минимум оно не будет нарушаться. На этих же примерах понятно, почему не используется поток – в нем просто нет необходимости для разового получения.
      Надеюсь, ответил на вопрос