1. 簡介
在本程式碼研究室中,您將瞭解 Jetpack Compose 中與 State 和 Side Effects API 相關的進階概念。我們會說明如何為邏輯較複雜的有狀態可組合函式建立狀態容器、建立協同程式並透過 Compose 程式碼呼叫暫停函式,以及如何觸發連帶效果來達成不同的用途。
課程內容
- 如何透過 Compose 程式碼觀察資料串流以更新使用者介面。
- 如何為有狀態複合元件建立狀態持有物件。
- 連帶效果 API,例如
LaunchedEffect
、rememberUpdatedState
、DisposableEffect
、produceState
和derivedStateOf
。 - 如何使用
rememberCoroutineScope
API 建立協同程式和呼叫暫停函式。
軟硬體需求
- 最新版 Android Studio
- 具備 Kotlin 語法經驗 (包括 lambda)。
- 有 Compose 的基本經驗。建議您先學習「Jetpack Compose 基本概念」程式碼研究室,再學習本程式碼研究室。
- 瞭解 Compose 的基本狀態概念,例如單向資料串流 (UDF)、ViewModel、狀態提升、無狀態/有狀態可組合項、Slot API,以及
remember
和mutableStateOf
狀態 API。如要瞭解這些相關知識,請參閱狀態與 Jetpack Compose 說明文件或完成「在 Jetpack Compose 中使用狀態」程式碼研究室。 - 具備 Kotlin 協同程式的基本知識。
- 對複合元件的生命週期有基礎瞭解。
建構項目
在本程式碼研究室中,我們會從未完成的 Crane Material Study 應用程式開始著手,並新增相關功能來改善應用程式。
2. 開始設定
取得程式碼
您可以在 android-compose-codelabs GitHub 存放區中找到本程式碼研究室的程式碼。如要複製該存放區,請執行下列命令:
$ git clone https://github.com/googlecodelabs/android-compose-codelabs
您也可以透過 ZIP 檔案下載存放區:
查看範例應用程式
您剛才下載的程式碼包含所有 Compose 程式碼研究室可用的程式碼。如要完成本程式碼研究室,請開啟 Android Studio Arctic Fox 中的 AdvancedStateAndSideEffectsCodelab
專案。
建議您先從 main 分支版本的程式碼著手,依自己的步調逐步完成本程式碼研究室。
在本程式碼研究室的學習過程中,我們會為您提供要新增到專案的程式碼片段。在某些地方,您還需要移除程式碼片段註解中明確提及的程式碼。
熟悉程式碼並執行範例應用程式
請先花點時間瀏覽專案結構,然後再執行應用程式。
從 main 分支版本執行應用程式時,您會發現部分功能 (例如導覽匣或載入航班目的地) 無法正常運作。這就是我們在本程式碼研究室後續步驟中要處理的部分。
使用者介面測試
系統會對應用程式執行 androidTest
資料夾中所提供的最基本使用者介面測試。而 main
和 end
分支版本應一律通過這些測試。
[選用] 在詳細資料畫面上顯示地圖
您不一定要根據說明在詳細資料畫面上顯示城市地圖。不過,如果您想顯示地圖,就必須取得個人 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
分支版本執行應用程式時,航班目的地清單竟一片空白!
如要解決這個問題,您必須完成以下兩個步驟:
在這個部分,您將完成第一個步驟。
良好的應用程式架構會劃分層級,遵循基本的好系統設計做法,例如關注點分離和可測試性。
UI 狀態產生作業是指應用程式存取資料層的程序,會視需要套用業務規則,並公開要從 UI 取用的 UI 狀態。
此應用程式已導入資料層。現在,您將產生狀態 (建議目的地清單),方便 UI 取用。
部分 API 可用於產生 UI 狀態。如要瞭解替代方案,請參閱「狀態產生管道的輸出類型」說明文件。一般來說,使用 Kotlin 的 StateFlow
產生 UI 狀態是不錯的做法。
如要產生 UI 狀態,請按照下列步驟操作:
- 開啟
home/MainViewModel.kt
。 - 定義
MutableStateFlow
類型的私人_suggestedDestinations
變數,用於表示建議目的地清單,然後將空白清單設為起始值。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
- 定義第二個不可變變數
suggestedDestinations
,類型為StateFlow
。這是可以從 UI 取用的可公開唯讀變數。建議您公開唯讀變數,並在內部使用可變動變數。這可確保 UI 狀態無法修改,除非是透過ViewModel
並將其設為單一可靠來源。擴充功能函式asStateFlow
會將可變動資料流轉換為不可變資料流。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
- 在
ViewModel
的 init 區塊新增destinationsRepository
中的呼叫,從資料層取得目的地。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
init {
_suggestedDestinations.value = destinationsRepository.destinations
}
- 最後,取消註解在這個類別中找到的內部變數
_suggestedDestinations
使用情況,以便利用來自 UI 的事件正確更新這個變數。
這樣就完成第一個步驟了!現在,ViewModel
已能產生 UI 狀態。在下一個步驟中,您將從 UI 中取用這個狀態。
4. 從 ViewModel 安全取用資料流
航班目的地清單仍是一片空白。在上一個步驟中,您在 MainViewModel
產生了 UI 狀態。現在您將取用要在 UI 中顯示,並由 MainViewModel
公開的 UI 狀態。
開啟 home/CraneHome.kt
檔案,然後查看 CraneHomeContent
可組合項。
在分配給已記住空白清單的 suggestedDestinations
定義上方,有一個 TODO 註解。這是畫面上顯示的內容:一份空白清單!在這個步驟中,我們會修正這項錯誤,並顯示 MainViewModel
提供的建議目的地。
開啟 home/MainViewModel.kt
,並查看 suggestedDestinations
StateFlow (已初始化為 destinationsRepository.destinations
,會在系統呼叫 updatePeople
或 toDestinationChanged
函式時更新)。
如果希望每次有新項目發送到 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
的該行程式碼替換為在 ViewModel
的 suggestedDestinations
屬性上對 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()
// ...
}
執行應用程式時,系統就會顯示已填入的目的地清單。每次輕觸旅遊人數時,目的地也都會隨之變更。
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 秒後消失。
6. rememberCoroutineScope
在這個步驟中,我們要讓導覽匣運作。目前,如果您嘗試輕觸漢堡選單,也不會顯示任何資訊。
開啟 home/CraneHome.kt
檔案並查看 CraneHome
可組合函式,藉此瞭解需要開啟導覽匣的位置:openDrawer
回呼中。
我們在 CraneHome
中有一個包含 DrawerState
的 scaffoldState
。DrawerState
提供透過程式開啟及關閉導覽匣的方法。不過,如果您嘗試在 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()
}
}
)
}
}
如果您執行應用程式,只要輕觸漢堡選單圖示就會開啟導覽匣圖示。
LaunchedEffect 和 rememberCoroutineScope
由於我們必須觸發呼叫才能在組合以外的一般回呼中建立協同程式,因此在這種情況下無法使用 LaunchedEffect
。
回想一下先前使用 LaunchedEffect
的到達畫面步驟,您可以在不使用 LaunchedEffect
的情況下,使用 rememberCoroutineScope
並呼叫 scope.launch { delay(); onTimeout(); }
嗎?
您本來可以這樣做,也似乎可行,但這樣並不正確。如 Compose 中的思維說明文件中所述,Compose 可以隨時呼叫複合元件。在對該複合元件的呼叫進入組合時,LaunchedEffect
保證會執行連帶效果。如果在 LandingScreen
的主體中使用 rememberCoroutineScope
和 scope.launch
,則每次 Compose 呼叫 LandingScreen
時,不論該呼叫是否對組合產生連帶效果,系統都會執行協同程式。因此,您不僅會浪費資源,還無法在受管控的環境中執行這個連帶效果。
7. 建立狀態容器
您發現了嗎?只要輕觸「Choose Destination」,您就能編輯欄位,並根據搜尋輸入內容篩選城市。您可能也會注意到,每次修改「Choose Destination」時,文字樣式都會隨之改變。
開啟 base/EditableUserInput.kt
檔案。CraneEditableUserInput
有狀態的可組合函式會採用部分參數,例如 hint
和 caption
,後者會對應圖示旁的選用文字。舉例來說,搜尋目的地時,系統會顯示 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
}
該類別應具備以下特徵:
text
是String
類型的可變動狀態,就像在CraneEditableUserInput
一樣。請務必使用mutableStateOf
,讓 Compose 追蹤值的變更,並在有變化時重組。text
是var
,可讓您直接從類別外部變更。- 該類別將
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,例如 listSaver
或 mapSaver
(用於存放要儲存在 List
或 Map
的值),減少需要編寫的程式碼數量,而不要為 EditableUserInputState
類別建立 Saver
的自訂實作內容。
建議您將 Saver
定義置於與其搭配使用的類別附近。由於需要使用靜態存取,讓我們在 companion object
中新增 EditableUserInputState
的 Saver
。在 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
來取代 text
和 isHint
,但不會僅用於 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)
}
}
}
我們之前已經用過 LaunchedEffect
和 rememberUpdatedState
,但上述程式碼還使用了新的 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
,系統就會在 lifecycle
或 mapView
發生變更時,移除觀察工具並再次將其新增至正確的 lifecycle
中。
完成剛才的變更後,MapView
一律會遵循目前 LifecycleOwner
的 lifecycle
,其行為也會像在 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() }
}
}
如果您執行應用程式,就會在顯示城市詳細資料之前,看到載入旋轉圖示如何出現。
10. derivedStateOf
接下來是最後一個要對 Crane 執行的改善項目:在捲動航班目的地清單時,一旦經過畫面上第一個元素,就顯示可捲動至頂端的按鈕。只要輕觸該按鈕,即可前往清單中的第一個元素。
開啟包含此程式碼的 base/ExploreSection.kt
檔案。ExploreSection
複合元件會對應到您在鷹架的背景中看到的內容。
按照影片中的行為來實作解決方案,對您來說應該不足為奇。不過,其中有個先前未介紹過的新 API,而此 API 在這個用途中格外重要,那就是 derivedStateOf
API。
當您想要的某個 Compose 衍生自另一個 State
的 State
,就會用到 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
複合元件的新程式碼。再來看看如何使用 rememberCoroutineScope
在 Button
的 onClick
回呼內,呼叫 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 (例如 LaunchedEffect
、rememberUpdatedState
、DisposableEffect
、produceState
和 derivedStateOf
),以及如何在 Jetpack Compose 中使用協同程式。
後續步驟
請查看 Compose 課程的其他程式碼研究室,以及 Crane 等其他程式碼範例。
說明文件
如需有關這些主題的更多資訊和指南,請參閱以下說明文件: