產生 UI 狀態

現代化的 UI 很少是靜態的。當使用者與使用者介面互動,或應用程式需要顯示新資料時,UI 狀態也會隨之改變。

本文件將針對 UI 狀態的產生與管理制定規範。閱讀這份文件後,您會瞭解:

  • 應該使用哪些 API 來產生 UI 狀態。這會遵循單向資料流原則,視狀態容器中可用狀態變更來源的性質而定。
  • 應該如何依據系統資源,調整產生 UI 狀態的涵蓋範圍。
  • 如何顯示供 UI 使用的 UI 狀態。

基本上,狀態產生是這類對 UI 狀態所做變更的逐步套用過程。狀態一直存在,而且會因事件而變更。下表匯總了事件與狀態之間的差異:

事件 狀態
短暫、無法預測,且存在於有限期間內。 一直存在。
狀態產生的輸入內容。 狀態產生的輸出內容。
UI 或其他來源的產物。 供 UI 取用。

總結以上差異,您可透過這句話幫助記憶:狀態一直存在,而事件則會不時發生。下圖以視覺化方式呈現在時間軸中事件發生時的狀態變化。每個事件都是由適當的狀態容器處理,而且會導致狀態變更:

事件與狀態
圖 1:事件導致狀態變更

事件可能來自:

  • 使用者:在使用者與應用程式的 UI 互動時。
  • 其他狀態變更來源:透過 UI 顯示應用程式資料的 API、網域,或是 Snackbar 逾時事件、用途或存放區等各資料層。

UI 狀態產生管道

Android 應用程式中的狀態產生可視為處理管道,由下列項目組成:

  • 輸入內容:狀態變更的來源。這些來源可能是:
    • UI 層內部:可能是使用者事件 (例如使用者在工作管理應用程式中為「待辦事項」輸入標題),或是可提供 UI 邏輯存取權並導致 UI 狀態變更的 API。例如,在 Jetpack Compose 中對 DrawerState 呼叫 open 方法。
    • UI 層外部:這些來源是來自導致 UI 狀態變更的網域或資料層。例如,從 NewsRepository 或其他事件載入完畢的新聞。
    • 混合以上所有項目。
  • 狀態容器:這是指可將商業邏輯和/或 UI 邏輯套用至狀態變更來源的類型,以及可處理使用者事件以產生 UI 狀態的類型。
  • 輸出內容:應用程式可轉譯以便為使用者提供所需資訊的 UI 狀態。
狀態產生管道
圖 2:狀態產生管道

狀態產生 API

狀態產生有兩個主要 API 可用,視您目前處於哪個管道階段而定:

管道階段 API
輸入 您應使用非同步 API 來執行 UI 執行緒以外的工作,以免發生 UI 資源浪費的情形。 例如,Kotlin 中的 Coroutine 或 Flows,以及 Java 程式設計語言中的 RxJava 或回呼。
輸出 您應使用可觀測的資料容器 API,在狀態變更時撤銷及重新轉譯 UI。例如 StateFlow、Compose State 或 LiveData。可觀測的資料容器可保證 UI 一律會在畫面上顯示 UI 狀態。

這兩種方法當中,與選擇用於輸出的可觀測 API 相較,選擇用於輸入的非同步 API 對狀態產生管道的性質影響更大。這是因為輸入內容決定了可套用至管道的處理類型

狀態產生管道組合

以下各節說明各種輸入內容最適用的狀態產生技術,以及相符的輸出 API。每個狀態產生管道都是輸入和輸出的組合,且應具有以下性質:

  • 生命週期感知:在 UI 不可見或無效的情況下,狀態產生管道不應耗用任何資源,除非有明確要求。
  • 易於取用:UI 應該能夠輕鬆轉譯產生的 UI 狀態。針對狀態產生管道輸出內容的注意事項,會因不同 View API (例如 View 系統或 Jetpack Compose) 而有所差異。

狀態產生管道的輸入內容

狀態產生管道的輸入內容可透過以下方式提供狀態變更來源:

  • 同步或非同步的一次性作業,例如對 suspend 函式的呼叫。
  • 串流 API,例如 Flows
  • 以上所有項目。

以下章節將說明如何為上述各項輸入內容組合狀態產生管道。

使用一次性 API 做為狀態變更來源

使用 MutableStateFlow API 做為可觀測且可變動的狀態容器。在 Jetpack Compose 應用程式中,您也可以考慮使用 mutableStateOf,特別是使用 Compose 文字 API 時。無論更新為同步或非同步作業,這兩個 API 所提供的方法,都能對其代管的值進行安全不可拆分的更新。

舉例來說,如果是在簡單的擲骰子應用程式中進行狀態更新,使用者每次擲出骰子時都會叫用同步的 Random.nextInt() 方法,並將結果寫入 UI 狀態中。

StateFlow

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

Compose State

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

透過非同步呼叫改變 UI 狀態

如果是需要非同步結果的狀態變更,請在適當的 CoroutineScope 中啟動 Coroutine。這麼做可讓應用程式在 CoroutineScope 取消時捨棄工作。接著,狀態容器會將暫停方法呼叫的結果寫入用於公開 UI 狀態的可觀測 API。

舉例來說,請考慮使用架構範例中的 AddEditTaskViewModel。暫停的 saveTask() 方法以非同步方式儲存工作時,MutableStateFlow 的 update 方法會將狀態變更傳播至 UI 狀態。

StateFlow

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Compose State

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

透過背景執行緒改變 UI 狀態

建議您在主要調度工具上啟動 Coroutine,以便產生 UI 狀態。也就是在下方程式碼片段中的 withContext 區塊之外。但是,如果您需要在不同背景環境中更新 UI 狀態,可以使用以下 API 進行:

  • 使用 withContext 方法在不同的並行環境中執行 Coroutine。
  • 使用 MutableStateFlow 時,照常使用 update 方法。
  • 使用 Compose State 時,使用 Snapshot.withMutableSnapshot 以保證在並行環境中對 State 進行不可拆分的更新。

舉例來說,假設在下方 DiceRollViewModel 程式碼片段中,SlowRandom.nextInt() 是需要透過受限於 CPU 的 Coroutine 呼叫的計算密集型 suspend 函式。

StateFlow

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Compose State

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

使用串流 API 做為狀態變更來源

如果串流狀態來源會於一段時間內在串流中產生多個值,則產生狀態的簡單方法就是將所有來源的輸出內容匯總為一個凝聚的整體。

使用 Kotlin Flows 時,您可以使用 combine 函式來完成。您可以前往「Now in Android」範例中的 InterestsViewModel,查看相關範例:

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

使用 stateIn 運算子建立 StateFlows,可以讓 UI 更精細地控管狀態產生管道的活動,因為只有在 UI 可見時,活動才需要運作。

  • 如果只有在 UI 可見,並以生命週期感知方式收集流程時,管道才能運作,請使用 SharingStarted.WhileSubscribed()
  • 如果只要使用者可以返回 UI (也就是 UI 位於返回堆疊上,或是在其他未顯示的分頁中),管道就應一直處於有效狀態,請使用 SharingStarted.Lazily

如果無法匯總以串流為基礎的狀態來源,則 Kotlin Flows 等串流 API 會提供豐富的轉換功能,例如合併簡化等等,以協助將串流處理成 UI 狀態。

使用一次性 API 和串流 API 做為狀態變更來源

如果狀態產生管道同時仰賴一次性呼叫和串流做為狀態變更來源,則串流就是決定性的限制。因此,必須將一次性呼叫轉換為串流 API,或透過管道將其輸出內容轉換為串流,然後按照上述串流部分的說明繼續處理。

使用資料流時,這通常意味著建立一或多個不公開的支援 MutableStateFlow 執行個體來傳播狀態變更。您也可以透過 Compose 狀態建立快照資料流

請參考下方架構範例存放區中的 TaskDetailViewModel

StateFlow

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Compose State

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

狀態產生管道的輸出類型

為 UI 狀態選擇輸出 API 以及其呈現性質,主要取決於應用程式用於轉譯 UI 的 API。在 Android 應用程式中,您可以選擇使用 Views 或 Jetpack Compose。需考量的事項包括:

下表匯總了特定輸入和取用端的狀態產生管道應使用的 API:

輸入 取用端 輸出
一次性 API View StateFlowLiveData
一次性 API Compose StateFlow 或 Compose State
串流 API View StateFlowLiveData
串流 API Compose StateFlow
一次性 API 和串流 API View StateFlowLiveData
一次性 API 和串流 API Compose StateFlow

狀態產生管道初始化

將狀態產生管道初始化時,需要為管道執行作業設定初始條件。這可能包括提供啟動管道所需的初始輸入值,例如用於新聞文章詳細檢視畫面或啟動非同步載入的 id

為節省系統資源,您應盡可能延遲狀態產生管道初始化作業。基本上,這通常是指等待輸出結果的取用端出現。如要這麼做,您可以搭配使用 Flow API 和 stateIn 方法中的 started 引數。如果無法使用這種做法,請定義冪等 initialize() 函式,明確啟動狀態產生管道,如以下程式碼片段所示:

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

範例

下列 Google 範例示範了使用者介面層的狀態產生過程。歡迎查看這些範例,瞭解實務做法: