在何種情況下提升狀態

在 Compose 應用程式中,會根據 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 邏輯

UI 邏輯需要讀取或寫入狀態時,您應根據其生命週期將狀態範圍限定為 UI。因此,您應在可組合函式中以正確的層級提升狀態。或者,您也可以在純狀態容器類別中執行這項操作,且範圍限定為 UI 生命週期。

以下是解決方案說明和使用時機。

以可組合項當做狀態擁有者

如果狀態和邏輯很簡單,在可組合項中納入 UI 邏輯和 UI 元素就會是不錯的做法。您可以視需要將狀態保留在可組合項內部,或提升狀態。

無須狀態提升

並非總是需要提升狀態。如果沒有其他需要控管狀態的可組合項,即可將狀態保留在可組合項內部。在這個程式碼片段中,可組合項會在輕觸時展開及收合:

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

變數 showDetails 是此 UI 元素的內部狀態。只會在這個可組合項中讀取和修改,對其套用的邏輯也非常簡單。在這種情況下,提升狀態不會帶來太大好處,因此您可以將其保留在內部。這個做法讓這個可組合項成為展開狀態的擁有者和單一可靠資料來源。

在可組合項內提升

如果您需要與其他可組合項共用 UI 元素狀態,並在不同的位置套用 UI 邏輯,可以在 UI 階層中提升。這樣一來,您的可組合項也更容易重複使用,以及更容易進行測試。

以下範例中的即時通訊應用程式會導入兩項功能:

  • JumpToBottom 按鈕會將訊息清單捲動至底部。該按鈕會在清單狀態上執行 UI 邏輯。
  • 使用者傳送新訊息後,MessagesList 清單會捲動至底部。UserInput 會對清單狀態執行 UI 邏輯。
即時通訊應用程式附有 JumpToBottom 按鈕,可以捲動至底部的新訊息
圖 1. 具有 JumpToBottom 按鈕的即時通訊應用程式,可捲動至底部的新訊息

可組合階層如下:

即時通訊可組合項樹狀結構
圖 2. 即時通訊可組合項樹狀結構

LazyColumn 狀態提升至對話螢幕,因此應用程式可以執行 UI 邏輯,並從所有需要相應狀態的可組合項中讀取狀態:

將 LazyColumn 狀態從 LazyColumn 提升至 ConversationScreen
圖 3.LazyColumn 狀態從 LazyColumn 提升至 ConversationScreen

最終可組合項如下:

從 LazyListState 提升至 ConversationScreen 的即時通訊可組合項樹狀結構
圖 4. 已使用 LazyListState 提升至 ConversationScreen 的即時通訊可組合項樹狀結構

程式碼如下:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState 會依據必須套用的 UI 邏輯提升級別。由於它在可組合函式中完成初始化,因此根據其生命週期會將其儲存在組合中。

請注意,lazyListState 是在 MessagesList 方法中定義,預設值為 rememberLazyListState()。這是 Compose 中的常見模式。因此可組合項更加易重複使用且更靈活。接著,針對可能不必控制狀態的應用程式,您可以在其中的不同部分使用可組合項。通常,在測試或預覽可組合項時會出現此情況。這正是 LazyColumn 定義其狀態的方式。

LazyListState 最小的共同祖系是 ConversationScreen
圖 5. LazyListState 的最小共同祖系為 ConversationScreen

以純狀態容器類別當做狀態擁有者

如果可組合項含有涉及 UI 元素一個或多個狀態欄位的複雜 UI 邏輯,則應將責任委派給狀態容器 (例如純狀態容器類別)。這樣一來,這個可組合項的邏輯會更容易在隔離狀態下進行測試,也能降低其複雜度。這種做法有利於關注點分離原則該可組合項負責投放 UI 元素,狀態容器收納 UI 邏輯和 UI 元素狀態。

純狀態容器類別可為可組合函式的呼叫端提供便利的函式,這樣他們就不必自行編寫這個邏輯。

這些純類別在組合中建立及記憶。這些類別會遵循可組合項的生命週期,因此可使用 Compose 程式庫提供的類型,例如 rememberNavController()rememberLazyListState()

舉例來說,LazyListState 純狀態容器類別可在 Compose 中實作,控制 LazyColumnLazyRow 的 UI 複雜性。

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState 會封裝用於儲存此 UI 元素之 scrollPositionLazyColumn 狀態。此外,它還會公開一些方法來修改捲動位置,例如捲動至特定項目。

如您所見,當可組合項的責任增加,也會增加對於狀態容器的需求。責任可以是 UI 邏輯,也可以只是需要追蹤的狀態數量。

另一個常見的模式是使用純狀態容器類別來處理應用程式中根可組合函式的複雜度。您可以使用此種類別來封裝應用程式層級狀態,例如導覽狀態和螢幕大小。如需這項功能的完整說明,請參閱 UI 邏輯及其狀態容器頁面

商業邏輯

如果可組合項和純狀態容器類別負責管理 UI 邏輯和 UI 元素狀態,則螢幕層級狀態容器會負責下列任務:

  • 提供應用程式的商業邏輯 (通常位於該階層的其他層,例如業務和資料層) 的存取權。
  • 準備要在特定螢幕上顯示的應用程式資料;這些資料會成為螢幕 UI 狀態。

以 ViewModels 當做狀態擁有者

在 Android 開發作業中,AAC ViewModels 具有許多優點,因此相當適合提供商業邏輯的存取權,以及準備在螢幕上顯示的應用程式資料。

ViewModel 中提升 UI 狀態時,可將其移至組合之外。

提升至 ViewModel 的狀態將儲存在組合之外。
圖 6. 提升至 ViewModel 的狀態將儲存在組合之外。

ViewModel 不會做為組合的一部分進行儲存。它們由架構提供,且範圍限定為 ViewModelStoreOwner,這可以是導覽圖的活動、片段、導覽圖或目的地。如要進一步瞭解 ViewModel 範圍,請參閱說明文件。

ViewModel 則是 UI 狀態的可靠來源和最小共同祖系

螢幕 UI 狀態

根據上述定義,螢幕 UI 狀態透過套用商業規則產生。假設螢幕層級狀態容器負責該操作,這意味著螢幕 UI 狀態通常在螢幕層級狀態容器中提升,在此案例中為 ViewModel

考慮即時通訊應用程式的 ConversationViewModel,及其如何公開螢幕 UI 狀態和事件以進行修改:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

可組合項會取用 ViewModel 中提升的螢幕 UI 狀態。您應在螢幕層級可組合項中插入 ViewModel 執行個體,以提供商業邏輯的存取權。

以下是在畫面層級可組合項中使用 ViewModel 的範例。可組合項 ConversationScreen() 會取用在 ViewModel 中提升的畫面 UI 狀態:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

資源細查

「資源細查」是指透過多個巢狀子項元件,將資料傳遞至其讀取所在的位置。

典型的例子是,您在頂層撰寫螢幕層級狀態容器,並將狀態和事件向下傳遞給子項的可組合項,即可在 Compose 中顯示屬性。這可能會額外產生可組合函式簽章的超載情況。

儘管將事件揭露做為個別 lambda 參數可能會使函式簽章超載,但可以提高可組合函式責任的瀏覽權限。您可以一眼看出函式的作用。

相較於建立包裝函式類別來將狀態和事件封裝到單一位置,我們更建議進行屬性細查,因為這可以降低可組合項所具有責任的可見性。如果沒有包裝函式類別,您更有可能只傳遞所需的參數,這是最佳做法

如果這些事件是導覽事件,則這些最佳做法同樣適用,請參閱導覽文件來瞭解詳情。

如果您發現效能問題,也可以選擇延遲讀取狀態。您可以參閱效能說明文件來瞭解詳情。

UI 元素狀態

如果商業邏輯需要讀取或寫入,則可將 UI 元素狀態提升至螢幕層級狀態容器。

以即時通訊應用程式為例,當使用者輸入 @ 和提示時,應用程式會在群組通訊中顯示使用者建議。這些建議來自資料層,且用於計算使用者建議清單的邏輯被視為商業邏輯。這項功能如下所示:

當使用者輸入「@」和提示時,這項功能會在群組通訊中顯示使用者建議
圖 7. 當使用者輸入 @ 和提示時,這項功能會在群組通訊中顯示使用者建議

實作此功能的 ViewModel 如下所示:

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessage 是儲存 TextField 狀態的變數。每次使用者輸入新的輸入內容時,應用程式都會呼叫商業邏輯以產生 suggestions

suggestions 是螢幕 UI 狀態,透過從 StateFlow 收集而來的 Compose UI 取用。

警告

對於某些 Compose UI 元素狀態,提升至 ViewModel 時可能需要特別注意。例如,某些 Compose UI 元素的狀態容器會公開修改狀態的方法。其中部分可能是觸發動畫的暫停函式。如果您從範圍未限定為組合的 CoroutineScope 中呼叫,則這些暫停函式可能會擲回例外狀況。

假設應用程式導覽匣的內容是動態的,且您需要在關閉後從資料層擷取並重新整理內容。您應將導覽匣狀態提升至 ViewModel,以便透過狀態擁有者呼叫這個元素的 UI 和商業邏輯。

然而,透過 Compose UI 中的 viewModelScope 呼叫 DrawerStateclose() 方法,會導致發生 IllegalStateException 類型的執行階段例外狀況,並顯示訊息「a MonotonicFrameClock is not available in this CoroutineContext”」。

如要修正這個問題,請使用範圍限定為組合的 CoroutineScope。必須在 CoroutineContext 中提供 MonotonicFrameClock,以便讓暫停函式正常運作。

如要修正此當機問題,請將 ViewModel 協同程式中的 CoroutineContext 切換至作用範圍僅限組合的協同程式。暫停螢幕如下所示:

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

瞭解詳情

如要進一步瞭解狀態與 Jetpack Compose,請參閱下列額外資源。

範例

程式碼研究室

影片