2011 年開始寫 Android,那時候還是 Gingerbread、ListView、AsyncTask、HttpClient 的年代。後來走過 RecyclerView、Dagger 2、Material Design、MVVM,後期才開始碰 Kotlin,但沒有到深入。2022 年前後,手上的 Android 工作大部分都變成維護:讓舊 App 繼續跑、修 bug、不追新版本。Compose 穩定了、Kotlin 2.0 出了,但只要 App 還在動,這些就不是緊急的事。
TackleBox 是我這段時間第一個從頭開始的 Android 專案。一個小釣具庫存管理 App,真正的目的是好好學 Jetpack Compose,順便用現代的方式重新走一遍 Clean Architecture。這篇不是 Android 新手的筆記——架構上的直覺還在——而是把這幾年沒追的東西補起來的過程。
Clean Architecture:概念熟,但 Kotlin 版本有差
三層分離(domain / data / presentation)不是新東西。MVP 時代就在用,MVVM 的浪潮也把大家推往類似的方向。讓我沒預期到的是:Kotlin 版本的邊界比以前嚴格得多。
Java 時代,沒有什麼東西真的能阻止你在「domain 層」裡 import Context。你靠 code review 和自律。Kotlin 的慣例是 domain 層是純 Kotlin,完全不碰 android.*,這個約束有沒有被遵守,比以前更容易一眼看出來——甚至在某種程度上可以靠模組邊界來強制執行。
Domain 層就一條規則
不能有任何 Android import。 沒有 Context、沒有 LiveData、沒有任何 android.*。這個約束讓 domain 層真正可以在不需要裝置或模擬器的情況下測試。
// domain/model/TackleItem.kt
data class TackleItem(
val id: Long,
val name: String,
val type: TackleType,
val quantity: Int,
val notes: String?
)
// domain/repository/TackleRepository.kt
interface TackleRepository {
suspend fun getAll(): List<TackleItem>
suspend fun save(item: TackleItem)
suspend fun delete(id: Long)
}
suspend function 是 Kotlin coroutine——這裡是這幾年沒追的部分開始出現的地方。Java 時代的非同步是 callback 或 AsyncTask。Coroutine 的模型更乾淨,但 scope 和 cancellation 的心智模型要重新建起來。
Data 層:Room 還在,但 Kotlin 版本順多了
Room 從 2017 年就有,概念不陌生。變化在於 Kotlin 版本的 mapper 寫起來差很多。
@Entity(tableName = "tackle_items")
data class TackleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val type: String,
val quantity: Int,
val notes: String?
)
class TackleRepositoryImpl @Inject constructor(
private val dao: TackleDao
) : TackleRepository {
override suspend fun getAll(): List<TackleItem> =
dao.getAll().map { it.toDomain() }
override suspend fun save(item: TackleItem) =
dao.upsert(item.toEntity())
}
Java 版本要寫很多樣板。Kotlin extension function(toDomain()、toEntity())讓 mapper pattern 真的可讀。沒什麼革命性,但摩擦力夠低,你才不會偷懶省略它。
Presentation 層:LiveData 沒了
這是最大的心智轉換。舊時代(包括早期 Kotlin)的標準是 LiveData——它感知 lifecycle、綁 LifecycleOwner、在 UI 層收。
現代 Compose App 用 StateFlow,差別比表面看起來大:
// 計劃中的寫法 — TackleBox 尚未接上
@HiltViewModel
class TackleListViewModel @Inject constructor(
private val repository: TackleRepository
) : ViewModel() {
private val _items = MutableStateFlow<List<TackleItem>>(emptyList())
val items: StateFlow<List<TackleItem>> = _items.asStateFlow()
init {
viewModelScope.launch {
_items.value = repository.getAll()
}
}
}
StateFlow 是純 Kotlin——ViewModel 本身不需要任何 Android dependency。LiveData 要綁 LifecycleOwner 才能 observe,把 Android 拉進了本來不需要的地方。StateFlow 給你一樣的響應式行為,沒有這個耦合。
一個以前沒想清楚的細節:ViewModel 絕對不能持有 Activity 的參考。Compose 的 lifecycle 處理方式和 XML view 不同,Activity 參考進了 ViewModel 會在旋轉或 split-screen 這類 config change 時造成洩漏——Framework 會重建 Activity,但 ViewModel 活著。
KSP vs KAPT
kapt 是 Java 時代拿來跑 Dagger 2、Room 的 annotation processor。KSP 是 Kotlin 原生的替代方案——設計上為 Kotlin 而生,build 速度快。
我計劃用 KSP 跑 Room 和 Hilt,但遇到相容性問題只好先退回 kapt:
// build.gradle.kts
// ksp(libs.room.compiler) // 暫時擱置——Hilt 相容性問題
kapt(libs.room.compiler)
這不是大問題,只是沒預期到的摩擦。等 Hilt 的 KSP 支援穩定之後再來做遷移。
真正改善的地方:Gradle Version Catalogs(libs.versions.toml)。Java 時代跨多個 build.gradle 管版本是真的很痛。把所有版本號集中在一個 TOML 檔案,是那種日常工作差很多、但很難被某個 demo 展示出來的改善。
沒追的那幾年積了哪些債
主要是新的心智模型,不是語法。Kotlin 語法大部分接得上,卡的是 coroutine scope 語義、Hilt 的 component 層級(@Singleton vs @ViewModelScoped vs 不加 scope 各自代表什麼)、還有 Compose 的 recomposition 觸發邏輯。
Navigation Compose 的 type-safe route 也卡了一下。新的 API(Compose Navigation 2.8 引入)比字串 route 好很多——沒有拼字錯誤、編譯器幫你抓型別不符。但遷移文件預設你知道舊的字串做法,所以兩份文件我都翻了。
架構直覺是活的。DI 的思路、關注點分離的價值、可獨立測試的 domain 層——這些沒有退步。過期的是特定 API 和新的非同步、UI 層的心智模型。要讓這些回來,靠的是真的把東西做出來,不是看文件。