開發人員關係工程師
Compose 的快照執行階段、自動依附元件追蹤和原子更新,不一定要在 UI 層停止。Jetpack AppState 可讓您將 Compose 狀態移至上游,在狀態進入 UI 之前,使用快照系統提升及衍生狀態,簡化到達畫面的內容。
在本課程中,您將瞭解 AppState 的兩個基礎程式庫。我們將深入探討如何使用 StateStore 提升狀態,以及如何使用 Transform 透過組合,以純 Kotlin 控制流程衍生狀態,進而消除複雜的 Flow 組合鏈,輕鬆保持 UI 同步。
combine 等複雜的 Flow 運算子鏈結,同時支援提早傳回和巢狀狀態衍生。轉錄稿
歡迎觀看「使用 Jetpack AppState 將 Compose 狀態延伸至 UI 以外」影片。
我是 Ben,目前擔任開發人員關係工程師,負責 Jetpack 架構程式庫。
我是 Elif,也是應用程式架構程式庫的工程師之一。
今天,我們將討論 Compose 的未來發展,探討如何將 Compose 應用於 UI 以外的領域,並深入應用程式的資料和網域層。
相信大家現在都知道,Compose 是 Android UI 開發作業的推薦工具包。
從手機、平板電腦到小工具、手錶、電視和 XR 裝置,Compose 都能提供支援。
不過,隨著應用程式在這些 UI 介面和背景元件中成長,管理其背後的狀態會變得更加困難,尤其是兩個常見的痛點。
首先是衍生複雜狀態。
將多個資料串流、本機 UI 輸入和環境狀態 (例如網路連線) 結合在一起,通常會變成一堆運算子,例如 combine、flatMapLatest 和 distinctUntilChanged。
第二個是跨元件共用狀態:讓活動、服務、應用程式小工具等保持同步,並確保狀態在需要時能不受程序終止影響。
當我們回頭檢視這些挑戰時,我們開始研究 Compose 本身的建構方式。
大多數人認為 Compose 是 UI 工具包,但實際上它是由兩個不同的層組成:上層是 Compose UI,下層是 Compose 執行階段。
Compose UI 負責處理版面配置、繪圖和輸入,而 Compose Runtime 則是狀態管理和重組的一般用途引擎。
不依附於 UI。
我們正是因為體認到這一點,才決定建構 Jetpack AppState。
這是一系列程式庫,著重於充分運用 UI 以外的 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 時,您會建立一個容器,透過快照系統轉送讀取和寫入作業。
每當程式碼在快照觀察器 (例如組合) 內執行時,您會取得兩個屬性,這是 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 和 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 寫入計數時,就沒有任何內容需要撤銷。
第二個特性是,不必在一致性和精細度之間做出選擇。
一致性是指當您更新快照中的多個值時,即使另一個執行緒同時寫入這些值,這些變更在程式碼執行期間仍會保持隔離。
快照完成後,這些更新就會一次顯示。
精細度是指可以個別宣告及追蹤不同的屬性,尤其是彼此不相關的屬性。
因此,更新某個屬性只會使實際依附該屬性的讀取器失效。
接著來看看實際程式碼。
舉例來說,我們將查看具有三個狀態的遊戲:關卡名稱、生存時間和分數。
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
)
}
}在 UI 中,GameScreen 會收集 UI 狀態並從中讀取,將相關屬性向下傳遞至 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
}
}接著在 UI 中,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) 會失效。
最後,請注意,這些都與 UI 無關。
而 UI 剛好是這些屬性真正發揮作用的地方。
以上就是今天的內容,感謝各位參與。
好了,不開玩笑了,接下來請 Elif 為我們介紹第一個程式庫:Transform。
好的。
謝謝 Ben。
正如 Ben 剛才提到的,Compose 快照狀態提供自動和動態讀取追蹤、一致性和精細程度,這讓我想到一個問題:如果資料流也能由 Compose 狀態快照 (快照狀態) 驅動,會怎麼樣?
如果狀態是資料模型,我們該如何實際計算及轉換所有消費者的狀態?
這時就能使用新的 Jetpack AppState Transform 程式庫。
在深入探討程式庫本身之前,我們需要先討論程式庫的來源。
因此,利用 Compose 引擎 (即執行階段和編譯器) 在無頭組合中產生狀態值的想法,並非新概念。
許多人可能已經使用或熟悉 Molecule 程式庫。
這裡的「無頭」是指沒有使用者介面。
Molecule 率先展示了 Compose Runtime 的強大功能,實際示範可組合函式不一定要發出 UI 節點,還能用來編寫傳回純資料模型的可組合函式。
以 Molecule 為例,這會是 Kotlin Flow 或 StateFlow。
這表示您不必鏈結 combine 等反應式運算子,而是可以編寫一般的 Kotlin。
不過,既然 UI 已經在 Compose 中,如果資料模型是 State,而不是處理流程,該怎麼辦?
使用 Transform 時,資料模型就是 State。
我們來看看實際的 API。
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>這就是整個 API。
這個函式非常簡單。
它會傳回一般可觀測的 State,而不是 Flow 或 StateFlow。
而使用這項資料的人,並不知道或不在意這項資料是由轉換作業產生。
這裡真正有趣的是這個區塊。
可組合
這表示您現有的知識都能派上用場。
請記住,LaunchedEffect、collectAsState、提早傳回、一般 if,以及任何可從可組合函式內部讀取的內容,都可以從轉換中衍生。
請注意,本簡報中介紹的所有 API 都處於 Alpha 版階段,隨時可能變更。不過,我們還是來看看一個範例。
我們以一個非常簡單的小型專注計時器應用程式為例。
你選定一項工作,設定 25 分鐘的計時器,時間開始倒數。
每次建立單一畫面時,即使是像這樣建立簡單的單一畫面,您可能也需要合併及管理多個獨立資料串流,才能在幕後組合狀態。
假設專注模式畫面有兩個:專注工作階段本身,也就是我們目前處於哪個階段,例如是否暫停、剩餘秒數,而這些資訊都會來自計時器引擎,做為每秒跳動一次的 StateFlow。
另一個資料串流則是使用者目前正在執行的工作,會以 Flow 的形式從 Room 資料庫傳入。
因此,最後會有兩個資料串流和一個畫面狀態。
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,
)
}因此,請將兩個流程合併為串流,在 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 逾時來暫停及繼續上游串流,而轉換作業會在該範圍的生命週期內持續執行。
同樣地,我們的來源是流程,歡迎使用流程。
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 和資訊一覽小工具,都會使用這個專注工作階段管理工具產生的摘要。
我們先用傳統方式建構,再用轉換功能建構。
因此,按照傳統做法,我們會先在應用程式範圍內衍生摘要,做為 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 範例中看到的合併和 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,系統只會重新執行這個區塊。
應用程式層級區塊和小工具甚至不需要知道。
最後是小工具。
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 狀態,因此我們不需要在這裡轉換任何內容。
最後,迷你計時器列和主畫面小工具讀取的是相同狀態,因此兩者顯示的資訊一律相同。
不僅如此,對該狀態所做的任何變更,都會反映在該狀態的所有取用者中。
這句話是什麼意思?
因此,如果您在這裡輕觸小工具上的暫停按鈕,系統就會透過管理工具進入工作階段串流,再透過摘要返回,這三個畫面都會同步更新。
現在我們已經稍微瞭解如何使用 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 擴充功能函式,在 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 的轉換。
此外,它也具有暫停執行函式,可啟動 TransformScope 並開始更新目前狀態轉換的程序。
這句話是什麼意思?
因此,執行實際上會設定 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
}
}因此,它會在您呼叫它的任何協同程式上執行,直到該協同程式取消為止。
現在,您可能會想知道,重組器需要影格時鐘,才能知道何時需要產生影格。
在 Compose UI 中,這就是顯示畫面,但如前所述,我們是在無頭環境中,因此沒有 UI。
因此,除非您提供時鐘,否則我們會自行建立。
Transform 會將重組與顯示影格時鐘分離,在 UI 執行緒外執行所有轉換和狀態衍生作業。
為此,我們建立了專屬的HeadlessTransformClock。
轉換影格時鐘。
由於小工具、服務或其他轉換下游可能正在等待轉換的值,因此時鐘會急於執行。
也就是說,只要轉換作業使用的任何狀態發生變化,系統就會立即產生新的影格。
這是一個小型無鎖狀態機,因此要求影格是簡單的比較和設定作業。
如果狀態沒有任何變化,時鐘就會停止。
因此,如果轉換作業使用的狀態完全沒有變更,就不會產生任何費用。
在交棒給 Ben 之前,我們也想提醒您,Transform 現在支援 rememberSaveable,確保您的資料在設定變更時能妥善儲存。
這項作業是透過新的 SavedStateContainer API 執行,該 API 現在是 Compose 的 SaveableStateRegistry、SavedStateHandle 和 SavedStateRegistry 的建構基礎。
您可以將其中任何一項轉換為 SavedStateContainer,或建立子容器並傳遞至轉換,確保資料已儲存。
最後再次提醒,今天討論的所有 API 都處於 Alpha 版階段,因此可能會有變動。
接著將時間交還給 Ben。
現在我們來做個小測驗
好的,謝謝 Elif。
現在我們已瞭解 Compose 狀態的強大功能,以及 Transform 如何讓我們在 UI 外部衍生狀態,您可能已經注意到,到目前為止,範例中的輸入內容都是使用 collectAsState 橋接的 Flow。
但這是否總是必要?
為什麼不直接在狀態容器中使用 Compose 狀態?
為了回答這個問題,我認為將狀態分為三大類別會很有幫助。
第一種是只會儲存在記憶體中的即時狀態。
例如網路或藍牙連線狀態。
您不會想使用保存的值。
第二種是暫時狀態,應在程序終止後保留,但不需要無限期儲存。
例如 UI 狀態 (如卡片是否展開) 和工作階段狀態 (如導覽返回堆疊或表單草稿)。
第三種是永久狀態,會無限期儲存。
例如使用者偏好設定或資料庫中的媒體庫。
首先,我們來看看如何使用 Compose 狀態,同時處理即時和暫時狀態。
到目前為止,我們的架構指南仍將 Compose 狀態保留在 UI 層,或有時保留在 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)
...
}這通常位於 Singleton 中,因為它會由多個畫面甚至多個應用程式元件 (例如服務) 讀取。
不過,這不代表您無法使用 Compose 狀態來儲存。
事實上,這通常比流程型版本更簡單。
您不需要回呼流程或任何其他項目來進行調整,只要直接在回呼中更新點值即可。
@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 的實際運作情形。
您可以直接在 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 區塊中,結合 Singleton 管理工具的狀態、ViewModel 中的狀態,以及存放區的流程。
現在,讓我們回到暫時狀態。
稍早提過,狀態分為 UI 狀態和工作階段狀態兩種。
如果是 UI 狀態,甚至是單一 Activity 內的工作階段狀態,ViewModel 和 SavedState 都能發揮良好效果。
這正是這類工具的設計目的。
不過,如果需要在多個不同的 UI 和應用程式元件之間共用工作階段狀態,就會開始出現問題。
一旦該狀態移出 ViewModel,現有的 API 和指南就會出現一些落差。
因此,SavedState API 會先儲存至 Activity 的 Bundle,但這不適用於其他應用程式元件,例如服務。
第二,單例模式中的 mutableStateOf 只會存在於記憶體中,因此無法在程序終止後繼續存留。
而 Jetpack AppState StateStore 正好能填補這項缺口。
這是一種 Compose 狀態的類型安全對應,可在應用程式元件之間共用,並由 DataStore 支援,可在程序終止後保留。
您可以自行將 DataStore 連結至每個 Singleton,但這表示您必須為每個屬性編寫儲存和還原樣板。
使用 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,預設值為閒置。
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)接著,我們在 Hilt 中提供 Singleton 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,這個函式會傳回鍵的唯讀狀態,並可選擇覆寫預設值,以便在特定呼叫位置變更該值。
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}此外,在這個管理工具中公開 FocusSession 狀態,表示消費者不必直接存取 StateStore 或其金鑰。
您可以維護寫入和讀取封裝。
接著,他們不使用消費者,而是插入 Focus Session Manager (他們可能已經有這個元件,因為他們需要控制工作階段),然後讀取狀態。
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}接著,這些讀取器可以直接重新公開狀態,也可以使用轉換功能建立新的更新狀態,就像我們之前看到的迷你計時器 ViewModel 範例一樣。
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}接著,UI 可組合項就能像任何其他 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,這基本上與 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 狀態,就可以使用 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" }