上一篇我們聊到告別 XML、跳進 Jetpack Compose 之後的痛苦與甜蜜。我們用 @Immutable 馴服了動不動就全量重繪的編譯器,也把列表滑動幀率穩穩鎖在 120Hz。但當畫面不再卡頓之後,另一個讓所有 Android 工程師頭皮發麻的幽靈浮出了水面:畫面上的狀態開始互相打架。
這不是在開玩笑。當你把傳統 MVVM 那套「在 ViewModel 裡到處開 StateFlow」的寫法搬到宣告式 UI 上時,只要網路稍微延遲、或者使用者手速太快,畫面就會陷入精神分裂。這篇就來深入聊聊,我們在 2024 年的 mvi-rewrite 分支裡,是怎麼從狀態打架的泥潭中爬出來,並一步步打造出堅固如鐵的 MVI 單向資料流。
一、 翻車現場:五個各自為政的 StateFlow
我們先還原一下剛從 MVP 轉進 MVVM 時,大部分工程師(包括我)是怎麼寫 ViewModel 的。
那時候我們覺得,既然每個 UI 元件需要不同的資料,那就為每個狀態各開一個水龍頭嘛:
// 看似合情合理、實則暗藏殺機的 MVVM 寫法
class HomeViewModel @Inject constructor(
private val voteRepository: VoteRepository
) : ViewModel() {
// 水龍頭一號:控制進度條
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
// 水龍頭二號:主要的投票列表
private val _votes = MutableStateFlow<List<VoteWithDetails>>(emptyList())
val votes = _votes.asStateFlow()
// 水龍頭三號:錯誤訊息
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage = _errorMessage.asStateFlow()
// 水龍頭四號:目前選中的分頁標籤
private val _selectedTab = MutableStateFlow(0)
val selectedTab = _selectedTab.asStateFlow()
fun refreshVotes() {
viewModelScope.launch {
_isLoading.value = true
try {
val data = voteRepository.fetchVotesFromNetwork()
_votes.value = data
_errorMessage.value = null
} catch (e: Exception) {
_errorMessage.value = "網路連線異常,請稍後再試"
} finally {
_isLoading.value = false
}
}
}
}
在十年前的 XML 時代,這段代碼勉強能混過去。但在 Compose 宣告式 UI 這種「每 16 毫秒都在重新評估畫面」的環境下,這段代碼簡直是定時炸彈。
為什麼?因為這四個 StateFlow 是非同步且彼此獨立發射的!在協程執行緒切換的微小時間差裡,Compose 重組機制會精準捕捉到那些我們根本沒想過的中間縫隙:
- 轉圈圈消失了,但資料還沒到:當網路請求剛結束,
_isLoading.value = false先被 Compose 收到,但_votes.value = data還卡在下一個調度週期。使用者會看到畫面上的轉圈圈突然消失,接著螢幕閃過十毫秒的「空空如也」,然後資料才猛然彈出來。這就是最典型的畫面撕裂與閃爍。 - 錯誤訊息跟舊資料撞在一起:如果下拉刷新失敗,
_errorMessage發射了錯誤文字,但_votes依然保留著五分鐘前的舊資料。畫面上同時出現了「列表正常顯示」與「網路連線失敗」的荒謬矛盾狀態。 - 狀態組合爆炸:四個獨立的布林值與可空變數,理論上可以組出
2^4 = 16種不同的狀態組合。身為開發者,你根本不可能保證這 16 種狀態在畫面上都不會打架。
這不叫響應式編程,這叫把畫面的控制權交給運氣。
二、 批駁公版教程的「玩具架構」:別再無腦用 Sealed Class 搞白屏了
當工程師發現狀態會打架之後,第一反應通常是去翻 Google 或 Medium 上的教學文章。然後你就會看到千篇一律的經典解答:「大家不要開多個 StateFlow,改用 Kotlin 的 Sealed Class 封裝成單一狀態不就搞定了嗎?」
// 網路上隨處可見的「玩具架構」
sealed class HomeUiState {
object Idle : HomeUiState()
object Loading : HomeUiState()
data class Success(val votes: List<VoteWithDetails>) : HomeUiState()
data class Error(val message: String) : HomeUiState()
}
這種寫法在寫 Demo 或面試刷題時看起來無懈可擊,架構純淨得像藝術品。但在真實的商業產品裡,如果照抄這套架構,產品經理隔天就會提著刀來找你。
為什麼?請想像一下這個日常場景:
使用者正在看熱門投票列表(此時狀態是 Success)。這時候他手指向下一拉,想要刷新最新票數。ViewModel 收到指令,第一件事就是把狀態切換成 HomeUiState.Loading。
這時候畫面會發生什麼事?
因為 Loading 裡面根本沒有夾帶既有的 votes 資料,Compose 一看到狀態變成 Loading,只能把整張列表從畫面上拔掉,換成一顆孤零零的菊花轉圈圈!等到三秒後網路回來變成 Success,列表才又猛然重新畫出來!
這就是極其業餘的白屏閃退感。在成熟的移動端產品裡,下拉刷新必須是在既有清單的上方滑出一個小進度條,底下的內容必須穩如泰山地留在畫面上,怎麼可以把使用者正在看的內容直接抹掉?
玩具架構的致命缺陷在於:它把「資料本身」跟「資料的加載動作」粗暴地互斥綁死在一起。你不能因為系統正在加載,就假裝使用者之前看到的資料不存在。
三、 FunnyVote 的實戰解法:單一不可變狀態快照(HomeUiState)
為了解決白屏與狀態矛盾,我們在 HomeContract.kt 裡拋棄了玩具式的互斥 Sealed Class,回歸到了極度成熟的單一不可變 Data Class:
// FunnyVote 真實採用的單一不可變狀態模型
@Immutable
data class HomeUiState(
val votes: List<VoteWithDetails> = emptyList(),
val popularVotes: List<VoteWithDetails> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val selectedCategory: String = "hot",
val searchQuery: String = "",
val errorMessage: String? = null
) {
// 透過衍生屬性優雅判定是否處於全螢幕空狀態
val isInitialLoading: Boolean
get() = isLoading && votes.isEmpty()
val isEmpty: Boolean
get() = !isLoading && votes.isEmpty() && errorMessage == null
}
大家仔細看這個設計的精妙之處:
- 增量複製(Incremental Copy):當使用者下拉刷新時,ViewModel 只需要呼叫
_uiState.update { it.copy(isRefreshing = true) }。既有的votes毫髮無傷,只有isRefreshing變成 true。Compose 收到通知,只會在畫面上方轉動下拉指示器,底下的投票列表連動都不用動。 - 狀態永不打架:在任何一個時間點,畫面上所有元件看到的都是同一張不可分割的「狀態快照(Snapshot)」。永遠不可能出現轉圈圈停了但資料沒到的中間幽靈態。
- 乾淨的業務衍生:透過
isInitialLoading,我們清楚區分了「首次進 App 的全螢幕骨架屏」與「背景靜默刷新的局部轉圈」,邏輯一清二楚。
四、 意圖顯式化(HomeIntent):讓所有使用者操作乖乖排隊
搞定狀態之後,接下來要收拾的是「使用者的動作」。
以前在 ViewModel 裡,我們通常會開出十幾二十個 public 函式:onTabSelected()、onSearchChanged()、onFavoriteClicked()、retry()。只要專案變大,ViewModel 就變成一個大雜燴,你根本不知道這些函式是被誰在什麼時機點呼叫的。
在 MVI 的精神下,我們把所有使用者在畫面上能做的事情,全部收斂成強型別的密封介面 HomeIntent:
// 首頁所有操作的唯一宣告處
sealed interface HomeIntent {
data class SelectCategory(val category: String) : HomeIntent
data class UpdateSearchQuery(val query: String) : HomeIntent
data class ToggleFavorite(val voteCode: String) : HomeIntent
data object Refresh : HomeIntent
data object LoadMore : HomeIntent
data object Retry : HomeIntent
}
在 HomeViewModel 內部,所有的對外窗口被收斂成唯一的入口點 processIntent(intent: HomeIntent):
// 所有動作統一分發與處理
fun processIntent(intent: HomeIntent) {
when (intent) {
is HomeIntent.SelectCategory -> handleSelectCategory(intent.category)
is HomeIntent.UpdateSearchQuery -> handleSearchQuery(intent.query)
is HomeIntent.ToggleFavorite -> handleToggleFavorite(intent.voteCode)
is HomeIntent.Refresh -> refreshVotes()
is HomeIntent.LoadMore -> loadNextPage()
is HomeIntent.Retry -> retryLoading()
}
}
這套設計的爽度在於排查除錯時的極致純粹。每當實體機回報某個詭異的 Bug,我只需要在 processIntent 的開頭印一行 Log。只要看著 Intent 的發射順序,整套業務邏輯就像放電影重播一樣,清清楚楚,完全不需要在各個函式之間下斷點瞎猜。
五、 一次性副作用的救贖:為什麼拋棄 SharedFlow 改用 Channel?
搞定 State 與 Intent 之後,MVI 的最後一塊拼圖是:一次性副作用(UiEffect)。
有些事情本質上不是「狀態」,而是「事件」。例如:彈出一個 Toast、顯示 SnackBar、或是跳轉到投票詳情頁。這些動作只應該發生一次,不能因為使用者把手機橫過來放,就重新執行一遍。
網路上很多文章會教你用 MutableSharedFlow(replay = 0) 來處理事件。但這又是另一個大坑:
如果使用 SharedFlow,當使用者旋轉螢幕的那幾百毫秒裡,Activity 正在銷毀重建,Composable 暫時取消了對 Flow 的收集。如果這時候背景協程剛好發射了一個「投票成功」的導航事件,這個事件就會像潑出去的水一樣,直接消失在太空中,使用者被卡在原地不知所措!
為了杜絕丟失事件的悲劇,我們在 HomeViewModel 採用了具備背壓緩衝機制的 Channel:
// 用具備緩衝的 Channel 兜住所有一次性事件
sealed interface HomeUiEffect {
data class ShowToast(val message: String) : HomeUiEffect
data class NavigateToDetail(val voteCode: String) : HomeUiEffect
data class NavigateToLogin(val returnRoute: String) : HomeUiEffect
}
class HomeViewModel @Inject constructor(...) : ViewModel() {
// 使用 BUFFERED Channel,確保 UI 重建期間事件不會被吃掉
private val _effectChannel = Channel<HomeUiEffect>(Channel.BUFFERED)
val effect: Flow<HomeUiEffect> = _effectChannel.receiveAsFlow()
private fun handleVoteSubmitted(voteCode: String) {
viewModelScope.launch {
_effectChannel.send(HomeUiEffect.ShowToast("投票成功!"))
_effectChannel.send(HomeUiEffect.NavigateToDetail(voteCode))
}
}
}
在 UI 端,透過 LaunchedEffect 配合 Lifecycle.repeatOnLifecycle 進行安全收集:
// 乾淨利落的事件消費端
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
navController: NavController
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(viewModel.effect) {
viewModel.effect.collect { effect ->
when (effect) {
is HomeUiEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
is HomeUiEffect.NavigateToDetail -> {
navController.navigate(Screen.Detail.createRoute(effect.voteCode))
}
is HomeUiEffect.NavigateToLogin -> {
navController.navigate(Screen.Login.route)
}
}
}
}
HomeScreenContent(
uiState = uiState,
onIntent = viewModel::processIntent
)
}
利用 Channel.BUFFERED,就算你在旋轉螢幕、打橫放打直放,未消費的事件會老老實實躺在緩衝佇列裡。等到新畫面繪製完成,事件立刻順序消費,絕不遺失、絕不重複。
六、 結尾:架構純淨了,但我的強迫症發作了
把 MVI 架構打磨完畢的那一刻,看著單一的 HomeUiState 在 HomeScreenContent 裡流轉,點擊按鈕井然有序,旋轉螢幕穩如泰山,內心的成就感確實無法言喻。
但是,當我把 App 退回到主畫面,冷靜審視整個專案時,身為原作者的強迫症又開始隱隱作痛了。
在 2024 年為了追求 Compose 與 MVI 的架構純度,我們採取了極限的「垂直切片」策略——為了把核心投票路徑走通,我們狠心砍掉了 2016 年舊版的所有旁支細節:當年精心設計的 6 頁新手導覽、4 個關於子頁面、客製化的自訂分享彈窗,通通被我們暫時擱置了。
現在核心架構穩固了,技術債也還得差不多了。看著這個只有骨架、缺少靈魂的現代化 App,我知道是時候在 2026 年開出 modern-android 分支,來一場把青春與回憶 100% 找回來的無損復刻了。
下一篇,我們來聊聊怎麼挖出十年前模糊不堪的 PNG 像素圖標、把它們重繪成 SVG 向量,以及將 30 份 XML 寫死的配色暴力遷徙到 Material 3 的體力活血淚史。

發佈留言