【Android 架構系列 07】狀態的藝術:Presentation Layer 設計——用 Sealed Interface 終結變數大爆炸
01. 前言:AI 製造的「布林值地獄」
這是系列文的第七篇。
在前幾篇,我們已經建立了一個堅固的後端邏輯(Domain/Data Layer)。現在,資料已經準備好,錯誤也被優雅地攔截了。是時候把它們送到最前線——Presentation Layer (UI 層)。
這裡是用戶與 App 互動的地方,也是 AI 最容易「寫出 Bug」的重災區。為什麼?因為 UI 狀態充滿了不確定性:旋轉、顯示資料、報錯、空畫面... 如果沒有約束,AI 會用最直覺(但也最脆弱)的方式來管理這些狀態。
這篇將深入探討如何利用 Sealed Interface 與 單向資料流 (UDF),終結 ViewModel 裡的變數大爆炸。
在沒有明確架構指示的情況下,如果你叫 AI:「幫我寫一個 ViewModel,要處理載入中、錯誤、資料顯示和空狀態。」
AI 為了滿足你的需求,通常會生成這樣的程式碼:
// ❌ AI 預設生成的「布林值地獄」
class BadViewModel : ViewModel() {
// 五個變數管理一個畫面的狀態
val isLoading = MutableStateFlow(false)
val isError = MutableStateFlow(false)
val errorMessage = MutableStateFlow("")
val data = MutableStateFlow<List<News>>(emptyList())
val isEmpty = MutableStateFlow(false)
}
這看起來很正常?錯,這是災難的開始。
請問:當 isLoading = true 且 isError = true 時,你的 UI 該顯示什麼?是轉圈圈還是錯誤訊息?
這就是 「狀態衝突」(State Conflict)。AI 在處理複雜邏輯時,常常會忘記在設 isError = true 時把 isLoading 改回 false。結果就是 App 畫面上同時出現了一個 Loading 轉圈圈和一個錯誤視窗,或者在資料載入後 isEmpty 還是 true。
為了防止 AI 犯這種低級錯誤,我們必須沒收它的「自由發揮權」,改為給它做「選擇題」。
02. 核心策略:有限狀態機 (FSM)
我們使用 Sealed Interface 來定義 UI 狀態。這對 AI 來說具有強大的約束力,因為它強制狀態必須是 互斥 (Mutually Exclusive) 的。
Prompt 策略:
"請為 NewsScreen 定義一個 UiState。它是互斥的,只能是 Loading、Success 或 Error 其中一種。請使用 Sealed Interface 實作。"
AI 生成的合約:
// presentation/news/NewsUiState.kt
sealed interface NewsUiState {
// 初始狀態或載入中
data object Loading : NewsUiState
// 成功狀態:只攜帶必要的資料
data class Success(val news: List<Article>) : NewsUiState
// 錯誤狀態:只攜帶錯誤訊息
data class Error(val message: String) : NewsUiState
}
為什麼這對 AI 有效?
這就像是給了 AI 一個下拉式選單。當 AI 在 ViewModel 裡寫程式時,它只能從這三個選項裡挑一個賦值。它不可能創造出「既是 Success 又是 Error」的狀態,因為編譯器不允許。
03. ViewModel 實作:單向資料流 (UDF)
有了 UiState,我們就要規範 AI 如何在 ViewModel 中操作它。
我們要求 AI 遵守三個規則:
- Read-Only Public: 外部只能看到不可變的 StateFlow。
- Atomic Update: 狀態的改變必須是原子性 (Atomic) 的切換。
- stateIn: 使用
stateIn將冷流 (Cold Flow) 轉為熱流 (Hot Flow),確保 UI 旋轉時資料不流失。
Prompt 策略:
"請實作 NewsViewModel。 使用 getNewsRepository.getNewsStream()。 使用 map 將資料轉換為 UiState。 使用 stateIn 將 Flow 轉換為 StateFlow,設定 timeout 為 5000ms。"
AI 生成的防彈代碼:
// presentation/news/NewsViewModel.kt
@HiltViewModel
class NewsViewModel @Inject constructor(
private val repository: NewsRepository
) : ViewModel() {
// ✅ 完美的 UDF 實作
// AI 不需要手動維護 isLoading/isError,而是透過 map 轉換資料流
val uiState: StateFlow<NewsUiState> = repository.getNewsStream()
.map { result ->
// 當資料流進來時,決定狀態
if (result.isNotEmpty()) {
NewsUiState.Success(result)
} else {
NewsUiState.Error("目前沒有新聞")
}
}
.onStart { emit(NewsUiState.Loading) } // 開始時發射 Loading
.catch { emit(NewsUiState.Error(it.message ?: "Unknown")) } // 捕捉上游錯誤
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), // 關鍵:防手抖與旋轉
initialValue = NewsUiState.Loading
)
}
筆者點評:
看,程式碼變得多麼優雅。AI 甚至不需要寫 launch { ... } 和 _uiState.value = ...。透過 Flow 的操作符 (Operators),狀態的轉換變成了宣告式的管道。這消除了「忘記重置 Loading」的 Bug。
04. 進階陷阱:單次事件 (One-time Events)
這是 AI 最常搞砸的地方。
有些事情不是「狀態」,而是「事件」。例如:顯示 Toast、跳轉頁面。
如果你讓 AI 把 showToast 放在 UiState 裡:
- 用戶斷網 -> State 變成 Error。
- UI 顯示 Toast "網路錯誤"。
- 用戶旋轉螢幕 -> Activity 重建 -> 重新訂閱 State。
- UI 再次顯示 Toast "網路錯誤"。
這是典型的 Bug。對於這種「射後不理」的事件,我們需要教 AI 使用 Channel 或 SharedFlow。
Prompt 策略:
"請在 ViewModel 中增加一個 OneTimeEvent 的 Channel。 用於處理導航和 Toast 顯示。不要把這些放在 UiState 裡。"
AI 生成的代碼:
// 定義事件
sealed interface NewsEvent {
data class ShowToast(val message: String) : NewsEvent
data object NavigateToDetail : NewsEvent
}
class NewsViewModel(...) : ViewModel() {
// 使用 Channel 處理單次事件
private val _events = Channel<NewsEvent>()
val events = _events.receiveAsFlow()
fun onNewsClicked(id: String) {
viewModelScope.launch {
// 觸發事件
_events.send(NewsEvent.NavigateToDetail)
}
}
}
05. UI 層的消費:笨一點比較好
在 Clean Architecture 中,UI (Activity/Fragment/Composable) 應該是最笨的元件。它不應該有任何邏輯,只負責「把狀態畫出來」。
這種「笨 UI」非常適合 AI 生成。你只需要把 UiState 的定義丟給 AI,叫它寫 Compose 介面。
Prompt 策略:
"請寫一個 Jetpack Compose 的 NewsScreen。 輸入參數是 NewsUiState。 使用 when 表達式處理所有狀態:Loading 顯示 ProgressIndicator,Success 顯示列表,Error 顯示紅字。"
AI 生成的 UI 代碼:
@Composable
fun NewsScreen(state: NewsUiState) {
// AI 被強制處理所有狀態,少寫一個編譯器就會報錯
when (state) {
is NewsUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is NewsUiState.Success -> {
LazyColumn {
items(state.news) { article ->
NewsItem(article)
}
}
}
is NewsUiState.Error -> {
// 錯誤狀態
ErrorView(message = state.message)
}
}
}
06. 結論:把申論題變成選擇題
在 Presentation Layer,我們對 AI 的控制策略可以總結為一句話:「把申論題變成選擇題」。
- Bad (申論題): "請管理這個頁面的狀態。" -> AI 會發揮創意,寫出一堆布林值變數,導致狀態衝突。
- Good (選擇題): "請從 UiState 定義的三個狀態中選一個來顯示。" -> AI 只能乖乖填空,且互斥性保證了 UI 的一致性。
現在,我們的架構已經非常完整了。但是,隨著 Compose 的普及,如何讓 AI 寫出高效、可預覽且不與 ViewModel 耦合的 UI 元件,又是一門學問。
下一篇,我們將進入 UI 實作細節,看看如何打造 「笨」元件。
下集預告:【Part 8】現代化 UI (Compose):打造「笨」元件——利用 Preview 與 Slot API 讓 AI 生成完美的無狀態 UI

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。
系列文章目錄
- 【Android 架構系列 05】離線優先 (Offline-First):當 AI 遇上快取——如何設計 Single Source of Truth 避免資料打架
- 【Android 架構系列 06】錯誤處理 (Error Handling):別讓 AI 吞掉你的 Exception
- 【Android 架構系列 07】狀態的藝術:Presentation Layer 設計——用 Sealed Interface 終結變數大爆炸 (本文)
- 【Android 架構系列 08】現代化 UI (Compose):打造「笨」元件——利用 Preview 與 Slot API 讓 AI 生成完美的無狀態 UI
- 【Android 架構系列 09】遺產繼承 (Legacy Code):絞殺榕模式 (Strangler Fig)——如何利用 AI 安全地重構一座屎山