Connect with us

Разработка

Первый взгляд на retain{}: новый способ сохранения состояния в Jetpack Compose

Появление retain в Compose Runtime стало важным шагом к тому, чтобы сделать Compose самостоятельной UI-системой. Этот механизм закрывает разрыв между краткоживущим состоянием и длительным хранением в памяти, позволяя сохранять значения при переходах, не выходя за пределы composable-мирa.

Опубликовано

/

     
     

Jetpack Compose изменил наше представление о создании пользовательского интерфейса в Android. Благодаря своей декларативной природе, реактивной модели состояния и composable функциям, он радикально сократил шаблонный код и повысил ясность логики. Но, несмотря на все свои сильные стороны, Compose долгое время не хватало одного механизма: нативного способа сохранения состояния при разрушении и воссоздании иерархии композиции.

Мы используем функцию remember для восстановления значений при рекомпозиции. В большинстве случаев это работает хорошо, но потом перестаёт работать. После удаления всей композиции, например, из-за изменения конфигурации, сворачивания контента или всплывающей панели навигации, эти запомненные значения исчезают. Вам приходится перезагружать данные или заново создавать объекты, которые вы только что инициализировали. Это не только неэффективно, но и потенциально раздражает пользователей.

Похоже, разработка в AOSP всё ещё продолжается, и недавно разработчики работали над новым API retain в рантайме Jetpack Compose. Это дополнение заполняет давний пробел в Compose, позволяя значениям сохраняться после рекомпозиций и временных удалений из иерархии пользовательского интерфейса. Благодаря функции retain, Compose теперь может управлять состоянием способом, очень похожим на поведение ViewModel, но при этом полностью компонуемым и независимым от жизненного цикла.

Понимание отличий retain

Если вы использовали remember, вы уже понимаете часть функций retain. Но ключевое отличие заключается в длительности хранения значений. В то время как remember сохраняется только после рекомпозиций, retain позволяет значениям сохраняться даже после полного выхода связанного композабл объекта из композиции.

Например, если экран уходит, но остается в бек-стеке, или если Activity пересоздаётся во время поворота, любое значение, запомненное с помощью remember, будет удалено. В отличие от этого, значение, созданное с помощью retain, может оставаться в памяти и быть восстановлено при возвращении компонуемого объекта в композицию.

Давайте подробнее рассмотрим API retain.

/**
 * ┌──────────────────────┐
 * │                      │
 * │ retain(keys) { ... } │
 * │        ┌────────────┐│
 * └────────┤  value: T  ├┘
 *          └──┬─────────┘
 *             │   ▲
 *         Exit│   │Enter
 *  composition│   │composition
 *    or change│   │
 *         keys│   │                         ┌──────────────────────────┐
 *             │   ├───No retained value─────┤   calculation: () -> T   │
 *             │   │   or different keys     └──────────────────────────┘
 *             │   │                         ┌──────────────────────────┐
 *             │   └───Re-enter composition──┤    Local RetainScope     │
 *             │       with the same keys    └─────────────────┬────────┘
 *             │                                           ▲   │
 *             │                      ┌─Yes────────────────┘   │ value not
 *             │                      │                        │ restored and
 *             │   .──────────────────┴──────────────────.     │ scope stops
 *             └─▶(   RetainScope.isKeepingExitedValues   )    │ keeping exited
 *                 `──────────────────┬──────────────────'     │ values
 *                                    │                        ▼
 *                                    │      ┌──────────────────────────┐
 *                                    └─No──▶│     value is retired     │
 *                                           └──────────────────────────┘
 * ```
 *
 * @param calculation A computation to invoke to create a new value, which will be used when a
 *   previous one is not available to return because it was neither remembered nor retained.
 * @return The result of [calculation]
 * @throws IllegalArgumentException if the return result of [calculation] both implements
 *   [RememberObserver] and does not also implement [RetainObserver]
 * @see remember
 */
@Composable
public inline fun <reified T> retain(noinline calculation: @DisallowComposableCalls () -> T): T {
    return retain(
        positionalKey = currentCompositeKeyHashCode,
        typeHash = classHash<T>(),
        calculation = calculation,
    )
}

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

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

retain отличается тем, что он привязывается не к жизненному циклу Android-компонента, а к RetainScope. Это граница composition-local, которая управляет тем, как и когда значения сохраняются, удерживаются или удаляются.

Стандартная реализация — ForgetfulRetainScope — ведёт себя так же, как remember, но новые реализации могут интегрироваться со скоупами, учитывающими жизненный цикл, например активити или навигационными графами.

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

Обычный процесс удержания выглядит так:

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

2. Контент выходит из композиции: Когда composable удаляется, значения, запомненные через remember, забываются. Но значения, созданные через retain, передаются в скоуп с помощью saveExitingValue(), чтобы их можно было затем восстановить.

3. Контент возвращается: Позже, если композабл воссоздаётся на той же позиции и с теми же ключами, retain вызывает getExitedValueOrDefault(). Если сохранённое значение есть, оно возвращается и повторно привязывается к композиции.

4. Завершение удержания: После полного восстановления UI владелец должен вызвать unRequestKeepExitedValues(). Любые сохранённые значения, которые не были повторно использованы, немедленно удаляются.

Таким образом, RetainScope — это composition-scoped объект , который отслеживает значения, запомненные с помощью retain. Он определяет, нужно ли держать значение в памяти после выхода из композиции и когда его следует восстановить или удалить.

Внутри рантайма Compose используется локальная композиция LocalRetainScope, чтобы предоставить доступ к текущему RetainScope. По умолчанию используется ForgetfulRetainScope, из-за чего retain ведет себя так же, как remember. Однако в Android скоупы с поддержкой жизненного цикла (например, связанные с Activity или навигационным графом) могут переопределять стандартное поведение и обеспечивать сохранение состояния при более сложных переходах.

/**
 * The ForgetfulRetainScope is an implementation of [RetainScope] that is incapable of keeping any
 * exited values. When installed as the [LocalRetainScope], all invocations of [retain] will behave
 * like a standard [remember]. [RetainObserver] callbacks are still dispatched instead of
 * [RememberObserver] callbacks, meaning that this class will always immediately retire a value as
 * soon as it exits composition.
 */
public object ForgetfulRetainScope : RetainScope() {
    override fun onStartKeepingExitedValues() {
        throw UnsupportedOperationException("ForgetfulRetainScope can never keep exited values.")
    }

    override fun onStopKeepingExitedValues() {
        // Do nothing. This implementation never keeps exited values.
    }

    override fun getExitedValueOrDefault(key: Any, defaultIfAbsent: Any?): Any? {
        return defaultIfAbsent
    }

    override fun saveExitingValue(key: Any, value: Any?) {
        throw UnsupportedOperationException("ForgetfulRetainScope can never keep exited values.")
    }
}

Внутренне любое значение, запомненное с помощью retain, регистрируется в активном RetainScope. Когда контент покидает и возвращается в композицию, скоуп определяет, какие значения сохраняются, а какие удаляются.

Взгляд внутрь: как работает retain

На первый взгляд использование retain почти не отличается от remember. Вы пишете код примерно так же:

val user = retain { someData() }

Внутри Compose это реализовано иначе. Каждый вызов retain получает уникальный ключ, который формируется из позиционной информации (через currentCompositeKeyHashCode) и, при необходимости, пользовательских ключей. Эти значения заворачиваются в объект RetainKeys и сохраняются в RetainedValueHolder, который отслеживает текущее значение и его принадлежность к конкретному RetainScope.

Когда composable выходит из композиции, RetainScope решает — сохранить значение или удалить. Если удержание должно быть активным (например, потому что экран находится в back stack навигации или активити пересоздаётся), значение остаётся в памяти и будет возвращено при возврате композабл.

Это поведение удержания так же наблюдаемое. Если ваш объект реализует интерфейс RetainObserver, он будет получать callback-методы, похожие на жизненный цикл: onRetained(), onEnteredComposition(), onExitedComposition() и, наконец, onRetired(). С их помощью вы можете корректно управлять ресурсами и избегать утечек памяти или неожиданных ситуаций.

Есть ещё одно правило: если значение реализует интерфейс RememberObserver и используется с retain, оно обязательно также должно реализовывать RetainObserver. В противном случае будет выброшено исключение. Это гарантирует, что Compose всегда может корректно управлять жизненным циклом удерживаемых объектов.

Нативное для Compose хранение состояния без ViewModel

Один из самых интересных аспектов retain — он позволяет по-новому реализовать сохранение состояния без использования традиционного жизненного цикла Android. Используя retain, вам не нужно выносить логику во ViewModel, не требуется SavedStateHandle и жёсткой привязки к жизненному циклу Activity или Fragment. Всё происходит прямо внутри дерева композиции, где и должно быть.

Это не означает, что ViewModel становится ненужным — напротив, он по-прежнему необходим для общего состояния между экранами или при интеграции с Android API. Однако retain обеспечивает более точное и лёгкое решение для хранения состояния локально. Это особенно полезно в динамических, вложенных UI-структурах, когда использование ViewModel — это избыточно.

Например, в экране детализации внутри навигационного потока через retain можно так получить и закэшировать данные:

@Composable
fun ArticleScreen(articleId: String) {
    val article = retain(articleId) { loadArticle(articleId) }
    ArticleContent(article)
}

Теперь, даже если пользователь покидает экран и возвращается вскоре после этого, статья остаётся закэшированной в памяти. Нет лишних повторных загрузок данных и неожиданных сбросов UI. Вы получаете плавные переходы и постоянство состояния, управляемое непосредственно Compose Runtime.

Жизненный цикл удерживаемого значения

Каждое значение, сохранённое с помощью retain, проходит чётко определённый жизненный цикл. Изначально значение создаётся с помощью переданной вами лямбды. Когда composable выходит из композиции, Compose обращается к RetainScope, чтобы решить — сохранить его в памяти или удалить. Если значение нужно сохранить, оно удерживается в памяти, но не участвует в активной композиции.

Позже, если composable снова возвращается в иерархию с теми же ключами, Compose повторно использует сохранённое значение, не вызывая лямбду повторно. В противном случае, как и с remember, лямбда вызывается заново для инициализации нового значения.

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

Заключение

Появление retain в Compose Runtime стало важным шагом к тому, чтобы сделать Compose самостоятельной UI-системой. Этот механизм закрывает разрыв между краткоживущим состоянием и длительным хранением в памяти, позволяя сохранять значения при переходах, не выходя за пределы composable-мирa.

Будь то сложная навигация, обработка конфигурационных изменений или управление временным UI-состоянием — retain предлагает новый многообещающий механизм восстановления состояния за пределами композиции непосредственно в рамках Compose Runtime.

Если вы хотите быть в курсе последних изменений, новостей, технических статей, вопросов для собеседований и полезных практических советов по программированию, обратите внимание на Dove Letter — это ресурс с регулярными обновлениями и подборками для Android-разработчиков. Также для более глубокого изучения вопросов подготовки к собеседованиям по Android стоит ознакомиться с Manifest Android Interview — подробным гидом по теме.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: