Jetpack Compose 的狀態概念說明

1. 事前準備

本程式碼研究室會說明在 Jetpack Compose 使用狀態的核心概念。在本文中,我們會展示應用程式的狀態如何決定 UI 的顯示內容、Compose 如何藉由不同的 API 在狀態變更後更新 UI、如何最佳化可組合函式的結構,以及如何在 Compose 使用 ViewModel 等內容。

必要條件

課程內容

  • 如何思考 Jetpack Compose UI 的狀態和事件。
  • Compose 如何使用狀態判斷畫面要顯示的元素。
  • 認識「狀態提升」。
  • 有狀態和無狀態可組合函式的運作方式。
  • Compose 如何透過 State<T> API 自動追蹤狀態。
  • 可組合函式中的記憶體和內部狀態運作方式:使用 rememberrememberSaveable API。
  • 如何使用清單和狀態:使用 mutableStateListOftoMutableStateList API。
  • 如何透過 Compose 使用 ViewModel

軟硬體需求

建議/非強制

建構項目

您將會實作簡單的 Wellness 應用程式:

775940a48311302b.png

這個應用程式提供兩項主要功能:

  • 追蹤水分攝取量的喝水計數器。
  • 記載一天健康任務的清單。

如果您在閱讀本程式碼研究室時需要更多支援,請參閱下列程式碼:

2. 做好準備

建立新的 Compose 專案

  1. 如果想建立新的 Compose 專案,請開啟 Android Studio。
  2. 如果您位於「Welcome to Android Studio」視窗,請按一下「Start a new Android Studio project」。如果您已開啟 Android Studio 專案,請從選單列中依序選取「File」>「New」>「New Project」
  3. 若是建立新專案,請從系統提供的範本中選取「Empty Activity」

新增專案

  1. 按一下「Next」,然後設定專案,命名為「BasicStateCodelab」

確定「minimumSdkVersion」選擇至少 API 級別 21,這是 Compose 支援的最低 API 版本。

選取「Empty Compose Activity」(空白 Compose 活動) 範本之後,Android Studio 就會在專案內為您設定以下項目:

  • 以可組合函式設定的 MainActivity 類別,可在螢幕上顯示部分文字。
  • AndroidManifest.xml 檔案,用來定義應用程式的權限、元件及自訂資源。
  • build.gradle.ktsapp/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.material3.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)
   )
}

我們會建立一個代表整個螢幕的可組合函式,其中會有兩個區段:喝水計數器和健康任務清單。現在先來新增計數器。

  1. 建立代表主畫面的檔案 WellnessScreen.kt,然後呼叫 WaterCounter 功能:
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   WaterCounter(modifier)
}
  1. 開啟 MainActivity.kt。移除 GreetingDefaultPreview 可組合函式。在活動 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.colorScheme.background
               ) {
                   WellnessScreen()
               }
           }
       }
   }
}
  1. 如果您在此時執行應用程式,就可以看到基本的喝水計數器畫面,並有硬式編碼的喝水杯數。

7ed1e6fbd94bff04.jpeg

WaterCounter 可組合函式的狀態是變數 count。但是靜態狀態不能修改,實在不怎麼好用。您可以新增 Button 增加計數,並追蹤一天喝了幾杯水。

任何會修改狀態的操作都稱作「事件」,這在下個章節會有更清楚的說明。

4. Compose 中的事件

我們說過,所謂狀態就是任何會隨著時間經過變動的值 (例如即時通訊應用程式收到的最新訊息),但是狀態為什麼會更新呢?在 Android 應用程式裡,狀態會依據事件而進行更新。

事件指的是應用程式內部或外部產生的輸入內容,例如:

  • 使用者和 UI 互動,例如按下按鈕。
  • 其他因素,例如感應器傳送新值或網路的回應。

應用程式的狀態描述要在 UI 顯示的內容,事件則是狀態變動的機制,會使 UI 內容改變。

事件會讓程式內的特定部分知道發生了某個情況。所有 Android 應用程式中都有核心 UI 更新迴圈,如下所示:

f415ca9336d83142.png

  • Event (事件) - 使用者或程式其他內容產生事件。
  • Update State (更新狀態) - 由事件處理常式變更 UI 使用的狀態。
  • Display State (顯示狀態) - 更新 UI 內容,顯示新的狀態。

想在 Compose 管理狀態,重點就是瞭解狀態和事件之間的互動方式。

現在,請新增一個增加杯數的按鈕,方便使用者修改狀態。

前往 WaterCounter 可組合函式並在我們的標籤 Text 下新增 ButtonColumn 可以協助您垂直對齊 TextButton 可組合函式。您可以把外部的邊框間距移到 Column 可組合函式裡面,然後在 Button 上方額外加入邊框間距,以便跟文字隔開。

Button 可組合函式會收到 onClick lambda 函式,也就是按下按鈕時所發生的事件。之後,您會看到更多 lambda 函式的範例。

count 變更為 var (而不是 val),讓值可以變動。

import androidx.compose.material3.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 需要在狀態變更後重新繪製螢幕 (也就是「重新組成」可組合函式)。您將在後續步驟中修正這個問題。

e4dfc3bef967e0a1.gif

5. 可組合函式中的記憶體

Compose 應用程式會藉由呼叫可組合函式將資料轉換為 UI。當用 Compose 建構的 UI 執行可組合函式時,我們把它稱作「組成。如果狀態變更,Compose 會以新的狀態重新執行受影響的可組合函式,進而建立更新過的 UI (稱為「重新組成)。Compose 也會注意每個可組合函式需要的資料,只重新組成資料有變的元件,略過不受影響的元件。

為了達到這個結果,Compose 必須瞭解要追蹤哪些狀態,才能在狀態收到更新時排定重新組成的時程。

Compose 有一種特殊的狀態追蹤系統,可以為任何讀取特定狀態的可組合函式排定重新組成時程。有了這個系統,Compose 可以精準地重新組成需要變更的可組合函式,而不需要變更整個 UI。其中的運作原理就是不只追蹤狀態的「寫入」(等於狀態變更),同時也追蹤「讀取」。

運用 StateMutableState 類型,讓 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 變更任何內容,系統就會自動排定時程,重新組成任何讀取 countvalue 的可組合函式。在本範例中,只要有人按下按鈕,WaterCounter 就會重新組成。

如果您現在執行應用程式,會再度發現什麼都沒有發生!

e4dfc3bef967e0a1.gif

重組的排程功能照常運作。但是當系統重新組成的時候,變數 count 會重新初始化為 0,所以我們需要在重新組成的過程中保留這個值。

我們可以藉由使用 remember 可組合項內嵌函式達到這個效果。在「初始組成」期間,remember 會計算出一個值並儲存在組成裡面,並且會持續在重新組成過程中保留這個值。

在可組合函式裡,通常會一起使用 remembermutableStateOf

也有其他幾種撰寫方式可以達到同樣的效果,請看 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 匯入項目後,不用每次都明確參照 MutableStatevalue 屬性即可間接讀取及變更 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 會使用者展示狀態 (以文字顯示目前的計數)。
  • 由使用者產生的事件會和現有狀態結合,產生新的狀態 (按下按鈕會為現有的計數加一)

現在可以使用計數器了!

a9d78ead2c8362b6.gif

6. 以狀態為準的 UI

Compose 是一種宣告式 UI 架構。我們不必在狀態變更時移除 UI 元件或變更元件是否可見,而是要說明 UI 在特定狀態條件下的樣子。系統呼叫重新組成並更新 UI 之後,元件可能會進入或退出組成。

7d3509d136280b6c.png

這種做法可以避免手動更新檢視畫面的繁複程序 (例如處理 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」按鈕,展開整個元件樹狀結構。

按一下右側畫面中的元素,即可前往相對應的樹狀結構元素。

677bc0a178670de8.png

如果您按下應用程式的「Add one」按鈕:

  • 計數會增加為 1 並變更狀態。
  • 系統會呼叫重新組成。
  • 系統會以新的元素重新組成畫面。

您現在使用 Android Studio 的版面配置檢查器工具查看元件樹狀結構時,也能看到 Text 可組合函式:

1f8e05f6497ec35f.png

狀態會決定 UI 當下展示的元素。

UI 的不同部分可以使用同一個狀態。修改 Button,使其在 count 到達 10 之前都是啟用狀態,然後在到達後停用 (表示已經達成當天的目標)。請用 Buttonenabled 參數達到此目標。

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    ...
        Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
    ...
}

執行應用程式。count 狀態的變化會決定是否要顯示 Text,以及 Button 是啟用或停用。

1a8f4095e384ba01.gif

7. 在組成內記憶內容

remember 會把物件儲存在組成內,當重新組成期間並未再度叫用呼叫 remember 的來源時,則會遺忘該物件。

為了在視覺上呈現這個行為,您將在應用程式內實作以下功能:如果使用者喝了至少一杯水,就顯示健康任務給使用者比照辦理,而使用者也能選擇關閉任務。因為可組合函式應該精簡而可供重複利用,請建立新的可組合函式並命名為 WellnessTaskItem,讓這個可組合函式將收到的字串當做參數,並按照這個參數顯示健康任務,以及一個「關閉」圖示按鈕。

建立新檔案 WellnessTaskItem.kt 並加入以下程式碼。本程式碼研究室後續步驟中將會用到這個可組合函式。

import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.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 看起來會像這樣:

6e8b72a529e8dedd.png

為了讓應用程式更優質、功能更完善,請更新 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")
       }
   }
}

使用 WellnessTaskItemonClose 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")
           }
       }
   }
}

執行應用程式時,畫面會顯示初始狀態:

元件圖表的樹狀結構,顯示應用程式的初始狀態,計數為 0

右側顯示的是簡化過的元件樹狀結構,可以幫助您深入瞭解狀態變更時發生的情況。系統會記住 countshowTask 等值。

您現在可以在應用程式內操作以下步驟:

  • 按下「Add one」(加一) 按鈕。這樣做將會增加 count 的數量 (並會發生重新組成),並開始顯示 WellnessTaskItem 和計數器 Text

元件圖表樹狀結構,顯示狀態變更,當使用者按下「Add one」按鈕後,系統會顯示提示文字和提及喝水杯數的文字。

865af0485f205c28.png

  • 按下 WellnessTaskItem 元件的「X」,這會再度引發重組作業。showTask 現在為 false,表示系統不再顯示 WellnessTaskItem

元件圖表樹狀結構,顯示使用者按下關閉按鈕後,任務可組合函式便會消失。

82b5dadce9cca927.png

  • 按下「Add one」按鈕 (再度進行重組)。如果您加入更多喝水杯數,showTask 會在下次重新組成時記得您已經關閉了 WellnessTaskItem

  • 按下「Clear water count」按鈕,將 count 重設為 0 並啟動重組。Text 會顯示 count,系統不會叫用任何 WellnessTaskItem 相關程式碼並讓這些程式碼退出組成。

ae993e6ddc0d654a.png

  • 因為系統並未叫用呼叫記憶 showTask 的程式碼區塊,因此會遺忘 showTask。您已經回到第一步了。

  • 按下「Add one」按鈕,讓 count 大於 0 (重組)。

7624eed0848a145c.png

  • 系統會再度顯示 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 並消失。

2c1134ad78e4b68a.gif

當您變更語言、切換深色和淺色模式,或變更任何會導致 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) }
        ...
}

執行應用程式,然後嘗試變更一些設定。您應該會看到系統正確儲存計數器。

bf2e1634eff47697.gif

重新建立活動只是 rememberSaveable 的其中一種用途。我們會在之後編輯清單時說明另一種用途。

請根據應用程式的狀態和使用者體驗需求考慮要使用 remember 還是 rememberSaveable

9. 狀態提升

使用 remember 儲存物件的可組合函式含有內部狀態,因此稱為有狀態。這種做法在呼叫端不需要控制狀態的情況下很有用,不必自行管理狀態也能使用。不過,含有內部狀態的組件比較不適合重複使用,且難以測試

未保留任何狀態的可組合函式稱為無狀態可組合函式。如果想建立無狀態可組合函式,最簡單的方法就是使用狀態提升。

Compose 中的狀態提升,是一種將狀態移往可組合函式的呼叫端,使可組合函式變成無狀態的模式。在 Jetpack Compose 中進行狀態提升的一般模式,是將狀態變數替換成兩個參數:

  • value: 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)
}

太棒了!您已經把 countStatelessCounter「提升」StatefulCounter

您可以把這個項目插入應用程式,並用 StatefulCounter 更新 WellnessScreen

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   StatefulCounter(modifier)
}

綜上所述,狀態提升的確有其實用之處。我們會進一步探討不同的程式編寫方式,以便說明狀態提升的一些相關優點,您不需要把以下程式碼片段貼到您的應用程式裡

  1. 您現在可以重複使用無狀態可組合函式了。請看以下範例。

為了計算喝水和果汁的杯數,請記住 waterCountjuiceCount,但使同一個 StatelessCounter 可組合函式顯示兩種不同的獨立狀態。

@Composable
fun StatefulCounter() {
    var waterCount by remember { mutableStateOf(0) }

    var juiceCount by remember { mutableStateOf(0) }

    StatelessCounter(waterCount, { waterCount++ })
    StatelessCounter(juiceCount, { juiceCount++ })
}

8211bd9e0a4c5db2.png

當修改 juiceCount 之後,StatefulCounter 便會進行重組。在重組期間,Compose 會找出讀取 juiceCount 的函式,然後只觸發這些函式的重組程序。

2cb0dcdbe75dcfbf.png

當使用者輕觸增加 juiceCount 數量時,StatefulCounter 和讀取 juiceCountStatelessCounter 會重新組成。不過讀取 waterCountStatelessCounter 不會重組。

7fe6ee3d2886abd0.png

  1. 有狀態可組合函式可以為多個可組合函式提供相同的狀態
@Composable
fun StatefulCounter() {
   var count by remember { mutableStateOf(0) }

   StatelessCounter(count, { count++ })
   AnotherStatelessMethod(count, { count *= 2 })
}

在本範例中,如果 StatelessCounterAnotherStatelessMethod 更新計數,則所有內容都會重新組成,這是本來就預期會出現的行為。

因為狀態在提升後是可以共用的,因此務必只傳遞可組合函式需要的狀態,避免發生不必要的重組,這樣之後要重複使用就更容易了。

如果想進一步瞭解狀態和狀態提升,請參閱 Compose 狀態說明文件

10. 使用清單

接下來,我們要新增應用程式的第二個功能,也就是健康任務清單。您可以對清單項目進行兩種操作:

  • 勾選清單項目並標示為任務完成。
  • 從清單中移除不想完成的任務。

設定

  1. 我們先來修改清單項目。您可以重複使用「在組成內記憶內容」章節的 WellnessTaskItem,更新其內容並加入 Checkbox。請務必提升 checked 狀態和 onCheckedChange 回呼,讓函式成為無狀態。

a0f8724cfd33cb10.png

本節的 WellnessTaskItem 可組合函式應如下所示:

import androidx.compose.material3.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")
        }
    }
}
  1. 在相同檔案中加入一個有狀態的 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,
   )
}
  1. 建立檔案 WellnessTask.kt,以便模組化含有一組 ID 和一個標籤的「任務」。把這個檔案定義為資料類別
data class WellnessTask(val id: Int, val label: String)
  1. 為任務清單本身建立一個名為 WellnessTasksList.kt 的新檔案,然後加入可以產生假資料的方法:
fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

請注意,如果這是真正的應用程式,您就需要從資料層擷取資料。

  1. 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)
        }
    }
}
  1. 把清單加入 WellnessScreen。使用 Column 垂直對齊清單和現有的計數器。
import androidx.compose.foundation.layout.Column

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()
       WellnessTasksList()
   }
}
  1. 現在您可以執行應用程式並試用看看!您應該可以勾選任務了,但是沒有辦法刪除。您將在後續章節實作此功能。

f9cbc49c960fd24c.gif

還原 LazyList 內的項目狀態

讓我們仔細看看 WellnessTaskItem 可組合函式裡面的一些內容。

checkedState 屬於各個 WellnessTaskItem 可組合函式,彼此互無關聯,就像私人變數一樣。當 checkedState 變更時,只有 WellnessTaskItem 的執行個體會重新組成,而非 LazyColumn 內的所有 WellnessTaskItem

請按照以下步驟實際操作看看:

  1. 勾選清單頂端的任何元素 (例如元素 1、2)。
  2. 捲動到清單底部,讓這些元素位於畫面外。
  3. 捲動回到頂端之前勾選的項目處。
  4. 您可以看到這些元素已經取消勾選。

正如您所看到的問題,一旦有項目退出組成,系統就會遺忘之前記憶的狀態。當您捲動 LazyColumn 上的項目,到看不見這些項目時,這些項目就會完全退出組成。

a68b5473354d92df.gif

該如何修正這個問題?請再次使用 rememberSaveable。您的狀態將透過已儲存的執行個體狀態機制,在活動或程序重建期間繼續保留。多虧了 rememberSaveableLazyList 搭配運作的成果,項目在離開組成後也能繼續保留。

只需要將有狀態 WellnessTaskItem 中的 remember 替換為 rememberSaveable,就大功告成了:

import androidx.compose.runtime.saveable.rememberSaveable

var checkedState by rememberSaveable { mutableStateOf(false) }

85796fb49cf5dd16.gif

Compose 中的常見模式

請注意 LazyColumn 的實作內容:

@Composable
fun LazyColumn(
...
    state: LazyListState = rememberLazyListState(),
...

可組合函式 rememberLazyListState 會使用 rememberSaveable 為清單建立初始狀態。在重新建立活動時,系統會維持捲軸的狀態,您不必特意撰寫任何程式碼。

許多應用程式都必須回應並監聽捲軸的位置、項目版面配置變更,以及其他和清單狀態相關的活動。LazyColumnLazyRow 這類 Lazy 元件可以藉由提升 LazyListState 來支援這種用途。如果您想進一步瞭解這種模式,請參閱清單狀態說明文件

在內建可組合函式中,讓狀態參數使用公開 rememberX 函式提供的預設值是很常見的模式。您可以在 BottomSheetScaffold 裡看到另一個例子,這個例子使用 rememberBottomSheetScaffoldState 提升狀態。

11. 可觀察的 MutableList

接下來,如果想新增從清單移除任務的行為,首先就是要讓清單可以變動內容。

例如 ArrayList<T>mutableListOf, 這類可變動物件在這邊無法發揮效果。這些類型不會通知 Compose 清單項目有變並排定 UI 重新組成時程。您必須使用其他的 API。

您必須建立可由 Compose 觀察的 MutableList 例項。這種架構可以讓 Compose 追蹤變更,從而在新增或移除清單項目時重組 UI。

首先,先定義可觀察的 MutableList。您可以利用擴充功能函式 toMutableStateList(),從初始的可變動或不可變動 Collection (例如 List) 建立可觀察的 MutableList

您也可以使用工廠方法 mutableStateListOf 建立可觀察的 MutableList,然後加入元素做為初始狀態。

  1. 開啟 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") }
  1. 藉由移除清單的預設值修改 WellnessTasksList 可組合函式,因為清單已經提升到螢幕層級。新增 lambda 函式參數 onCloseTask (接收要刪除的 WellnessTask)。把 onCloseTask 傳遞至 WellnessTaskItem

您還必須變更一項內容。items 方法會收到 key 參數。根據預設,每個項目的狀態都會與項目在清單中的位置對應。

在可變動清單的資料集內容有變時,這樣就會發生問題,因為變更位置的項目就等同於喪失任何已記憶的狀態。

您可以使用每項 WellnessTaskItemid 當做每個項目的鍵,就能輕鬆解決問題。

如果想進一步瞭解清單項目鍵,請看說明文件。

WellnessTasksList 看起來會像這樣:

@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) })
       }
   }
}
  1. 修改 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 重新組成畫面。

47f4a64c7e9a5083.png

如果您嘗試使用 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 邏輯和商業邏輯與 UI 狀態分開,並將 UI 狀態遷移至 ViewModel。

現在我們把 UI 狀態 (也就是清單) 遷移到 ViewModel,然後開始擷取商業邏輯到 ViewModel 中。

  1. 建立檔案 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") }
  1. 我們可以藉由呼叫 viewModel() 函式透過任何可組合函式存取這個 ViewModel。

如要使用這個函式,請開啟 app/build.gradle.kts 檔案、加入以下程式庫,然後在 Android Studio 同步處理新的依附元件:

implementation("androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}")

使用 Android Studio Giraffe 時,請採用 2.6.2 版本。若想檢查程式庫的最新版本,請前往這個頁面

  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 管理所有狀態,可以讓程式碼更簡潔,也更容易測試。

  1. 首先,修改 WellnessTask 模型類別,用來儲存勾選狀態,並把預設值設為 false。
data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)
  1. 在 ViewModel 中實作 changeTaskChecked 方法,接收要用勾選狀態的新值來修改的工作。
class WellnessViewModel : ViewModel() {
   ...
   fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
       _tasks.find { it.id == item.id }?.let { task ->
           task.checked = checked
       }
}
  1. 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)
           }
       )
   }
}
  1. 開啟 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) }
           )
       }
   }
}
  1. 清理 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")
       }
   }
}
  1. 執行應用程式,然後嘗試勾選任何任務。您會發現目前還無法順利勾選任務。

1d08ebcade1b9302.gif

這是因為 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 在建構函式內接收預設值為 falseinitialChecked 變數,這樣一來,我們就能用工廠方法 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)
}

大功告成!這個解決方案有效,所有變更內容都能在重組和設定變更後繼續保留!

e7cc030cd7e8b66f.gif

測試

現在商業邏輯已經重構為 ViewModel,而不是在可組合函式裡搭配使用,這樣也讓單元測試更容易進行了。

您可以使用檢測設備測試確認 Compose 程式碼和 UI 狀態是否都能正常運作。您不妨進行程式碼研究室的「在 Compose 進行測試」,瞭解如何測試 Compose UI。

13. 恭喜

太棒了!您已經成功完成本程式碼研究室,並學會 Jetpack Compose 應用程式的基礎 API 如何使用狀態了!

您已經瞭解如何思考狀態和事件的概念,以便在 Compose 擷取無狀態的可組合函式,以及 Compose 如何使用狀態更新變更 UI。

後續步驟

請參閱 Compose 課程中的其他程式碼研究室。

範例應用程式

  • JetNews 可以展示本程式碼研究室說明的最佳做法。

其他說明文件

參考 API