昨天整理 ViewModel 時提到一個很重要的差異:
Configuration Change
≠
Process Death
平常開發其實很常遇到「狀態要不要保留」這個問題,但如果只用一句:
畫面重建後資料要保留
其實不太夠。
因為 Android 裡的「消失再回來」有很多種情況,而不同情況下,還活著的東西完全不一樣。
所以今天想把這件事拆開來看。
先從 Fragment 開始。
Fragment 的生命週期和它的 View 生命週期其實不是完全一樣的。
例如使用 ViewBinding 時,很常看到:
private var _binding: FragmentDayBinding? = null
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
為什麼 onDestroyView() 要把 binding 清掉?
因為:
Fragment 還存在,不代表它原本的 View 還存在。
也就是可能出現:
Fragment 還活著
↓
View 被銷毀
↓
之後重新建立 View
所以如果把某個重要狀態只存在 TextView:
TextView 顯示 00:30
View 被重建後,新的 TextView 本身並不知道之前是 00:30。
這也是為什麼 UI 最好是:
State
↓
Render UI
而不是反過來把 UI 當成狀態本身。
TextView 顯示什麼應該是結果,不是真實資料唯一存在的地方。
再來是很常拿來說明 ViewModel 的例子:旋轉螢幕。
假設 Activity 因為 Configuration Change 被重新建立:
Old Activity
↓
destroy
ViewModel
↓
保留
New Activity
↓
重新取得同一個 ViewModel
這時候如果資料存在 ViewModel,就可以繼續使用。
所以像:
class SearchViewModel : ViewModel() {
val keyword = MutableLiveData<String>()
}
使用者輸入的 keyword 不會因為 Activity 單純重新建立就一定消失。
這也是 ViewModel 很重要的用途之一。
但這裡要特別注意:
ViewModel 能撐過 Configuration Change,不代表它是永久保存資料的地方。
Android App 執行時,本質上還是在一個 Process 裡。
ViewModel、Singleton、一般 Kotlin Object,這些東西都存在記憶體裡。
所以可能出現:
App 到背景
↓
系統需要回收資源
↓
Process 被殺掉
↓
記憶體裡的物件一起消失
這時:
ViewModel
Singleton
LiveData
一般變數
都不能因為「它原本活得很久」,就期待它還存在。
這點放回前幾天看的 TimeManager 就很有意思。
TimeManager 是 Singleton,所以在同一個 Process 還活著時:
Pomodoro
↓
TimeManager
↑
Stopwatch
大家可以取得同一個 instance。
但:
Singleton 是 Process 內只有一份,不是跨 Process Death 永遠存在。
如果 Process 沒了,Singleton 裡面的記憶體狀態一樣沒了。
所以「做成 Singleton」和「狀態可以被恢復」完全是兩回事。
這時就會輪到 SavedStateHandle。
它適合處理的是:
Process 被系統回收後,如果使用者回到這個畫面,有些必要的 UI 狀態希望可以恢復。
例如一個搜尋畫面:
keyword = "Android"
filter = "Newest"
selectedCategory = 3
這些資料體積不大,而且是重新建立畫面時很有價值的資訊。
就可以考慮:
class SearchViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
var keyword: String?
get() = savedStateHandle["keyword"]
set(value) {
savedStateHandle["keyword"] = value
}
}
如果 Process Death 後系統重新建立這個 destination,ViewModel 可以透過 SavedStateHandle 拿回之前保存的資料。
但這裡很容易又走到另一個極端:
那我把整個畫面的資料都放 SavedStateHandle 不就好了?
其實也不太對。
假設一個畫面的資料是:
搜尋條件
↓
呼叫 API
↓
取得 100 筆商品
↓
顯示畫面
Process Death 後,不一定需要保存完整 100 筆商品。
有時候真正需要的是:
搜尋條件
然後:
Process 被重新建立
↓
取得搜尋條件
↓
重新呼叫 API
↓
重新取得資料
這時候保存的是:
重新建立畫面所需要的最小資訊。
而不是把記憶體裡所有東西完整複製一份。
這個概念其實跟資料來源有關。
如果資料原本就能從:
Database
API
Repository
重新取得,那 ViewModel 或 SavedStateHandle 不一定需要自己永久保存完整資料。
這又跟 Process Death 不完全一樣。
如果使用者已經完成一筆番茄鐘紀錄:
Pomodoro 完成
↓
建立紀錄
↓
Room
那這筆資料的需求就是:
明天重新打開 App,我還是要看到。
這時候不管是:
ViewModel
SavedStateHandle
Singleton
都不是最適合的最終保存位置。
它應該進真正的持久化儲存,例如 Room。
所以幾種情況放在一起看,大概會變成:
View 被重建
→ State 重新 Render UI
Configuration Change
→ ViewModel 可以保留狀態
Process Death
→ 必要的狀態需要可以恢復
App 下次重新開啟仍要存在
→ Persistent Storage
這幾層解決的是不同問題。
如果回到現在這個 Project,Timer 還有另一個值得思考的地方。
假設 Stopwatch 已經跑了:
00:37:42
如果只是不斷:
elapsedMillis += 1000
那 Process 一旦出問題,原本累加出來的數字就很難恢復。
但如果真正保存的是:
startedAt = 某個時間點
那重新建立之後就可以:
現在時間 - startedAt
↓
重新算出 elapsed time
這兩種設計代表的恢復能力其實差很多。
保存「現在累加到多少」
vs
保存「計時從什麼時候開始」
前者比較像保存結果。
後者保存的是可以重新推導結果的資訊。
這也是狀態設計裡另一個我覺得很重要的問題:
這個 State 是真正的 Source of Truth,還是其實可以從其他資料算出來?
如果 elapsedMillis 可以由開始時間推導,那真正需要保護的也許不是每一次更新後的 elapsed,而是足以恢復 Timer 的關鍵資訊。
現在如果遇到一個新的 State,我覺得至少可以先問:
這只是 View 被重建嗎?
↓
Activity / Fragment 被重建嗎?
↓
Process Death 後也要恢復嗎?
↓
App 下次開啟還要存在嗎?
然後再問另一組:
這份資料可以重新取得嗎?
可以重新計算嗎?
還是它一旦消失就真的找不回來?
例如:
TextView 現在顯示什麼
→ 可以重新 Render
API Response
→ 可能可以重新 Request
elapsedMillis
→ 可能可以重新計算
使用者已完成的計時紀錄
→ 應該真正保存
這樣再回頭決定要放:
UI
ViewModel
SavedStateHandle
Repository
Room
會比看到 State 就先選一個容器來得合理。
這幾天從 Singleton、ViewModel 一路看到 Process Death,我覺得它們其實一直在回答同一個問題:
這份 State 到底需要活過哪些事情?
如果只需要活過 View 重建,和需要活過 Process Death,設計當然不會一樣。
而如果連 App 下次重新開啟都要存在,那又是另一個層級。
所以與其先問:
這個 State 要用 LiveData 還是 StateFlow?
我現在更想先問:
它是誰的 State?
↓
它需要活多久?
↓
它消失後能不能重新取得?
↓
真正需要保存的是它本身,還是足以重新推導它的資料?
把這幾題回答完,後面的技術選擇反而會清楚很多。
State management 不只是怎麼把資料送到 UI,更重要的是,在 Android 不同生命週期變化下,哪些資料應該留下來,哪些其實可以重新建立。
下一篇見:)