1. 事前準備
本程式碼研究室會說明在 Jetpack Compose 使用狀態的核心概念。在本文中,我們會展示應用程式的狀態如何決定 UI 的顯示內容、Compose 如何藉由不同的 API 在狀態變更後更新 UI、如何最佳化可組合函式的結構,以及如何在 Compose 使用 ViewModel 等內容。
必要條件
- 瞭解 Kotlin 語法。
- 瞭解 Compose 基礎知識 (您可以從 Jetpack Compose 教學課程開始入門)。
- 瞭解架構元件
ViewModel
的基礎知識。
課程內容
- 如何思考 Jetpack Compose UI 的狀態和事件。
- Compose 如何使用狀態判斷畫面要顯示的元素。
- 認識「狀態提升」。
- 有狀態和無狀態可組合函式的運作方式。
- Compose 如何透過
State<T>
API 自動追蹤狀態。 - 可組合函式中的記憶體和內部狀態運作方式:使用
remember
和rememberSaveable
API。 - 如何使用清單和狀態:使用
mutableStateListOf
和toMutableStateList
API。 - 如何透過 Compose 使用
ViewModel
。
軟硬體需求
建議/非強制
- 閱讀「Compose 的程式設計概念」。
- 建議您先練習操作 Jetpack Compose 的基本程式碼研究室,再開始進行本程式碼研究室。我們將在本程式碼研究室中完整複習狀態的觀念。
建構項目
您將會實作簡單的 Wellness 應用程式:
這個應用程式提供兩項主要功能:
- 追蹤水分攝取量的喝水計數器。
- 記載一天健康任務的清單。
如果您在閱讀本程式碼研究室時需要更多支援,請參閱下列程式碼:
2. 做好準備
建立新的 Compose 專案
- 如果想建立新的 Compose 專案,請開啟 Android Studio。
- 如果您位於「Welcome to Android Studio」視窗,請按一下「Start a new Android Studio project」。如果您已開啟 Android Studio 專案,請從選單列中依序選取「File」>「New」>「New Project」。
- 如果是新專案,請從系統提供的範本中選取「Empty Activity」。
- 按一下「Next」,然後設定專案,命名為「BasicStateCodelab」。
確定「minimumSdkVersion」選擇至少 API 級別 21,這是 Compose 支援的最低 API 版本。
選取「Empty Compose Activity」(空白 Compose 活動) 範本之後,Android Studio 就會在專案內為您設定以下項目:
- 以可組合函式設定的
MainActivity
類別,可在螢幕上顯示部分文字。 AndroidManifest.xml
檔案,用來定義應用程式的權限、元件及自訂資源。build.gradle.kts
和app/build.gradle.kts
檔案,內含 Compose 所需的選項和依附元件。
本程式碼研究室的解決方案
您可以從 GitHub 取得 BasicStateCodelab
的解決方案程式碼:
$ git clone https://github.com/android/codelab-android-compose
或者,您也可以將存放區下載為 ZIP 檔案。
您可以在 BasicStateCodelab
專案內找到解決方案程式碼。建議您依自己的步調按照程式碼研究室的說明逐步操作,當您需要協助的時候,請參考解決方案。在本程式碼研究室的學習過程中,我們會為您提供要新增到專案的程式碼片段。
3. Compose 中的狀態
應用程式中的「狀態」指的是任何可能隨時間變化的值。這個定義非常廣泛,從 Room 資料庫到某類別中的變數等所有項目都包含在內。
所有 Android 應用程式都會向使用者顯示狀態。以下列舉幾種 Android 應用程式中的狀態範例:
- 最近透過即時通訊應用程式收到的訊息。
- 使用者的個人資料相片。
- 項目清單的捲動位置。
讓我們開始撰寫 Wellness 應用程式吧。
為了讓過程簡單明瞭,在本程式碼研究室中:
- 您可以把所有 Kotlin 檔案都放到
app
模組的根層級com.codelabs.basicstatecodelab
套件中。不過在正式版應用程式中,所有檔案都應該按照邏輯架構放在子套件內。 - 程式碼片段內嵌的所有字串都將是硬式編碼。在實際的應用程式中,您應該將這些字串設為
strings.xml
檔案的字串資源,然後再用 Compose 的stringResource
API 進行參照。
第一個需要建構的功能是喝水計數器,負責計算一天喝幾杯水。
建立一個名為 WaterCounter
的可組合函式,內有可以顯示杯數的 Text
可組合函式。杯數應該儲存在名為 count
的值內,目前這個值可以使用硬式編碼。
建立新的檔案 WaterCounter.kt
,使其擁有 WaterCounter
可組合函式,如下所示:
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
val count = 0
Text(
text = "You've had $count glasses.",
modifier = modifier.padding(16.dp)
)
}
我們會建立一個代表整個螢幕的可組合函式,其中會有兩個區段:喝水計數器和健康任務清單。現在先來新增計數器。
- 建立代表主畫面的檔案
WellnessScreen.kt
,然後呼叫WaterCounter
功能:
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
WaterCounter(modifier)
}
- 開啟
MainActivity.kt
。移除Greeting
和DefaultPreview
可組合函式。在活動setContent
區塊內呼叫剛建立的WellnessScreen
可組合函式,如下所示:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicStateCodelabTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
WellnessScreen()
}
}
}
}
}
- 如果您在此時執行應用程式,就可以看到基本的喝水計數器畫面,並有硬式編碼的喝水杯數。
WaterCounter
可組合函式的狀態是變數 count
。但是靜態狀態不能修改,實在不怎麼好用。您可以新增 Button
增加計數,並追蹤一天喝了幾杯水。
任何會修改狀態的操作都稱作「事件」,這在下個章節會有更清楚的說明。
4. Compose 中的事件
我們說過,所謂狀態就是任何會隨著時間經過變動的值 (例如即時通訊應用程式收到的最新訊息),但是狀態為什麼會更新呢?在 Android 應用程式裡,狀態會依據事件而進行更新。
事件指的是應用程式內部或外部產生的輸入內容,例如:
- 使用者和 UI 互動,例如按下按鈕。
- 其他因素,例如感應器傳送新值或網路的回應。
應用程式的狀態描述要在 UI 顯示的內容,事件則是狀態變動的機制,會使 UI 內容改變。
事件會讓程式內的特定部分知道發生了某個情況。所有 Android 應用程式中都有核心 UI 更新迴圈,如下所示:
- Event (事件) - 使用者或程式其他內容產生事件。
- Update State (更新狀態) - 由事件處理常式變更 UI 使用的狀態。
- Display State (顯示狀態) - 更新 UI 內容,顯示新的狀態。
想在 Compose 管理狀態,重點就是瞭解狀態和事件之間的互動方式。
現在,請新增一個增加杯數的按鈕,方便使用者修改狀態。
前往 WaterCounter
可組合函式並在我們的標籤 Text
下新增 Button
。Column
可以協助您垂直對齊 Text
和 Button
可組合函式。您可以把外部的邊框間距移到 Column
可組合函式裡面,然後在 Button
上方額外加入邊框間距,以便跟文字隔開。
Button
可組合函式會收到 onClick
lambda 函式,也就是按下按鈕時所發生的事件。之後,您會看到更多 lambda 函式的範例。
將 count
變更為 var
(而不是 val
),讓值可以變動。
import androidx.compose.material.Button
import androidx.compose.foundation.layout.Column
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count = 0
Text("You've had $count glasses.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
如果您執行應用程式並按下按鈕,會發現沒有任何變化。設定不同的 count
變數值不會讓 Compose 偵測到「狀態變更」,因此不會發生任何變化。原因是因為您並未告訴 Compose 需要在狀態變更後重新繪製螢幕 (也就是「重新組成」可組合函式)。您將在後續步驟中修正這個問題。
5. 可組合函式中的記憶體
Compose 應用程式會藉由呼叫可組合函式將資料轉換為 UI。當用 Compose 建構的 UI 執行可組合函式時,我們把它稱作「組成」。如果狀態變更,Compose 會以新的狀態重新執行受影響的可組合函式,進而建立更新過的 UI (稱為「重新組成」)。Compose 也會注意每個可組合函式需要的資料,只重新組成資料有變的元件,略過不受影響的元件。
為了達到這個結果,Compose 必須瞭解要追蹤哪些狀態,才能在狀態收到更新時排定重新組成的時程。
Compose 有一種特殊的狀態追蹤系統,可以為任何讀取特定狀態的可組合函式排定重新組成時程。有了這個系統,Compose 可以精準地重新組成需要變更的可組合函式,而不需要變更整個 UI。其中的運作原理就是不只追蹤狀態的「寫入」(等於狀態變更),同時也追蹤「讀取」。
運用 State
和 MutableState
類型,讓 Compose 可以觀察狀態。
Compose 會追蹤每個可讀取狀態 value
屬性的可組合函式,並在 value
變更時觸發重新組成。您可以使用 mutableStateOf
函式建立 Compose 能觀察到的 MutableState
。該函式會接收封裝在 State
物件中設為參數的初始值,使 value
變為可觀察狀態。
更新 WaterCounter
可組合函式,讓 count
使用 mutableStateOf
API 並以 0
為初始值。當 mutableStateOf
回傳 MutableState
類型時,您可以更新 value
讓狀態更新,此時 Compose 便會重新組成讀取 value
的函式。
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
// Changes to count are now tracked by Compose
val count: MutableState<Int> = mutableStateOf(0)
Text("You've had ${count.value} glasses.")
Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
如之前說明的一樣,當 count
變更任何內容,系統就會自動排定時程,重新組成任何讀取 count
的 value
的可組合函式。在本範例中,只要有人按下按鈕,WaterCounter
就會重新組成。
如果您現在執行應用程式,會再度發現什麼都沒有發生!
重組的排程功能照常運作。但是當系統重新組成的時候,變數 count
會重新初始化為 0,所以我們需要在重新組成的過程中保留這個值。
我們可以藉由使用 remember
可組合項內嵌函式達到這個效果。在「初始組成」期間,remember
會計算出一個值並儲存在組成裡面,並且會持續在重新組成過程中保留這個值。
在可組合函式裡,通常會一起使用 remember
和 mutableStateOf
。
也有其他幾種撰寫方式可以達到同樣的效果,請看 Compose 狀態說明文件。
修改 WaterCounter
,並在 mutableStateOf
呼叫前後加上 remember
內嵌可組合函式:
import androidx.compose.runtime.remember
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
val count: MutableState<Int> = remember { mutableStateOf(0) }
Text("You've had ${count.value} glasses.")
Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
我們也可以使用 Kotlin 的「委派屬性」簡化 count
的用法。
您可以使用 by 關鍵字把 count
定義為 var。新增委派的 getter 和 setter 匯入項目後,不用每次都明確參照 MutableState
的 value
屬性即可間接讀取及變更 count
。
現在,WaterCounter
看起來會像這樣:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
Text("You've had $count glasses.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
挑選時,請考量哪一種語法能讓您撰寫的可組合函式程式碼最簡單易讀。
讓我們看看到目前為止完成的內容:
- 定義隨時間經過記住的變數,名為
count
。 - 建立文字顯示內容,以此告訴使用者我們記住的數字。
- 新增按鈕,隨著有人按下按鈕,我們記住的數字就會逐漸增加。
這些內容和使用者形成了一個資料流意見回饋循環:
- UI 會使用者展示狀態 (以文字顯示目前的計數)。
- 由使用者產生的事件會和現有狀態結合,產生新的狀態 (按下按鈕會為現有的計數加一)
現在可以使用計數器了!
6. 以狀態為準的 UI
Compose 是一種宣告式 UI 架構。我們不必在狀態變更時移除 UI 元件或變更元件是否可見,而是要說明 UI 在特定狀態條件下的樣子。系統呼叫重新組成並更新 UI 之後,元件可能會進入或退出組成。
這種做法可以避免手動更新檢視畫面的繁複程序 (例如處理 View 系統的方法)。這樣做也能降低發生錯誤的可能性,因為您不用怕忘記按照新狀態更新檢視畫面,系統會自動幫您處理。
如果在初始組成或重新組成期間呼叫可組合函式,我們就會說這個可組合函式在組成裡「出現」。未呼叫的可組合函式則稱為「不在」組成內。未呼叫的原因包括函式呼叫位置在 if 陳述式內,且未符合條件。
詳情請參閱「可組合函式的生命週期」一文。
組成的輸出內容是描述 UI 的樹狀結構。
您可以使用 Android Studio 的版面配置檢查器工具查看由 Compose 產生的應用程式版面配置,我們會在接下來的章節操作這部分。
為了示範,請修改程式碼,以便根據狀態顯示 UI。開啟 WaterCounter
,然後在 count
大於 0 的時候顯示 Text
:
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
// This text is present if the button has been clicked
// at least once; absent otherwise
Text("You've had $count glasses.")
}
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
執行應用程式,然後依序前往「Tools」>「Layout Inspector」,開啟 Android Studio 的版面配置檢查器工具。
您會看到一個分割畫面:左側是元件的樹狀結構,而右側是應用程式的預覽畫面。
輕觸左側畫面的根元素 BasicStateCodelabTheme
,以便導覽樹狀結構。按一下「Expand all」按鈕,展開整個元件樹狀結構。
按一下右側畫面中的元素,即可前往相對應的樹狀結構元素。
如果您按下應用程式的「Add one」按鈕:
- 計數會增加為 1 並變更狀態。
- 系統會呼叫重新組成。
- 系統會以新的元素重新組成畫面。
您現在使用 Android Studio 的版面配置檢查器工具查看元件樹狀結構時,也能看到 Text
可組合函式:
狀態會決定 UI 當下展示的元素。
UI 的不同部分可以使用同一個狀態。修改 Button
,使其在 count
到達 10 之前都是啟用狀態,然後在到達後停用 (表示已經達成當天的目標)。請用 Button
的 enabled
參數達到此目標。
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
...
Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
...
}
執行應用程式。count
狀態的變化會決定是否要顯示 Text
,以及 Button
是啟用或停用。
7. 在組成內記憶內容
remember
會把物件儲存在組成內,當重新組成期間並未再度叫用呼叫 remember
的來源時,則會遺忘該物件。
為了在視覺上呈現這個行為,您將在應用程式內實作以下功能:如果使用者喝了至少一杯水,就顯示健康任務給使用者比照辦理,而使用者也能選擇關閉任務。因為可組合函式應該精簡而可供重複利用,請建立新的可組合函式並命名為 WellnessTaskItem
,讓這個可組合函式將收到的字串當做參數,並按照這個參數顯示健康任務,以及一個「關閉」圖示按鈕。
建立新檔案 WellnessTaskItem.kt
並加入以下程式碼。本程式碼研究室後續步驟中將會用到這個可組合函式。
import androidx.compose.foundation.layout.Row
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding
@Composable
fun WellnessTaskItem(
taskName: String,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f).padding(start = 16.dp),
text = taskName
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
WellnessTaskItem
函式會收到任務說明,以及 onClose
lambda 函式 (就像內建的 Button
可組合函式收到 onClick
一樣)。
WellnessTaskItem
看起來會像這樣:
為了讓應用程式更優質、功能更完善,請更新 WaterCounter
以便在 count
> 0 時顯示 WellnessTaskItem
。
當 count
大於 0 時,定義變數 showTask
藉此定義是否要顯示 WellnessTaskItem
,並初始化為 true。
新增 if 陳述式,在 showTask
為 true 時顯示 WellnessTaskItem
。運用在上一章節學到的 API 確定重新組成後依然可以保留 showTask
。
@Composable
fun WaterCounter() {
Column(modifier = Modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
var showTask by remember { mutableStateOf(true) }
if (showTask) {
WellnessTaskItem(
onClose = { },
taskName = "Have you taken your 15 minute walk today?"
)
}
Text("You've had $count glasses.")
}
Button(onClick = { count++ }, enabled = count < 10) {
Text("Add one")
}
}
}
使用 WellnessTaskItem
的 onClose
lambda 函式,這樣當使用者按下「X」按鈕時,變數 showTask
會變更為 false
,系統也會停止顯示任務。
...
WellnessTaskItem(
onClose = { showTask = false },
taskName = "Have you taken your 15 minute walk today?"
)
...
接下來請新增一個 Button
,寫上文字「Clear water count」(清除喝水計數),然後把它放在「Add one」(加一)Button
旁邊。您可以使用 Row
協助對齊這兩個按鈕。也可以在 Row
加入邊框間距。當有人按下「Clear water count」(清除喝水計數) 的按鈕後,變數 count
就會重設為 0。
完成的 WaterCounter
可組合函式應會如下所示:
import androidx.compose.foundation.layout.Row
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
var showTask by remember { mutableStateOf(true) }
if (showTask) {
WellnessTaskItem(
onClose = { showTask = false },
taskName = "Have you taken your 15 minute walk today?"
)
}
Text("You've had $count glasses.")
}
Row(Modifier.padding(top = 8.dp)) {
Button(onClick = { count++ }, enabled = count < 10) {
Text("Add one")
}
Button(
onClick = { count = 0 },
Modifier.padding(start = 8.dp)) {
Text("Clear water count")
}
}
}
}
執行應用程式時,畫面會顯示初始狀態:
右側顯示的是簡化過的元件樹狀結構,可以幫助您深入瞭解狀態變更時發生的情況。系統會記住 count
和 showTask
等值。
您現在可以在應用程式內操作以下步驟:
- 按下「Add one」(加一) 按鈕。這樣做將會增加
count
的數量 (並會發生重新組成),並開始顯示WellnessTaskItem
和計數器Text
。
- 按下
WellnessTaskItem
元件的「X」,這會再度引發重組作業。showTask
現在為 false,表示系統不再顯示WellnessTaskItem
。
- 按下「Add one」按鈕 (再度進行重組)。如果您加入更多喝水杯數,
showTask
會在下次重新組成時記得您已經關閉了WellnessTaskItem
。
- 按下「Clear water count」按鈕,將
count
重設為 0 並啟動重組。Text
會顯示count
,系統不會叫用任何WellnessTaskItem
相關程式碼並讓這些程式碼退出組成。
- 因為系統並未叫用呼叫記憶
showTask
的程式碼區塊,因此會遺忘showTask
。您已經回到第一步了。
- 按下「Add one」按鈕,讓
count
大於 0 (重組)。
- 系統會再度顯示
WellnessTaskItem
可組合函式,因為showTask
在上述步驟退出組成時,系統已遺忘此項目先前的值。
如果我們要求在 count
歸零之後繼續保留 showTask
,讓保留時間超過 remember
允許的上限 (也就是系統並未在重組期間叫用呼叫 remember
的程式碼區塊),會發生什麼情形?我們將在後面的章節探討如何解決這類問題,並提供更多參考範例。
現在您已經瞭解 UI 和狀態如何在退出組成時重設,請清除您的程式碼,然後回到本章節一開始的 WaterCounter
:
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
Text("You've had $count glasses.")
}
Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}
8. 在 Compose 內還原狀態
執行應用程式,在計數器多加幾杯水,然後旋轉裝置。確定您已經開啟裝置的自動旋轉設定。
在設定變更 (在本例為螢幕方向變更) 之後,系統會重新建立活動,因此會遺忘之前儲存的狀態:計數器會變回 0 並消失。
當您變更語言、切換深色和淺色模式,或變更任何會導致 Android 重新建立執行中活動的設定時,都會發生這種情形。
雖然 remember
可以協助您在重新組成之後保留狀態,但是無法在設定變更後繼續保留狀態。此時,您必須使用 rememberSaveable
而不是 remember
。
rememberSaveable
會自動儲存可儲存在 Bundle
中的任何值。其他值可以在自訂儲存器物件中傳送。如果想進一步瞭解如何在 Compose 內還原狀態,請看說明文件。
將 WaterCounter
內的 remember
置換為 rememberSaveable
:
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
...
var count by rememberSaveable { mutableStateOf(0) }
...
}
執行應用程式,然後嘗試變更一些設定。您應該會看到系統正確儲存計數器。
重新建立活動只是 rememberSaveable
的其中一種用途。我們會在之後編輯清單時說明另一種用途。
請根據應用程式的狀態和使用者體驗需求考慮要使用 remember
還是 rememberSaveable
。
9. 狀態提升
使用 remember
儲存物件的組件內含內部狀態,使該組件「有狀態」。這種做法在呼叫端不需要控制狀態的情況下可能會非常有用,不必自行管理狀態也能使用。不過,含有內部狀態的組件比較不適合重複使用,且難以測試。
未保留任何狀態的可組合函式稱為無狀態可組合函式。如果想建立無狀態可組合函式,最簡單的方法就是使用狀態提升。
Compose 中的狀態提升,是一種將狀態移往可組合函式的呼叫端,使可組合函式變成無狀態的模式。在 Jetpack Compose 中進行狀態提升的一般模式,是將狀態變數替換成兩個參數:
- 值: T - 目前要顯示的值
- onValueChange: (T) -> Unit - 要求變更值的事件,其中 T 是預定使用的新值
這個值代表任何可以被修改的狀態。
以這種方式提升的狀態具備下列重要屬性:
- 單一可靠資料來源:採用移動而非複製的方式處理狀態,可確保可靠資料來源只有一個。這有助於避免錯誤。
- 可共用:提升過的狀態可讓多個組件共用。
- 可攔截:無狀態組件的呼叫端在變更狀態前可決定忽略或修改事件。
- 已分離:無狀態可組合函式的狀態可以儲存在任何地方,例如 ViewModel 中。
請嘗試為 WaterCounter
實作這個項目,以便利用以上各種屬性。
有狀態與無狀態
如果某個可組合函式的所有狀態都可以擷取出去,最終行程的可組合函式就稱為無狀態。
把 WaterCounter
可組合函式分割成兩個部分,藉此進行重構:有狀態和無狀態計數器。
StatelessCounter
的角色是顯示 count
,並在 count
增加時呼叫函式。為了提供這個功能,請按照上方說明的模式傳遞狀態 count
(做為可組合函式的參數),以及狀態需要增量時所要呼叫的 lambda (onIncrement
)。StatelessCounter
看起來會像這樣:
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
if (count > 0) {
Text("You've had $count glasses.")
}
Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}
StatefulCounter
擁有狀態。這表示該項目擁有 count
狀態,並在呼叫 StatelessCounter
函式時修改狀態。
@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
var count by rememberSaveable { mutableStateOf(0) }
StatelessCounter(count, { count++ }, modifier)
}
太棒了!您已經把 count
從 StatelessCounter
「提升」為 StatefulCounter
。
您可以把這個項目插入應用程式,並用 StatefulCounter
更新 WellnessScreen
:
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
StatefulCounter(modifier)
}
綜上所述,狀態提升的確有其實用之處。我們會進一步探討不同的程式編寫方式,以便說明狀態提升的一些相關優點,您不需要把以下程式碼片段貼到您的應用程式裡。
- 您現在可以重複使用無狀態可組合函式了。請看以下範例。
為了計算喝水和果汁的杯數,您記憶了 waterCount
和 juiceCount
,但是使用範例 StatelessCounter
可組合函式顯示兩種不同且獨立的狀態。
@Composable
fun StatefulCounter() {
var waterCount by remember { mutableStateOf(0) }
var juiceCount by remember { mutableStateOf(0) }
StatelessCounter(waterCount, { waterCount++ })
StatelessCounter(juiceCount, { juiceCount++ })
}
當修改 juiceCount
之後,StatefulCounter
便會進行重組。在重組期間,Compose 會找出讀取 juiceCount
的函式,然後只觸發這些函式的重組程序。
當使用者輕觸增加 juiceCount
數量時,StatefulCounter
和讀取 juiceCount
的 StatelessCounter
會重新組成。不過讀取 waterCount
的 StatelessCounter
不會重組。
- 有狀態可組合函式可以為多個可組合函式提供相同的狀態。
@Composable
fun StatefulCounter() {
var count by remember { mutableStateOf(0) }
StatelessCounter(count, { count++ })
AnotherStatelessMethod(count, { count *= 2 })
}
在本範例中,如果 StatelessCounter
或 AnotherStatelessMethod
更新計數,則所有內容都會重新組成,這是本來就預期會出現的行為。
因為狀態在提升後是可以共用的,因此務必只傳遞可組合函式需要的狀態,避免發生不必要的重組,這樣之後要重複使用就更容易了。
如果想進一步瞭解狀態和狀態提升,請參閱 Compose 狀態說明文件。
10. 使用清單
接下來,我們要新增應用程式的第二個功能,也就是健康任務清單。您可以對清單項目進行兩種操作:
- 勾選清單項目並標示為任務完成。
- 從清單中移除不想完成的任務。
設定
- 我們先來修改清單項目。您可以重複使用「在組成內記憶內容」章節的
WellnessTaskItem
,更新其內容並加入Checkbox
。請務必提升checked
狀態和onCheckedChange
回呼,讓函式成為無狀態。
本節的 WellnessTaskItem
可組合函式應如下所示:
import androidx.compose.material.Checkbox
@Composable
fun WellnessTaskItem(
taskName: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
text = taskName
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
- 在相同檔案中加入一個有狀態的
WellnessTaskItem
可組合函式,藉此定義狀態變數checkedState
並把這個變數傳遞給同名的無狀態方法。目前您還不用處理onClose
,可以傳遞空白的 lambda 函式。
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
var checkedState by remember { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = {}, // we will implement this later!
modifier = modifier,
)
}
- 建立檔案
WellnessTask.kt
,以便模組化含有一組 ID 和一個標籤的「任務」。把這個檔案定義為資料類別。
data class WellnessTask(val id: Int, val label: String)
- 為任務清單本身建立一個名為
WellnessTasksList.kt
的新檔案,然後加入可以產生假資料的方法:
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
請注意,如果這是真正的應用程式,您就需要從資料層擷取資料。
- 在
WellnessTasksList.kt
中新增可以建立清單的可組合函式。定義LazyColumn
以及剛才建立的清單方法內的項目。如果您需要相關說明,請參閱清單說明文件。
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.runtime.remember
@Composable
fun WellnessTasksList(
modifier: Modifier = Modifier,
list: List<WellnessTask> = remember { getWellnessTasks() }
) {
LazyColumn(
modifier = modifier
) {
items(list) { task ->
WellnessTaskItem(taskName = task.label)
}
}
}
- 把清單加入
WellnessScreen
。使用Column
垂直對齊清單和現有的計數器。
import androidx.compose.foundation.layout.Column
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList()
}
}
- 現在您可以執行應用程式並試用看看!您應該可以勾選任務了,但是沒有辦法刪除。您將在後續章節實作此功能。
還原 LazyList 內的項目狀態
讓我們仔細看看 WellnessTaskItem
可組合函式裡面的一些內容。
checkedState
屬於各個 WellnessTaskItem
可組合函式,彼此互無關聯,就像私人變數一樣。當 checkedState
變更時,只有 WellnessTaskItem
的執行個體會重新組成,而非 LazyColumn
內的所有 WellnessTaskItem
。
請按照以下步驟實際操作看看:
- 勾選清單頂端的任何元素 (例如元素 1、2)。
- 捲動到清單底部,讓這些元素位於畫面外。
- 捲動回到頂端之前勾選的項目處。
- 您可以看到這些元素已經取消勾選。
正如您所看到的問題,一旦有項目退出組成,系統就會遺忘之前記憶的狀態。當您捲動 LazyColumn
上的項目,到看不見這些項目時,這些項目就會完全退出組成。
該如何修正這個問題?請再次使用 rememberSaveable
。您的狀態將透過已儲存的執行個體狀態機制,在活動或程序重建期間繼續保留。多虧了 rememberSaveable
與 LazyList
搭配運作的成果,項目在離開組成後也能繼續保留。
只需要將有狀態 WellnessTaskItem
中的 remember
替換為 rememberSaveable
,就大功告成了:
import androidx.compose.runtime.saveable.rememberSaveable
var checkedState by rememberSaveable { mutableStateOf(false) }
Compose 中的常見模式
請注意 LazyColumn
的實作內容:
@Composable
fun LazyColumn(
...
state: LazyListState = rememberLazyListState(),
...
可組合函式 rememberLazyListState
會使用 rememberSaveable
為清單建立初始狀態。在重新建立活動時,系統會維持捲軸的狀態,您不必特意撰寫任何程式碼。
許多應用程式都必須回應並監聽捲軸的位置、項目版面配置變更,以及其他和清單狀態相關的活動。LazyColumn
或 LazyRow
這類 Lazy 元件可以藉由提升 LazyListState
來支援這種用途。如果您想進一步瞭解這種模式,請參閱清單狀態說明文件。
在內建可組合函式中,讓狀態參數使用公開 rememberX
函式提供的預設值是很常見的模式。您可以在 Scaffold
裡看到另一個例子,這個例子使用 rememberScaffoldState
提升狀態。
11. 可觀察的 MutableList
接下來,如果想新增從清單移除任務的行為,首先就是要讓清單可以變動內容。
例如 ArrayList<T>
或 mutableListOf,
這類可變動物件在這邊無法發揮效果。這些類型不會通知 Compose 清單項目有變並排定 UI 重新組成時程。您必須使用其他的 API。
您必須建立可由 Compose 觀察的 MutableList
例項。這種架構可以讓 Compose 追蹤變更,從而在新增或移除清單項目時重組 UI。
首先,先定義可觀察的 MutableList
。您可以利用擴充功能函式 toMutableStateList()
,從初始的可變動或不可變動 Collection
(例如 List
) 建立可觀察的 MutableList
。
您也可以使用工廠方法 mutableStateListOf
建立可觀察的 MutableList
,然後加入元素做為初始狀態。
- 開啟
WellnessScreen.kt
檔案把getWellnessTasks
方法移到這個檔案內,以便使用。先呼叫getWellnessTasks()
,然後使用您之前學到的擴充函式toMutableStateList
建立清單。
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
StatefulCounter()
val list = remember { getWellnessTasks().toMutableStateList() }
WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
}
}
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
- 藉由移除清單的預設值修改
WellnessTaskList
可組合函式,因為清單已經提升到螢幕層級。新增 lambda 函式參數onCloseTask
(接收要刪除的WellnessTask
)。把onCloseTask
傳遞至WellnessTaskItem
。
您還必須變更一項內容。items
方法會收到 key
參數。根據預設,每個項目的狀態都會與項目在清單中的位置對應。
在可變動清單的資料集內容有變時,這樣就會發生問題,因為變更位置的項目就等同於喪失任何已記憶的狀態。
您可以使用每項 WellnessTaskItem
的 id
當做每個項目的鍵,就能輕鬆解決問題。
如果想進一步瞭解清單項目鍵,請看說明文件。
WellnessTaskList
看起來會像這樣:
@Composable
fun WellnessTasksList(
list: List<WellnessTask>,
onCloseTask: (WellnessTask) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(modifier = modifier) {
items(
items = list,
key = { task -> task.id }
) { task ->
WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
}
}
}
- 修改
WellnessTaskItem
:加入onClose
lambda 函式當做有狀態WellnessTaskItem
的參數並進行呼叫。
@Composable
fun WellnessTaskItem(
taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
var checkedState by rememberSaveable { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = onClose,
modifier = modifier,
)
}
太棒了!這個功能已經完成,現在可以從清單中刪除項目了。
只要按下每一列的 X,事件往上移動並尋找擁有該狀態的清單,然後移除清單內的項目,並導致 Compose 重新組成畫面。
如果您嘗試使用 rememberSaveable()
在 WellnessScreen
裡面儲存清單,就會發生執行階段例外狀況:
這個錯誤說明您需要提供自訂儲存工具。但是,請勿使用 rememberSaveable
儲存需要長時間序列化或去序列化的大量資料或複雜的資料結構。
當使用活動的 onSaveInstanceState
時,也需要遵守一樣的規定,詳情請參閱儲存 UI 狀態說明文件。如果要這樣做,需要採用其他儲存機制。您可以參閱說明文件,瞭解其他的保留 UI 狀態方法。
接下來,我們要瞭解 ViewModel 在保留應用程式狀態方面扮演的角色。
12. ViewModel 中的狀態
畫面或 UI 狀態會說明畫面上要顯示的內容 (例如任務清單)。由於這個狀態含有應用程式資料,因此通常連結著該階層的其他層。
UI 狀態可以說明畫面尚要顯示的內容,而應用程式邏輯可以說明應用程式如何行動,以及如何回應狀態變更。邏輯有兩種類型:UI 行為或稱 UI 邏輯,以及商業邏輯。
- UI 邏輯和「如何顯示」畫面上的狀態變更有關 (例如導覽邏輯或顯示 Snackbar)。
- 商業邏輯則是「如何處理」狀態變更 (例如付款或儲存使用者的偏好設定)。這個邏輯通常位於商業或資料層,不會在 UI 層。
ViewModel 會提供 UI 狀態,並能存取應用程式其他層的商業邏輯。另外 ViewModel 也能在設定變更後繼續保留,所以生命週期比組成要長。ViewModel 可以遵循 Compose 內容主機的生命週期,這些 Compose 內容包括活動、片段,以及使用 Compose Navigation 時的導覽圖目的地。
如果想深入瞭解 UI 層架構,請參閱 UI 層說明文件。
遷移清單並移除方法
讓我們把 UI 狀態 (也就是清單) 遷移到 ViewModel,然後開始擷取商業邏輯。
- 建立檔案
WellnessViewModel.kt
,並加入 ViewModel 類別。
把「資料來源」getWellnessTasks()
移到 WellnessViewModel
。
透過和之前一樣的方法,利用 toMutableStateList
定義內部 _tasks
變數,然後以清單形式顯示 tasks
,這樣就無法透過 ViewModel 以外的方式加以變更。
實作簡單的 remove
功能,以便委任清單的內建移除功能。
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel
class WellnessViewModel : ViewModel() {
private val _tasks = getWellnessTasks().toMutableStateList()
val tasks: List<WellnessTask>
get() = _tasks
fun remove(item: WellnessTask) {
_tasks.remove(item)
}
}
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
- 我們可以藉由呼叫
viewModel()
函式透過任何可組合函式存取這個 ViewModel。
如要使用這個函式,請開啟 app/build.gradle.kts
檔案、加入以下程式庫,然後在 Android Studio 同步處理新的依附元件:
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}")
使用 Android Studio Flamingo 時,請採用 2.6.1
版本。若想檢查程式庫的最新版本,請前往這個頁面。
- 開啟
WellnessScreen
。呼叫viewModel()
,藉此例項化wellnessViewModel
ViewModel 並當做螢幕可組合函式的參數,以便在測試這個可組合函式時取代,並可在需要時進行提升。向WellnessTasksList
提供任務清單,並移除onCloseTask
lambda 的函式。
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun WellnessScreen(
modifier: Modifier = Modifier,
wellnessViewModel: WellnessViewModel = viewModel()
) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList(
list = wellnessViewModel.tasks,
onCloseTask = { task -> wellnessViewModel.remove(task) })
}
}
viewModel()
會傳回現有的 ViewModel
或在指定範圍內建立新的 ViewModel。只要範圍維持有效,系統就會保留 ViewModel 執行個體。例如,如果在 Activity 中使用可組合函式,viewModel()
會傳回相同的例項,直到 Activity 完成或程序結束為止。
大功告成!您已經把 ViewModel 和一部分狀態與螢幕的商業邏輯整合。由於狀態會保留在組成之外,並由 ViewModel 儲存,因此清單就可以在變更設定後繼續維持變更內容了。
ViewModel 無法在所有情況下自動維持應用程式的狀態 (例如系統終止程式)。如果想詳細瞭解如何維持應用程式的 UI 狀態,請參閱說明文件。
遷移勾選狀態
最後一項重構就是把勾選的狀態和邏輯遷移到 ViewModel。透過讓 ViewModel 管理所有狀態,可以讓程式碼更簡潔,也更容易測試。
- 首先,修改
WellnessTask
模型類別,用來儲存勾選狀態,並把預設值設為 false。
data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)
- 在 ViewModel 中實作
changeTaskChecked
方法,接收要用勾選狀態的新值來修改的工作。
class WellnessViewModel : ViewModel() {
...
fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
tasks.find { it.id == item.id }?.let { task ->
task.checked = checked
}
}
- 在
WellnessScreen
呼叫 ViewModel 的changeTaskChecked
方法,為清單的onCheckedTask
提供行為。函式現在應如下所示:
@Composable
fun WellnessScreen(
modifier: Modifier = Modifier,
wellnessViewModel: WellnessViewModel = viewModel()
) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList(
list = wellnessViewModel.tasks,
onCheckedTask = { task, checked ->
wellnessViewModel.changeTaskChecked(task, checked)
},
onCloseTask = { task ->
wellnessViewModel.remove(task)
}
)
}
}
- 開啟
WellnessTasksList
並加入onCheckedTask
lambda 函式參數,以便向下傳遞給WellnessTaskItem.
@Composable
fun WellnessTasksList(
list: List<WellnessTask>,
onCheckedTask: (WellnessTask, Boolean) -> Unit,
onCloseTask: (WellnessTask) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier
) {
items(
items = list,
key = { task -> task.id }
) { task ->
WellnessTaskItem(
taskName = task.label,
checked = task.checked,
onCheckedChange = { checked -> onCheckedTask(task, checked) },
onClose = { onCloseTask(task) }
)
}
}
}
- 清理
WellnessTaskItem.kt
檔案。我們不再需要有狀態的方法了,現在系統會把核取方塊狀態提升到清單層級。這個檔案只有以下這個可組合函式:
@Composable
fun WellnessTaskItem(
taskName: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
text = taskName
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
- 執行應用程式,然後嘗試勾選任何任務。您會發現目前還無法順利勾選任務。
這是因為 Compose 針對 MutableList
追蹤的變更內容,是與新增及移除元素相關。所以刪除功能可以正常運作。但是 Compose 不知道列項目的值有所變更 (在本例為 checkedState
),除非您指示 Compose 同時追蹤這些值。
解決方法有兩種:
- 變更資料類別
WellnessTask
,讓checkedState
變成MutableState<Boolean>
而不是Boolean
,讓 Compose 追蹤項目變更內容。 - 複製您要變動的項目,從清單中移除這個項目,然後重新把變動過的項目加入清單,讓 Compose 追蹤清單變更內容。
這兩種方法各有優缺點。舉例來說,根據您使用的清單實作方式不同,移除並讀取元素可能非常耗費資源。
建議您避免讓清單作業消耗太多資源,然後請讓 Compose 能觀察 checkedState
,這樣有助提升效能,也是 Compose 的慣用做法。
新的 WellnessTask
看起來可能會像這樣:
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
data class WellnessTask(val id: Int, val label: String, val checked: MutableState<Boolean> = mutableStateOf(false))
和您之前看到的一樣,您可以使用委派屬性,讓本範例的變數 checked
使用方式更加簡潔。
將 WellnessTask
變更為類別,而不是資料類別。讓 WellnessTask
在建構函式內接收預設值為 false
的 initialChecked
變數,這樣一來,我們就能用工廠方法 mutableStateOf
初始化 checked
變數,並採用 initialChecked
做為預設值。
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class WellnessTask(
val id: Int,
val label: String,
initialChecked: Boolean = false
) {
var checked by mutableStateOf(initialChecked)
}
大功告成!這個解決方案有效,所有變更內容都能在重組和設定變更後繼續保留!
測試
現在商業邏輯已經重構為 ViewModel,而不是在可組合函式裡搭配使用,這樣也讓單元測試更容易進行了。
您可以使用檢測設備測試確認 Compose 程式碼和 UI 狀態是否都能正常運作。您不妨進行程式碼研究室的「在 Compose 進行測試」,瞭解如何測試 Compose UI。
13. 恭喜
太棒了!您已經成功完成本程式碼研究室,並學會 Jetpack Compose 應用程式的基礎 API 如何使用狀態了!
您已經瞭解如何思考狀態和事件的概念,以便在 Compose 擷取無狀態的可組合函式,以及 Compose 如何使用狀態更新變更 UI。
後續步驟
請參閱 Compose 課程中的其他程式碼研究室。
範例應用程式
- JetNews 可以展示本程式碼研究室說明的最佳做法。
其他說明文件
- Compose 的程式設計概念
- 狀態和 Jetpack Compose
- Jetpack Compose 的單向資料流
- 在 Compose 中還原狀態
- ViewModel 總覽
- Compose 和其他程式庫