狀態持有物件和 UI 狀態

Stay organized with collections Save and categorize content based on your preferences.

使用者介面層指南說明如何使用單向資料流 (UDF) 來產生及管理使用者介面層的 UI 狀態。

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

這份指南也會重點提示將 UDF 管理委派至名為「狀態持有物件」的特殊類別有哪些好處。您可以透過 ViewModel 或純類別實作狀態持有物件。本文件將進一步說明狀態持有物件,以及狀態持有物件在使用者介面層扮演的角色。

在本文件的最後,您將瞭解如何在 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 中的生命週期、設定變更和 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 元素與對應的狀態持有物件都具有 1:1 的關係。此外,狀態持有物件必須能夠接受和處理任何可能導致 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,以確保您的狀態會自動清理。這不同於監聽在多種情況下 (例如前往新畫面、設定變更等) 導致的組件清除。
  • 與其他 Jetpack 程式庫相互整合,例如 Hilt

UI 邏輯及其狀態持有物件

UI 邏輯是針對 UI 本身提供的資料運作的邏輯。這可能位於 UI 元素狀態,或是 UI 資料來源 (例如權限 API 或 Resources)。使用 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 重建後失效:Composition 會 remembered (保留記錄),方法是透過可組合函式 rememberNiaAppState 並遵循 Compose 命名慣例建立 NiaAppStateActivity 重建後,先前的執行個體已遺失,系統會建立新的執行個體,並傳入所有相關依附元件,以符合重建的 Activity 新設定。這些依附元件可能來自新設定,也可能是還原自先前的設定。例如,rememberNavController() 可使用在 NiaAppState 建構函式中,並透過委派給 rememberSaveable 的方式在 Activity 重建期間保留狀態。
  • 參照 UI 範圍內的資料來源navigationControllerResources 和其他類似生命週期範圍類型的參照可以安全地儲存在 NiaAppState 中,因為這些類型的生命週期範圍皆相同。

選擇 ViewModel 或純類別狀態持有物件

在上方章節中,在 ViewModel 和純類別狀態持有物件之間的選擇,取決於套用至 UI 狀態的邏輯和邏輯運作的資料來源。

綜上所述,下圖顯示 UI 狀態產生管道中狀態持有物件的位置:

資料從資料產生層流向使用者介面層
圖 6:UI 狀態產生管道中的狀態持有物件

最後,UI 狀態應放置在最接近使用位置的狀態持有物件中,並從中產生狀態。非正式的說法是,狀態持有物件的位置應盡可能降低,但仍保持良好的持有狀態。如果要存取商業邏輯,且需要 UI 狀態在可能導覽至畫面的期間留存 (甚至是在 Activity 重建期間),建議您選擇 ViewModel 來實作商業邏輯狀態持有物件。如果是短期使用的 UI 狀態和 UI 邏輯,生命週期僅相依於 UI 的純類別就已足夠。