狀態容器和 UI 狀態

使用者介面層指南將單向資料流 (UDF) 討論為 產生及管理 UI 層的 UI 狀態

資料會從資料層單向流向 UI。
圖 1:單向資料流

這份指南也會重點提示將 UDF 管理委派至特殊類別的優點 稱為狀態容器您可以透過 ViewModel 或純類別實作狀態容器。本文件將進一步說明狀態容器,以及狀態容器在 UI 層扮演的角色。

閱讀完本文件後,您應瞭解如何在 UI 層 (也就是 UI 狀態產生管道) 管理應用程式狀態,對於以下內容應也能有所掌握:

  • 瞭解 UI 層中的 UI 狀態類型。
  • 瞭解 UI 層中在這些 UI 狀態上運作的邏輯類型。
  • 瞭解如何選擇狀態容器的正確實作方式,例如 ViewModel 或簡易類別。

UI 狀態產生管道元素

UI 層是由 UI 狀態及產生此狀態的邏輯所定義。

UI 狀態

UI 狀態是描述 UI 的屬性。UI 狀態分為兩種類型:

  • 螢幕 UI 狀態是指要在螢幕中顯示的「內容」。舉例來說,NewsUiState 類別可包含轉譯 UI 所需的新文章和其他資訊。由於這個狀態含有應用程式資料,因此通常會與該階層的其他層連結。
  • UI 元素狀態是指會影響 UI 元素如何轉譯的屬性內建函式。UI 元素可能會顯示或隱藏,而且可能有特定的字型、字型大小或字型顏色。在 Android View 中,View 會自行管理此狀態,因為 View 在本質上就帶有狀態,並且公開提供用於修改或查詢其狀態的方法。例如 TextView 類別的 getset 方法就是針對其文字使用。在 Jetpack Compose 中,狀態位於可組合項外部,您甚至可以將狀態從可組合項的附近提升至正在呼叫的可組合函式或狀態容器中。比如 Scaffold 可組合函式的 ScaffoldState 就是如此。

邏輯

UI 狀態並非靜態屬性,因為應用程式資料和使用者事件會導致 UI 狀態就會隨時間改變邏輯會決定變更的具體細節,包括 UI 狀態已變更哪些部分、為何變更,以及應在何時變更。

邏輯會產生 UI 狀態
圖 2:生產 UI 狀態的邏輯

應用程式中的邏輯可以是商業邏輯或 UI 邏輯:

  • 商業邏輯是實作應用程式資料的產品需求條件。舉例來說,當使用者輕觸新聞閱讀器應用程式中的相應按鈕,就會為文章加上書籤。這個可將書籤儲存至檔案或資料庫的邏輯,通常位於網域或資料層中。狀態容器通常會透過呼叫公開的方法,將這些邏輯委派給這些層。
  • UI 邏輯與「如何」在螢幕上顯示 UI 狀態有關。適用對象 例如,在使用者選取 類別、捲動至清單中的特定項目,或瀏覽邏輯 使用者點選按鈕後進入特定畫面,

Android 生命週期和 UI 狀態及邏輯類型

UI 層包含兩個部分:一個與 UI 生命週期有關,另一個則無關。這項差異決定了各部分可用的資料來源,因此需要不同類型的 UI 狀態和邏輯。

  • 與 UI 生命週期無關:這部分的 UI 層負責處理應用程式的資料產生層 (資料或網域層),並由商業邏輯定義。UI 中的生命週期、設定變更和 Activity 重建 可能會影響 UI 狀態產生管道是否已啟用,但不會影響 確保產生的資料有效性
  • 與 UI 生命週期有關:這部分的 UI 層處理 UI 邏輯。 會直接受到生命週期或設定變更影響這些變更會直接影響其中讀取的資料來源是否有效,因此 UI 層狀態只能在生命週期內變更。優先放送示例 包括執行階段權限,以及取得與設定相關的資源 例如本地化字串

上述內容可用下表總結:

與 UI 生命週期無關 與 UI 生命週期有關
商業邏輯 UI 邏輯
螢幕 UI 狀態

UI 狀態產生管道

UI 狀態產生管道是指為產生 UI 狀態而採取的步驟。這些步驟包含套用先前定義的邏輯類型,且完全取決於 UI 的需求。部分 UI 可能受益於管道中與 UI 生命週期無關和/或有關的部分,也可能不受益於任何部分

也就是說,下列 UI 層管道排列為有效:

  • 由 UI 自行產生及管理的 UI 狀態。例如,一個可重複使用的簡易基本計數器:

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • UI 邏輯 → UI。例如,顯示或隱藏可讓使用者跳至清單頂端的按鈕。

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • 商業邏輯 → UI。這個 UI 元素會在螢幕畫面上顯示當前使用者的相片。

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • 商業邏輯 → UI 邏輯 → UI。捲動這個 UI 元素可在螢幕畫面上顯示特定 UI 狀態的正確資訊。

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

如果將這兩種邏輯都套用至 UI 狀態產生管道,則一律必須先套用商業邏輯,再套用 UI 邏輯。正在嘗試套用 UI 邏輯後方的商業邏輯,意味著商業邏輯依附於 UI 邏輯。以下各節將深入說明各種邏輯類型及其狀態容器,解釋上述做法為何會產生問題。

資料從資料產生層流向 UI
圖 3:在 UI 層中套用邏輯

狀態持有物件及其責任

狀態容器負責儲存狀態,供應用程式讀取。需要使用邏輯時,狀態容器可扮演中介角色,並提供權限來存取代管所需邏輯的資料來源。透過這種方式,狀態容器會將邏輯委派給適當的資料來源。

這樣可以帶來以下好處:

  • 簡易 UI:UI 只會繫結其狀態。
  • 可維護性:可疊代狀態容器中定義的邏輯 且無須變更 UI 本身
  • 可測試性:可以獨立測試 UI 及其狀態產生邏輯。
  • 可讀性:程式碼讀取器可以清楚瞭解不同 UI 之間的差異 呈現程式碼和 UI 狀態產生程式碼

無論大小或範圍為何,每個 UI 元素 對應的狀態容器此外,狀態容器必須能夠接受及處理任何可能導致 UI 狀態變更的使用者操作,且必須產生後續的狀態變更。

狀態持有物件的類型

與 UI 狀態和邏輯的種類類似,狀態容器有兩種類型 在 UI 層中,根據與 UI 生命週期之間的關係定義。

  • 商業邏輯狀態容器。
  • UI 邏輯狀態容器。

以下各節將進一步說明狀態容器的類型。 我們先從商業邏輯狀態容器開始

商業邏輯及其狀態容器

商業邏輯狀態容器會處理使用者事件,並從資料或網域轉換資料 將層層載入畫面的 UI 狀態當您考量 Android 生命週期和應用程式設定異動時,為了提供最佳使用者體驗,使用商業邏輯的狀態容器應具備下列屬性:

資源 詳細資料
產生 UI 狀態 商業邏輯狀態容器負責為相關 UI 產生 UI 狀態。此 UI 狀態通常是處理使用者事件以及從網域層和資料層讀取資料的結果。
透過重新建立活動保留 商業邏輯狀態容器會在 Activity 重新建立期間保留自身狀態和狀態處理管道,以提供順暢的使用者體驗。如果狀態容器無法保留並重新建立 (通常在程序終止後),則狀態容器必須能夠輕易重建其最後狀態,以確保提供一致的使用者體驗。
擁有長期存在的狀態 商業邏輯狀態容器通常用於管理導覽目的地的狀態。因此,物件通常會在導覽變更時保留其狀態,直到物件從導覽圖中移除為止。
專屬於其 UI,且無法重複使用 商業邏輯狀態容器通常會針對特定應用程式函式 (例如 TaskEditViewModelTaskListViewModel) 產生狀態,因此只適用於該應用程式函式。相同的狀態容器可以在不同的板型規格中支援這些應用程式函式。舉例來說,行動裝置、電視和平板電腦版本的應用程式可以重複使用同一個商業邏輯狀態容器。

例如,請考慮使用「Now in Android」應用程式:

在這個 Now in Android 應用程式中,我們可以看到代表主要應用程式函式的導覽目的地應採取什麼方式,才能擁有專屬的商業邏輯狀態容器。
圖 4:Now in Android 應用程式

在本例中,AuthorViewModel 會做為商業邏輯狀態容器,進而產生 UI 狀態:

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = …

    // Business logic
    fun followAuthor(followed: Boolean) {
      …
    }
}

請注意,AuthorViewModel 具有前文列出的屬性:

資源 詳細資料
產生 AuthorScreenUiState AuthorViewModel 會從 AuthorsRepositoryNewsRepository 中讀取資料,並使用該資料產生 AuthorScreenUiState。它也會在使用者委派給 AuthorsRepository 時套用商業邏輯,以便追蹤或取消追蹤 Author
可以存取資料層 AuthorsRepositoryNewsRepository 的例項傳遞至其建構函式,使其可以實作遵循 Author 的商業邏輯。
Activity 重新建立後仍然有效 由於實作時採用 ViewModel,因此可以在快速 Activity 重建期間保持有效狀態。如果是程序終止的情況,您可以讀取 SavedStateHandle 物件,藉此提供從資料層還原 UI 狀態所需的最低資訊量。
擁有長期存在的狀態 ViewModel 的範圍限定在導覽圖,因此除非將作者目的地從導覽圖表移除,否則 uiState StateFlow 中的 UI 狀態仍會保留在記憶體中。使用 StateFlow 也可以套用產生狀態延遲的商業邏輯,因為只有在具備 UI 狀態收集器時才會產生狀態。
與其 UI 不重複 AuthorViewModel 僅適用於作者導覽目的地,無法在其他位置重複使用。如有任何商業邏輯在多個導覽目的地中重複使用,該商業邏輯就必須封裝在限定資料層或網域層範圍的元件中。

使用 ViewModel 做為商業邏輯狀態容器

在 Android 開發作業中,ViewModel 具有許多優點,因此相當適合提供商業邏輯的存取權,以及準備在畫面上顯示的應用程式資料。這些優點包括:

  • ViewModel 觸發的作業在設定變更後仍會保存下來。
  • 能與導覽整合:
    • 畫面在返回堆疊時,「導覽」會快取 ViewModel。這點十分重要,可讓您在返回目的地時立即取得先前載入的資料。這點較不容易 遵循可組合畫面生命週期的狀態容器。
    • 目的地從返回堆疊彈出時,系統也會清除 ViewModel,以確保您的狀態會自動清理。這不同於監聽因多種情況導致的可組合函式清除,例如前往新畫面、設定變更等情況。
  • Hilt 等其他 Jetpack 程式庫相互整合。
,瞭解如何調查及移除這項存取權。

UI 邏輯及其狀態容器

UI 邏輯這類邏輯會在 UI 本身提供的資料上運作,可能位於 UI 元素狀態,或權限 API、Resources 等 UI 資料來源上。使用 UI 邏輯的狀態容器通常具備以下屬性:

  • 產生 UI 狀態及管理 UI 元素狀態
  • Activity 重新建立後無法保留:在 UI 邏輯中代管的狀態容器通常依附於 UI 本身的資料來源,如果試圖在設定變更期間保留這項資訊,多半會導致記憶體流失。如果狀態容器需要在設定期間保留資料 並做出變更 需要委派給其他更適合繼續保持 Activity 的元件 重新建立。例如 Jetpack Compose 中的可組合項 UI 元素狀態 使用 remembered 函式建立時,通常會委派給 rememberSaveableActivity 重建期間保留狀態。這類函式的例子包括 rememberScaffoldState()rememberLazyListState()
  • 參照 UI 範圍內的資料來源: 安全參照生命週期 API 和資源,並以 UI 邏輯讀取。 狀態容器的生命週期與 UI 相同。
  • 可重複使用於多個 UI:同一個 UI 邏輯的不同執行個體 狀態容器可以在應用程式的不同部分重複使用。舉例來說 管理方塊群組的使用者輸入事件的容器,可能用於搜尋 頁面,以及「至」欄位。

UI 邏輯狀態容器通常會以純類別實作。這是因為 UI 本身負責建立 UI 邏輯狀態容器,且 UI 邏輯狀態容器的生命週期與 UI 本身相同。例如在 Jetpack Compose 中,狀態容器是 Composition 的一部分,且會遵循 Composition 的生命週期。

以上所述在以下的 Now in Android 範例中有更多說明:

Now in Android 使用純類別狀態容器管理 UI 邏輯
圖 5:Now in Android 範例應用程式

Now in Android 範例會根據裝置的螢幕大小,為導覽畫面顯示底部應用程式列或導覽邊欄。小型螢幕會使用底部應用程式列,而大型螢幕則使用導覽邊欄。

由於決定 NiaApp 可組合函式中所用適當導覽 UI 元素的邏輯與商業邏輯無關,因此可以由名為 NiaAppState 的純類別狀態容器管理:

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

在上述範例中,請注意下列有關 NiaAppState 的詳細資料:

  • Activity 重新建立後失效NiaAppState 是透過遵循 Compose 命名慣例的可組合函式 rememberNiaAppState 建立而成,在 Composition 中的狀態會是 rememberedActivity 重新建立後,先前的例項便會遺失,但系統會透過傳入的所有相關依附元件來建立新例項,以符合 Activity 重新建立後的新設定。這些依附元件可能是新的,也有可能是根據先前的設定還原而成。舉例來說,rememberNavController() 會用於 NiaAppState 建構函式中,並透過委派給 rememberSaveable 的方式在 Activity 重新建立期間保留狀態。
  • 參照 UI 範圍內的資料來源:對 navigationControllerResources 和其他類似的生命週期範圍類型 這些生命週期範圍具有相同的生命週期範圍,因此可以在 NiaAppState 中安全保留。

在 ViewModel 和純類別之間選擇適合的狀態容器

在上面幾節中,我們學到選擇 ViewModel 還是純類別狀態容器,取決於套用至 UI 狀態的邏輯和邏輯運作時所處的資料來源。

綜上所述,下圖顯示 UI 狀態產生管道中狀態容器的位置:

資料從資料產生層流向 UI 層
圖 6:UI 狀態產生管道中的狀態容器。 箭頭表示資料流。

最終,您應使用最靠近使用位置的狀態容器產生 UI 狀態。非正式的說法是,您應盡可能降低狀態容器的位置,同時仍保有適當的持有狀態。如果要存取商業邏輯,且需要 UI 狀態在可能導覽至畫面的期間留存 (甚至是在 Activity 重建期間),建議您選擇 ViewModel 來實作商業邏輯狀態容器。如果是效期較短的 UI 狀態和 UI 邏輯,生命週期僅依附於 UI 的純類別就已足夠。

狀態容器可以混合

狀態容器可以依附其他狀態容器,前提是該依附元件擁有相同或較短的生命週期。例如:

  • UI 邏輯狀態容器可以依附另一個 UI 邏輯狀態容器。
  • 畫面層級狀態容器可以依附 UI 邏輯狀態容器。

下列程式碼片段說明 Compose 的 DrawerState 如何依附另一個內部狀態容器 SwipeableState,以及應用程式的 UI 邏輯狀態容器如何依附 DrawerState

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

UI 邏輯狀態容器依附畫面層級狀態容器,就是依附元件生命週期超越狀態容器的一個例子。這樣會導致生命週期較短的狀態容器可重複使用性降低,使其能夠存取比實際所需更多的邏輯和狀態。

如果生命週期較短的狀態容器需要來自較高層級範圍狀態容器的特定資訊,請僅將所需資訊當做參數傳遞,不要傳遞狀態容器的例項。舉例來說,在下列程式碼片段中,UI 邏輯狀態容器類別僅會以參數形式從 ViewModel 接收所需資料,而不會將整個 ViewModel 例項以依附元件的形式傳遞。

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

下圖表示前述程式碼片段中 UI 與不同狀態容器之間的依附元件:

同時依附 UI 邏輯狀態容器和畫面層級狀態容器的 UI
圖 7:依附不同狀態容器的 UI。箭頭表示依附元件。

範例

下列 Google 範例示範如何使用 UI 層的狀態容器。歡迎查看這些範例,瞭解實務做法: