Инженер по связям с разработчиками
Среда выполнения снимков Compose, автоматическое отслеживание зависимостей и атомарные обновления не обязательно должны останавливаться на уровне пользовательского интерфейса. Jetpack AppState позволяет перемещать состояние Compose вверх по потоку, оптимизируя то, что попадает на экраны, за счет подъема и получения состояния с помощью системы моментальных снимков до того, как оно попадет в пользовательский интерфейс.
На этом занятии вы узнаете о двух основных библиотеках AppState. Мы рассмотрим StateStore для подъема состояния и Transform для использования композиции, чтобы получить состояние с помощью простого потока управления Kotlin. Это позволит устранить сложные цепочки объединения Flow и легко синхронизировать интерфейс.
combine, и поддерживает ранний возврат и вложенные производные состояния.Расшифровка
Здравствуйте! Добро пожаловать на курс "Как использовать состояние Compose за пределами пользовательского интерфейса с помощью Jetpack AppState".
Меня зовут Бен, и я инженер по связям с разработчиками, работающий над библиотеками архитектуры Jetpack.
А я Элиф, один из инженеров, работающих над библиотеками архитектуры приложений.
Сегодня мы поговорим о будущем, в котором Compose выйдет за рамки пользовательского интерфейса и проникнет в слои данных и доменов приложений.
Как вы, вероятно, уже знаете, Compose – это рекомендуемый набор инструментов для разработки пользовательского интерфейса на Android.
Compose поддерживает все типы устройств: от телефонов и планшетов до виджетов, часов, телевизоров и XR-устройств.
Но по мере того, как ваше приложение растет за счет этих поверхностей пользовательского интерфейса и фоновых компонентов, управлять состоянием, стоящим за ними, становится все сложнее, особенно в связи с двумя распространенными проблемами.
Первый – это получение сложного состояния.
Объединение нескольких потоков данных, локальных входных данных пользовательского интерфейса и состояния окружающей среды, например сетевого подключения, часто превращается в клубок операторов, таких как combine, flatMapLatest и distinctUntilChanged.
Второй – это обмен состояниями между компонентами: синхронизация действий, сервисов, виджетов приложений и т. д., а также обеспечение того, чтобы это состояние могло пережить завершение процесса, когда это необходимо.
Мы проанализировали эти проблемы и поняли, что они связаны с тем, как устроен Compose.
Большинство людей думают о Compose как о наборе инструментов пользовательского интерфейса, но на самом деле это два разных уровня: пользовательский интерфейс Compose сверху и среда выполнения Compose снизу.
Compose UI отвечает за макет, отрисовку и ввод, а Compose Runtime – это универсальный движок для управления состоянием и рекомпозиции.
Он не зависит от пользовательского интерфейса.
Именно это понимание привело нас к созданию Jetpack AppState.
Это семейство библиотек, предназначенных для максимально эффективного использования состояния 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.
Окей.
Спасибо, Богдан.
Итак, как только что отметил Бен, состояние снимка 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 и как 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, который существует только в памяти, поэтому он не переживает завершение процесса.
Именно этот пробел заполняет 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 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" }Попробуйте использовать их в своем приложении.
Отправьте нам отзыв.
Сейчас мы находимся на стадии альфа-тестирования, поэтому можем вносить изменения, чтобы обеспечить соответствие вашим требованиям.
/**
* 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
}/**
* 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
}@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++ })
}
}@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")
}
}@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++ })
}
}@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++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)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
)
}
}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
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@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
}
}@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)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>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,
)
}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(...)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(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}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
)
}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(...)
}
}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 } }
}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 } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}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
}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()
}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
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@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()
}
}
}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
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@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)
}
}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)
}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
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: 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)
}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)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@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)
}
}@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),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}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
}
}
}
}
}[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" }