Jetpack MVVM 七宗罪之六:ViewModel 介面暴露不合理
theme: vuepress highlight: androidstudio
在 Jetpack 架構規範中, ViewModel 與 View 之間應該遵循單向資料流的通訊方式,Events
永遠從 View 流向 VM ,而 State
從 VM 流向 View。
如果 ViewModel 對 View 暴露的介面型別不合理很容易會破壞資料的單向流動。不合理的介面常見於以下兩點:
- 暴露 Mutable 狀態
- 暴露 Suspend 方法
不合理1:暴露 Mutable 狀態
ViewModel 對外暴露的資料狀態,無論是 LiveData 或是 StateFlow 都應該使用 Immutable 的介面型別進行暴露而非 Mutable 的具體實現。View 只能單向訂閱這些狀態的變化,避免對狀態反向更新。
kotlin
class MyViewModel: ViewModel() {
private val _loading = MutableLiveData<Boolean>()
val loading: LiveData<Boolean>
get() = _loading
}
未來避免暴露 Mutable 型別,我們需要像上面這樣處理,將 loading
的具體實現定義為一個 private
的 Mutable 型別,便於內部更新。
private val _loading : MutableStateFlow<Boolean?> = MutableStateFlow(null)
val loading = _loading.asStateFlow()
StateFlow 的寫法也類似,但是通過 asStateFlow
可以少寫一個型別宣告,但是要注意此時不要使用 custom get(), 不然 asStateFlow
會執行多次。
每次都要多宣告一個帶劃線的私有變數會讓程式碼顯得有些累贅,也正因如此,有 issue 希望 Kotlin 增加類似下面的語法使得對外對內可以暴露不同型別。
kotlin
//http://youtrack.jetbrains.com/issue/KT-14663
private val loading = MutableLiveData<Boolean>()
public get(): LiveData<Boolean>
在新語法還未出現的當下,一個讓程式碼變整潔的思路是為 ViewModel 提取對外暴露的抽象類:
```kotlin
abstract class MyViewModel: ViewModel() {
abstract val loading: LiveData
class MyViewModelImpl: MyViewModel() {
override val loading = MutableLiveData
fun doSomeWork() { // ... loading.value = true } } ```
如上, MyViewModelImpl
內重寫的 loading
可以作為 Mutable 型別使用。雖然這種做法會增加了一個抽象類程式碼量不減反增,但是它使 MyViewModelImpl
內的程式碼更加簡潔,而且對外可以隱藏更多 ViewModel 的實現細節,封裝性更好。
但是需要特別注意的是,為了建立 MyViewModel
必須使用自定義 Factory:
kotlin
val vm : MyViewModel by viewModels { MyViewModelFactory() }
如果你的工程引入了 Hilt ,那麼可以通過 @Bind
繫結 ViewModel 的介面與實現,無需自定義 Factory 了,寫法跟以前一樣,直接使用 by viewModels()
即可
```kotlin @Module @InstallIn(ViewModelComponent::class) abstract class MyViewModule { @Binds abstract fun MyViewModel(instance: MyViewModelImpl): MyViewModel }
@HiltViewModel class MyViewModelImpl @Inject constructor() : MyViewModel() ```
不合理2:暴露 Suspend 方法
相對於暴露 Mutable 狀態,暴露 Suspend 方法的錯誤則更為常見。 按照單向資料流的思想 ViewModel 需要提供 API 給 View 用於傳送 Events,我們在定義 API 時需要注意避免使用 Suspend 函式,理由如下:
- 來自 ViewModel 的資料應該通過訂閱 UiState 獲取,因此 ViewModel 的其他方法方法不應該有返回值,而 suspend 函式會鼓勵返回值的出現。
- 理想的 MVVM 中 View 的職責僅僅是渲染 UI,業務邏輯儘量移動到 ViewModel 執行,利於單元測試的同時,
ViewModelScope
可以保證一些耗時任務的穩定執行。如果暴露掛起函式給 View,則協程需要在lifecycleScope
中啟動,在橫豎屏等場景中會中斷任務的進行。
因此,ViewModel 為 View 暴露的 API 應該是非掛起且無法返回值的方法,以下是官網的程式碼例項:
```kotlin // DO create coroutines in the ViewModel class LatestNewsViewModel( private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase ) : ViewModel() {
private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun loadNews() {
viewModelScope.launch {
val latestNewsWithAuthors = getLatestNewsWithAuthors()
_uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
}
}
}
// Prefer observable state rather than suspend functions from the ViewModel class LatestNewsViewModel( private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase ) : ViewModel() { // DO NOT do this. News would probably need to be refreshed as well. // Instead of exposing a single value with a suspend function, news should // be exposed using a stream of data as in the code snippet above. suspend fun loadNews() = getLatestNewsWithAuthors() } ```
程式碼中建議暴露一個普通的無返回值的 loadNews
,而 latestNewsWithAuthors
的資訊應該通過訂閱 LatestNewsUiState
獲得 。
有一點讓人迷惑的是,官方文件上有這麼一句話:
Suspend functions in the ViewModel can be useful if instead of exposing state using a stream of data, only a single value needs to be emitted.
http://developer.android.com/kotlin/coroutines/coroutines-best-practices#viewmodel-coroutines
對於單發資料的請求允許使用掛起函式返回。但我建議大家忘掉這句話,理由有兩點:
- 掛起函式的口子一開就容易不分場景的濫用,如果整體資料流結構造成破壞反而因小失大,索性應該從源頭禁止
- 理論上來說,UI 上不存在單發資料請求的必要性,完全可以通過良好的設計轉化成 UiState ,這也更符合響應式的程式設計模型。
更多閱讀
- Android Studio Electric Eel 起支援手機投屏
- Compose 為什麼可以跨平臺?
- 一看就懂!圖解 Kotlin SharedFlow 快取系統
- 深入淺出 Compose Compiler(2) 編譯器前端檢查
- 深入淺出 Compose Compiler(1) Kotlin Compiler & KCP
- Jetpack MVVM七宗罪之三:在 onViewCreated 中載入資料
- 為什麼說 Compose 的宣告式程式碼最簡潔 ?Compose/React/Flutter/SwiftUI 語法對比
- Compose 型別穩定性註解:@Stable & @Immutable
- Fragment 這些 API 已廢棄,你還在使用嗎?
- 告別KAPT!使用 KSP 為 Kotlin 編譯提速
- 探索 Jetpack Compose 核心:深入 SlotTable 系統
- 盤點 Material Design 3 帶來的新變化
- Compose 動畫邊學邊做 - 夏日彩虹
- Google I/O :Android Jetpack 最新變化(二) Performance
- Google I/O :Android Jetpack 最新變化(一) Architecture
- Google I/O :Android Jetpack 最新變化(四)Compose
- Google I/O :Android Jetpack 最新變化(三)UI
- 一文看懂 Jetpack Compose 快照系統
- 聊聊 Kotlin 代理的“缺陷”與應對
- AAB 扶正!APK 再見!