Android Jetpack Compose:UDF 單向資料流架構筆記


Android Jetpack Compose:UDF 單向資料流架構筆記

Table Of Contents


什麼是 UDF?

UDF = Unidirectional Data Flow(單向資料流)

這是一種 UI 架構設計模式,核心概念是:資料永遠只往一個方向流動

State → UI → Event → ViewModel → State(更新)→ UI(重繪)

不只 Android,前端框架如 React、Vue 也廣泛採用這個模式。

相關關鍵字

關鍵字 說明
UDF Unidirectional Data Flow(單向資料流)
MVI Model-View-Intent,UDF 的一種實作方式
State Hoisting Compose 的狀態提升,也是 UDF 概念
SSOT Single Source of Truth(單一資料來源)

傳統寫法 vs UDF 架構

❌ 傳統寫法:分散的狀態

class MainViewModel : ViewModel() {
    // 狀態分散在多個 Flow 中
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()
    
    private val _userName = MutableStateFlow("")
    val userName: StateFlow<String> = _userName.asStateFlow()
    
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    fun incrementCount() {
        _count.value = _count.value + 1
    }
}
// UI 層:分別收集多個 Flow
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val count by viewModel.count.collectAsStateWithLifecycle()
    val userName by viewModel.userName.collectAsStateWithLifecycle()
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
    
    // ...
}

問題:

  • 狀態分散,難以追蹤整體狀態
  • 狀態可能不一致(例如 isLoading = true 但 error 也有值)
  • 難以測試、Preview 困難

✅ UDF 架構:集中的狀態

// 1. 定義 UiState data class
data class MainUiState(
    val count: Int = 0,
    val userName: String = "",
    val isLoading: Boolean = false,
    val error: String? = null
)

// 2. ViewModel 只暴露單一 StateFlow
class MainViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(MainUiState())
    val uiState = _uiState.asStateFlow()

    fun incrementCount() {
        _uiState.update { it.copy(count = it.count + 1) }
    }
}
// 3. UI 層:只收集一個 Flow
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    MainContent(
        uiState = uiState,
        onIncrementClick = viewModel::incrementCount
    )
}

完整程式碼範例

MainViewModel.kt

// UiState:封裝所有 UI 狀態
data class MainUiState(
    val count: Int = 0
)

class MainViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(MainUiState())
    val uiState = _uiState.asStateFlow()

    fun incrementCount() {
        _uiState.update { it.copy(count = it.count + 1) }
    }
}

MainScreen.kt

// Preview:直接傳入假資料
@Preview(showSystemUi = true, showBackground = true)
@Composable
fun MainScreenPreview() {
    MyApplicationTheme {
        MainContent(
            uiState = MainUiState(count = 5),
            onIncrementClick = {}
        )
    }
}

// Screen 層:連接 ViewModel 與 UI
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    MainContent(
        uiState = uiState,
        onIncrementClick = viewModel::incrementCount
    )
}

// Content 層:純 UI(Stateless)
@Composable
fun MainContent(
    uiState: MainUiState,
    onIncrementClick: () -> Unit = {}
) {
    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text(text = "count: ${uiState.count}")
            Button(onClick = onIncrementClick) {
                Text(text = "Click me")
            }
        }
    }
}

架構圖

┌─────────────────────────────────────────────────────────────┐
│                        ViewModel                            │
│                                                             │
│   data class MainUiState(count, userName, isLoading...)    │
│                           │                                 │
│              ┌────────────┴────────────┐                   │
│              │  uiState: StateFlow     │                   │
│              └────────────┬────────────┘                   │
└───────────────────────────┼─────────────────────────────────┘
                            ↓  單一 collect
┌───────────────────────────┴─────────────────────────────────┐
│                      MainScreen                             │
│         val uiState = viewModel.uiState.collect()          │
│                           │                                 │
│                           ↓                                 │
│                      MainContent                            │
│              (Stateless, 只負責渲染)                         │
└─────────────────────────────────────────────────────────────┘

資料流向

User 點擊按鈕
MainContent: onClick = onIncrementClick
MainScreen: viewModel.incrementCount()
ViewModel: _uiState.update { it.copy(count = count + 1) }
StateFlow emit 新的 MainUiState
MainScreen: collectAsStateWithLifecycle() 收到更新
MainContent(uiState = uiState) → UI 重繪

重要概念

1. State Hoisting(狀態提升)

將狀態從子元件「提升」到父元件,子元件只接收參數:

// ✅ 好:Stateless,方便測試和 Preview
@Composable
fun MainContent(
    uiState: MainUiState,           // 狀態由外部傳入
    onIncrementClick: () -> Unit    // 事件回傳給外部
)

// ❌ 不好:自己管理狀態
@Composable
fun MainContent() {
    var count by remember { mutableStateOf(0) }  // 內部狀態
}

2. data class copy 不會造成全部重繪

Compose 的智能重組機制會比較各欄位的值:

data class MainUiState(
    val count: Int = 0,
    val userName: String = ""
)

// 當只有 count 變化時
_uiState.update { it.copy(count = it.count + 1) }

// Text("count: ${uiState.count}")    → 會重繪(值變了)
// Text("user: ${uiState.userName}")  → 不會重繪(值沒變)

3. Preview 變得簡單

@Preview
@Composable
fun MainScreenPreview() {
    MainContent(
        uiState = MainUiState(count = 99),  // 直接傳假資料
        onIncrementClick = {}
    )
}

優點總結

項目 傳統寫法 UDF 架構
狀態管理 分散在多個 Flow 集中在單一 UiState
狀態一致性 可能不一致 保證一致
可測試性 需 mock 多個 Flow 只需準備 UiState
Preview 困難 直接傳入假資料
除錯 追蹤多個狀態 單一狀態快照
擴展性 加欄位要加 Flow 只需修改 data class

參考資源


WRITTEN BY
Aki

熱愛寫code的開發者,專注於 Android 手機 Native App 開發,對於 IOS 也有涉略。閒暇之餘也學習 JavaScript 等前端框架