开发者关系工程师
Compose 的快照运行时、自动依赖项跟踪和原子更新不一定非要停留在界面层。借助 Jetpack AppState,您可以将 Compose 状态上移,在状态到达界面之前,使用快照系统提升和派生状态,从而简化到达屏幕的内容。
在本课程中,您将了解 AppState 的两个基础库。我们将深入探讨用于提升状态的 StateStore,以及用于通过组合和普通 Kotlin 控制流派生状态的 Transform,从而消除复杂的 Flow 组合链,让界面轻松保持同步。
combine),同时支持提前返回和嵌套状态派生。转写文稿
大家好,欢迎观看“使用 Jetpack AppState 将 Compose 状态扩展到界面之外”视频。
我叫 Ben,是一名开发者关系工程师,负责 Jetpack 架构库。
我是 Elif,是负责应用架构库的工程师之一。
今天,我们要和大家聊聊 Compose 的未来:它将超越界面,深入到应用的数据层和领域层。
相信大家现在已经非常清楚,Compose 是 Android 界面开发推荐使用的工具包。
从手机和平板电脑到微件、手表、电视和 XR 设备,Compose 都能提供强力支持。
但随着应用在这些界面和后台组件中不断扩展,管理其背后的状态变得越来越困难,尤其是在两个常见痛点方面。
第一种是派生复杂状态。
将多个数据流、本地界面输入和环境状态(例如网络连接)交织在一起,通常会变成一团乱麻,其中包含 combine、flatMapLatest 和 distinctUntilChanged 等运算符。
第二种情况是跨组件共享状态:让 activity、服务、应用 widget 等保持同步,并确保该状态在需要时能够经受住进程终止的考验。
当我们退一步审视这些挑战时,我们开始研究 Compose 本身的构建方式。
大多数人认为 Compose 是一个界面工具包,但实际上它由两个不同的层组成:顶层的 Compose UI 和底层的 Compose Runtime。
Compose UI 负责处理布局、绘制和输入,而 Compose 运行时是一个用于状态管理和重组的通用引擎。
它不依赖于界面。
正是这一认识促使我们构建了 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 状态时,通常指的是快照状态。
因此,当您调用 mutableStateOf 时,您是在创建一个容器,该容器会通过快照系统路由读取和写入操作。
每当您的代码在快照观察器(例如 Composition)中运行时,您都会获得 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 会向组合报告该读取操作,并且组合会在其读取器列表中注册 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 和计数。
因此,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 写入计数时,没有任何内容需要失效。
现在,第二个属性是无需在一致性和精细度之间做出选择。
一致性意味着,当您更新快照中的多个值时,这些更改在代码运行期间会保持隔离,即使另一个线程同时在写入这些值也是如此。
快照完成后,这些更新会同时显示。
粒度意味着可以单独声明和跟踪不同的属性,尤其是在它们不相关时。
因此,更新一个属性只会使实际依赖于该属性的读取器失效。
我们来看一下实际代码中的示例。
例如,我们来看一款具有三种状态的游戏:关卡名称、存活时间和得分。
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 失效,即使标题的数据从未发生变化。
现在,您可以将每个属性拆分为各自的 Flow 以实现精细度,但这样会失去一致性,因为另一个线程可能会在另一个更新块运行时更新值。
现在,我们来考虑另一种基于状态的实现。
@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 将状态直接传递给 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)会失效。
最后,请注意,这些内容都与界面无关。
界面恰好是这些属性真正发挥作用的地方。
以上就是本次演讲的全部内容,感谢大家的聆听。
好了,不开玩笑了,接下来有请 Elif 为我们介绍第一个库:Transform。
好的。
谢谢 Ben。
正如 Ben 刚才提到的,Compose 快照状态为我们提供了自动和动态读取跟踪、一致性和精细度,这让我想到一个问题:如果我们的数据流也可以由 Compose 状态快照(即快照状态)驱动,会怎么样?
如果状态是我们的数据模型,那么我们该如何为所有使用方实际计算和转换状态呢?
这时,全新的 Jetpack AppState Transform 库就派上用场了。
在深入了解该库之前,我们需要先了解该库的由来。
因此,利用 Compose 引擎(即此处的运行时和编译器)在 Headless 组合中生成状态值的想法并不新鲜。
许多人可能已经在使用或熟悉 Molecule 库。
这里的“无头”是指没有界面。
因此,Molecule 率先展示了使用 Compose Runtime 的强大功能,它通过实际行动向我们展示了可组合函数不一定非要发出界面节点,实际上还可以用来编写返回纯数据模型的可组合函数。
在 Molecule 中,这是一个 Kotlin Flow 或 StateFlow。
这意味着,您无需链接 combine 等响应式运算符,而是可以编写普通的 Kotlin 代码。
不过,既然界面已经采用 Compose,那么如果数据模型是 State,而不是 Flow,会怎么样?
使用 Transform 时,数据模型就是状态。
我们来看看实际的 API。
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>这就是整个 API。
它非常简单,只有一个函数。
它会返回一个普通的可观察 State,而不是 Flow,也不是 StateFlow。
而使用该 PCollection 的任何人都不知道也不关心它是由转换生成的。
这里真正有趣的是这个代码块。
可组合。
这意味着,您已经知道的所有内容都可以在其中运行。
请记住,LaunchedEffect、collectAsState、提前返回、普通 if 语句,以及您可以在可组合项中读取的任何内容,都可以在转换中派生。
需要注意的是,今天在演示中看到的所有 API 都处于 Alpha 版阶段,可能会发生变化,但无论如何,我们还是来看一个示例。
我们来看一个非常简单的小型专注计时器应用。
你选定一项任务,设置一个 25 分钟的计时器,然后开始倒计时。
每次创建单个界面时,即使是像这样简单的单个界面,您也可能需要合并和管理多个独立的数据流,以便在后台组合状态。
对于专注屏幕,假设我们有两个:专注时段本身,它将显示我们处于哪个阶段,即是否暂停、还剩多少秒,所有这些都将来自一个计时器引擎,该引擎将作为一个 StateFlow,假设每秒计时一次。
另一个数据流将是用户当前正在处理的任务,该任务将以 Flow 的形式从 Room 数据库传入。
因此,最后会有两个数据流和一个屏幕状态。
ViewModel 是将它们组合在一起的地方,其输出(我们称之为摘要)将是我们要编写的下一段代码。
传统上,您需要在 ViewModel 中使用链式运算符来组合这些 flow。
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,
)
}因此,将这两个流合并为一个流,在 lambda 内部进行分支,即使用 if-else 语句,最后再添加一个状态。
借助 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 超时来暂停和恢复上游数据流,而转换会在该作用域的生命周期内持续运行。
同样,我们的来源是 flow,并且欢迎使用 flow。
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 是冷流,所以我们为其提供了一个初始值。
以上就是订阅的全部内容。
读取操作会订阅该数据。
这就是 Ben 在我们之前看到的示例中提到的自动读取跟踪,但它是在 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
)
}这与我们之前在 ViewModel 示例中看到的 combine 和 stateIn 调用相同,只是上移了一个级别。
借助 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。
我们再添加一个状态。
借助 Transform,我们无需再次使用这些链式运算符即可实现数据流之间的同步。
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 会读取摘要(即上游转换的输出),就像读取任何其他状态一样,然后使用 collectAsState 收集 isExpanded。
因此,我们可以在 StateFlow 的同一代码块中混合使用上游状态和下游数据流。
同时牢记粒度
因此,如果您切换 isExpanded,系统只会重新运行此代码块。
应用级块和 widget 甚至无需注意。
最后是微件。
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,因此我们可以像任何其他可组合项一样,在 provideContent 中使用 collectAsState 收集 StateFlow。
借助 Transform,这一过程变得更加简单。
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}焦点管理器会为我们提供 Compose 状态,因此我们无需在此处进行任何转换。
最终,迷你计时器栏和主屏幕 widget 绝不会出现不一致的情况,因为它们读取的是相同的状态。
不仅如此,对该状态所做的任何更改都会反映到该状态的所有使用方。
这是什么意思?
因此,如果您在此处的小组件上点击暂停,该操作将通过管理器进入会话流,再通过摘要传出,这三个屏幕都会同步更新。
现在,我们已经了解了如何使用 Transform,接下来让我们深入探究其底层机制。
我们之前看到的 Transform API 实际上是一个辅助函数,用于创建基本的托管式 Transform。
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 扩展函数,该函数会在此 TransformScope 中启动可组合转换,并返回一个可释放状态。
从上到下阅读该文件,您会发现它会为您完成所有操作。
它会创建 TransformScope 并将您的转换启动到其中,这将是您返回的状态。
然后,它还会在您提供的调度程序上启动 TransformScope 中的运行,以便作用域的生命周期和线程属于您。
最后,在作用域结束时,它会处置您的转换。
这就是 Transform 的整个生命周期。
我们来快速了解一下 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 的 Transform。
它还具有一个挂起运行函数,该函数会启动 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 界面中,这就是显示效果,但正如我们之前提到的,我们处于无头环境中,因此没有界面。
因此,除非您向我们提供时钟,否则我们将自行创建。
通过将重组与显示帧时钟解耦,Transform 会在界面线程之外运行其所有转换和状态派生。
为此,我们构建了专用的 HeadlessTransformClock。
转换帧时钟。
由于微件、服务或其他下游 Transform 可能会等待 Transform 的值,因此我们的时钟会急切地进行计算。
这意味着,只要 Transform 使用的任何状态发生变化,系统就会立即生成新帧。
它是一个小型无锁状态机,因此请求帧是一个简单的比较并设置操作。
当状态没有发生任何变化时,时钟会停止。
因此,如果转换使用的状态完全没有发生变化,则不会产生任何费用。
在将时间交还给 Ben 之前,我们还应注意,Transform 现在支持 rememberSaveable,以确保您的数据在配置更改后仍能保存。
它通过新的 SavedStateContainer API 实现此目的,该 API 现在是 Compose 的 SaveableStateRegistry、SavedStateHandle 和 SavedStateRegistry 的构建基础。
您可以将其中任何一个转换为 SavedStateContainer,也可以创建子容器并将其传递到 Transform 中,以确保数据得到保存。
最后,再次提醒大家,我们今天讨论的所有 API 都处于 Alpha 版阶段,因此可能会发生变化。
接下来,我将把时间交还给 Ben。
好了
好的,谢谢 Elif。
现在,我们已经了解了 Compose 状态的强大功能,以及 Transform 如何让我们在界面之外派生状态。您可能已经注意到,到目前为止,示例中的输入都是我们使用 collectAsState 桥接的 Flow。
但这样做总是必要的吗?
为什么不直接在状态容器中使用 Compose 状态?
为了回答这个问题,我发现将状态分为三大类很有帮助。
第一种是仅存储在内存中的实时状态。
例如网络或蓝牙连接状态。
您不希望使用持久化值。
第二种是应在进程终止后继续保留但无需无限期存储的临时状态。
例如界面状态(如卡片是否展开)和会话状态(如导航返回堆栈或表单草稿)。
第三种是永久状态,会无限期存储。
例如,数据库中的用户偏好设置或媒体库。
首先,我们来看看如何将 Compose 状态用于实时状态和瞬态。
到目前为止,我们的架构指南将 Compose 状态保留在界面层中,有时也保留在 ViewModel 中,并建议您在其他所有位置使用 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 结合使用,您只需在收集 Flow 时直接在 Transform 中读取它。
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)
...
}这通常位于单例中,因为它会被多个屏幕甚至多个应用组件(例如服务)读取。
但这并不意味着您不能使用 Compose State 来存储它。
事实上,它通常比基于流程的版本更简单。
您不需要回调流程或任何其他操作来完成适配,只需直接在回调中更新点值即可。
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}如果消费者仍需要使用 flow,您可以使用 snapshotFlow 桥接回 flow。
@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 中的具体情况。
您可以直接在 Transform 中读取 isOnline,当值发生变化时,它会重组。
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 可以在单个 Transform 代码块中组合来自单例管理器的 State、ViewModel 中的 State 以及来自代码库的 Flow。
现在,我们回到瞬态。
之前,我提到了两种类型:界面状态和会话状态。
对于界面状态,甚至单个 activity 内的会话状态,ViewModel 和 SavedState 都非常适用。
这正是它们的设计初衷。
但是,当需要在多个不同的界面和应用组件之间共享该会话状态时,问题就会开始出现。
一旦该状态移出 ViewModel,我们现有的 API 和指南就会出现一些空白。
因此,首先 SavedState API 会保存到 Activity 的 Bundle 中,但该 Bundle 在其他应用组件(例如服务)中不可用。
第二种方法是将 mutableStateOf 存储在单例中,该单例仅驻留在内存中,因此在进程终止后将失效。
而这正是 Jetpack AppState StateStore 填补的空白。
它是 Compose 状态的类型安全映射,可在应用组件之间共享,并由 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)然后,我们在 Hilt 中提供一个单例 StateStore,可以将其注入到需要它的不同组件中,例如 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,它会返回键的只读 State,并且可以选择替换默认值,以便在特定调用位置更改该值。
@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
}然后,这些读取器可以直接重新公开 State,也可以使用 Transform 创建新的、更新后的 State,就像我们之前看到的 Mini-Timer ViewModel 示例一样。
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}然后,界面可组合项可以像读取任何其他 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 使用 Transform 的监听器 API,该 API 与 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 状态了。
从用于实时状态的 mutableStateOf,到用于屏幕本地呈现状态的 SavedState API,再到用于跨组件会话状态的 StateStore,最后还有用于与 Flow 互操作的 collectAsState。
但实际上,互操作性并不局限于任何单一类别,只要您需要将可观察类型桥接到 Compose State,就可以使用 collectAsState 等辅助函数。
但对于永久状态,我们正在研究的另一件事是与 Compose 合作,或者让 DataStore 和 Room 直接公开 Compose 状态,这样您就不必编写自己的代码,比如使用更简洁的语法来实现这一点。
因此,我很高兴地宣布,Transform、StateStore 和 DataStore 集成现已推出 Alpha 版,我们还在 developer.android.com 上发布了所有文档,并在 GitHub 上提供了一个 AppState Recipes 代码库,其中包含使用 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" }请在您的应用中试用这些功能。
欢迎提供反馈。
比如,我们现在处于 Alpha 版阶段,所以我们现在可以真正进行迭代和做出更改,以确保这些更改适用于您的用例。
/**
* 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" }