應用程式基礎

使用 Jetpack AppState 將 Compose 狀態延伸至 UI 以外

喇叭

  • Ben Sagmoe

    開發人員關係工程師

  • Elif Bilgin Morris

    軟體工程師

關於

講座簡介

Compose 的快照執行階段、自動依附元件追蹤和原子更新,不一定要在 UI 層停止。Jetpack AppState 可讓您將 Compose 狀態移至上游,在狀態進入 UI 之前,使用快照系統提升及衍生狀態,簡化到達畫面的內容。

在本課程中,您將瞭解 AppState 的兩個基礎程式庫。我們將深入探討如何使用 StateStore 提升狀態,以及如何使用 Transform 透過組合,以純 Kotlin 控制流程衍生狀態,進而消除複雜的 Flow 組合鏈,輕鬆保持 UI 同步。

摘要

  • Compose 執行階段可做為一般用途的狀態管理引擎,不需使用 Compose UI。其快照系統會動態追蹤讀取作業,並在各屬性之間保持並行更新的一致性。
  • Jetpack AppState Transform 會從無頭可組合區塊計算並公開 Compose 狀態。這項功能可取代 combine 等複雜的 Flow 運算子鏈結,同時支援提早傳回和巢狀狀態衍生。
  • Transform 會使用積極的無頭影格時鐘,在 UI 執行緒外執行轉換。只要監控的狀態不變,時鐘就會暫停,避免浪費資源。
  • Jetpack AppState StateStore 可在活動和背景服務等不同元件之間共用 Compose 狀態。與 DataStore 整合後,個別鍵就能在程序終止後保留,不必使用自訂樣板。

資源

Gemini Notebook

主題

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

轉錄稿

簡介

  • 歡迎觀看「使用 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 狀態。

什麼是 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。

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。

UI 外部的 Compose 狀態

  • 現在我們來做個小測驗

  • 好的,謝謝 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 只會存在於記憶體中,因此無法在程序終止後繼續存留。

StateStore 簡介

  • 而 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 狀態類別摘要

  • 回到狀態類別,現在我們已清楚瞭解如何在整個應用程式中使用 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 版階段,因此可以真正反覆調整和變更,確保這些功能符合您的使用需求。

程式碼

程式碼片段

「什麼是 Compose 狀態?」影片 01:56 處的 Kotlin 程式碼片段
/**
 * 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
}
「什麼是 Compose 狀態?」影片 02:00 處的 Kotlin 程式碼片段
/**
 * 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 狀態?」影片 02:23 處的 Kotlin 程式碼片段
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
「什麼是 Compose 狀態?」影片 02:32 處的 Kotlin 程式碼片段
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
「什麼是 Compose 狀態?」影片 02:52 處的 Kotlin 程式碼片段
@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")
    }
}
「什麼是 Compose 狀態?」影片 03:08 處的 Kotlin 程式碼片段
@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++ })
    }
}
「什麼是 Compose 狀態?」影片 03:26 處的 Kotlin 程式碼片段
@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++ })
    }
}
「什麼是 Compose 狀態?」影片 04:15 處的 Kotlin 程式碼片段
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
「什麼是 Compose 狀態?」影片 04:23 處的 Kotlin 程式碼片段
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
「什麼是 Compose 狀態?」影片 04:33 處的 Kotlin 程式碼片段
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
        )
    }
}
「什麼是 Compose 狀態?」影片 04:42 處的 Kotlin 程式碼片段
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
        )
    }
}
「什麼是 Compose 狀態?」影片 05:09 處的 Kotlin 程式碼片段
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
「什麼是 Compose 狀態?」影片 05:21 處的 Kotlin 程式碼片段
@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
    }
}
「什麼是 Compose 狀態?」影片 05:30 處的 Kotlin 程式碼片段
@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)
    }
}
「Introducing Transform」影片 07:35 處的 Kotlin 程式碼片段
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
「Introducing Transform」影片 09:20 處的 Kotlin 程式碼片段
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,
)
}
「Introducing Transform」影片 09:37 處的 Kotlin 程式碼片段
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(...)
「Introducing Transform」影片 10:01 處的 Kotlin 程式碼片段
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(...)
        }
}
「巢狀轉換」中 11:39 的 Kotlin 程式碼片段
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

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

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
「巢狀轉換」中 12:03 的 Kotlin 程式碼片段
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: StateFlow<FocusSummary> =
        combine(
            sessionFlow,
            taskRepository.tasksFlow
        ) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
        }.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}
「巢狀轉換」中 12:17 的 Kotlin 程式碼片段
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(...)
        }
}
「巢狀轉換」中 12:28 的 Kotlin 程式碼片段
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

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

    fun toggleExpanded() { isExpanded.update { !it } }
}
「巢狀轉換」中 12:56 的 Kotlin 程式碼片段
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

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

    fun toggleExpanded() { isExpanded.update { !it } }
}
「巢狀轉換」中 13:37 的 Kotlin 程式碼片段
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
「巢狀轉換」中 13:47 的 Kotlin 程式碼片段
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
「巢狀轉換」中 14:33 的 Kotlin 程式碼片段
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R> {
        val transformScope = TransformScope()
    val transform = transformScope
.launchTransform(initialValue, onUpdate)
    scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
 ) {
        try { transformScope.run() } finally {
            transform.dispose()
        }
    }
    return transform
}
「巢狀轉換」中 15:18 的 Kotlin 程式碼片段
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope

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

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

public interface DisposableState<T> : State<T> {
public fun dispose()
}
「巢狀轉換」中 16:02 的 Kotlin 程式碼片段
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 State outside of the UI」影片 19:12 處的 Kotlin 程式碼片段
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
「Compose State outside of the UI」影片 19:17 處的 Kotlin 程式碼片段
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
「Compose State outside of the UI」影片 19:33 處的 Kotlin 程式碼片段
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
「Compose State outside of the UI」影片 19:41 處的 Kotlin 程式碼片段
@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()
        }
    }
}
「Compose State outside of the UI」影片 19:52 處的 Kotlin 程式碼片段
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)
}
「Compose State outside of the UI」影片 20:00 處的 Kotlin 程式碼片段
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
「Compose State outside of the UI」影片 20:17 處的 Kotlin 程式碼片段
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
「Compose State outside of the UI」影片 20:23 處的 Kotlin 程式碼片段
@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)
    }
}
「Compose State outside of the UI」影片 20:32 處的 Kotlin 程式碼片段
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)
}
「StateStore 簡介」影片 22:07 處的 Kotlin 程式碼片段
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 簡介」影片 22:14 的 Kotlin 程式碼片段
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
「StateStore 簡介」影片 22:23 處的 Kotlin 程式碼片段
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
「StateStore 簡介」影片 22:42 處的 Kotlin 程式碼片段
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)
}
「StateStore 簡介」影片 22:52 處的 Kotlin 程式碼片段
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)
    }
}
「StateStore 簡介」影片 23:03 處的 Kotlin 程式碼片段
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
「StateStore 簡介」影片 23:22 處的 Kotlin 程式碼片段
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
「StateStore 簡介」影片 23:33 處的 Kotlin 程式碼片段
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
「Introducing StateStore」影片 23:45 處的 Kotlin 程式碼片段
@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)
    }
}
「StateStore 簡介」影片 23:58 處的 Kotlin 程式碼片段
@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) }
    }
}
「StateStore 簡介」影片 24:04 處的 Kotlin 程式碼片段
@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
「StateStore 簡介」中 24:22 的 Kotlin 程式碼片段
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
「Introducing StateStore」中 24:33 的 Kotlin 程式碼片段
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
                }
            }
        }
    }
}
「Compose 狀態類別摘要」中 25:39 的 TOML 程式碼片段
[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" }