前言
最近看到不少介紹MVI架構,即Model-View-Intent的文章,有人留言説Google炒冷飯或者為了湊KPI“發明”了MVI這麼一個詞。和後端的朋友描述了一下,他們聽了第一印象也是和MVVM好像區別不大。但是憑印象Google應該還沒有到需要這樣來湊數。
去看了一下官網,發現完全沒有提到MVI這個詞。。但是推薦的架構圖確實是更新了,用來演示MVI也確實很搭。
(官網圖)
想了想,決定總結一下自己的發現,和掘友們一起討論學習。
案例分享
看過一些分析MVI的文章,裏面實現的方法各種各樣,細節也不盡相同。甚至對於Model邊界的劃分也會不一樣。
下面先分享一下在特定場景下我的MVVM和MVI實現(不重要的細節會省略)。
場景
先預設一個場景,我們的界面(View/Fragment)裏有一個鍋。主要任務就是完成一道菜的烹飪:
flowchart LR
開火 --> 熱油 --> 加菜 --> 加調料 --> 出鍋
幾個需要注意的點:
- 初始狀態:開火
- 加入材料時:都是異步獲取材料,再加入鍋中
- 結束狀態:出鍋
本文主要是比較MVVM和MVI,這裏只分享這兩種實現。
經典MVVM
為了加強對比,這裏的實現比較接近Android Architecture Components剛發佈時官網的的代碼架構和片段:
(當時的官網圖)
// PotFragment.kt
class PotFragment {
...
// 觀察是否點火
viewModel.fireStatus.observe(
viewLifecycleOwner,
Observer {
updateUi()
if (fireOn) addOil()
}
)
// 觀察油温
viewModel.oilTemp.observe(
viewLifecycleOwner,
Observer {
updateUi()
if (oilHot) addIngredients()
}
)
// 觀察菜熟沒熟
viewModel.ingredientsStatus.observe(
viewLifecycleOwner,
Observer {
updateUi()
if (ingredientsCooked) {
// 加調料
addPowder(SALT)
addPowder(SOY_SAUCE)
}
}
)
// 觀察油鹽是否加完
viewModel.allPowderAdded.observe(
viewLifecycleOwner,
Observer {
// 出鍋!
}
)
viewModel.loading.observe(
viewLifecycleOwner,
Observer {
if (loading) {
// 顛勺
} else {
// 放下鍋
}
}
)
// 一切準備就緒,點火
turnOnFire()
...
}
// PotViewModel.kt
class PotViewModel(val repo: CookingRepository) {
private val _fireStatus = MutableLiveData<FireStatus>()
val fireStatus: LiveData<FireStatus> = _fireStatus
private val _oilTemp = MutableLiveData<OilTemp>()
val oilTemp: LiveData<OilTemp> = _oilTemp
private val _ingredientsStatus = MutableLiveData<IngredientsStatus>()
val ingredientsStatus: LiveData<IngredientsStatus> = _ingredientsStatus
// 所有調料加好了才更新。這裏Event內部會有flag提示這個LiveData的更新是否被使用過
//(當年我們還真用這種方式實現過單次消費的LiveData)。
private val _allPowderAdded = MutableLiveData<Event<Boolean>>()
val allPowderAdded: LiveData<Event<Boolean>> = _allPowderAdded
// 假設已經實現邏輯從repo獲取是否有還在進行的數據獲取
private val _loading = MutableLiveData<Boolean>()
val loading: LiveData<Boolean> = _loading
fun turnOfFire() {}
// 假設下面都是異步獲取材料,這裏簡化一下代碼
fun addOil() {
repo.fetchOil()
}
fun addIngredients() {
repo.fetchIngredients()
}
fun addPowder(val powderType: PowderType) {
repo.fetchPowder(powderType)
// 更新_allPowderAdded的邏輯會在這裏
}
...
}
特點:
- 使用多個
LiveData觀察不同的數據,並以此來更新UI。每個LiveData都是一個State,每個View有自己的State。 UI是否顯示loading由Repository決定(是否有正在進行的數據讀取)。- 對於觀察的
LiveData要做出何種操作,UI層的邏輯代碼往往無法避免。
很久以前也聽説過用狀態機(state machine)管理UI界面,但是思路還是限制在使用多個LiveData,使用時進行合併。雖然狀態更清晰了,但是對於代碼的可維護性並沒有明顯的幫助,甚至ViewModel裏還多了些合併LiveData以及狀態管理的代碼。代碼貌似還更復雜了。後來發現了Redux式的思路,才有了下面這個版本的MVI實現。
MVI
下圖是我對這個思路的理解:
- 單一信息源
- 單向/環形數據流
定義幾個下面代碼會用到的名稱(不用細究命名,只要自己和團隊覺得有意義叫什麼都行):
- State:不管數據從哪裏來,經過什麼處理,都會歸於現在的狀態。
- Event:上圖中的意圖產生或代表的事件,也可以理解為
Intent或者Action,最終產生Event讓我們更新State。 - Reducer:驅動狀態變化的核心。這個例子裏可以想象成廚師的手,用來改變鍋的狀態。
- Side effects:用户無感知,就當它是“額外效果”(或者“副作用”)。對於數據的請求或者記錄上傳用户操作的代碼都歸於此類。
下面開始展示~偽~代碼:
// PotState.kt
sealed class PotState {
object Initial: CookingStatus()
object FireOn: CookingStatus()
class Cooking(val data: List<EdibleStuff>): CookingStatus()
object Finished: CookingStatus()
}
// CookEvent.kt
sealed class CookEvent {
object TurnOnFire(): CookEvent()
object RequestOil(): CookEvent()
object AddOil(): CookEvent()
class RequestIngredient(val ingredientType: IngredientType): CookEvent()
class AddIngredient(val ingredient: Ingredient): CookEvent()
class RequestPowder(val powderType: PowderType): CookEvent()
class AddPowder(val powder: Powder): CookEvent()
object ServeFood()
}
// models.kt
interface EdibleStuff
data class Oil(...) implements EdibleStuff
data class Ingredient(...) implements EdibleStuff
data class Powder(...) implements EdibleStuff
// PotReducer.kt
class PotReducer {
fun reduce(state: PotState, event: CookEvent) =
when (state) {
Initial -> reduceInitial(event)
FireOn -> reduceFireOn(event)
is Cooking -> reduceCooking(event)
Finished -> reduceFinished(state, event)
}
// 每個狀態只接受某些特定的Event,其它的會忽略(無法影響當前狀態)
private fun reduceInitial(state: PotState, event: CookEvent) =
when (event) {
TurnOnFire -> flowOf(FireOn) // 生成一個Cooking狀態並加好油
else -> // handle exception
}
private fun reduceFireOn(state: PotState, event: CookEvent) =
when (event) {
AddOil -> flowOf(Cooking(mutableListOf<Cooking>(Oil)) // 生成一個Cooking狀態並加好油
else -> // handle exception
}
private fun reduceCooking(state: PotState, event: CookEvent) =
when (event) {
AddIngredient -> flowOf(state.apply { data.add(event.ingredient) }) // 加菜
AddPowder -> flowOf(state.apply { data.add(event.powder) }) // 加調料
else -> // handle exception
}
private fun reduceFinished(state: PotState, event: CookEvent) =
when (event) {
ServeFood -> flowOf(Finished) // 出鍋
else -> // handle exception
}
}
// PotViewModel.kt
class PotViewModel(val potReducer: PotReducer, val repo: CookingRepository) {
...
var potState: PotState = Initial
// 生成下一狀態,更新Flow
fun processEvent(event: CookEvent) =
potReducer.reduce(potState, event)
.updateState()
.handleSideEffects(event)
.launchIn(viewModelScope)
// 對於不直接影響UI的事件,當做side effects處理
private fun handleSideEffects(event: CookEvent) =
onEach { event ->
when (event) {
is RequestOil -> fetchOil()
is RequestIngredient -> fetchIngredient(...)
is RequestPowder -> fetchPowder(...)
}
}
// 收到Repository傳來的食料,啓動新Event:添加入鍋
private fun fetchOil() = repo.fetchOil().onEach { processEvent(AddOil) }.collect()
// fetchIngredient(...) 與 fetchPowder(...) 也類似
...
}
// PotFragment.kt
class PotFragment {
...
@Composable
fun Pot(viewModel: PotViewModel) {
val state by viewModel.potState.collectAsState()
Column {
//Render toolbar
Toolbar(...)
//Render screen content
when (state) {
FireOn -> // render UI
is Cooking -> // render UI
Finished -> // render UI:出鍋!
}
}
}
// 準備就緒,挑個合適的時機開火
viewModel.processEvent(TurnOnFire)
...
}
特點:
- Fragment/Activity只負責渲染
- 用户意圖會產生Event,並被ViewModel中的Reducer處理
- 特定的狀態下,只會接收能被處理的Event
分析
經典MVVM
優點:
- 相比
MVC或者MVP,相信大家都熟悉。
缺點:
- 每個
View有自己的State。很多View混合在一起時,代碼和我們的思路都容易變混亂。審核代碼也需要對全局有很好的理解。 - 需要觀察的數據多了之後,
LiveData管理可以變得很複雜。 - 可以看到,
Fragment中無論何時都在觀察並接收所有LiveData的更新。仔細想想,其實這當中是包含了一些邏輯的。比如説,開火之後我們不希望接收加調料的操作。這些邏輯不容易單獨拿出來寫測試,通常要被包含在Fragment的測試離。
MVI
優點:
State是single source of truth,單一信息源,不用擔心各個View的狀態到處都是,甚至相互衝突。- 伴隨着預設的狀態值,可以接受的意圖
Intent或者操作Action也可以預設。不在計劃裏的意圖/操作不會對UI界面產生影響,也不會有額外效果。審核代碼只需要瞭解新增的意圖對某一兩個受影響的狀態就足夠,不用把整個界面的內容都覆盤一遍。單元測試也是類似。也算是符合關注點分離(Separation of Concerns)。
缺點:
- 隨着View變得複雜,可以有的狀態以及能接受的意圖也會迅速膨脹。
- 文件數量變多(這個和從MVC到MVP的感覺有點像)。
- 新手學習、理解起來不容易。
比較
兩種架構都有優缺點。
因為大家都熟悉MVVM,新團隊的接受度肯定會好。
有些缺點也可以想辦法改進。例如MVI的狀態膨脹可以通過劃分為幾個小的分狀態來緩解。
對於複雜的場景,我個人更傾向於採用MVI的全局狀態管理的思路。主要還是覺得傳統MVVM每次添加新的LiveData時(當然現在常常用Flow),需要仔細檢查其它所有的View或者LiveData,生怕漏掉什麼改動,不利於高效開發和維護。
總結
我認為傳統的MVVM和MVI主要的區別還是在於全局狀態管理。而且這個全局狀態管理的思路用傳統MVVM架構也能實現,很多人覺得MVI和MVVM差不多的原因可能正是如此。 其實也不足為奇,不少設計模式兩兩之間也很相似,但並不妨礙大家給他們安上不同的名字。只要我們把握住核心概念,合理運用,叫什麼名字也不重要。正如官方的建議:
就算叫MVI只是為了唬人,讓人一聽到就知道你運用了Redux/State machine的思路,而不是“經典”的安卓版MVVM,好像也是個不錯的理由。
題外話
從官網架構圖的變化產生的聯想:
ViewModel 化身 LifecycleObserver
最近看到不少文章分享他們對於讓ViewModel也lifecycle-aware的實驗。從官方文檔看,UI elements和State holders(在我看來就是Fragment/Activity和ViewModel)也在被視作一個整體的UI Layer。不知道以後是不是會有這麼一個趨勢。
有時候,不經意間就會錯過一些有趣實用的想法。回想2017年的時候,聽到WeWork的員工分享他們自制的Declarative UI庫。當時覺得都不能預覽,應該不會好用到哪去吧。沒想到後來官方發佈了Compose,預覽功能都加入了Android Studio。
選擇性使用的 Domain Layer
也許是隨着這幾年Clean Architecture的熱度上升,看到不少團隊開始加入領域層。官方推薦的架構圖(開頭提到)中也加入了Domain Layer (optional)。添加這麼一層的確可以幫助我們解耦部分邏輯。