Вы когда-нибудь задумывались, зачем нужны библиотеки для навигации в Jetpack Compose? Почему мы не можем просто взять mutableStateOf со списком экранов и переключаться между ними? Оказывается, если мы попробуем реализовать такой наивный подход, то столкнёмся с рядом проблем: rememberSaveable не работает, ViewModel не очищаются после ухода с экрана, Lifecycle не работает корректно и многое другое.

В статье разберём, как работают библиотеки навигации в Jetpack Compose и какие задачи они решают на примере библиотеки навигации Modo.

Меня зовут Игорь Кареньков, я Android-разработчик и тимлид команды mobile-core в hh.ru. Наша команда занимается общими платформенными решениями для iOS и Android: архитектурой, CI/CD, аналитикой, навигацией и другими архитектурными задачами. Также я мейнтейнер библиотеки навигации для Jetpack Compose Modo и автор телеграм канала Заметки core-разработчика.

Это первая статья из серии о том, как устроена навигация в Jetpack Compose под капотом: какие проблемы возникают при наивной реализации, как библиотеки навигации изолируют состояние экранов и почему одного списка экранов недостаточно.

Из этой статьи вы узнаете:

  • Почему наивный подход к навигации через mutableStateOf ломает rememberSaveableViewModel и Lifecycle

  • Как устроена библиотека навигации Modo: ScreenContainerScreendispatch и дерево навигации

  • Как SaveableStateHolder решает проблему изоляции состояния между экранами

  • Как Modo сохраняет граф навигации при смерти процесса и поддерживает стабильность инстансов экранов

  • Как работает механизм очистки ресурсов и защита от преждевременного удаления данных во время анимаций

Серия будет состоять из двух частей:

  • Разбор работы навигации Jetpack в Compose на примере Modo:

    • Пробуем писать навигацию с нуля и находим подводные камни

    • Разбираем работу Modo под капотом

  • Интеграция Modo с Android: Lifecycle, ViewModel, SavedState

Введение: пишем навигацию с нуля и находим подводные камни

Что такое навигация в самом простом виде? Обычно это возможность переключаться между экранами по тем или иным правилам. Самым простым примером будет открытие и закрытие экрана:

  • Изначально виден экран A

  • Открываем экран B

  • Закрываем B и снова видим предыдущий экран A:

По сути, такая навигация работает как стек:

  • Видим только верхний экран

  • Можем добавить новый экран в стек

  • Можем убрать экран из стека и вернуться на предыдущий

Примерно так:

Давайте реализуем подобную логику самостоятельно: сделаем 2 экрана с минимальными отличиями и попробуем отображать их через стек (полный код):

Скрытый текст
@Parcelize
sealed interface NavigationScreen : Parcelable {
    data class ScreenA(val index: Int) : NavigationScreen
    data class ScreenB(val index: Int) : NavigationScreen
}

@Composable
fun SimpleNavigation(modifier: Modifier = Modifier) {
    // 1. Определяем состояние нашей навигации:
    //    * List для хранения стека экранов
    //    * mutableStateOf для вызова рекомпозиции для новых значений
    var navigationStack: List<NavigationScreen> by rememberSaveable {
        mutableStateOf(listOf(NavigationScreen.ScreenA(1)))
    }
    // 2. Берём текущий экран - последний в стеке
    val currentScreen = navigationStack.lastOrNull()
    Column(/*...*/) {
        /*...*/
        // 3. Определяем контент для текущего экрана
        if (currentScreen != null) {
            when (currentScreen) {
                is NavigationScreen.ScreenA -> ScreenAContent(
                    screen = currentScreen,
                    canGoBack = navigationStack.size > 1,
                    // Для перехода на следующий экран просто добавляем экран в конец списка.
                    // Под капотом это затригерит рекомпозицию из-за обновления состояния MutableState
                    onNavigateForwardA = { navigationStack += NavigationScreen.ScreenA(navigationStack.size + 1) },
                    onNavigateForwardB = { navigationStack += NavigationScreen.ScreenB(navigationStack.size + 1) },
                    onGoBack = { navigationStack = navigationStack.dropLast(1) }
                )
                is NavigationScreen.ScreenB -> ScreenBContent(
                    /* Same as for ScreenAContent */
                )
            }
        }
    }
}

@Composable
fun ScreenAContent(
    modifier: Modifier = Modifier,
    screen: NavigationScreen.ScreenA,
    canGoBack: Boolean,
    onNavigateForwardA: () -> Unit,
    onNavigateForwardB: () -> Unit,
    onGoBack: () -> Unit
) {
    ScreenContent(
        title = "ScreenA: index = ${screen.index}",
        // ...
    )
}

@Composable
fun ScreenBContent(
    modifier: Modifier = Modifier,
    screen: NavigationScreen.ScreenB,
    canGoBack: Boolean,
    onNavigateForwardA: () -> Unit,
    onNavigateForwardB: () -> Unit,
    onGoBack: () -> Unit
) {
    ScreenContent(
        title = "ScreenB: index = ${screen.index}",
        // ...
    )
}

@Composable
fun ScreenContent(
    modifier: Modifier = Modifier,
    title: String,
    canGoBack: Boolean,
    onNavigateForwardA: () -> Unit,
    onNavigateForwardB: () -> Unit,
    onGoBack: () -> Unit
) {
    // Проблема:
    // Без использования SaveableStateHolder значение может как сброситься (при возврате назад),
    // так и остаться от предыдущего экрана (при переходе вперед на экран того же типа)
    val randomNumber = rememberSaveable { Random.nextInt(100) }
    Column(/*...*/) {
        Text(title)
        Text("RememberSaveable value: $randomNumber")
        Spacer(modifier = Modifier.height(10.dp))
        Row {
            Button(onClick = onNavigateForwardA) { Text("Forward A") }
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = onNavigateForwardB) { Text("Forward B") }
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = onGoBack, enabled = canGoBack) { Text("Back") }
        }
    }
}

Теперь попробуем запустить этот код и сделать следующие переходы:

  1. [A] -> [A, A]

  2. [A] -> [A, B] -> [A] — при переходе назад мы видим, что для первого экрана получаем новое значение RememberSaveableValue

И сразу видно, что rememberSaveable работает не совсем так, как мы могли бы ожидать:

  1. При открытии нового экрана того же типа значение остаётся прежним, хотя для нового экземпляра экрана ожидается новое состояние

  2. После возврата назад значение может измениться, хотя пользователь вернулся к тому же экрану

Например, экран A сначала получил значение 43, а после сценария [A] → [A, B] → [A] уже показывает 41.

Это происходит из-за логики работы rememberSaveable — он привязывается к позиции в дереве композиции и ничего не знает о нашем экране. Из-за чего ломается важный контракт: для одного и того же экземпляра экрана rememberSaveable должен возвращаться один и тот же результат.

Есть и другая проблема — очистка данных. В нашей текущей реализации, когда мы закрываем экран и удаляем его из navigationStack, состояние из rememberSaveable продолжает висеть в памяти до тех пор, пока родитель не очистит данные из SaveableStateHolder.

На этом проблемы не заканчиваются. В Android-мире существует множество сущностей, которые должны быть жестко привязаны к жизненному циклу конкретного экрана, а не всего приложения. К ним относятся:

  • LocalLifecycleOwner — отвечает за доставку событий жизненного цикла. Например, ON_PAUSEON_STOPON_DESTROY при уходе с экрана

  • LocalViewModelStoreOwner — управляет жизненным циклом ViewModel, который корректно вызовет onCleared()при закрытии (удалении из стека) экрана

  • LocalSavedStateRegistryOwner — обеспечивает сохранение и восстановление состояния после смерти процесса

В нашей реализации все эти CompositionLocal будут взяты напрямую из корневой Activity. Это означает, что если мы создадим ViewModel внутри ScreenB, то он привяжется к жизненному циклу всей Activity и никогда не очистится при закрытии экрана B.

Именно решением всех этих неочевидных проблем и занимаются полноценные библиотеки навигации.

Введение в Modo

Теперь, когда мы увидели, какие проблемы возникают при наивном подходе, давайте посмотрим, как их решает Modo.

Modo — это state-based библиотека навигации основанная на UDF принципах для Jetpack Compose. Её главные идеи:

  • Навигация представляет собой граф, а точнее — корневое дерево экранов.

  • Навигация и UI описываются состоянием. Чтобы изменить навигацию, достаточно обновить состояние.

  • Объект экрана стабилен в рамках одного процесса приложения. Благодаря этому экраны корректно переживают изменения конфигурации (например, поворот устройства), а также упрощается работа с DI: экземпляр экрана можно безопасно передавать как зависимость или параметр.

Визуально это можно представить так:

Благодаря такой древовидной структуре, Modo позволяет легко масштабировать навигацию от простого стека до сложных вложенных конструкций.

Screen — базовая единица навигации

Каждый экран в этой структуре — это обычный Kotlin-класс, реализующий интерфейс Screen. Давайте посмотрим на пример реализации простого экрана и что в нём есть:

// Аргументы для экрана следует передавать в конструктор.
// @Parcelize генерирует реализацию Parcelable для сохранения и восстановления.
@Parcelize
class SampleScreen(
    // Обязательный параметр, генерируемый библиотечной функцией generateScreenKey().
    // Она создает уникальный ключ при вызове.
    // Стабильность ключа для экрана достигается благодаря сохранению экрана на уровне библиотеки.
    override val screenKey: ScreenKey = generateScreenKey(),
    // Пример аргумента
    val index: Int
) : Screen {
    // UI экрана определяется напрямую в классе
    @Composable
    override fun Content(modifier: Modifier) {
        Text("Это экран №$index")
    }
}

ContainerScreen — тот же Screen, но с вложенными экранами

Основное отличие ContainerScreen от обычного Screen заключается в том, что он содержит вложенные экраны и собственное состояние навигации. Именно контейнер отвечает за обновление этого состояния и отображение вложенных экранов. Самый распространенный пример — StackScreen.

Рассмотрим реализацию простого навигационного стека:

@Parcelize
open class SampleStackScreen(
    // В отличие от обычного Screen, мы должны определить в конструкторе модель нашей навигации.
    // В данном случае это стек с одним экраном SampleScreen
    private val stackNavModel: StackNavModel = StackNavModel(SampleScreen())
) : StackScreen(stackNavModel) {

    // Мы можем определить собственные конструкторы для удобства
    constructor(rootScreen: Screen) : this(StackNavModel(rootScreen))

    @Composable
    override fun Content(modifier: Modifier) {
        Box(modifier.fillMaxSize()) {
            // Отображаем верхний экран стека при помощи специальной функции
            TopScreenContent(
                modifier = Modifier.fillMaxSize(),
                dialogModifier = Modifier.fillMaxSize()
            ) { contentModifier ->
                // Декорируем наш контент, например добавляя анимацию переходов
                SlideTransition(contentModifier)
            }
        }
    }
}

Что такое StackNavModel? Это специальный typealias, за которым кроется NavModel, который отвечает за состояние навигации и его обновление. Ниже пример стека и его состояния:

typealias StackNavModel = NavModel<StackState>

// Пример NavigationState - это обычный data class с описанием вашей модели навигации
@Parcelize
data class StackState(
    val stack: List<Screen> = emptyList(),
) : NavigationState, Parcelable {

    constructor(vararg screensStack: Screen) : this(screensStack.toList())

    // Обязательный метод для получения всех прямых детей для последующей очистки ресурсов
    override fun getChildScreens(): List<Screen> = stack

}

Переходы между экранами

Переход между экранами может выглядеть как магия, но на самом деле любое действие по навигации сводится к изменению состояния (State) соответствующего ContainerScreen через dispatch.

Самый частый сценарий — навигация в стеке. Для этого нам нужно получить StackNavigation через CompositionLocal или DI и вызвать привычные методы:

val stackNavigation = LocalStackNavigation.current

// Открыть новый экран
stackNavigation.forward(SampleScreen(index = 2))

// Вернуться назад
stackNavigation.back()

Что происходит «под капотом» dispatch?

На самом деле методы forward и back — это всего лишь удобные расширения над базовым методом dispatch. Dispatch принимает на вход reducer — лямбду, которая вычисляет новое состояние на основе старого:

Давайте посмотрим как можно реализовать forward и back самостоятельно:

// Аналог forward: просто добавляем экран в конец списка
stackNavigation.dispatch { oldState ->
    StackState(oldState.stack + SampleScreen(customParam = 2))
}

// Аналог back: убираем последний экран, если их больше одного
stackNavigation.dispatch { oldState ->
    if (oldState.stack.size > 1) {
        StackState(oldState.stack.dropLast(1))
    } else {
        oldState
    }
}

Такой подход с dispatch позволяет реализовать абсолютно любую логику переходов. Например, вы можете одним действием очистить весь стек и оставить только нужные экраны, или заменить конкретный экран в середине стека. Библиотека просто берет новый стейт и обновляет UI, обеспечивая при этом все те интеграции жизненного цикла, о которых мы говорили выше.

Внутреннее устройство Modo

Перед тем как начинать копать вглубь, давайте определимся, что мы ожидаем от библиотек навигации в Jetpack Compose:

  • При обновлении состояния мы видим изменения в UI

  • Сохранение иерархии навигации и состояния экранов при изменениях конфигурации

  • Очистка данных после удаления экрана из графа навигации

Устройство ContainerScreen

Исходный код:

Как происходит обновление навигации?

Давайте начнём с метода ContainerScreen.dispatch. При его вызове обновляется состояние внутри StateFlow:

override fun dispatch(reducer: NavigationReducer<State>) {
    _navigationState.update { reducer.reduce(it) }
}

На этом этапе возникает вопрос: что происходит дальше и как мы в Compose получаем обновление UI? Рассмотрим этот процесс подробнее:

Давайте подробнее посмотрим на связывание StateFlow с Compose MutableState (исходный код):

internal class ComposeRenderer<State : NavigationState>(
    private val containerScreen: ContainerScreen<State>,
    stateFlow: StateFlow<State>,
) {
    internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    private var lastState: State? = null
    // MutableState позволяет автоматически тригерить рекомпозицию при обновлении
    var state: State by mutableStateOf(stateFlow.value, neverEqualPolicy())
        private set

    // Для последующей очистки
    private val removedScreens = mutableSetOf<Screen>()

    init {
        scope.launch {
            stateFlow.drop(1).collect { newState ->
                // Вычисляем, какие экраны были удалены. Потребуется дальше для очистки ресурсов.
                removedScreens.addAll(calculateRemovedScreens(state, newState))
                // Запоминаем предыдущее состояние, потребуется для анимаций
                lastState = state
                // Обновляем состояние
                state = newState
                // Переводим экраны в состояние DESTROYED, если они были удалены из дерева навигации
                onPreDispose()
            }
        }
    }
}

Интеграция навигации в приложение

Чтобы добавить навигацию Modo в ваше приложение в Activity или Fragment нужно сделать всего 2 шага:

class ModoSampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // 1. Запоминаем иерархию навигации
            val rootScreen = rememberRootScreen {
                SampleStack(MainScreen(1))
            }
            // 2. Отображаем корневой экран
            rootScreen.Content(modifier = Modifier.fillMaxSize())
        }
    }
}

Исходный код примера: ModoSampleActivity

Запоминаем иерархию навигации

Точкой входа для интеграции Modo в Android-компоненты является функция rememberRootScreen. Для простоты мы рассмотрим реализацию для rememberRootScreen для Activity, а реализацию для Fragment вы можете рассмотреть самостоятельно.

Для сохранения данных используем простой rememberSaveable, который сохраняет данные при уходе из композиции:

val rootScreen = rememberRootScreen {
    SampleStack(MainScreen(1))
}

@Composable
fun <T : Screen> Activity.rememberRootScreen(
    rootScreenFactory: () -> T
): RootScreen<T> {
    val rootScreen = rememberCounterAndRoot(rootScreenFactory)
    // Шаг 3. Очищаем данные при завершении приложения
    DisposableEffect(rootScreen, this) {
        onDispose {
            if (isFinishing) {
                onRootScreenFinished(rootScreen)
            }
        }
    }
    return rootScreen
}

@Composable
private fun <T : Screen> rememberCounterAndRoot(rootScreenFactory: () -> T): RootScreen<T> {
    // Шаг 1. Сохраняем глобальный счётчик экранов.
    rememberSaveable(
        key = MODO_SCREEN_COUNTER_KEY,
        saver = Saver(
            restore = {
                restoreScreenCounterIfNeeded(it as Int)
                it
            },
            save = {
                val counter = screenCounterKey.get()
                if (counter == -1) {
                    null
                } else {
                    counter
                }
            }
        )
    ) {
        screenCounterKey.get()
    }

    // Шаг 2. Сохраняем граф навигации.
    val rootScreen = rememberSaveable(
        key = MODO_GRAPH,
        saver = Saver(
            save = { it },
            restore = { saved ->
                @Suppress("UNCHECKED_CAST")
                rootScreens.getOrPut(saved.screenKey) { saved } as RootScreen<T>
            }
        )
    ) {
        RootScreen(rootScreenFactory()).also { newRoot ->
            rootScreens[newRoot.screenKey] = newRoot
        }
    }

    return rootScreen
}

Шаг 1. Счётчик экранов — глобальный на весь процесс (AtomicInteger в объекте Modo)

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

Если после пересоздания процесса не восстановить значение счётчика, новые экраны могут получить ключи, уже занятые восстановленными экранами. В результате возможна потеря данных или даже падение приложения. Например, rememberSaveable может вернуть новое значение вместо ранее сохранённого.

Функция restoreScreenCounterIfNeeded восстанавливает счётчик только если мы действительно пришли после смерти процесса. Во всех остальных случаях используются актуальные данные, которые уже находятся в памяти.

Шаг 2. Сохраняем граф навигации

При первом создании экрана мы просто вызываем фабричный метод rootScreenFactory для создания root-экрана, а также кладем root в in-memory кэш.

Такой подход позволяет сохранять важный контракт библиотеки — стабильность инстанса. В рамках одного процесса экран всегда представлен одним и тем же объектом Screen, а его ссылка не меняется. Это позволяет использовать экраны (особенно контейнеры) в DI и в подобных сценариях.

Важно: если вы инжектите экран в какой-либо объект, то его время жизни должно быть меньше или равно времени жизни экрана, иначе это приведёт к утечке памяти.

В save наш root-экран сохраняется без проблем, потому что он реализует Parcelable. Именно для сохранения и восстановления экранов мы используем @Parcelize для каждого экрана.

В restore мы сначала пытаемся получить существующий root из in-memory кэша. Если кэш пустой, то восстанавливаем значение из сохранённых данных и кладём его в кэш.

Шаг 3. Очищаем данные при завершении приложения

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

Сама логика очистки выглядит так:

private fun <T : Screen> finishRootScreen(rootScreen: RootScreen<T>?) {
    if (rootScreen != null) {
        Log.d("Modo", "rootScreen removing $rootScreen")
        // Удаляем данные из in-memory кэша
        rootScreens.remove(rootScreen.screenKey)
        // Очищаем внутренние данные
        clearScreenModel(rootScreen)
    }
}

private fun clearScreenModel(screen: Screen) {
    // Очищаем данные из ScreenModelStore для данного экрана
    ScreenModelStore.remove(screen)
    // Рекурсивно проходимся по детям и вызываем эту же функцию
    (screen as? ContainerScreen<*, *>)?.navigationState?.getChildScreens()?.forEach(::clearScreenModel)
}

ScreenModelStore — это специальный класс для хранения моделей и зависимостей, связанных с экраном. Подробнее его устройство рассмотрим в следующей статье.

Итог: какие проблемы решает rememberRootScreen

rememberRootScreen решает сразу несколько важных задач:

  1. Process Death и сохранение стейта. Если система убьёт процесс приложения, то весь стек экранов и счётчик должны восстановиться в том же виде.

  2. Стабильность инстанса (Instance Stability). Навигация часто нужна в DI-графе или ScreenModel. Нам важно, чтобы при повороте экрана (Configuration Change) возвращался тот же экземпляр объекта Screen, а не просто десериализованная копия.

  3. Очистка ресурсов (Memory Leaks). Когда навигационный контейнер (Activity или Fragment) окончательно уничтожается, нужно рекурсивно очистить все ScreenModel для всех экранов в дереве.

Как состояние отрисовывается на экране (от Container к Content)

Главный вопрос: как Modo понимает, какой экран нужно рисовать в данный момент?

После того как мы «запомнили» корень навигации, нам нужно его отрисовать — это делается через rootScreen.Content().

Давайте разберёмся, как абстрактное состояние навигации (список экранов) превращается в реальные Composable-функции и как при этом решается проблема «сломанного» rememberSaveable.

Цепочка вызовов: матрешка навигации

Навигация в Modo — это матрёшка. Каждый контейнер (стек, табы) отвечает за отрисовку своих детей. Но делает это не напрямую, а через единый механизм.

RootScreen.Content() — начало UI

Что именно происходит в RootScreen.Content():

@Composable
override fun Content(modifier: Modifier) {
    // Запоминаем SaveableStateHolder и предоставляем его для всей иерархии навигации
    val stateHolder: SaveableStateHolder = LocalSaveableStateHolder.current ?: rememberSaveableStateHolder()
    CompositionLocalProvider(
        LocalSaveableStateHolder providesDefault stateHolder
    ) {
        // Отрисовываем наш экран, который мы передали в качестве корня. Например SampleStack(MainScreen(1))
        InternalContent(navigationState.screen, modifier)
    }
}

SavableStateHolder — это специальный класс из androidx.compose.runtime.saveable, обеспечивающий корректность работы rememberSaveable в рамках поддерева compose. Логику его работы мы разберём дальше, а сейчас важно то, что мы используем 1 экземпляр на всё дерево навигации.

InternalContent

Любой ContainerScreen (включая корень) отрисовывает вложенные экраны при помощи функцию InternalContent(screen):

@Composable
fun InternalContent(
    screen: Screen,
    modifier: Modifier = Modifier,
    content: RendererContent<State> = defaultRendererContent
) {
    // Делегируем задачу рендереру
    renderer.Content(screen, modifier, content = content)
}

Давайте рассмотрим реализацию функции StackScreen.TopScreenContent, который мы использовали раньше. Она также в конечном итоге использует InternalContent:

@Composable
protected fun TopScreenContent(
    modifier: Modifier = Modifier,
    dialogModifier: Modifier = Modifier,
    content: RendererContent<StackState> = defaultRendererContent
) {
    // Определяем дефолтный обработчик нажатия на кнопку "назад"
    StackBackHandler()
    // Вычисляем, какие экраны нужно отрисовать: это последний экран, не являющийся диалогом, и видимые диалог(и)
    val screensToRender: ScreensToRender by rememberScreensToRender()
    screensToRender.screen?.let { screen ->
        // Отображаем последний экран, внутри обычный вызов InternalContent
        Content(screen, modifier, content)
    }
    // Дальше похожая логика отрисовки диалогов
}

То есть по факту мы просто берём данные из navigationState и отображаем их при помощи InternalContent!

ComposeRenderer — рабочая лошадка

Контейнер делегирует отрисовку специальному классу — ComposeRenderer. Он связывает состояние навигации с миром Compose:

@Composable
fun Content(
    screen: Screen,
    modifier: Modifier = Modifier,
    provideCompositionLocal: Array<ProvidedValue<*>> = emptyArray(),
    content: RendererContent<State> = defaultRendererContent
) {
    // Получаем запомненный ранее SaveableStateHolder
    val stateHolder: SaveableStateHolder = LocalSaveableStateHolder.currentOrThrow
    // Запоминаем лямбды для очистки ресурсов и работы с Lifecyle, которые будем использовать дальше
    val clearScreens = remember(stateHolder) { { clearScreens(stateHolder) } }
    val preDispose = remember { { onPreDispose() } }

    // Прокидываем в иерархию ниже лямбды и текущий контейнер экрана для удобного использования
    CompositionLocalProvider(
        LocalContainerScreen provides containerScreen,
        LocalClearScreens provides clearScreens,
        LocalPreDispose provides preDispose,
        *provideCompositionLocal
    ) {
        // 1. Формируем контекст для отрисовки экрана:
        //    * предыдущее и текущие состояние навигации, которые понадобятся для анимаций
        //    * экран, который нужно отрисовать
        // 2. Вызываем content аргумент: это нужно для возможности декорирования контента (например для определения анимации).
        //    В конечном итоге мы должны будем вызвать screen.SaveableContent(screenModifier)
        ComposeRendererScope(oldState = lastState, newState = state, screen).content(modifier)
    }
}

SaveableContent — сердце системы

SaveableContent — это финальная точка сборки. Именно здесь Modo настраивает всё окружение для вашего UI:

@Composable
fun Screen.SaveableContent(
    modifier: Modifier = Modifier
) {
    // Логика связи анимаций и LifeCycle будет рассмотрена в следующей статье
    val usesTransitionLifecycle = LocalInTransitionContext.current
    CompositionLocalProvider(LocalInTransitionContext provides false) {
        // 1. Получаем SaveableStateHolder и связываем дерево навигации с данным экраном при помощи screenKey.value
        LocalSaveableStateHolder.currentOrThrow.SaveableStateProvider(key = saveableStateKey) {
            // 2. Логика очистки и защиты от очистки данных во время анимаций
            SetupScreenCleanup()
            // 3. Android-интеграция (Lifecycle, ViewModel, SavedState)
            ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(usesTransitionLifecycle) {
                // 4. Финальный вызов вашего Screen.Content
                Content(modifier)
            }
        }
    }
}

Разбираем магию по частям

SavableStateHolder — исправляем проблему с rememberSaveable

SaveableStateHolder предоставляет 2 ключевые функции, которые обеспечивают корректность работы rememberSaveable:

  1. SaveableStateProvider(key: Any, content: @Composable () -> Unit) — связывает контент с предоставленным ключем. В качестве ключа используется уникальный screenKey. Это гарантирует, что если экран временно уйдет из композиции (например, при переключении табов), его внутренние rememberSaveable сохранятся.

  2. removeState(key: Any) — очищает данные, которые связаны с ключом key.

SetupScreenCleanup — настройка очистки ресурсов

Это критически важный механизм защиты и очистки ресурсов:

@Composable
internal inline fun Screen.SetupScreenCleanup() {
    val clearScreens = LocalClearScreens.current
    DisposableEffect(this) {
        cleanupProtectedScreens[this@SetupScreenCleanup] = Unit
        onDispose {
            cleanupProtectedScreens -= this@SetupScreenCleanup
            clearScreens.invoke()
        }
    }
}

Защита от очистки:

  • Пока экран в композиции, он является ключом в cleanupProtectedScreens: mutableStateMapOf<Screen, Unit>().

  • Если экран удалили из навигации (сделали back()), но он еще виден (идет анимация выхода), рендерер не сможет очистить его данные, пока он не покинет экран физически. Без этого стейт бы сбрасывался прямо во время анимации. А так как экран ещё в композиции, мы бы получали повторную инициализацию переменных и утечки памяти.

Очистка данных происходит через вызов clearScreens. В конечном итоге это ComposeRenderer.clearScreens. Логику очистки можно посмотреть ниже:

private fun clearScreens(stateHolder: SaveableStateHolder, clearAll: Boolean = false) {
    fun Iterable<Screen>.clearStates(stateHolder: SaveableStateHolder) = forEach { screen ->
        screen.clearState(stateHolder)
    }

    if (clearAll) {
        // При необходимости очищаем дочерние экраны
        state?.getChildScreens()?.clearStates(stateHolder)
    }
    // Берём экраны, удаленные из навигации и ушедшие из композиции
    val safeToRemove = removedScreens.filter { it !in cleanupProtectedScreens }
    safeToRemove.clearStates(stateHolder)
    if (removedScreens.isNotEmpty()) {
        safeToRemove.forEach {
            // Удаляем очищенные экраны из специальной коллекции
            removedScreens -= it
        }
    }
}

private fun Screen.clearState(stateHolder: SaveableStateHolder) {
    // Очищаем screenModel и dependencies для экрана
    ScreenModelStore.remove(this)
    // Удаляем данные из StateHolder
    stateHolder.removeState(saveableStateKey)
    // Дополнительная очистка интеграции Modo с диалогами
    stateHolder.removeState(overlaySaveableStateKey)
    // Рекурсивно очищаем все дочерние экраны
    ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.clearScreens(stateHolder, clearAll = true)
}

Предоставление Android CompositionLocal

Через ModoScreenAndroidAdapter библиотека предоставляет отдельные Android-компоненты для каждого экрана:

  • LocalLifecycleOwner — собственный Lifecycle для каждого экрана

  • LocalViewModelStoreOwner — ViewModel живут ровно столько, сколько существует экран

  • LocalSavedStateRegistryOwner — обеспечивает корректную работу SavedStateHandle после смерти процесса

(Подробнее об устройстве ModoScreenAndroidAdapter.kt расскажем в следующей статье)

Бонус: реализуем произвольный контейнер навигации

Давайте теперь применим библиотеку на практике и сделаем отображение нескольких экранов одновременно внутри LazyColumn!

Это делается достаточно просто. Пример реализации StackInLazyColumnScreen, можно посмотреть в демо-приложении.

Вот так просто выглядит отображение вложенных экранов внутри LazyColumn:

LazyColumn(...) {
    screenItems(navigationState.screens) { screen ->
        // Каждый элемент списка -- это полноценный экран со своим состоянием
        InternalContent(
            screen = screen,
            modifier = Modifier.animateItem()
        )
    }
}

Благодаря тому, что InternalContent берёт на себя всю работу по изоляции состояния и жизненного цикла, вы можете располагать экраны как угодно: в стеке, в табах, в списке или даже в сетке. Modo гарантирует, что каждый из них будет работать корректно.

Заключение

Мы подробно рассмотрели основные моменты устройства Modo: разобрали путь от rememberRootScreen в вашей Activity до вызова Content в конкретном экране. Но дальше — больше. В следующем материале мы подробнее рассмотрим интеграцию библиотеки с Android компонентами.

А какой навигацией для Jetpack Compose пользуетесь вы? И стало ли понятнее как работает навигация после прочтения статьи или остались вопросы? Обязательно делитесь вопросами и замечаниями, с радостью отвечу на ваши комментарии. А также заходите в мой канал Заметки core-разработчика, там больше подробностей о Modo и core-разработке в целом.

Понимание этих механизмов позволяет не только эффективнее использовать Modo, но и лучше понимать, как работает сам Jetpack Compose под капотом.

Что дальше?

В следующих статьях мы подробно разберем:

  • Интеграцию Modo с Android компонентами

  • Устройство иерархичного Lifecycle и связанные с ним возможности

  • Реализацию анимаций переходов между экранами

  • Тонкости работы с BackHandler в сложных вложенных структурах

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