狀態持有物件和 UI 狀態

使用者介面層指南說明如何使用單向資料流程 (UDF) 來產生及管理使用者介面層的 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 元素可能會顯示或隱藏,而且可能有特定的字型、字型大小或字型顏色。在 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 狀態。例如,一個可重複使用的簡易基本計數器:

    @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 元素與對應的狀態容器都具有 1:1 的關係。此外,狀態容器必須能夠接受及處理任何可能導致 UI 狀態變更的使用者操作,且必須產生後續的狀態變更。

狀態持有物件的類型

與 UI 狀態和邏輯的種類相似,使用者介面層中的狀態容器也有兩種類型,並且由狀態容器與 UI 生命週期之間的關係定義:

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

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

商業邏輯及其狀態容器

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

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

相關範例請參考「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 會從 AuthorsRepository 和 NewsRepository 中讀取資料,並使用該資料產生 AuthorScreenUiState。它也會在使用者委派給 AuthorsRepository 時套用商業邏輯,以便追蹤或取消追蹤 Author。
可以存取資料層 將 AuthorsRepository 和 NewsRepository 的例項傳遞至其建構函式,使其可以實作遵循 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 中,使用 remembered 函式建立的可組合項 UI 元素狀態通常會委派給 rememberSaveable,以便在 Activity 重建期間保留狀態。這類函式的例子包括 rememberScaffoldState() 和 rememberLazyListState()。
  • 參照 UI 範圍內的資料來源:由於 UI 邏輯狀態容器的生命週期與 UI 相同,因此可以安全參照及讀取生命週期 API 和資源等資料來源。
  • 可在多個 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 中的狀態會是 remembered。重建 Activity 後,先前的執行個體會遺失,然後系統會建立新的執行個體,其中所有依附元件都會傳入,因此適合重建的 Activity 新設定。這些依附元件可能是新的,也有可能是根據先前的設定還原而成。舉例來說,rememberNavController() 會用於 NiaAppState 建構函式中,並透過委派給 rememberSaveable 的方式在 Activity 重新建立期間保留狀態。
  • 參照 UI 範圍內的資料來源:navigationController、Resources 和其他類似生命週期範圍類型的參照可以安全地儲存在 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 層的狀態容器。歡迎查看這些範例,瞭解實務做法: