Основы работы с приложениями

Как использовать состояние Compose за пределами пользовательского интерфейса с помощью Jetpack AppState

Колонки

  • Бен Сагмо (Ben Sagmoe)

    Инженер по связям с разработчиками

  • Элиф Билгин Моррис (Elif Bilgin Morris)

    Разработчик ПО

О приложении

О выступлении

Среда выполнения снимков Compose, автоматическое отслеживание зависимостей и атомарные обновления не обязательно должны останавливаться на уровне пользовательского интерфейса. Jetpack AppState позволяет перемещать состояние Compose вверх по потоку, оптимизируя то, что попадает на экраны, за счет подъема и получения состояния с помощью системы моментальных снимков до того, как оно попадет в пользовательский интерфейс.

На этом занятии вы узнаете о двух основных библиотеках AppState. Мы рассмотрим StateStore для подъема состояния и Transform для использования композиции, чтобы получить состояние с помощью простого потока управления Kotlin. Это позволит устранить сложные цепочки объединения Flow и легко синхронизировать интерфейс.

Сводка

  • Compose Runtime работает как механизм управления состоянием общего назначения без Compose UI. Система мгновенных снимков динамически отслеживает операции чтения и обеспечивает согласованность параллельных обновлений в разных ресурсах.
  • Jetpack AppState Transform вычисляет и предоставляет Compose State из блоков composable-функций без графического интерфейса. Он заменяет сложные цепочки операторов Flow, такие как combine, и поддерживает ранний возврат и вложенные производные состояния.
  • Transform использует часы фрейма без графического интерфейса, чтобы выполнять преобразования вне потока UI. Если статус просмотра не меняется, часы останавливаются, чтобы не тратить ресурсы.
  • Jetpack AppState StateStore позволяет использовать Compose State в разных компонентах, например в действиях и фоновых службах. Интеграция с DataStore позволяет отдельным ключам сохраняться после завершения процесса без использования стандартного кода.

Ресурсы

Gemini Notebook

Темы

  • jetpack-compose
  • jetpack-appstate
  • state-management
  • android-architecture
  • compose-runtime

Расшифровка

Введение

  • Здравствуйте! Добро пожаловать на курс "Как использовать состояние Compose за пределами пользовательского интерфейса с помощью Jetpack AppState".

  • Меня зовут Бен, и я инженер по связям с разработчиками, работающий над библиотеками архитектуры Jetpack.

  • А я Элиф, один из инженеров, работающих над библиотеками архитектуры приложений.

  • Сегодня мы поговорим о будущем, в котором Compose выйдет за рамки пользовательского интерфейса и проникнет в слои данных и доменов приложений.

  • Как вы, вероятно, уже знаете, Compose – это рекомендуемый набор инструментов для разработки пользовательского интерфейса на Android.

  • Compose поддерживает все типы устройств: от телефонов и планшетов до виджетов, часов, телевизоров и XR-устройств.

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

  • Первый – это получение сложного состояния.

  • Объединение нескольких потоков данных, локальных входных данных пользовательского интерфейса и состояния окружающей среды, например сетевого подключения, часто превращается в клубок операторов, таких как combine, flatMapLatest и distinctUntilChanged.

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

  • Мы проанализировали эти проблемы и поняли, что они связаны с тем, как устроен Compose.

  • Большинство людей думают о Compose как о наборе инструментов пользовательского интерфейса, но на самом деле это два разных уровня: пользовательский интерфейс Compose сверху и среда выполнения Compose снизу.

  • Compose UI отвечает за макет, отрисовку и ввод, а Compose Runtime – это универсальный движок для управления состоянием и рекомпозиции.

  • Он не зависит от пользовательского интерфейса.

  • Именно это понимание привело нас к созданию Jetpack AppState.

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

Что такое состояние Compose?

  • Но прежде чем мы углубимся в AppState, давайте убедимся, что мы одинаково понимаем, что такое состояние Compose.

  • Правильный ответ: androidx.compose.runtime.State.

    /**
     * A value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value.
     */
    @Stable
    public interface State<out T> {
        public val value: T
    }
  • и интерфейс, с которым вы, вероятно, знакомы лучше, – MutableState.

    /**
     * A mutable value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
     * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
     * will be scheduled. If [value] is written to with the same value, no recompositions will be
     * scheduled.
     */
    @Stable
    public interface MutableState<T> : State<T> {
        override var value: T
        public operator fun component1(): T
        public operator fun component2(): (T) -> Unit
    }
  • По сути, это просто контейнеры.

  • Их работа основана на системе мгновенных снимков Compose.

  • Поэтому, когда мы говорим о состоянии Compose, мы обычно имеем в виду состояние Snapshot.

  • Поэтому, когда вы вызываете mutableStateOf, вы создаете контейнер, который направляет чтение и запись через систему моментальных снимков.

  • И всякий раз, когда ваш код выполняется внутри наблюдателя за снимками, например композиции, вы получаете два свойства, которые не дает вам поток, такой как Flow.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
    }
  • Первое – автоматическое и динамическое отслеживание прочитанных сообщений.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
  • То есть автоматический сбор означает, что вам не нужно подписываться или собирать данные.

  • Просто чтение .value – это подписка.

  • Итак, если мы посмотрим на Counter, чтение count.value непосредственно внутри composable-функции сообщает об этом чтении композиции, и она регистрирует Counter в своем списке читателей.

  • А теперь посмотрите на AddButton.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
    
    @Composable
    fun AddButton(onClick: () -> Unit) {
        Button(onClick = onClick) {
            Text("Add")
        }
    }
  • Он записывает только данные о кликах.

  • Значит, это не читатель.

  • Когда пользователь нажимает AddButton и изменяет счетчик, система снимков отслеживает эту запись, что позволяет композиции узнать, что нужно сделать недействительным Counter, но не AddButton.

  • Динамическое отслеживание означает, что оно происходит во время выполнения на основе того, какие пути выполнения кода действительно выполняются.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(true) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • Здесь мы защищаем текст счетчика с помощью флага isVisible.

  • Если для параметра isVisible задано значение true, то считываются и isVisible, и count.

  • Поэтому Counter зарегистрирован как читатель обоих.

  • А когда isVisible имеет значение false, ветвь if не выполняется, count не считывается, поэтому Counter регистрируется только как средство чтения isVisible, а не count.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(false) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • Поэтому, когда AddButton записывает значение в count, аннулировать уже нечего.

  • Второе свойство – вам не нужно выбирать между согласованностью и детализацией.

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

  • и все они становятся видны одновременно, когда снимок готов.

  • Детализация означает, что отдельные свойства могут быть объявлены и отслежены по отдельности, особенно когда они не связаны.

  • Таким образом, обновление одного свойства делает недействительными только те средства чтения, которые фактически зависят от него.

  • Давайте посмотрим, как это выглядит в реальном коде.

  • Например, в игре есть три состояния: название уровня, время выживания и счет.

    val levelName: String = "Level 1 - Stay Alive!"
    val timeSurvived: Int = 0
    val score: Double = 0.0
  • Для начала давайте рассмотрим реализацию Flow, которая выбирает согласованность за счет детализации, объединяя все в класс GameUiState.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
  • Это означает, что при каждом запуске updateScore вы копируете весь GameUiState, чтобы и timeSurvived, и оценка могли обновляться вместе синхронно.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
  • В пользовательском интерфейсе GameScreen собирает состояние интерфейса и считывает его, чтобы передать соответствующие свойства в Header и ScoreBadge.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state by viewModel.uiState.collectAsStateWithLifecycle()
        Column {
            Header(levelName = state.levelName)
            ScoreBadge(
                timeSurvived = state.timeSurvived,
                score = state.score
            )
        }
    }
  • Поскольку GameScreen считывает каждое состояние, каждый вызов updateScore делает GameScreen недействительным, даже если данные заголовка никогда не менялись.

  • Вы можете разделить каждое свойство на собственный поток, чтобы получить детализацию, но тогда вы потеряете согласованность, потому что другой поток может обновить значение, пока выполняется другой блок обновления.

  • Теперь рассмотрим альтернативную реализацию на основе состояний.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
  • В этом случае мы снова используем GameUiState.

  • Но в этом случае все немного иначе, потому что каждое свойство делегирует mutableStateOf.

  • Здесь updateScore оборачивает свои записи в withMutableSnapshot, и это то, что дает нам согласованность между записями.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
  • Затем в пользовательском интерфейсе GameScreen передает State напрямую в Header и ScoreBadge.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state = viewModel.uiState
        Column {
            Header(state = state)
            ScoreBadge(state = state)
        }
    }
  • Она не считывает свойства, поэтому при выполнении updateScore GameScreen не является читателем.

  • Недействительным будет признан только сам компонент, в данном случае ScoreBadge.

  • И, наконец, обратите внимание, что ничего из этого не связано с пользовательским интерфейсом.

  • Пользовательский интерфейс – это лишь одно из мест, где эти свойства действительно сияют.

  • Спасибо, что пришли на нашу презентацию.

  • Ладно, шутки в сторону. Передаю слово Элиф, которая расскажет о первой библиотеке – Transform.

Представляем Transform

  • Окей.

  • Спасибо, Богдан.

  • Итак, как только что отметил Бен, состояние снимка Compose предлагает нам автоматическое и динамическое отслеживание чтения, согласованность и детализацию, что подводит меня к вопросу: что, если бы наши потоки данных также могли управляться снимком состояния Compose — состоянием снимка?

  • И если состояние — это наша модель данных, то как мы на самом деле вычисляем и преобразуем состояние для всех его потребителей?

  • Для этого и нужна новая библиотека Jetpack AppState Transform.

  • Прежде чем мы погрузимся в саму библиотеку, нам нужно поговорить о том, как она появилась.

  • Поэтому идея использовать движок Compose, который является средой выполнения и компилятором, для создания значений состояния в Headless-композиции не нова.

  • Многие из вас, возможно, уже используют или знакомы с библиотекой Molecule.

  • В данном случае под безголовым режимом мы понимаем отсутствие пользовательского интерфейса.

  • Molecule стал пионером в демонстрации возможностей Compose Runtime, показав на практике, что composable-функция не обязательно должна генерировать узлы пользовательского интерфейса. Ее можно использовать для написания composable-функции, которая возвращает чистую модель данных.

  • В случае Molecule это был Kotlin Flow или StateFlow.

  • Это означало, что вместо связывания реактивных операторов, таких как combine, вы могли писать обычный код на Kotlin.

  • Но что, если вместо потоков ваша модель данных будет использовать состояние, поскольку ваш пользовательский интерфейс уже находится в Compose?

  • В Transform модель данных – это состояние.

  • Давайте посмотрим на сам API.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R>
  • Это весь API.

  • Это очень просто, одна функция.

  • Она возвращает простое наблюдаемое состояние, а не Flow или StateFlow.

  • А тот, кто его использует, не знает и не заботится о том, что его произвело преобразование.

  • Что действительно интересно, так это вот этот блок.

  • Это composable-функция.

  • Это означает, что все, что вы уже знаете, работает внутри него.

  • Помните, что LaunchedEffect, collectAsState, ранние возвраты, простые условия if и все, что можно прочитать внутри Composable, можно получить из Transform.

  • Обратите внимание, что все API, которые мы сегодня рассматриваем, находятся на стадии альфа-тестирования и могут измениться. Тем не менее давайте рассмотрим пример.

  • Давайте возьмем очень простое, небольшое приложение-таймер для концентрации внимания.

  • Вы выбираете задачу, устанавливаете таймер на 25 минут, и часы начинают тикать.

  • Каждый раз, когда вы создаете один экран, даже когда вы создаете простой одиночный экран, подобный этому, вам, вероятно, потребуется объединить и управлять несколькими независимыми потоками данных, чтобы собрать свое состояние под капотом.

  • Для экрана фокуса, допустим, у нас есть два: сам сеанс фокуса, который будет определять, на каком этапе мы находимся, то есть приостановлен или нет, сколько секунд осталось, и все это будет поступать от механизма таймера в виде StateFlow, который будет тикать, скажем, каждую секунду.

  • Другим потоком данных будет текущая задача, над которой работает пользователь, которая будет поступать из базы данных Room в виде Flow.

  • Итак, у нас есть два потока данных и одно состояние экрана в конце.

  • ViewModel – это место, где они будут объединены, а его выходные данные, которые мы назовем сводкой, будут частью кода, который мы напишем далее.

  • Обычно это означает, что в ViewModel вам нужно объединить эти потоки с помощью связанных операторов.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: StateFlow<FocusSummary> =
            combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find { it.id == session.activeTaskId }
                    FocusSummary.Active(...)
                }
            }.stateIn(
                    scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FocusSummary.Idle,
    )
    }
  • Итак, объедините два потока вместе, разветвление внутри лямбды, так что если иначе прямо там, а затем состояние в конце.

  • С помощью Transform мы можем удалить весь этот шаблонный код.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
    initialValue = FocusSummary.Idle,
    scope = viewModelScope,
    container = handle.getOrCreateContainer("transform")
    ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
  • Этот вызов выглядит знакомым, потому что у нас есть начальное значение в области видимости, но выше по течению нет ничего, что можно было бы связать, производная – это сам блок.

  • В отличие от stateIn, где вам, скорее всего, потребуется настроить тайм-аут WhileSubscribed, чтобы приостанавливать и возобновлять вышестоящие потоки, преобразование выполняется непрерывно в течение всего времени существования этой области.

  • Повторюсь, наши источники – это потоки, и потоки приветствуются.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
                initialValue = FocusSummary.Idle,
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                         FocusSummary.Active(...)
            }
    }
  • Мы свяжем каждый из них с помощью collectAsState, а сеанс – это StateFlow, поэтому нам не нужно начальное значение, но Room Flow – это холодный поток, поэтому мы даем ему его.

  • Вот и всё, что нужно знать о подписках.

  • Чтение – это то, что подписывает нас.

  • Это автоматическое отслеживание чтения, о котором Бен говорил ранее в примере, который мы видели, но в ViewModel.

  • Мы даже можем добавить досрочный возврат, когда находимся в состоянии бездействия.

  • Допустим, у нас нет новых задач, мы даже не будем обрабатывать остальные.

  • И снова, это динамическое отслеживание чтения снова окупается.

  • Когда мы бездействуем, Room Flow вообще не собирается.

  • Затем мы наконец-то выдаем краткий пересказ.

  • Поэтому, если пользователь приостанавливает задачу или таймер тикает, все автоматически обновляется.

Вложенные преобразования

  • Итак, все становится немного сложнее, когда мы выходим за рамки примера с одним ViewModel.

  • Допустим, этот же сеанс должен отображаться ещё на двух поверхностях: на панели мини-таймера внизу каждого экрана приложения и в виджете на главном экране.

  • Обоим компонентам потребуется одна и та же производная информация о сводке сеанса, о том, какая фаза, приостановлена она или нет, сколько времени осталось на таймере.

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

  • Поэтому нам нужно будет получить сводку один раз в области приложения, а затем все последующие элементы будут ее использовать.

  • Как же нам реализовать что-то подобное?

  • Для реализации нам, вероятно, понадобятся три части.

    class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
        ...
    }
    
    class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
        ...
    }
    
    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                ...
            }
        }
    }
  • Итак, у нас есть менеджер сеансов в области приложения, который будет получать сводку, мини-таймер ViewModel и виджет Glance. Оба они будут использовать сводку, полученную в этом менеджере сеансов.

  • Давайте построим каждый из них традиционным способом, а затем с помощью Transform.

  • Итак, традиционно, опять же, мы бы начали с получения сводки один раз в области действия приложения как StateFlow.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: StateFlow<FocusSummary> =
            combine(
                sessionFlow,
                taskRepository.tasksFlow
            ) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find {
                        it.id == session.activeTaskId
                    }
                    FocusSummary.Active(...)
                }
            }.stateIn(
    scope = appScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = FocusSummary.Idle
    )
    }
  • Это те же вызовы combine и stateIn, которые мы видели ранее в примере ViewModel, но они перемещены на уровень выше.

  • С помощью Transform мы также можем повторно использовать то же объявление из ViewModel, которое мы видели ранее.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
               initialValue = FocusSummary.Idle,
    scope = scope,
    container = handle.getOrCreateContainer("transform")
            ) {
                val session by sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow
                    .collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
    }
  • Единственное, что изменилось, – это то, что теперь он находится в области действия приложения.

  • Далее у нас есть ViewModel мини-таймера.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: StateFlow<MiniTimerUiState> =
            combine(
                focusManager.summary,
                isExpanded
            ) { summary, expanded ->
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = MiniTimerUiState()
    )
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • Допустим, мы хотим немного усложнить себе задачу.

  • Допустим, в мини-таймере, помимо двух потоков данных, которые у нас уже есть (StateFlow и Room Flow), мы также хотим отслеживать, развернута ли панель таймера.

  • Для этого мы используем MutableStateFlow.

  • Для этого мы будем использовать ещё один Combine, чтобы отслеживать поток сводки и isExpanded StateFlow.

  • И мы получаем второе состояние.

  • С помощью преобразования мы можем синхронизировать потоки данных без использования цепочек операторов.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: State<MiniTimerUiState> =
            transform(
                initialValue = MiniTimerUiState(),
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val summary by focusManager.summary
                       val expanded by isExpanded.collectAsState()
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • Что мы можем сделать, и это действительно круто, так это то, что мы можем делать вложенные преобразования.

  • Итак, здесь ViewModel считывает сводку, которая является выходными данными вышестоящего преобразования, как и любое другое состояние, а затем собирает isExpanded с помощью collectAsState.

  • Таким образом, мы можем смешать состояние выше по потоку и поток ниже по потоку в StateFlow прямо в одном блоке.

  • И помните о детализации.

  • Если вы переключите isExpanded, будет перезапущен только этот блок.

  • Блок на уровне приложения и виджет даже не заметят этого.

  • Наконец, у нас есть виджет.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                    .collectAsState()
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Поскольку Glance – это Compose, мы можем собирать StateFlow с помощью collectAsState внутри provideContent, как и любую другую Composable-функцию.

  • С помощью функции преобразования это сделать ещё проще.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Наш менеджер фокуса предоставляет нам состояние Compose, поэтому здесь нам не нужно ничего преобразовывать.

  • И, наконец, мини-таймер и виджет на главном экране никогда не будут показывать разные данные, потому что они считывают одно и то же состояние.

  • И не только это, но и любое изменение, внесенное в это состояние, будет отражено на всех потребителях этого состояния.

  • Что это значит?

  • Если вы нажмете на паузу в виджете, то это действие будет передано через менеджер в поток сеанса, а затем в сводку, и все три экрана будут обновлены синхронно.

  • Теперь, когда мы немного разобрались, как использовать Transform, давайте заглянем под капот.

  • Transform API, который мы видели ранее, на самом деле является вспомогательной функцией, используемой для создания базового управляемого преобразования.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R> {
            val transformScope = TransformScope()
        val transform = transformScope
    .launchTransform(initialValue, onUpdate)
        scope.launch(
    dispatcher,
    start = CoroutineStart.UNDISPATCHED
     ) {
            try { transformScope.run() } finally {
                transform.dispose()
            }
        }
        return transform
    }
  • На самом деле он использует функцию расширения TransformScope и launchTransform, которая запускает преобразование Composable в этом TransformScope и возвращает одноразовое состояние.

  • Если вы прочитаете его сверху вниз, вы увидите, что он делает все за вас.

  • Она создает TransformScope и запускает в ней преобразование, которое возвращает состояние.

  • Затем он также запускает выполнение в TransformScope на предоставленном вами диспетчере, чтобы время жизни области и поток принадлежали вам.

  • И, наконец, блок finally удаляет преобразование, когда область видимости заканчивается.

  • Это весь жизненный цикл преобразования.

  • Давайте быстро взглянем на интерфейс TransformScope.

    public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
    
    public abstract class TransformScope {
            public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
    public abstract suspend fun run()
    }
    
    public fun <T> TransformScope.launchTransform(
    initialState: T,
    onUpdate: @Composable (T) -> T,
    ): DisposableState<T>
    
    public interface DisposableState<T> : State<T> {
    public fun dispose()
    }
  • Мы видим, что у него есть функция createTransform, которая создаст преобразование, прикрепленное к этому TransformScope.

  • Также у него есть приостанавливающая функция run, которая запускает TransformScope и начинает процесс обновления преобразований текущего состояния.

  • Что это значит?

  • Итак, run фактически настраивает Recomposer и композицию.

  • И каждый Transform в области видимости живет внутри этой композиции и имеет свою собственную область рекомпозиции.

  • Вот почему мы хотим поделиться областью применения и почему у нас есть TransformScope.

  • чтобы при изменении состояния перезапускались только те преобразования, которые его считывают.

  • Как мы отмечали ранее, run – это функция приостановки.

    override suspend fun run() {
            val context = currentCoroutineContext()
            val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
            ...
            val recomposer = Recomposer(context)
            val composition = Composition(UnitApplier, recomposer)
            try {
                withContext(clock) {
                    if (clock is HeadlessTransformClock) {
                        launch(start = CoroutineStart.UNDISPATCHED) {
                            clock.runClock()
                        }
                    }
                    ...
                }
            } finally {
                // dispose of composition, cancel the clock, close the recomposer
            }
        }
  • Таким образом, он выполняется в любой сопрограмме, из которой вы его вызываете, пока эта сопрограмма не будет отменена.

  • Теперь вы можете спросить или задаться вопросом: Recomposer нужны часы кадра, чтобы сообщить ему, когда ему нужно создать кадр.

  • В Compose UI это экран, но, как мы упоминали ранее, мы находимся в среде без графического интерфейса, поэтому пользовательского интерфейса нет.

  • Поэтому, если вы не предоставите нам часы, мы создадим свои.

  • Отделив рекомпозицию от часов кадра дисплея, Transform выполняет все свои преобразования и выводы состояния вне потока UI.

  • Для этого мы создали специальный HeadlessTransformClock.

  • Преобразовать часы в рамке.

  • Поскольку виджет, сервис или другой преобразователь могут ожидать значение от преобразователя, наши часы работают в режиме немедленного выполнения.

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

  • Это небольшой конечный автомат без блокировок, поэтому запрос кадра – простая операция сравнения с обменом.

  • А когда в состоянии ничего не меняется, часы останавливаются.

  • Таким образом, преобразование, которое использует состояние, которое не было изменено, ничего вам не стоит.

  • Прежде чем я передам слово Бену, мы также должны отметить, что Transform теперь поддерживает rememberSaveable, чтобы гарантировать, что ваши данные сохраняются при изменении конфигурации.

  • Это делается с помощью нового API SavedStateContainer, который теперь является строительным блоком, на котором построены SaveableStateRegistry, SavedStateHandle и SavedStateRegistry в Compose.

  • Вы можете превратить любой из них в SavedStateContainer или создать дочерние контейнеры и передать их в Transform, чтобы обеспечить сохранение данных.

  • Напоминаем, что все API, о которых мы сегодня говорим, находятся на стадии альфа-тестирования, поэтому в них могут быть внесены изменения.

  • А теперь я передаю слово Бену.

Состояние Compose вне пользовательского интерфейса

  • Теперь

  • Хорошо, спасибо, Элиф.

  • Итак, мы увидели, насколько мощным является состояние Compose и как Transform позволяет нам получать его за пределами пользовательского интерфейса. Возможно, вы заметили, что в нашем примере входными данными были потоки, которые мы связали с помощью collectAsState.

  • Но всегда ли это необходимо?

  • Почему бы не использовать состояние Compose непосредственно в наших хранилищах состояния?

  • Чтобы ответить на этот вопрос, я считаю полезным разделить состояние на три широкие категории.

  • Первый – это текущее состояние, которое хранится только в памяти.

  • например статус подключения к сети или Bluetooth.

  • Не использовать сохраненное значение

  • Второй – это временное состояние, которое должно сохраняться после завершения процесса, но не должно храниться бесконечно.

  • Например, состояние пользовательского интерфейса, такое как развернутая или свернутая карточка, и состояние сеанса, такое как обратный стек навигации или черновик формы.

  • И, наконец, третий – постоянное состояние, которое хранится неограниченно долго.

  • Например, это могут быть пользовательские настройки или медиатека, хранящаяся в базе данных.

  • Давайте начнем с того, как использовать состояние Compose для текущего и временного состояния.

  • До сих пор в наших рекомендациях по архитектуре предлагалось хранить состояние Compose на уровне пользовательского интерфейса или иногда в ViewModels, а во всех остальных случаях использовать Flow.

  • Но почему?

  • И это потому, что как только вам нужно было объединить свойство состояния с Flow, вам приходилось преобразовывать его обратно с помощью snapshotFlow.

  • Но с помощью функции Transform это преобразование можно не выполнять.

  • Давайте посмотрим, как это выглядит и что означает, начиная с ViewModel.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(
        private val tasksRepository: TasksRepository,
        private val networkMonitor: NetworkMonitor,
        savedStateHandle: SavedStateHandle,
    ) : ViewModel() {
        ...
    }
  • Для локальных входных данных экрана, таких как selectedTaskIds, можно объявить простое свойство mutableStateOf вместо MutableStateFlow.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    }
  • И поскольку это не то, что вы обязательно хотите сохранить после завершения процесса, ничего страшного, что это не так, что это просто использование mutableStateOf.

  • Для данных, которые должны сохраняться после завершения процесса, например поискового запроса, можно напрямую использовать изменяемое состояние с помощью savedStateHandle.saveable.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    }
  • И затем, как и в случае с updateScore в примере с игрой, когда метод обновляет несколько значений состояния одновременно, упаковка записей в withMutableSnapshot обеспечивает согласованность.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    
        fun updateSearchQuery(query: String) {
            Snapshot.withMutableSnapshot {
                searchQuery = query
                selectedTaskIds = emptySet()
            }
        }
    }
  • Затем, чтобы объединить searchQuery с Room Flow, вы просто считываете его непосредственно внутри Transform, собирая Flow.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • А как насчет текущего состояния, например подключения к сети?

    @Singleton
    class NetworkMonitor @Inject constructor(
        @ApplicationContext context: Context,
    ) {
        private val connectivityManager =
            context.getSystemService(ConnectivityManager::class.java)
        ...
    }
  • Обычно это делается в Singleton, поскольку данные считываются на нескольких экранах или даже несколькими компонентами приложения, например сервисами.

  • Но это не значит, что вы не можете использовать Compose State для его хранения.

  • На самом деле это часто проще, чем версия на основе потока.

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

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    }
  • Если пользователю все же нужен поток, вы можете использовать snapshotFlow, чтобы вернуться к нему.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    
        private val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { _isOnline.value = true }
            override fun onLost(network: Network) { _isOnline.value = false }
        }
    
        init {
            connectivityManager.registerDefaultNetworkCallback(callback)
        }
    }
  • Давайте посмотрим, как это выглядит в ViewModel.

  • Вы можете читать isOnline непосредственно в Transform, и он будет перекомпонован при изменении значения.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        if (!networkMonitor.isOnline.value) {
            return@transform TaskListState.Offline
        }
    
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • И все это происходит напрямую, нет необходимости предоставлять область приложения или сопрограммы для обновления состояния, это просто прямое синхронное обновление.

  • Таким образом, с помощью Transform ваш ViewModel может объединить State из менеджеров Singleton, State в ViewModel и Flows из репозиториев в одном блоке Transform.

  • А теперь вернемся к переходному состоянию.

  • Ранее я упоминал два типа: состояние пользовательского интерфейса и состояние сеанса.

  • Для состояния пользовательского интерфейса и даже состояния сеанса, которое находится внутри одного действия, отлично подходят ViewModel и SavedState.

  • Именно для этого они и предназначены.

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

  • Если же состояние выходит за пределы ViewModel, то в наших API и рекомендациях возникает пробел.

  • Итак, сначала API SavedState сохраняют данные в Bundle объекта Activity, но они недоступны в других компонентах приложения, например в сервисе.

  • Во-вторых, mutableStateOf в Singleton, который существует только в памяти, поэтому он не переживает завершение процесса.

Представляем StateStore

  • Именно этот пробел заполняет Jetpack AppState, StateStore.

  • Это типобезопасная карта Compose State, которую можно использовать в разных компонентах приложения и которая поддерживается DataStore, чтобы сохранять данные при завершении процесса.

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

  • При интеграции StateStore DataStore вы один раз подключаете DataStore, а затем выбираете, какие данные хотите сохранять.

  • Давайте создадим его в нашем приложении таймера концентрации.

  • Сначала мы будем использовать оперативную память, а затем добавим сохранение в DataStore.

  • Для начала мы моделируем FocusSession как запечатанный интерфейс с состояниями бездействия, выполнения и паузы.

    sealed interface FocusSession {
        data object Idle : FocusSession
        data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
  • Чтобы сохранить это состояние в StateStore, мы определили StateStoreKey с типом FocusSession и значением по умолчанию idle.

    sealed interface FocusSession { ... }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
  • Затем мы создаем синглтон StateStore в Hilt, который можно внедрить в разные компоненты, например Focus Session Manager.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(): StateStore = StateStore()
    }
    
    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        ...
    }
  • Это похоже на то, как ViewModel вводит SavedStateHandle.

  • Окей.

  • Итак, у нас есть StateStore. Как его использовать?

  • Хорошо, что это небольшой API.

  • В нем всего три метода, поэтому мы рассмотрим их один за другим.

  • Первый – setState, который записывает новое значение для ключа.

    fun startFocus(taskId: String, durationMinutes: Int = 25) {
        val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
        stateStore.setState(
            FocusSessionKey,
            FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
        )
    }
    
    fun endFocus() {
        stateStore.setState(FocusSessionKey, FocusSession.Idle)
    }
  • Когда сеанс начинается, мы устанавливаем для него статус "Выполняется", а когда заканчивается – "Неактивно".

  • Вот и всё!

  • Во-вторых, у нас по-прежнему есть updateState, который позволяет вычислять новое значение на основе текущего, например рассчитывать оставшееся время при переключении между запущенным и приостановленным состоянием.

    fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Running) return@updateState session
            FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
        }
    }
    
    fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Paused) return@updateState session
            FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
        }
    }
  • И наконец, getState возвращает состояние только для чтения для ключа и позволяет переопределить значение по умолчанию, если вы хотите изменить его в определенном месте вызова.

    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
        ...
    }
  • А благодаря тому, что состояние FocusSession доступно в этом менеджере, пользователям не нужно напрямую обращаться к StateStore или его ключам.

  • Вы можете поддерживать инкапсуляцию записи и чтения.

  • И затем, вместо потребителей, они просто внедряют Focus Session Manager, который, ну, у них, вероятно, уже есть, потому что им нужно контролировать сеанс, и затем они могут прочитать State.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() { ... }
    
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WidgetEntryPoint {
        fun focusSessionManager(): FocusSessionManager
    }
  • Затем эти читатели могут либо напрямую повторно выставить состояние, либо использовать Transform, чтобы создать новое, обновленное состояние, как мы видели на примере Mini-Timer ViewModel ранее.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
  • Затем composable-функции пользовательского интерфейса могут считывать это состояние, как и любое другое состояние Compose, и при необходимости выполнять рекомпозицию.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
    
    @Composable
    fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
        val session by viewModel.session
        if (session !is FocusSession.Idle) {
            TimerReadout(session = session)
        }
    }
  • Но пока FocusSession живет только в памяти.

  • Давайте посмотрим, что нужно сделать, чтобы приложение пережило завершение процесса.

  • Поэтому сначала нам нужно выполнить однократную настройку, запустив syncToDataStore при создании магазина.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(
            @ApplicationContext context: Context,
            @ApplicationScope scope: CoroutineScope,
        ): StateStore = StateStore().apply {
            val path = context.dataStoreFile("app_state.json").absolutePath
            scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
        }
    }
  • Затем мы включаем каждый ключ, который хотим сохранить.

    @Serializable
    sealed interface FocusSession {
        @Serializable data object Idle : FocusSession
        @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
        PersistToDataStore
  • Все, что нам нужно сделать, это убедиться, что тип сериализуем и что ключ реализует интерфейс PersistToDataStore.

  • И тогда вы можете подумать: "Хорошо, это кажется простым.

  • Что же на самом деле происходит при сохранении ключа?

  • Поэтому syncToDataStore использует API прослушивателя Transform, который по сути является тем же самым, что и Transform, за исключением того, что он ничего не возвращает, а используется только для наблюдения и побочных эффектов.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • Затем в этом прослушивателе он считывает каждый ключ в хранилище и проверяет, является ли один из них ключом PersistToDataStore.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener {
            for (key in keys) {
                if (key is PersistToDataStore) {
                    val value = getState(key).value
    
                    LaunchedEffect(key) {
                        // Restore saved value from dataStore on first access
                    }
    
                    LaunchedEffect(key, value) {
                        // Flush updated value to dataStore on change
                    }
                }
            }
        }
    }
  • Когда это происходит, LaunchedEffect синхронизирует эти значения с DataStore при каждом их изменении.

  • Обратите внимание, что именно благодаря стандартному контейнеру StateStore мы можем предлагать такие простые интеграции.

  • DataStore – это только первый из них.

Сводка категорий состояния Compose

  • Итак, возвращаясь к нашим категориям состояний, теперь у нас есть довольно четкое представление о том, как вы можете использовать Compose State во всем своем приложении.

  • От mutableStateOf для текущего состояния до API SavedState для состояния представления на экране и StateStore для состояния сеанса между компонентами. И наконец, у нас есть collectAsState для взаимодействия с Flows.

  • Но на самом деле совместимость не ограничивается какой-то одной категорией. Вы можете использовать такие вспомогательные функции, как collectAsState, когда вам нужно преобразовать наблюдаемые типы в Compose State.

  • Но для постоянного состояния мы рассматриваем возможность работы с Compose или для DataStore и Room, чтобы напрямую предоставлять Compose State, чтобы вам не приходилось писать собственный более приятный синтаксис для этого.

  • И наконец, я рад сообщить, что Transform, StateStore и интеграция с DataStore уже доступны в альфа-версии. Мы также опубликовали всю документацию на сайте developer.android.com и создали репозиторий AppState Recipes на GitHub, где приведены примеры использования StateStore и Transform.

    [versions]
    appstate = "1.0.0-alpha01"
    ...
    
    [libraries]
    ...
    transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
    transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
    statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
    statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }
  • Попробуйте использовать их в своем приложении.

  • Отправьте нам отзыв.

  • Сейчас мы находимся на стадии альфа-тестирования, поэтому можем вносить изменения, чтобы обеспечить соответствие вашим требованиям.

Код

Фрагменты кода

Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (01:56)
/**
 * A value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value.
 */
@Stable
public interface State<out T> {
    public val value: T
}
Фрагмент кода на Kotlin на 2-й минуте видео "Что такое состояние Compose?"
/**
 * A mutable value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
 * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
 * will be scheduled. If [value] is written to with the same value, no recompositions will be
 * scheduled.
 */
@Stable
public interface MutableState<T> : State<T> {
    override var value: T
    public operator fun component1(): T
    public operator fun component2(): (T) -> Unit
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (2:23)
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (02:32)
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (02:52)
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}

@Composable
fun AddButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Add")
    }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (3:08)
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(true) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (3:26)
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(false) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (4:15)
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (04:23)
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (04:33)
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (04:42)
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Column {
        Header(levelName = state.levelName)
        ScoreBadge(
            timeSurvived = state.timeSurvived,
            score = state.score
        )
    }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (05:09)
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (05:21)
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}
Фрагмент кода на Kotlin в видео "Что такое состояние Compose?" (05:30)
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state = viewModel.uiState
    Column {
        Header(state = state)
        ScoreBadge(state = state)
    }
}
Фрагмент кода на Kotlin в видео "Introducing Transform"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
Фрагмент кода на Kotlin в видео "Introducing Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: StateFlow<FocusSummary> =
        combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
            }
        }.stateIn(
                scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FocusSummary.Idle,
)
}
Фрагмент кода на Kotlin в видео "Introducing Transform" (Представляем Transform)
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find { it.id == session.activeTaskId }
            FocusSummary.Active(...)
Фрагмент кода на Kotlin в видео "Знакомство с Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
            initialValue = FocusSummary.Idle,
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
                     FocusSummary.Active(...)
        }
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования"
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
    ...
}

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования"
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: StateFlow<FocusSummary> =
        combine(
            sessionFlow,
            taskRepository.tasksFlow
        ) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
        }.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования" на отметке 12:17
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
           initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
        ) {
            val session by sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow
                .collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
            FocusSummary.Active(...)
        }
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: StateFlow<MiniTimerUiState> =
        combine(
            focusManager.summary,
            isExpanded
        ) { summary, expanded ->
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = MiniTimerUiState()
)

    fun toggleExpanded() { isExpanded.update { !it } }
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: State<MiniTimerUiState> =
        transform(
            initialValue = MiniTimerUiState(),
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val summary by focusManager.summary
                   val expanded by isExpanded.collectAsState()
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }

    fun toggleExpanded() { isExpanded.update { !it } }
}
Фрагмент кода Kotlin на 13:37 в разделе "Вложенные преобразования"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования" (13:47)
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R> {
        val transformScope = TransformScope()
    val transform = transformScope
.launchTransform(initialValue, onUpdate)
    scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
 ) {
        try { transformScope.run() } finally {
            transform.dispose()
        }
    }
    return transform
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования"
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope

public abstract class TransformScope {
        public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}

public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>

public interface DisposableState<T> : State<T> {
public fun dispose()
}
Фрагмент кода на Kotlin в разделе "Вложенные преобразования" на 16:02
override suspend fun run() {
        val context = currentCoroutineContext()
        val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
        ...
        val recomposer = Recomposer(context)
        val composition = Composition(UnitApplier, recomposer)
        try {
            withContext(clock) {
                if (clock is HeadlessTransformClock) {
                    launch(start = CoroutineStart.UNDISPATCHED) {
                        clock.runClock()
                    }
                }
                ...
            }
        } finally {
            // dispose of composition, cancel the clock, close the recomposer
        }
    }
Фрагмент кода Kotlin на 19:12 в видео "Compose State вне пользовательского интерфейса"
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
Фрагмент кода Kotlin на 19:17 в видео "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
Фрагмент кода на Kotlin в видео "Compose State outside of the UI" (19:33)
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
Фрагмент кода на Kotlin в видео "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun updateSearchQuery(query: String) {
        Snapshot.withMutableSnapshot {
            searchQuery = query
            selectedTaskIds = emptySet()
        }
    }
}
Фрагмент кода на Kotlin в видео "Compose State outside of the UI" (19:52)
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Фрагмент кода Kotlin на 20:00 в видео "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
Фрагмент кода Kotlin на 20:17 в видео "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
Фрагмент кода на Kotlin в видео "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { _isOnline.value = true }
        override fun onLost(network: Network) { _isOnline.value = false }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }
}
Фрагмент кода Kotlin на 20:32 в видео "Compose State outside of the UI"
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    if (!networkMonitor.isOnline.value) {
        return@transform TaskListState.Offline
    }

    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Фрагмент кода на Kotlin в видео "Представляем StateStore"
sealed interface FocusSession {
    data object Idle : FocusSession
    data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
Фрагмент кода на Kotlin в видео "Introducing StateStore"
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
Фрагмент кода на Kotlin в видео "Знакомство со StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
Фрагмент кода на Kotlin в видео "Знакомство со StateStore"
fun startFocus(taskId: String, durationMinutes: Int = 25) {
    val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
    stateStore.setState(
        FocusSessionKey,
        FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
    )
}

fun endFocus() {
    stateStore.setState(FocusSessionKey, FocusSession.Idle)
}
Фрагмент кода на Kotlin в видео "Introducing StateStore"
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Running) return@updateState session
        FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
    }
}

fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Paused) return@updateState session
        FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
    }
}
Фрагмент кода на Kotlin в видео "Знакомство со StateStore"
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
Фрагмент кода на Kotlin в видео "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
Фрагмент кода на Kotlin в видео "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
Фрагмент кода на Kotlin в видео "Знакомство со StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}

@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
    val session by viewModel.session
    if (session !is FocusSession.Idle) {
        TimerReadout(session = session)
    }
}
Фрагмент кода на Kotlin в видео "Знакомство со StateStore" (23:58)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(
        @ApplicationContext context: Context,
        @ApplicationScope scope: CoroutineScope,
    ): StateStore = StateStore().apply {
        val path = context.dataStoreFile("app_state.json").absolutePath
        scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
    }
}
Фрагмент кода на Kotlin в видео "Знакомство со StateStore"
@Serializable
sealed interface FocusSession {
    @Serializable data object Idle : FocusSession
    @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
    PersistToDataStore
Фрагмент кода на Kotlin в разделе "Представляем StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
Фрагмент кода на Kotlin в видео "Introducing StateStore" (Представляем StateStore)
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener {
        for (key in keys) {
            if (key is PersistToDataStore) {
                val value = getState(key).value

                LaunchedEffect(key) {
                    // Restore saved value from dataStore on first access
                }

                LaunchedEffect(key, value) {
                    // Flush updated value to dataStore on change
                }
            }
        }
    }
}
Фрагмент TOML в строке 25, столбец 39 в разделе "Summary of Compose State categories"
[versions]
appstate = "1.0.0-alpha01"
...

[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }