Funny vote APP – 為了解決狀態打架:直上 MVI 單向流與拯救白屏災難(五)

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

Funny Vote MVI 單向流旗艦封面

接續上一篇的慘痛教訓。

上一篇我們聊到了把十年前的舊石器 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>()
}

這種寫法在極度簡單的教學範例裡看似條理清晰,但在真實產品的複雜並發情境下,簡直就是一場惡夢。

想像一下這個工位日常:

  1. 使用者進頁面,isLoading 變成 true。
  2. 網路稍微抖動了一下,API 逾時拋出例外,協程抓到錯誤,將 errorMessage 設為 "網路連線逾時"。
  3. 但與此同時,本地 Room 資料庫的快取 Flow 剛好載入完畢,把舊的十筆資料塞給了 voteList。
  4. 更慘的是,使用者在背景點了某張卡片,觸發了 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 分支吧,通通都放在那,不浪費版面。

感謝觀看,收工。


發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *