beio Logobeio

【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 = trueisError = 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 遵守三個規則:

  1. Read-Only Public: 外部只能看到不可變的 StateFlow。
  2. Atomic Update: 狀態的改變必須是原子性 (Atomic) 的切換。
  3. 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 裡:

  1. 用戶斷網 -> State 變成 Error。
  2. UI 顯示 Toast "網路錯誤"。
  3. 用戶旋轉螢幕 -> Activity 重建 -> 重新訂閱 State。
  4. UI 再次顯示 Toast "網路錯誤"

這是典型的 Bug。對於這種「射後不理」的事件,我們需要教 AI 使用 ChannelSharedFlow

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

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner