[CODE]

Android Clean Architecture 重訪:三年維護模式真正讓你失去了什麼

14 年 Android 開發、3 年維護模式,然後是 TackleBox:一個刻意設計的 greenfield 重置,用來找出現代 Kotlin + Compose + Clean Architecture 留下的盲點在哪裡。

2 min read AI 生成
android kotlin clean-architecture jetpack-compose

維護模式的問題不是讓你變慢,而是讓你看不見自己失去了什麼。

2025 年,我有三個 Android 客戶 app 在維護中。每個都活著,每個都在出貨。這個狀態讓人誤以為「沒問題」——直到你需要接一個全新的 Android 專案,打開空白的 project,才知道有些東西在你沒有注意的時候悄悄退化了。

我從 Kotlin 問世前就寫 Android,一路寫了 14 年,最後一個真正的 greenfield 是 2022 年。那之後進入維護期:patch、版本升級、修 bug,但不是從零開始建新東西。維護既有的程式碼不會逼你面對自己忘了什麼。差距是隱形的,要等到你需要用上那個已經退化的能力才會現形。

TackleBox 是一個釣竿庫存 app,我自己的專案。故意壓小範圍:沒有 deadline,沒有客戶,目的只有一個——弄清楚三年在哪裡留下了盲點。答案是:分散在工具層,架構層完好無缺。後者完好是因為結構執行的邊界,不靠你有沒有在寫新程式碼來維持。

第一個決策就讓步了

開空白專案,第一件事:選 annotation processor。KSP——Kotlin Symbol Processing、Kotlin-native、無反射開銷——是 2026 年的自然選擇。Hilt 的 KSP 整合有相容性問題。最後用了 kapt

一行產品程式碼都還沒寫,第一個決定就妥協了。這不是 blocking 的問題,kapt 能用。但這個摩擦告訴我一件事:看過 changelog 和真的能用它出貨,是不同的事。Changelog 不會告訴你哪些整合實際上是穩定的,哪些還在消化兼容性問題。這個你要試過才知道。

三層模型,不是一層

TackleBox 最核心的架構決策是三種獨立的 Model,各屬於各自的層。

// Data Layer:RodEntity 知道 Room,不知道其他
@Entity(tableName = "rods")
data class RodEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val series: String,
    val model: String,
    val brand: String,
    @ColumnInfo(name = "length_mm") val lengthMm: Int,
    @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)

// Domain Layer:Rod 是純 Kotlin,沒有 android.* 或 Room 的任何東西
data class Rod(
    val id: Long,
    val series: String,
    val model: String,
    val brand: String,
    val lengthInMm: Int
)

// Presentation Layer:RodUiModel 是顯示用格式
data class RodUiModel(
    val id: Long,
    val displayName: String,   // "Megabass Ballistick 100ML"
    val lengthDisplay: String  // "3.06m"
)

Domain 層的 RodRepository 回傳 Flow<List<Rod>>,不是 Entity,不是 UI Model。Repository 介面定義在 Domain,實作在 Data Layer。ViewModel 負責 Domain Model → UI Model 的轉換,再推進 state。每一層擁有自己的資料格式,不跨越自己的邊界。

Mapper 的放置位置是細節,但細節裡有邏輯。RodEntity.toDomain() 放在 Data Layer,因為 Data Layer 依賴 Domain Layer,兩種型別都看得到。Rod.toUiModel() 放在 Presentation Layer,因為 Presentation Layer 依賴 Domain Layer,同樣兩種型別都看得到。Domain Layer 不放任何 Mapper,因為 Domain 不能依賴外層。換句話說:Mapper 需要同時知道輸入型別和輸出型別。Domain Layer 只知道自己的型別,無法建立任何 Mapper。這條規則讀起來清楚,在第一個真實 injection graph 裡,還是要停下來確認一次。

為什麼是 Flow,不是 suspend

RodRepositoryFlow<List<Rod>> 而不是 suspend fun getAll(): List<Rod>。這個選擇不是 coroutines 偏好問題,是反應性問題。

Room 的 DAO 每次底層資料表變動就發出新的 list。如果 repository 界面回傳快照,ViewModel 每次寫入後都要手動重新 fetch。如果是 Flow,寫入自動反映在所有訂閱者上。在一個使用者持續新增刪除釣竿的 app 裡,這是整整一類資料過期 bug 的差距。

StateFlow 的論點是依賴消除,不是「比較新」

所有遷移指南都說從 LiveData 換到 StateFlow。理由通常停在「更 Kotlin-native」。結構層面的理由是:兩者對依賴圖的影響不同。

LiveData 需要 LifecycleOwner 才能觀察。這代表任何需要觀察狀態的程式碼都要 import android.lifecycle,這個依賴就滲進了 ViewModel。StateFlow 是純 Kotlin,ViewModel 本身不帶任何 android.*。這和三模型分層是同樣的道理:compiler 告訴你邊界什麼時候被穿越了。

class RodViewModel(
    private val repository: RodRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow<RodUiState>(RodUiState.Loading)
    val uiState: StateFlow<RodUiState> = _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            try {
                repository.getAllRods()
                    .map { rods -> rods.map { it.toUiModel() } }
                    .collect { uiModels -> _uiState.value = RodUiState.Success(uiModels) }
            } catch (e: Exception) {
                _uiState.value = RodUiState.Error(e.message)
            }
        }
    }
}

UI 狀態用 sealed class 建模:LoadingSuccess(rods)Error(message)。Compose 層被迫窮舉所有情況。一次性事件——刪除確認 toast、返回上一頁——走 Channel,不走 StateFlowStateFlow 在每次新的訂閱時重播最後的值,新訂閱者進來會再看到一次刪除確認。Channel 每個事件只消費一次。

ViewModel 不能持有 Activity reference 這條規則,Compose 之前就存在。用了 Compose 之後爆炸半徑大了很多。Framework 在 configuration change 時重建 Activity,ViewModel 存活。每次旋轉、每次進入分割畫面都 leak 一個。規則比 Compose 更老;代價是 Compose 之後才放大的。

什麼撐住了,什麼沒有

三年維護模式之後,完整轉移過來的:dependency inversion、domain layer 不帶框架依賴的價值、ViewModel 不持有 Android reference 的設計原則。這些在維護模式下不會退化,因為是結構性的。Compiler 負責執行,不需要你寫新程式碼來維持。

沒有轉移的:coroutine scope 在多層嵌套下的語意直覺。不是語法的問題,語法一天就讀懂了。是心智模型的問題:使用者動作觸發的 coroutine,在另一個 scope 裡啟動子 coroutine,取消語意是什麼。這個直覺要做過幾個真實 feature 才會重建,不是讀文件能補回來的。Hilt component lifecycle 的細節也是:@Singleton@ViewModelScoped、unscoped 三者的區別,文件上讀得懂,在第一個真實的 injection graph 裡決定有狀態的資料庫連線和無狀態的格式化工具分別掛在哪一層,還是要停下來確認一次。Compose Navigation 2.8 的 type-safe route 是另一個重學——遷移指南預設你熟悉字串式 route,但那個 API 在 Compose 問世前我就不再用了,最後兩份文件並行讀。

沒有什麼是 blocking 的。架構把骨架撐住,讓 API 知識有地方重新落地。三年維護模式讓我花了幾週重學工具層,架構層什麼都不需要重建。

這不是巧合。結構執行的邊界,設計本意就是這樣——不靠人記著,不靠 review comment 提醒,靠 compiler 報錯。架構的價值不是讓平台知識的缺口變小,而是讓缺口找得到。