Jetpack Compose 中的進階狀態和連帶效果

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

1. 簡介

在本程式碼研究室中,您將瞭解 Jetpack Compose 中與 StateSide Effects API 相關的進階概念。我們會說明如何為邏輯較複雜的有狀態可組合函式建立狀態容器、建立協同程式並透過 Compose 程式碼呼叫暫停函式,以及如何觸發連帶效果來達成不同的用途。

課程內容

軟硬體需求

建構項目

在本程式碼研究室中,我們會從未完成的 Crane Material Study 應用程式開始著手,並新增相關功能來改善應用程式。

b2c6b8989f4332bb.gif

2. 開始設定

取得程式碼

您可以在 android-compose-codelabs GitHub 存放區中找到本程式碼研究室的程式碼。如要複製該存放區,請執行下列命令:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

您也可以透過 ZIP 檔案下載存放區:

查看範例應用程式

您剛才下載的程式碼包含所有 Compose 程式碼研究室可用的程式碼。如要完成本程式碼研究室,請開啟 Android Studio Arctic Fox 中的 AdvancedStateAndSideEffectsCodelab 專案。

建議您先從 main 分支版本的程式碼著手,依自己的步調逐步完成本程式碼研究室。

在本程式碼研究室的學習過程中,我們會為您提供要新增到專案的程式碼片段。在某些地方,您還需要移除程式碼片段註解中明確提及的程式碼。

熟悉程式碼並執行範例應用程式

請先花點時間瀏覽專案結構,然後再執行應用程式。

162c42b19dafa701.png

從 main 分支版本執行應用程式時,您會發現部分功能 (例如導覽匣或載入航班目的地) 無法正常運作。這就是我們在本程式碼研究室後續步驟中要處理的部分。

b2c6b8989f4332bb.gif

使用者介面測試

系統會對應用程式執行 androidTest 資料夾中所提供的最基本使用者介面測試。而 mainend 分支版本應一律通過這些測試。

[選用] 在詳細資料畫面上顯示地圖

您不一定要根據說明在詳細資料畫面上顯示城市地圖。不過,如果您想顯示地圖,就必須取得個人 API 金鑰,方法請參閱地圖說明文件中的說明。在 local.properties 檔案中加入該金鑰,方法如下:

// local.properties file
google.maps.key={insert_your_api_key_here}

本程式碼研究室的解決方案

如要使用 Git 取得 end 分支版本,請使用下列指令:

$ git clone -b end https://github.com/googlecodelabs/android-compose-codelabs

或者,您也可以在以下位置下載解決方案程式碼:

常見問題

3. UI 狀態產生管道

您可能已經發現,從 main 分支版本執行應用程式時,航班目的地清單竟一片空白!

如要解決這個問題,您必須完成以下兩個步驟:

  • ViewModel 中新增邏輯,產生 UI 狀態。在這個示例中,就是指建議目的地清單。
  • 從 UI 中取用 UI 狀態,這樣就能在畫面中顯示 UI。

在這個部分,您將完成第一個步驟。

良好的應用程式架構會劃分層級,遵循基本的好系統設計做法,例如關注點分離和可測試性。

UI 狀態產生作業是指應用程式存取資料層的程序,會視需要套用業務規則,並公開要從 UI 取用的 UI 狀態。

此應用程式已導入資料層。現在,您將產生狀態 (建議目的地清單),方便 UI 取用。

部分 API 可用於產生 UI 狀態。如要瞭解替代方案,請參閱「狀態產生管道的輸出類型」說明文件。一般來說,使用 Kotlin 的 StateFlow 產生 UI 狀態是不錯的做法。

如要產生 UI 狀態,請按照下列步驟操作:

  1. 開啟 home/MainViewModel.kt
  2. 定義 MutableStateFlow 類型的私人 _suggestedDestinations 變數,用於表示建議目的地清單,然後將空白清單設為起始值。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. 定義第二個不可變變數 suggestedDestinations,類型為 StateFlow。這是可以從 UI 取用的可公開唯讀變數。建議您公開唯讀變數,並在內部使用可變動變數。這可確保 UI 狀態無法修改,除非是透過 ViewModel 並將其設為單一可靠來源。擴充功能函式 asStateFlow 會將可變動資料流轉換為不可變資料流。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. ViewModel 的 init 區塊新增 destinationsRepository 中的呼叫,從資料層取得目的地。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  1. 最後,取消註解在這個類別中找到的內部變數 _suggestedDestinations 使用情況,以便利用來自 UI 的事件正確更新這個變數。

這樣就完成第一個步驟了!現在,ViewModel 已能產生 UI 狀態。在下一個步驟中,您將從 UI 中取用這個狀態。

4. 從 ViewModel 安全取用資料流

航班目的地清單仍是一片空白。在上一個步驟中,您在 MainViewModel 產生了 UI 狀態。現在您將取用要在 UI 中顯示,並由 MainViewModel 公開的 UI 狀態。

開啟 home/CraneHome.kt 檔案,然後查看 CraneHomeContent 可組合項。

在分配給已記住空白清單的 suggestedDestinations 定義上方,有一個 TODO 註解。這是畫面上顯示的內容:一份空白清單!在這個步驟中,我們會修正這項錯誤,並顯示 MainViewModel 提供的建議目的地。

66ae2543faaf2e91.png

開啟 home/MainViewModel.kt,並查看 suggestedDestinations StateFlow (已初始化為 destinationsRepository.destinations,會在系統呼叫 updatePeopletoDestinationChanged 函式時更新)。

如果希望每次有新項目發送到 suggestedDestinations 資料串流時,CraneHomeContent 可組合函式中的 UI 都會更新,可以使用 collectAsStateWithLifecycle() 函式。collectAsStateWithLifecycle() 會從 StateFlow 收集值,並使用 Compose 的 State API 表示最新的值。如此一來,讀取狀態值的 Compose 程式碼就會在新項目發出時重組。

如要開始使用 collectAsStateWithLifecycle API,請先在 app/build.gradle 中新增下列依附元件。此專案已使用適當版本定義變數 lifecycle_version

dependencies {
    implementation "androidx.lifecycle:lifecycle-runtime-compose$lifecycle_version"
}

返回 CraneHomeContent 可組合項,將指派 suggestedDestinations 的該行程式碼替換為在 ViewModelsuggestedDestinations 屬性上對 collectAsStateWithLifecycle 的呼叫:

import androidx.compose.runtime.collectAsState

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
    // ...
}

執行應用程式時,系統就會顯示已填入的目的地清單。每次輕觸旅遊人數時,目的地也都會隨之變更。

d656748c7c583eb8.gif

5. LaunchedEffect 和 rememberUpdatedState

專案中有一個目前未使用的 home/LandingScreen.kt 檔案。我們要在應用程式中新增一個到達畫面,它可能會用來載入背景所需的一切資料。

到達畫面會占據整個螢幕,並會在畫面中央顯示應用程式的標誌。理想情況下,我們會顯示畫面,並在所有資料載入完畢後通知呼叫端,讓對方知道可以使用 onTimeout 回呼來關閉到達畫面。

如要在 Android 中執行非同步作業,建議使用 Kotlin 協同程式。一般情況下,應用程式會在啟動時使用協同程式在背景中載入內容。而 Jetpack Compose 提供的 API,可讓您在使用者介面層中安全地使用協同程式。由於這個應用程式不會與後端通訊,因此我們會使用協同程式的 delay 函式,來模擬在背景中載入內容。

Compose 的連帶效果是指應用程式狀態變化,且發生在可組合函式以外。將狀態變更為顯示/隱藏到達畫面的操作會發生在 onTimeout 回呼中;由於在呼叫 onTimeout 前,我們必須使用協同程式載入內容,因此狀態變更必須發生在協同程式的情境中。

如要從可組合項內部安全地呼叫暫停函式,請使用 LaunchedEffect API,在 Compose 中觸發協同程式範圍的連帶效果。

LaunchedEffect 進入組合時,就會啟動一個協同程式,並將程式碼當做參數傳遞。如果 LaunchedEffect 離開組合,協同程式就會取消。

雖然接下來的程式碼不正確,但我們可以看看如何使用這個 API,並討論下列程式碼錯誤的原因。我們會在這個步驟的後面呼叫 LandingScreen 複合元件。

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

某些連帶效果 API (例如 LaunchedEffect) 會使用任意數量的鍵做為參數,用於在其中一個鍵變更時重新啟動效應。您發現錯誤了嗎?我們不希望在 onTimeout 改變時重新啟動特效!

如要在這個可組合函式的生命週期中只觸發一次連帶效果,請使用常數做為鍵,例如 LaunchedEffect(true) { ... }。不過,我們現在並未防止 onTimeout 變更!

如果 onTimeout 在連帶效果進行期間發生變化,效果結束時不一定會呼叫最後一個 onTimeout。如要透過擷取和更新到新的值來保證做到這一點,請使用 rememberUpdatedState API:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

顯示到達畫面

現在,我們要在應用程式開啟時顯示到達畫面。開啟 home/MainActivity.kt 檔案,然後查看第一個呼叫的 MainScreen 複合元件。

MainScreen 可組合項中,我們只需新增一種內部狀態,即可追蹤是否應顯示到達畫面:

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

如果您現在執行應用程式,畫面上應會顯示 LandingScreen,然後在 2 秒後消失。

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

在這個步驟中,我們要讓導覽匣運作。目前,如果您嘗試輕觸漢堡選單,也不會顯示任何資訊。

開啟 home/CraneHome.kt 檔案並查看 CraneHome 可組合函式,藉此瞭解需要開啟導覽匣的位置:openDrawer 回呼中。

我們在 CraneHome 中有一個包含 DrawerStatescaffoldStateDrawerState 提供透過程式開啟及關閉導覽匣的方法。不過,如果您嘗試在 openDrawer 回呼中寫入 scaffoldState.drawerState.open(),就會收到錯誤訊息!這是因為 open 函式是一個暫停函式。我們又再次回到協同程式了。

除了讓您安全地從使用者介面圖層呼叫協同程式的 API 外,某些 Compose API 屬於暫停函式。其中一個例子就是開啟導覽匣的 API。暫停函式除了能執行非同步程式碼外,還有助於表示隨著時間演變而出現的概念。開啟導覽匣需要時間和動作,某些情況下還需要動畫,因此很適合透過暫停函式完全體現出來。這是因為暫停函式可以在遭呼叫的位置暫停執行協同程式,直到函式完成並恢復執行為止。

您必須在協同程式內呼叫 scaffoldState.drawerState.open()。我們可以怎麼做?openDrawer 是個簡單的回呼函式,因此:

  • 我們不能只在其中呼叫暫停函式,因為 openDrawer 並未在協同程式的情境中執行。
  • 我們無法像先前一樣使用 LaunchedEffect,因為無法在 openDrawer 中呼叫複合元件。我們並不在組合中。

我們希望能夠啟動協同程式,該使用哪個範圍呢?在理想情況下,我們希望 CoroutineScope 能遵循其呼叫點的生命週期。方法就是使用 rememberCoroutineScope API。離開組合後,範圍就會自動取消。有了這範圍,即使您不在組合中 (例如在 openDrawer 回呼中),也能啟動協同程式。

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

如果您執行應用程式,只要輕觸漢堡選單圖示就會開啟導覽匣圖示。

92957c04a35e91e3.gif

LaunchedEffect 和 rememberCoroutineScope

由於我們必須觸發呼叫才能在組合以外的一般回呼中建立協同程式,因此在這種情況下無法使用 LaunchedEffect

回想一下先前使用 LaunchedEffect 的到達畫面步驟,您可以在不使用 LaunchedEffect 的情況下,使用 rememberCoroutineScope 並呼叫 scope.launch { delay(); onTimeout(); } 嗎?

您本來可以這樣做,也似乎可行,但這樣並不正確。如 Compose 中的思維說明文件中所述,Compose 可以隨時呼叫複合元件。在對該複合元件的呼叫進入組合時,LaunchedEffect 保證會執行連帶效果。如果在 LandingScreen 的主體中使用 rememberCoroutineScopescope.launch,則每次 Compose 呼叫 LandingScreen 時,不論該呼叫是否對組合產生連帶效果,系統都會執行協同程式。因此,您不僅會浪費資源,還無法在受管控的環境中執行這個連帶效果。

7. 建立狀態容器

您發現了嗎?只要輕觸「Choose Destination」,您就能編輯欄位,並根據搜尋輸入內容篩選城市。您可能也會注意到,每次修改「Choose Destination」時,文字樣式都會隨之改變。

dde9ef06ca4e5191.gif

開啟 base/EditableUserInput.kt 檔案。CraneEditableUserInput 有狀態的可組合函式會採用部分參數,例如 hintcaption,後者會對應圖示旁的選用文字。舉例來說,搜尋目的地時,系統會顯示 caption「To」

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

原因是什麼?

用來更新 textState 以及決定顯示內容是否與提示對應的邏輯,全都位於 CraneEditableUserInput 可組合函式的內文中。這樣會帶來一些缺點:

  • TextField 的值並未提升,因此無法從外部控制,使得測試變得困難。
  • 此可組合項的邏輯可能會變得更加複雜,且內部狀態也無法輕鬆同步。

只要建立負責這個可組合項內部狀態的狀態容器,就能將所有狀態變更集中在一處。這樣一來,狀態要不同步就更難了,且所有相關的邏輯全都會歸類到單一類別中。此外,這個狀態容易向上提升,而且可以從這個可組合函式的呼叫端使用。

在這種情況下,提升狀態是個不錯的做法,因為這是低階的使用者介面元件,可能會重複用於應用程式的其他部分。因此,越靈活可控越好。

建立狀態持有物件

由於 CraneEditableUserInput 是可重複使用的元件,所以我們要在同一個檔案中建立名為 EditableUserInputState 的一般類別做為狀態容器,如下所示:

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint
}

該類別應具備以下特徵:

  • textString 類型的可變動狀態,就像在 CraneEditableUserInput 一樣。請務必使用 mutableStateOf,讓 Compose 追蹤值的變更,並在有變化時重組。
  • textvar,可讓您直接從類別外部變更。
  • 該類別將 initialText 做為依附元件,用來初始化 text
  • 用來判斷 text 是否為提示的邏輯位於隨選執行檢查的 isHint 屬性中。

如果日後邏輯變得更加複雜,我們只需變更其中一個類別:EditableUserInputState

記住狀態持有物件

請務必記住狀態持有物件,以便保留在組合中,而不必每次都新增一個。建議您在同一個檔案中建立執行此操作的方法,這樣就能移除樣板並避免發生任何錯誤。在 base/EditableUserInput.kt 檔案中,新增下列程式碼:

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

如果我們只是使用 remember 這個狀態,它在活動重新建立時就不會繼續保留。為達到這個目標,我們可以改用 rememberSaveable API,這個 API 的行為與 remember 類似,但在活動和程序重建期間,儲存的值仍會保留下來。在內部,它會使用已儲存的執行個體狀態機制。

對於可儲存在 Bundle 內的物件,rememberSaveable 無需任何額外操作,就可以完成這些工作。不過,對於我們在專案中建立的 EditableUserInputState 類別,卻並非如此。因此,我們需要告知 rememberSaveable 如何使用 Saver 來儲存和還原這個類別的執行個體。

建立自訂儲存工具

Saver 說明了如何將物件轉換為 Saveable 的內容。實作 Saver 需要覆寫兩個函式:

  • save:將原始值轉換為可儲存的值。
  • restore:將還原的值轉換為原始類別執行個體。

在本例中,我們可以使用一些現有的 Compose API,例如 listSavermapSaver (用於存放要儲存在 ListMap 的值),減少需要編寫的程式碼數量,而不要為 EditableUserInputState 類別建立 Saver 的自訂實作內容。

建議您將 Saver 定義置於與其搭配使用的類別附近。由於需要使用靜態存取,讓我們在 companion object 中新增 EditableUserInputStateSaver。在 base/EditableUserInput.kt 檔案中,新增 Saver 的實作:

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

在本示例中,我們使用 listSaver 做為實作詳細資料,以便在儲存工具中儲存及還原 EditableUserInputState 的例項。

現在,我們可以利用之前建立的 rememberEditableUserInputState 方法,在 rememberSaveable (而不是 remember) 中使用這個儲存工具。

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

這樣,重新建立程序和活動時,就會保留 EditableUserInput 記住的狀態。

使用狀態持有物件

我們要改用 EditableUserInputState 來取代 textisHint,但不會僅用於 CraneEditableUserInput 的內部狀態,因為呼叫端的可組合項無法控制狀態。相反,我們想要提升 EditableUserInputState,以便呼叫端可以控制 CraneEditableUserInput 的狀態。如果提升狀態,由於您可以從呼叫端修改可組合項的狀態,就能在預覽畫面中使用這個可組合項,也更易於進行測試。

為此,我們必須變更可組合函式的參數,並視情況提供預設值。我們可能想要允許 CraneEditableUserInput 有空白提示,因此要新增預設引數:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

相信您已經發現,onInputChanged 參數已經不存在了!由於狀態可以提升,因此如果呼叫端想知道輸入內容是否有所變更,對方可以控制該狀態,並將該狀態傳入這個函式。

接著,我們要調整函式主體,以便使用提升的狀態,而不是先前使用的內部狀態。重構後,函式應如下所示:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.text = it },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

狀態持有物件呼叫端

由於我們變更了 CraneEditableUserInput 的 API,我們必須檢查呼叫此 API 的所有位置,確保傳遞正確的參數。

在此專案中,我們只有在 home/SearchUserInput.kt 檔案呼過此 API。開啟這個檔案並前往 ToDestinationUserInput 可組合函式;該位置應會出現建構錯誤。由於這個提示現在已是狀態容器的一部分,而我們希望在組合中自訂此 CraneEditableUserInput 例項的提示,因此必須記住 ToDestinationUserInput 層級的狀態並傳入 CraneEditableUserInput

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

上述程式碼缺少了在輸入內容變更時通知 ToDestinationUserInput 呼叫端的功能。基於應用程式的結構,我們不希望在階層中將 EditableUserInputState 提升到更高的級別,因為我們想將其他可組合項 (例如 FlySearchContent) 與此狀態箱結合。我們要如何從 ToDestinationUserInput 呼叫 onToDestinationChanged lambda,同時保持此複合元件可重複使用呢?

我們可以在每次輸入內容變更時,使用 LaunchedEffect 觸發連帶效果,並呼叫 onToDestinationChanged lambda:

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

我們之前已經用過 LaunchedEffectrememberUpdatedState,但上述程式碼還使用了新的 API!我們使用snapshotFlow API 將 ComposeState<T> 物件轉換為 Flow。當 snapshotFlow 內讀取到的狀態發生變化時,Flow 就會向收集器發出新的值。在本例子中,我們將狀態轉換為 Flow,以便使用 Flow 運算子的強大功能。這樣,我們就可以在 text 不是 hint 時使用 filter 來篩選,並使用 collect 收集發出的項目,以通知父項目前的目的地已變更。

在這個程式碼研究室的步驟中沒有出現任何視覺上的變化,但我們改善了這部分程式碼的品質。現在執行應用程式的話,應該會看到一切照常運作。

8. DisposableEffect

輕觸目的地後,系統會開啟詳細資料畫面,讓您查看該城市在地圖上的位置。這個程式碼位於 details/DetailsActivity.kt 檔案中。在 CityMapView 複合元件中,我們呼叫了 rememberMapViewWithLifecycle 函式。如果您開啟位於 details/MapViewUtils.kt 檔案中的函式,會發現該函式並未連結至任何生命週期。它只是記住了 MapView,並對其呼叫 onCreate

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

雖然應用程式可以正常執行,但 MapView 並未遵循正確的生命週期,因此也是個問題。因此,它不知道應用程式是否已移至背景、何時應暫停 View 等等。讓我們來修正這個問題!

由於 MapView 是 View,而不是複合元件,因此我們希望它遵循「活動」的生命週期,而不是組合的生命週期。這代表我們必須建立一個 LifecycleEventObserver,以便監聽生命週期事件,並在 MapView 中呼叫正確的方法。接著,我們必須將這個觀察工具新增至目前活動的生命週期。

首先請建立一個傳回 MapView 的函式;在特定事件中,它會在 LifecycleEventObserver 中呼叫相應的方法:

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

現在,我們必須將這個觀察工具新增至目前的生命週期,我們可以使用目前的 LifecycleOwner 搭配 LocalLifecycleOwner 本機組合來取得該生命週期。但光是新增觀察工具還不夠,我們還需要能移除這個觀察工具!我們需要一個連帶效果,可以在特效離開組合時通知我們,以便我們可以執行一些清理程式碼。我們要尋找的連帶效果 API 是 DisposableEffect

DisposableEffect 適用於在鍵變更後或複合元件離開組合後,需要清理的連帶效果。最終的 rememberMapViewWithLifecycle 程式碼正好起到這種作用。請在專案中實作下列程式碼:

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

觀察工具已新增到目前的 lifecycle,只要目前的生命週期有所變更,或是這個複合元件離開組合時,系統都會移除觀測工具。只要使用 DisposableEffect 中的 key,系統就會在 lifecyclemapView 發生變更時,移除觀察工具並再次將其新增至正確的 lifecycle 中。

完成剛才的變更後,MapView 一律會遵循目前 LifecycleOwnerlifecycle,其行為也會像在 View 環境中使用一樣。

您可以隨時執行應用程式並開啟詳細資料畫面,確保 MapView 仍可正確顯示。這個步驟不含任何視覺上的變更。

9. produceState

我們會在這部分改善詳細資料畫面的啟動方式。details/DetailsActivity.kt 檔案中的 DetailsScreen 組合可從 ViewModel 同步取得 cityDetails,如果結果成功,則會呼叫 DetailsContent

不過,cityDetails 在 UI 執行緒上載入的成本可能會變得越來越高,而且可以使用協同程式將載入資料的工作移到其他執行緒。我們要改善這個程式碼,以便新增載入畫面,並在資料準備就緒時顯示 DetailsContent

模擬畫面狀態的一種方法是使用下列類別,此類別涵蓋了所有可能性:要在畫面上顯示的資料,以及載入操作和錯誤信號。將 DetailsUiState 類別新增到 DetailsActivity.kt 檔案:

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

我們可以使用資料串流 (DetailsUiState 類型的 StateFlow) 來對應要在畫面上顯示的內容和 ViewModel 圖層中的 UiState,ViewModel 會在資訊準備就緒時更新資料串流,而 Compose 則會透過您先前學過的 collectAsState() API 收集資料串流。

不過,為了方便執行本練習,我們會實作替代方案。如要我們想將 uiState 對應邏輯移至 Compose 環境,可以使用 produceState API。

produceState 可讓您將非 Compose 狀態轉換為 Compose 狀態,它會啟動一個限定範圍為組合的協同程式,該協同程式可以使用 value 屬性將值推送至傳回的 State。與 LaunchedEffect 相同,produceState 也採用鍵來取消並重新啟動運算。

以我們的用途為例,我們可以使用 produceState 發出初始值為 DetailsUiState(isLoading = true)uiState 更新,如下所示:

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

接下來,視 uiState 而定,我們會顯示資料、顯示載入畫面或回報錯誤。以下是 DetailsScreen 複合元件的完整程式碼:

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

如果您執行應用程式,就會在顯示城市詳細資料之前,看到載入旋轉圖示如何出現。

aa8fd1ac660266e9.gif

10. derivedStateOf

接下來是最後一個要對 Crane 執行的改善項目:在捲動航班目的地清單時,一旦經過畫面上第一個元素,就顯示可捲動至頂端的按鈕。只要輕觸該按鈕,即可前往清單中的第一個元素。

2c112d73f48335e0.gif

開啟包含此程式碼的 base/ExploreSection.kt 檔案。ExploreSection 複合元件會對應到您在鷹架的背景中看到的內容。

按照影片中的行為來實作解決方案,對您來說應該不足為奇。不過,其中有個先前未介紹過的新 API,而此 API 在這個用途中格外重要,那就是 derivedStateOf API。

當您想要的某個 Compose 衍生自另一個 StateState,就會用到 derivedStateOf。使用此函式,就可保證只有在運算中使用的其中一個狀態有所變化時,才會進行運算。

使用 listState 來計算使用者是否已通過第一個項目,就像檢查是否符合 listState.firstVisibleItemIndex > 0 一樣簡單。不過,firstVisibleItemIndex 包裝在 mutableStateOf API 中,它便成為可觀察的 Compose 狀態。計算結果也必須為 Compose 狀態,因為我們想要重組使用者介面來顯示按鈕。

以下是一種簡略而效率不佳的實作例子。請勿複製到專案中。系統稍後會將正確的實作內容以及畫面的其他邏輯一併複製到專案中:

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

更好且更有效率的替代方法是使用 derivedStateOf API,這個 API 只會在 listState.firstVisibleItemIndex 變更時計算 showButton

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

您應該已經熟悉 ExploreSection 複合元件的新程式碼。再來看看如何使用 rememberCoroutineScopeButtononClick 回呼內,呼叫 listState.scrollToItem 暫停函式。我們使用 Box 將有條件的 Button 放置在 ExploreList 的頂端:

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

現在執行應用程式的話,只要捲動畫面並經過第一個元素,該按鈕就會顯示在底部。

11. 恭喜!

恭喜!您已成功完成本程式碼研究室,並瞭解 Jetpack Compose 應用程式中有關狀態和連帶效果 API 的進階概念!

您瞭解如何建立狀態容器、連帶效果 API (例如 LaunchedEffectrememberUpdatedStateDisposableEffectproduceStatederivedStateOf),以及如何在 Jetpack Compose 中使用協同程式。

後續步驟

請查看 Compose 課程的其他程式碼研究室,以及 Crane 等其他程式碼範例

說明文件

如需有關這些主題的更多資訊和指南,請參閱以下說明文件: