Android Clean Architecture 是做什麼的?
適用於 Android 和 KMP 專案的 Clean Architecture 模式。涵蓋模組邊界、依賴反轉、UseCase/Repository 模式,以及搭配 Room、SQLDelight 和 Ktor 的資料層設計。
啟用時機
- 規劃 Android 或 KMP 專案模組結構
- 實作 UseCases、Repositories 或 DataSources
- 設計層(領域層、資料層、表示層)之間的資料流程
- 使用 Koin 或 Hilt 設定依賴注入
- 在分層架構中使用 Room、SQLDelight 或 Ktor
模組結構
建議佈局
project/
├── app/ # Android 入口點、DI 接線、Application 類別
├── core/ # 共用工具、基礎類別、錯誤類型
├── domain/ # UseCases、領域模型、倉儲介面(純 Kotlin)
├── data/ # 倉儲實作、DataSources、資料庫、網路
├── presentation/ # 畫面、ViewModels、UI 模型、導航
├── design-system/ # 可重複使用的 Compose 元件、主題、排版
└── feature/ # 功能模組(可選,適用於大型專案)
├── auth/
├── settings/
└── profile/
依賴規則
app → presentation, domain, data, core
presentation → domain, design-system, core
data → domain, core
domain → core(或無相依)
core →(無相依)
重要:domain 層絕對不能依賴 data、presentation 或任何框架。它只能包含純 Kotlin 程式碼。
領域層
UseCase 模式
每個 UseCase 代表一個業務操作。使用 operator fun invoke 以保持呼叫端簡潔:
class GetItemsByCategoryUseCase(
private val repository: ItemRepository
) {
suspend operator fun invoke(category: String): Result<List<Item>> {
return repository.getItemsByCategory(category)
}
}
// 基於 Flow 的 UseCase,用於響應式資料流
class ObserveUserProgressUseCase(
private val repository: UserRepository
) {
operator fun invoke(userId: String): Flow<UserProgress> {
return repository.observeProgress(userId)
}
}
領域模型
領域模型是純 Kotlin 的 data class——不帶任何框架註解:
data class Item(
val id: String,
val title: String,
val description: String,
val tags: List<String>,
val status: Status,
val category: String
)
enum class Status { DRAFT, ACTIVE, ARCHIVED }
倉儲介面
定義在領域層,實作在資料層:
interface ItemRepository {
suspend fun getItemsByCategory(category: String): Result<List<Item>>
suspend fun saveItem(item: Item): Result<Unit>
fun observeItems(): Flow<List<Item>>
}
資料層
倉儲實作
協調本地和遠端資料來源:
class ItemRepositoryImpl(
private val localDataSource: ItemLocalDataSource,
private val remoteDataSource: ItemRemoteDataSource
) : ItemRepository {
override suspend fun getItemsByCategory(category: String): Result<List<Item>> {
return runCatching {
val remote = remoteDataSource.fetchItems(category)
localDataSource.insertItems(remote.map { it.toEntity() })
localDataSource.getItemsByCategory(category).map { it.toDomain() }
}
}
override suspend fun saveItem(item: Item): Result<Unit> {
return runCatching {
localDataSource.insertItems(listOf(item.toEntity()))
}
}
override fun observeItems(): Flow<List<Item>> {
return localDataSource.observeAll().map { entities ->
entities.map { it.toDomain() }
}
}
}
Mapper 模式
將 mapper 以擴充函數的形式放在資料模型旁:
// 在資料層
fun ItemEntity.toDomain() = Item(
id = id,
title = title,
description = description,
tags = tags.split("|"),
status = Status.valueOf(status),
category = category
)
fun ItemDto.toEntity() = ItemEntity(
id = id,
title = title,
description = description,
tags = tags.joinToString("|"),
status = status,
category = category
)
Room 資料庫(Android)
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: String,
val title: String,
val description: String,
val tags: String,
val status: String,
val category: String
)
@Dao
interface ItemDao {
@Query("SELECT * FROM items WHERE category = :category")
suspend fun getByCategory(category: String): List<ItemEntity>
@Upsert
suspend fun upsert(items: List<ItemEntity>)
@Query("SELECT * FROM items")
fun observeAll(): Flow<List<ItemEntity>>
}
SQLDelight (KMP)
-- Item.sq
CREATE TABLE ItemEntity (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
tags TEXT NOT NULL,
status TEXT NOT NULL,
category TEXT NOT NULL
);
getByCategory:
SELECT * FROM ItemEntity WHERE category = ?;
upsert:
INSERT OR REPLACE INTO ItemEntity (id, title, description, tags, status, category)
VALUES (?, ?, ?, ?, ?, ?);
observeAll:
SELECT * FROM ItemEntity;
Ktor 網路客戶端 (KMP)
class ItemRemoteDataSource(private val client: HttpClient) {
suspend fun fetchItems(category: String): List<ItemDto> {
return client.get("api/items") {
parameter("category", category)
}.body()
}
}
// HttpClient 設定,包含內容協商
val httpClient = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(Logging) { level = LogLevel.HEADERS }
defaultRequest { url("https://api.example.com/") }
}
依賴注入
Koin(支援 KMP)
// Domain 模組
val domainModule = module {
factory { GetItemsByCategoryUseCase(get()) }
factory { ObserveUserProgressUseCase(get()) }
}
// Data 模組
val dataModule = module {
single<ItemRepository> { ItemRepositoryImpl(get(), get()) }
single { ItemLocalDataSource(get()) }
single { ItemRemoteDataSource(get()) }
}
// Presentation 模組
val presentationModule = module {
viewModelOf(::ItemListViewModel)
viewModelOf(::DashboardViewModel)
}
Hilt(僅限 Android)
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
@HiltViewModel
class ItemListViewModel @Inject constructor(
private val getItems: GetItemsByCategoryUseCase
) : ViewModel()
錯誤處理
Result/Try 模式
使用 Result<T> 或自訂密封類別來傳播錯誤:
sealed interface Try<out T> {
data class Success<T>(val value: T) : Try<T>
data class Failure(val error: AppError) : Try<Nothing>
}
sealed interface AppError {
data class Network(val message: String) : AppError
data class Database(val message: String) : AppError
data object Unauthorized : AppError
}
// 在 ViewModel 中——對應到 UI 狀態
viewModelScope.launch {
when (val result = getItems(category)) {
is Try.Success -> _state.update { it.copy(items = result.value, isLoading = false) }
is Try.Failure -> _state.update { it.copy(error = result.error.toMessage(), isLoading = false) }
}
}
慣例插件 (Gradle)
對於 KMP 專案,使用慣例插件來減少建構檔案的冗餘:
// build-logic/src/main/kotlin/kmp-library.gradle.kts
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
kotlin {
androidTarget()
iosX64(); iosArm64(); iosSimulatorArm64()
sourceSets {
commonMain.dependencies { /* 共用依賴 */ }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
在模組中套用:
// domain/build.gradle.kts
plugins { id("kmp-library") }
應避免的反模式
- 在
domain中匯入 Android 框架類別——保持純 Kotlin - 將資料庫實體或 DTO 暴露給 UI 層——務必對應到領域模型
- 在 ViewModel 中放置業務邏輯——提取到 UseCases
- 使用
GlobalScope或非結構化的協程——使用viewModelScope或結構化並行 - 臃腫的倉儲實作——拆分為專注的 DataSources
- 循環模組依賴——如果 A 依賴 B,則 B 不得依賴 A
參考資料
請參閱技能:compose-multiplatform-patterns 以了解 UI 模式。
請參閱技能:kotlin-coroutines-flows 以了解非同步模式。