# Android筆記–Jetpack Compose入門(五)狀態管理 在 Jetpack Compose 中,介面的更新是透過不斷重新執行 <code>Composable 函式</code>來達成的,因此,如果在 Compose 函式中定義了一個變數,==每當資料或畫面更新,這個變數就會被重新賦值,資料就消失了==。 這時就==需要 <code>remember</code> 來幫忙「記住」變數的值==,使它在同一個 <code>Composition 生命週期</code>內不會因為重組而被重設。 但光是「記住」還不夠,Compose 還需要知道「這個值改變了,請重新繪製畫面」,因此通常==會搭配 <code>mutableStateOf()</code> 建立可觀察的狀態物件==。 不過,<code>remember</code> 只能撐過一般的重組,==一旦遇到螢幕旋轉或系統回收資源,記憶就會消失;這時就需要換用 <code>rememberSaveable</code>==,它會將狀態資料保存到可序列化的儲存機制中,讓 UI 在重建後也能恢復到先前的狀態。 --- <code>remember</code> 與 <code>mutableStateOf()</code> --- 在 Compose 中,只要資料有變化,畫面就會自動重新組合,因而「遺忘」原本的變數值,所以我們需要使用 <code>remember</code> 來進行記憶。 當我們使用 <code>remember</code> 時,通常會搭配 <code>mutableStateOf()</code> 使用,<code>mutableStateOf()</code> 是[觀察者模式](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/DesignPattern_ObserverPattern)的實作,當 <code>muteableStateof()</code> 中的資料內容發生變動時,會主動發出訊號通知 Compose 重新繪製 UI >[!Warning]不使用 <code>remember</code> 的錯誤範例 ><details><summary>展開</summary> ><div> > >--- >```kotlin= >//不使用Remember的錯誤範例 >@Composable >fun Counter() { > var count = 0 > Button(onClick = { count++ }) { > Text("Clicked $count times") > } >} >``` >會發現不管怎麼按,都是顯示「Clicked 0 times」,這是因為每次點擊按鈕時,畫面會重新組合,count 又重新變成 0。 > ></div> ></details> >[!Tip]使用 <code>remember</code> 和 <code>mutableStateOf()</code> 的正確作法 ><details><summary>展開</summary> ><div> > >--- >```kotlin= >//使用Remember的正確範例 >@Composable >fun Counter() { > var count by remember { mutableStateOf(0) } // 加入了Remember > Button(onClick = { count++ }) { > Text("Clicked $count times") > } >} >``` >- <code>mutableStateOf()</code>(可變狀態容器): 將任何型別的普通值,包成 Compose 可觀察、可驅動 UI 更新的 <code>MutableState&lt;T&gt;</code>,也就是將一個變數變為"[Composable](https://hackmd.io/5timxP8GSIatbH1sknAXIg?view=#:~:text=%E8%88%87%E5%82%B3%E9%81%94%E8%A8%8A%E6%81%AF%E3%80%82-,%40Composable,-%E2%80%93%20%E5%AE%9A%E7%BE%A9%E5%8F%AF%E7%B5%84%E5%90%88)"。 > >|不使用remember | 有使用remember | >| -------- | -------- | >| ![1742390008061](https://hackmd.io/_uploads/ByNDdFt3yg.gif =60%x) | ![1742390074324](https://hackmd.io/_uploads/SkEd_Kt2Jx.gif =60%x) | > > ></div> ></details> </br> <font size=5>&lt; <u><code>remember</code> 官方推薦寫法</u> &gt;</font> ```kotlin= var value by remember { mutableStateOf(defaultValue) } // 最常用 val value = remember { mutableStateOf(defaultValue) } // 次常用 val (value, setValue) = remember { mutableStateOf(defaultValue) } ``` <details id="byRemember"><summary><code>var value by remember { mutableStateOf(defaultValue) }</code></summary> <div> --- Kotlin 的[委託語法(by)](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/kotlin_by),會自動把 <code>getter</code>/<code>setter</code> 對應到 <code>mutableState.value</code>,讓你可以直接使用變量,而不需要每次都訪問 <code>.value</code>,==最常用的寫法==。 - 使用方式: - 讀取值:直接使用 <code>value</code> - 修改值:直接賦值 <code>value = newValue</code> 或 <code>value++</code> - 特點: - 語法最簡潔 - 使用起來就像一個普通變量 - 大多數情況下的首選方式 </div> </details> <details><summary><code>val value = remember { mutableStateOf(defaultValue) }</code></summary> <div> --- 直接存儲 <code>MutableState&lt;T&gt;</code> 對象,是最原始的狀態物件寫法。 - 使用方式: - 讀取值:`mutableState.value` - 修改值:`mutableState.value = newValue` - 特點: - 明確顯示這是一個狀態對象 - 需要每次都使用 `.value` 來訪問實際的值 </div> </details> <details><summary><code>val (value, setValue) = remember { mutableStateOf(defaultValue) }</code></summary> <div> --- 使用了 <code>Kotlin 解構宣告</code>,將狀態對象分解為值和設置值的函數。 - 使用方式: - 讀取值:直接使用 <code>value</code> - 修改值:調用函數 <code>setValue(newValue)</code> - 特點: - 將讀取和修改操作分開 - 類似於 React 中的 <code>useState Hook</code> - 提供了一個明確的設置函數 </div> </details> - 參考:[可組合項中的狀態](https://developer.android.com/develop/ui/compose/state?hl=zh-tw#state-in-composables) </br> <font size=5>&lt; <u><code>keyed remember</code> -- <code>remember</code> 的參數</u> &gt;</font> - 一般的 <code>remember {...}</code> 只會在 Compose 執行時,之後即使發生重組,也會持續回傳同一個已記住的物件,不會重新建立(重複使用同一個 <code>lambda 區塊</code>)。 - 如果是 <code>remember(key) { ... }</code> 則會持續監視 <code>key</code> 的值,若 key 的值發生變化,則執行初始化邏輯並更新緩存</code>(建一個全新的 <code>lambda 區塊</code>)。此方法能有效優化效能,防止不必要的重複計算。 - <code>keyed remember</code> 和 <code>mutableStateOf()</code> 乍看之下很像,都是在變動時進行更新 - <code>keyed remember</code> 重新建立的是整個 <code>remember 的 lambda 區塊</code>(「記憶」本身被銷毀並重新初始化)。 - <code>mutableStateOf()</code> 則只是更新容器內部的數值(記憶本身沒變,只是內容物變了,並觸發「讀取過它的畫面」重新繪製)。 ```kotlin= remember(key) { ... } ``` ```kotlin= // 範例 @Composable fun SkeletonExample(id: Int) { // 步驟 1:定義 Key (這裡的 id 就是 Key) val cachedValue = remember(id) { // 步驟 2:將 key 值作為參數放入 remember(key){...} 中 "Current ID is $id" // 只有當 id(key 值) 變動時,這行才會重新執行 } // 步驟 3:使用資料 Text(text = cachedValue) } ``` </br> <font size=5>&lt; <u><code>remember</code> 和 <code>mutableStateOf()</code> 的工作原理</u> &gt;</font> <details style="border-left: 4px solid #1565C0;background-color: #0D1B2A;padding: 12px 16px;margin: 10px 0;border-radius: 4px;font-family: Arial, sans-serif;"><summary><code>remember</code> 的工作原理</summary> <div> --- - 按序記錄: 當 Compose 執行時,會像在「座次表」上填空位一樣,==依照 <code>remember</code> 在程式碼中出現的先後順序,把資料一個接一個存入格子==,這個格子稱為「<code>插槽表(slot table)</code>」。 - 依序存取: 每次<code>重組(Recomposition)</code>時,Compose 會拿著游標以==依序推進的方式開始往後走訪 <code>slot table</code>==,預期在相同的順序位置,讀到上一次存進去的資料。 - 位置依賴: 由於系統是「認位置不認人」,如果把 <code>remember</code> 放在不確定的結構中(例如 <code>if 條件式</code>)導致它消失,游標就會找錯格子、讀到錯誤的資料,因此必須維持穩定的執行順序。 </div> </details> <details style="border-left: 4px solid #1565C0;background-color: #0D1B2A;padding: 12px 16px;margin: 10px 0;border-radius: 4px;font-family: Arial, sans-serif;"><summary><code>mutableStateOf()</code>的工作原理</summary> <div> --- - 讀取時註冊:當 <code>Composable 函式</code>「讀取」這個狀態的值時,Compose 會默默記下:「喔!這個 UI 元件依賴於這筆資料。」 - 寫入時通知:當你「修改」這個狀態的值時,它會立刻發出訊號給 <code>Compose Runtime</code>。 - 觸發重組:Compose 收到訊號後,會精準地通知「讀取過這個值的 Composable 區塊」,叫它們重新執行一遍(Recomposition)。這讓 Compose 能夠進行精確的局部刷新,效能更好。 </div> </details> --- RememberSaveable(基本) --- 當螢幕旋轉或系統回收資源,<code>remember</code> 的記憶就會消失,此時就需要改用 <code>rememberSaveable</code>。 <code>rememberSaveable</code> 和 <code>remember</code> 一樣,是 <code>Jetpack Compose</code> 中用來保存 UI 狀態的一種工具 - 不同的是,<code>rememberSaveable</code> 會將資料存進 [Bundle](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/android_bundleClass),以便在 <code>Activity</code>/<code>Fragment</code> 重構後能自動還原先前的狀態 - 預設情況下,<code>rememberSaveable</code> 只支援基本型別(因為是由 [Bundle](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/android_bundleClass) 來實現) - 如果需要保存複雜型態,就必須自訂一個 Saver,讓 Compose 知道如何序列化和反序列化該資料。 ```kotlin= // 範例 // rememberSaveable 的使用和 remember 一樣 @Composable fun InputField() { // 使用 rememberSaveable 保留文字輸入,即使螢幕旋轉也不會丟失 var text by rememberSaveable { mutableStateOf("") } TextField( value = text, onValueChange = { text = it }, label = { Text("請輸入內容") } ) } ``` --- <code>rememberSaveable</code>-- 進階(自訂型別) --- 預設的情況下,<code>rememberSaveable</code> 只支援基本型別(參見: [Bundle](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/android_bundleClass)),但如果要保存自訂的或複雜的型態,就必須定義一個 「Saver」。 - <code>Saver</code> 負責告訴系統:「如何把這個物件轉成可存的格式,以及如何還原」(類似於 [Room 中的 @TypeConverter](https://hackmd.io/lza-34jBT-2zEnqDsiU1VA#%E2%96%BCTypeConverter%E3%80%81TypeConverters)) - <code>Saver</code> 介面的實作,須提供 <code>save</code> 與 <code>restore</code> 兩個 <code>lambda</code> - <code>save</code>: 負責提供如何將資料轉換成可序列化的格式,格式為 <code>Saver&lt;T, S&gt;</code>,<code>T</code> 代表你原本的資料型態,<code>S</code> 代表序列化後的型態(例如 <code>Map&lt;String, Any&gt;</code>)。 - <code>restore</code>: 負責提供如何從序列化的資料中還原成原本的資料型態。 <font size=5>&lt; 範例 &gt;</font> <details><summary>步驟1. 定義一個自訂型別 <code>DataClass</code></summary> <div> [甚麼是 DataClass ?](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/kotlin_DataClass) ```kotlin= // 自訂型別 data class Point(val x: Int, val y: Int) ``` </div> </details> <details><summary>步驟2. 定義一個 <code>Saver</code> 來描述資料的轉換格式</summary> <div> ```kotlin= val PointSaver = Saver<Point, List<Int>>( save = { point -> listOf(point.x, point.y) }, // 將 Point Class 轉換為 List Class restore = { list -> Point(list[0], list[1]) } // 將 List Class 轉換為 Point Class ) ``` </div> </details> <details><summary>步驟3. 使用</summary> <div> ```kotlin= @Composable fun MyScreen() { // 螢幕旋轉後,point 的值依然保留 var point by rememberSaveable(stateSaver = PointSaver) { mutableStateOf(Point(0, 0)) } Button(onClick = { point = Point(point.x + 1, point.y + 1) }) { Text("目前座標:(${point.x}, ${point.y})") } } ``` </div> </details> --- <details><summary>範例 2</summary> <div> <font size=4><u>1. 定義一個 DataClass</u></font> ```kotlin= data class PointDataClass(val x: Int, val y: Int) ``` 假設我們有一個自訂的Dataclass`PointDataClass`,因為 `PointDataClass` 並非基本型別,我們需要自訂一個 Saver 來告訴 Compose 如何保存與還原它。 <font size=4><u>2. 定義 Saver</u></font> 定義一個 Saver,將 PointDataClass 轉換成一個包含 x 與 y 的 List,再從 List 還原回 Point。建議另外開一個File檔案以[object宣告](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/kotlin_object)製作成工具類別。 ```kotlin= object PointDataClassUtils{ // 自訂 Saver:將 PointDataClass 轉換成 List<Int> 儲存,並從 List 還原 val pointSaver = Saver<PointDataClass, List<Int>>( save = {point -> listOf(point.x, point.y) }, restore = { list -> PointDataClass(list[0], list[1])} ) } ``` <font size=4><u>3. 使用 rememberSaveable 搭配自訂 Saver</u></font> 在 Composable 中使用 `rememberSaveable`,並指定剛剛定義的 `PointSaver`,這樣即使螢幕旋轉或配置變更,狀態也能被正確保存與還原。 ```kotlin= @Composable fun PointScreen(){ //在 Composable 中使用 rememberSaveable,並指定剛剛定義在PointDataClassUtils中的 PointSaver,這樣即使螢幕旋轉或配置變更,狀態也能被正確保存與還原 //使用自定義的 PointDataClassUtils中的pointSaver 來儲存和恢復 Point 狀態 var point by rememberSaveable(stateSaver = PointDataClassUtils.pointSaver) { mutableStateOf(PointDataClass(10, 20)) } Column(modifier = Modifier.padding(16.dp)) { Text(text = "x: ${point.x}, y: ${point.y}") Spacer(modifier = Modifier.height(8.dp)) //用 copy() 函數來創建一個新的 Point 物件,並遞增 x 座標 Button(onClick = { point = point.copy(x = point.x + 1) }) { Text("Increase x") } } } ``` ![1742875208820](https://hackmd.io/_uploads/SkcWss1aJg.gif =60%x) </div> </details> <details><summary>範例 3</summary> <div> <font size=4><u>1. DataClass</u></font> ```kotlin= data class UserDataClass(val name: String, val age: Int) ``` <font size=4><u>2. Saver</u></font> ```kotlin= object UserDataClassUtil{ val UserSaver = Saver<UserDataClass, Map<String, Any>>( save = { user -> // 將 User 轉換成 Map<String, Any> mapOf("name" to user.name, "age" to user.age) }, restore = { map -> // 從 Map 還原出 User // 由於 map 的值型態是 Any,因此需要做類型轉換 val name = map["name"] as? String ?: "" val age = map["age"] as? Int ?: 0 UserDataClass(name, age) } ) } ``` <font size=4><u>3. 使用</u></font> ```kotlin= @Composable fun UserProfile() { // 使用 rememberSaveable 並指定自訂的 UserSaver var user by rememberSaveable(stateSaver = UserDataClassUtil.UserSaver) { mutableStateOf(UserDataClass("Alice", 25)) } // 假設這裡有一個 UI 來展示 user 的內容 Column { Text("Name: ${user.name}") Text("Age: ${user.age}") Button(onClick = { // 例如,點擊後更新 user 的年齡 user = user.copy(age = user.age + 1) }) { Text("Increase Age") } } } ``` <font size=5>成果:</font> ![1742957932294](https://hackmd.io/_uploads/H1oap1ZTye.gif) </div> </details> >[!Note]如果你的自訂型態能夠實作 [Parcelable](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/android_ParcelableObject)(例如透過 @Parcelize) >那麼 <code>rememberSaveable</code> 也能直接保存它,而不需要自訂Saver。 >參考:[Android筆記–Parcelable實作產生器(@Parcelize)](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/android_Parcelize) --- [狀態提升](https://developer.android.com/develop/ui/compose/state?hl=zh-tw#state-hoisting) --- 使用 <code>remember</code> 儲存物件的可組合項會建立內部狀態,使該可組合項「有狀態」。這種做法在呼叫端不需要控制狀態的情況下很有用。 不過,具有內部狀態的可組合項往往不易重複使用,也更難測試。 因此,在實務上我們會根據 <code>SOLID</code> 中的 [單一職責原則(SRP)](https://hackmd.io/QWzetRmyRoKh2JPe43CDWA?view#:~:text=Single%20Responsibility%20Principle),我們將其拆分為「持有狀態的責任」和「顯示 UI 的責任」分開,並將保有狀態的責任向上交付給上層元件。 >[!Warning]不狀態提升: 狀態在內部,有狀態 ><details><summary>範例</summary> ><div> > >```kotlin= >// HelloContent 自己管理狀態 → 外部無法控制 >@Composable >fun HelloContent() { > // 狀態鎖在這裡,外部看不到也摸不到 > var name by rememberSaveable { mutableStateOf("") } > > OutlinedTextField( > value = name, > onValueChange = { name = it } // 自己改自己 > ) >} >``` > ></div> ></details> >[!Tip]狀態提升: 狀態在上層元件,將「持有狀態的責任(包含實務邏輯)」和「顯示的責任分開」 ><details><summary>負責持有狀態及業務邏輯</summary> ><div> > >```kotlin= >// HelloScreen:狀態的擁有者(有狀態) >@Composable >fun HelloScreen() { > var name by rememberSaveable { mutableStateOf("") } // 狀態在這裡 > val handleNameChange: (String) -> Unit = { newName -> ... } // 邏輯寫在這裡 > > // 呼叫負責處理顯示的子元件,並依據需求將狀態及業務邏輯作為參數傳入 > HelloContent( > name = name, > onNameChange = handleNameChange > ) >} >``` ></div> ></details> > > ><details><summary>負責顯示</summary> ><div> > >```kotlin= >// HelloContent:純粹顯示,不擁有狀態(無狀態) >@Composable >fun HelloContent( > name: String, // 只接收值 > onNameChange: (String) -> Unit // 只回報事件,不自己改 >) { > OutlinedTextField( > value = name, > onValueChange = onNameChange // 使用者輸入 → 通知上層 > ) >} >``` ></div> ></details> --- <code>mutableStateOf()</code> 與 ViewModel 協作 --- 在 <code>Jetpack Compose</code> 的狀態管理架構中,除了利用 <code>remember</code> 與 <code>rememberSaveable</code> 處理 UI 區域狀態外,當我們結合 <code>ViewModel</code> 與 <code>mutableStateOf()</code> 時,通常會採用 『[後備屬性 (Backing Properties)](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/kotlin_BackingProperty)』 的技巧,以確保狀態的封裝性與安全性。 - 須注意,在 ViewModel 中存取 <code>MutableStateOf&lt;T&gt;</code> 的值時,需使用 <code>.value</code>(就算使用了 [by 語法](#byRemember) 也是一樣) - 如果存取的不是基本類型而是自訂的 <code>DataClass</code>,通常不會直接修改內部欄位,而是透過 [DataClass 的 copy() 方法](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/kotlin_DataClass#copy) 建立一份新的物件,只更新需要變更的欄位,再重新指定給 <code>.value</code> ```kotlin= class CounterViewModel : ViewModel() { // 1. 內部私有狀態:可讀寫 (Mutable),負責實際的數據儲存 // 目的:封裝數據,防止外部 UI 組件直接修改 private val _count = mutableStateOf(0) // 2. 外部公開屬性:唯讀 (State),僅供 UI 觀察 // 目的:利用 Kotlin 的 get() 將類型轉為不可變的 State,確保單向資料流 val count: State<Int> = _count // 3. 定義明確的行為 (Action) // 目的:這是修改狀態的唯一入口,方便後續加入邏輯(如:最大值檢查) fun increment() { _count.value++ } } ``` - <code>State<T></code> 是 <code>MutableStateOf</code> 的唯讀型態 --- <code>remember</code>、<code>rememberSaveable</code>、<code>ViewModel</code> 的選擇 --- <code>remember</code>、<code>rememberSaveable</code>、<code>ViewMode</code> 都可以「保留狀態」,但他們的責任範圍和保存時機不一樣。 ```bash= 這個狀態需要在「螢幕旋轉或系統回收」後還原嗎? ↙ No ↘ Yes 這個狀態需要 這個狀態需要跨 跨 Composable 共享嗎? Composable 共享嗎? ↙ No ↘ Yes ↙ No ↘ Yes remember ViewModel rememberSaveable ViewModel + SavedStateHandle ``` | | `remember` | `rememberSaveable` | `ViewModel` | | --- | --- | --- | --- | | 撐過重組 | ✅ | ✅ | ✅ | | 撐過螢幕旋轉 | ❌ | ✅ | ✅ | | 撐過系統回收 | ❌ | ✅ | ❌ | | 跨 Composable 共享 | ❌ | ❌ | ✅ | | 適合放商業邏輯 | ❌ | ❌ | ✅ | --- Q&A --- <details><summary>既然 <code>rememberSaveable</code> 已經把資料存進 <code>Bundle</code> 中了,那我們可以直接拿來用在不同 <code>Activity</code> 之間的傳遞嗎?</summary> <div> --- 不行,雖然 <code>rememberSaveable</code> 確實使用了 <code>Bundle</code> 機制,但它的 <code>Bundle</code> 是私有的,它的用途只有一個:在同一個 <code>Composable</code> 的生命週期事件後還原資料。 </div> </details> --- 上一篇:[Android筆記–Jetpack Compose入門(四)Modifier](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/Android_JetpackCompose_4) 下一篇:[Android筆記–Jetpack Compose入門(六)Coil](https://hackmd.io/@9YAtszqXS2OLNZOrLY_-Jg/Android_JetpackCompose_6) --- GitHub: - Remember的示範: [MyPratice_JetpackCompose_Remember_Sample](https://github.com/PudCheetah/MyPratice_JetpackCompose_Remember_Sample) - RememberSaveable示範1:[MyPratice_JetpackCompose_RememberSaveable_Sample_1](https://github.com/PudCheetah/MyPratice_JetpackCompose_RememberSaveable_Sample_1) --- 參考資料: --- - [Jetpact Compose状态管理remember简单理解](https://juejin.cn/post/7000137483220418590) - [狀態與 Jetpack Compose ](https://developer.android.com/develop/ui/compose/state?hl=zh-tw) -