本文為《Funny Vote 十年演進系列》第 5 篇(全系列共 8 篇)|查看全系列架構導覽

接續上一篇的慘痛教訓。
上一篇我們聊到了把十年前的舊石器 XML 拆掉、全面換上 Jetpack Compose 的爽度,以及在實機高刷螢幕上被全量重組與天真 Sealed Class 毒打的慘況。
特別是結尾提到的那個經典翻車現場:使用者手指輕輕往下一拉想刷新列表,ViewModel 興沖沖發射了一個 MainUiState.Loading,結果 UI 裡的 when (state) 條件反射似地當場把整個投票列表連根拔起,畫面瞬間一片死白,正中央留一顆孤獨的小菊花在那邊空轉——等半秒後資料抓回來了,畫面又猛然彈回列表。
我在工位上測到這個 Bug 時,自己都覺得這體驗簡直蠢到哭出來。如果這是正式上線的產品,使用者肯定立刻刪 App 跑去 Google Play 刷一顆星。
身為一個信奉「先動手再說,不然嘴巴說再多也沒屁用」的工程師,這種巨嬰級別的白屏災難顯然不能留著過夜。於是,我拉出了 mvi-rewrite 分支,下定決心跳過那些看似甜美實則狀態滿天飛的傳統 MVVM,直奔嚴格的 MVI(Model-View-Intent)單向資料流(Unidirectional Data Flow, UDF)。
想看分支真實原始碼的,GitHub 傳送門放在這:
1. 狀態撕裂(State Tearing):傳統 MVVM 是怎麼把人搞瘋的?
在開始動手術之前,我們先來盤點一下為什麼傳統 MVVM 在宣告式 UI 面前會顯得如此蒼白無力。
過去在寫傳統 MVVM 時,主流的公版教學最喜歡在 ViewModel 裡開出「各自為政」的多個狀態流:
// 傳統 MVVM 裡滿地開花的狀態流:狀態撕裂的溫床
class LegacyHomeViewModel : ViewModel() {
val isLoading = MutableStateFlow(false)
val voteList = MutableStateFlow<List<VoteData>>(emptyList())
val errorMessage = MutableStateFlow<String?>(null)
val toastMessage = MutableSharedFlow<String>()
}
這種寫法在極度簡單的教學範例裡看似條理清晰,但在真實產品的複雜並發情境下,簡直就是一場惡夢。
想像一下這個工位日常:
- 使用者進頁面,
isLoading變成true。 - 網路稍微抖動了一下,API 逾時拋出例外,協程抓到錯誤,將
errorMessage設為"網路連線逾時"。 - 但與此同時,本地 Room 資料庫的快取 Flow 剛好載入完畢,把舊的十筆資料塞給了
voteList。 - 更慘的是,使用者在背景點了某張卡片,觸發了
toastMessage。
因為這些 StateFlow 是獨立發射的,在 Compose 響應重新繪製的瞬間,畫面出現了極度荒謬的一幕:頂部轉圈圈還在轉、中央彈出了紅字錯誤訊息、底層卻整整齊齊排著十筆舊資料,螢幕下方還在跳 Toast!
這就是所謂的 「狀態撕裂 (State Tearing)」。
因為沒有一個統一的事實來源,每個屬性都在各自的時間軸上跳動,你的畫面在並發競爭下直接人格分裂。
2. 核心救贖:單一不可變狀態容器(Single Immutable State)
要徹底終結這種狀態撕裂與下拉刷新時的白屏慘劇,解法只有一個:將整個畫面的所有維度,收斂進唯一的一個不可變容器(Single Source of Truth, SSOT)中。
在 mvi-rewrite 分支裡,我把散落一地的狀態打包成這顆乾淨俐落的 HomeUiState:
// 2024 年在 mvi-rewrite 裡的真實狀態容器:唯一真相來源
data class HomeUiState(
val isLoading: Boolean = false, // 首次全屏加載
val isRefreshing: Boolean = false, // 下拉刷新指示
val hotVotes: List<VoteData> = emptyList(), // 投票列表(舊資料永遠留存)
val errorMessage: String? = null // 業務錯誤提示
) : UiState
大家仔細看一下這裡的兩個關鍵旗標:isLoading 與 isRefreshing。
這兩個布林值的拆分,正是拯救全屏白屏的靈魂所在:
- 首次進入頁面時:
isLoading = true,hotVotes為空,這時候展示中央轉圈圈合情合理; - 使用者下拉刷新時:我們發射新狀態,將
isRefreshing設為true,但原本的hotVotes毫髮無傷地保留在原地!
回到 Compose 畫面的渲染層,我們的寫法徹底變成了優雅的圖層疊加,而不是暴力的分支清空:
// 告別 when(state) 的粗暴清空,採用真正的圖層投影
@Composable
fun HomeScreen(state: HomeUiState, onIntent: (HomeUiIntent) -> Unit) {
Box(modifier = Modifier.fillMaxSize()) {
// 1. 底層:只要有資料,舊列表就穩如泰山釘在畫面上
if (state.hotVotes.isNotEmpty()) {
VoteList(votes = state.hotVotes, onVoteClick = { ... })
}
// 2. 頂層:首次加載的中央轉圈圈
if (state.isLoading && state.hotVotes.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
// 3. 下拉刷新:頂部原生 Material 3 刷新指示條
PullToRefreshContainer(
isRefreshing = state.isRefreshing,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
使用者手指下拉,頂部指示器平滑滑出旋轉;資料回來後,狀態原子更新(Atomic Update),列表內容無縫替換,頂部指示器優雅收回。
全屏白屏?不存在的。閃爍卡頓?徹底拜拜。
3. 副作用隔離:把 Toast 與導航逐出狀態樹
搞定了狀態容器,下一個差點把工程師搞瘋的坑是:一次性副作用(Side-effects)。
在 Android 開發裡,有些事情不是「狀態」,而是一次性的「事件」:例如彈出 SnackBar、跳 Toast、或是點擊卡片跳轉到投票詳情頁。
過去很多人的偷吃步寫法,是直接把這些東西塞進 UiState 裡:
// 偷吃步的自殺寫法:把事件當狀態
data class BadUiState(val toastMessage: String? = null)
這種寫法的下場通常非常精彩:
當 ViewModel 把 toastMessage 設為 "投票成功",UI 彈出 Toast。接著使用者旋轉了一下手機螢幕,Activity 重建,Compose 重新訂閱 UiState——恭喜你,那個 "投票成功" 的 Toast 會不信邪地再彈出來嘲諷你一次!
為了解決這個歷史沉痾,在 MVI 管線中,我們將狀態與副作用徹底分流:
- UiState:代表畫面的持久外觀,走
StateFlow,天然具備黏性(Sticky),適合重組。 - UiEffect:代表瞬時動作,走
Channel<UiEffect>(Channel.BUFFERED),透過receiveAsFlow()暴露給 UI,保證單次消費、過期不候:
// 獨立的一次性副作用定義與安全暴露
sealed interface HomeUiEffect : UiEffect {
data class ShowToast(val message: String) : HomeUiEffect
data class NavigateToDetail(val voteId: String) : HomeUiEffect
}
// ViewModel 內部使用緩衝 Channel 轉 Flow,旋轉螢幕不漏事件也不重放
private val _effect = Channel<HomeUiEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
在 Composable 裡,透過 LaunchedEffect 安全收集這個串流:
// 透過 LaunchedEffect 安全消費副作用,旋轉螢幕絕不重複彈出
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is HomeUiEffect.ShowToast -> snackbarHostState.showSnackbar(effect.message)
is HomeUiEffect.NavigateToDetail -> onNavigateToDetail(effect.voteId)
}
}
}
乾淨俐落。就算使用者把手機在桌上轉得像指尖陀螺,Toast 也絕對只會彈出該彈的那一次。
4. 意圖顯式化(Explicit Intent):連除錯都省力十倍
在傳統架構裡,ViewModel 往往充滿了一堆隨意暴露的 public 方法:loadData()、refresh()、onClickItem(id)、filterCategory(cat)… 隨著業務增長,ViewModel 裡的公開方法像雜草一樣瘋長,最後誰也不知道這個方法到底是被哪個 View、在哪個生命週期裡偷偷調用的。
在 MVI 中,我們關閉了所有公開方法的後門,只留下一扇唯一的大門:sendIntent(intent)。
所有的操作意圖,都被規範成強型別的 Sealed Interface:
// 顯式操作意圖:所有的 UI 行為全部白紙黑字寫清楚
sealed interface HomeUiIntent : UiIntent {
object Refresh : HomeUiIntent
data class VoteOption(val voteId: String, val optionId: String) : HomeUiIntent
data class FilterCategory(val category: String) : HomeUiIntent
}
在 ViewModel 內部,意圖處理變成了一個單純的 Reducer 管道:
// 核心管線:意圖分發與狀態流轉
fun processIntent(intent: HomeUiIntent) {
when (intent) {
is HomeUiIntent.Refresh -> handleRefresh()
is HomeUiIntent.VoteOption -> handleVote(intent.voteId, intent.optionId)
is HomeUiIntent.FilterCategory -> handleFilter(intent.category)
}
}
這套設計帶來的爽度,只有在深夜除錯時才能深刻體會:
當線上回報了某個詭異的 Bug,你不需要去猜測使用者到底按了什麼、跳了哪個流程。你只需要在 processIntent 門口掛一行 Log,使用者的每一次點擊、每一次滑動意圖就像黑盒子飛行紀錄器一樣整整齊齊排出來。
甚至要寫單元測試時,測試代碼乾淨到像詩一樣:丟一個 HomeUiIntent.Refresh 進去,斷言最後吐出來的 HomeUiState,收工。
5. 踩坑代價與瀟灑收工
很多工程師在接觸 MVI 時,第一反應往往是:「定義那麼多 Intent、State、Effect,樣板代碼不會變多嗎?」
說實話,會多一點點。
你問多寫這些密封介面累不累?廢話當然累,但比起半夜被線上白屏客訴叫起來除錯,在冷氣房裡多打幾行密封介面簡直是天堂。零狀態撕裂、徹底杜絕全屏白屏、永遠不重複消費的副作用,加上單元測試秒級過關,這筆交易划算到了極點。
把 MVI 的底層管線徹底夯實之後,Funny Vote 的骨架總算從「東拼西湊的玩具」進化成了「具備確定性的現代應用」。
但架構搞定了,強迫症工程師的下一個自虐關卡又來了:
這時候的 App 雖然架構現代,但畫面卻殘缺不全——十年前原版擁有的 6 頁經典導覽教學、自訂社群分享彈窗、四個層級的關於頁面以及個人中心,通通還沒補齊。
在接下來的 modern-android 分支裡,我們將展開一場硬核的「100% 像素級畫面復刻」大戰,看我們怎麼用純 Compose 把十年前的青春細節一個不漏搬進 Material 3。
以上是本次小弟的感想,我真的懶得貼落落長的全專案 CODE,各位真的想看當年完整的提交細節,去 GitHub 翻 mvi-rewrite 分支吧,通通都放在那,不浪費版面。
感謝觀看,收工。
《Funny Vote 十年演進系列》章節導航

發佈留言