Clean Architecture — 由Robert Martin(Uncle Bob)于2012年提出的多层架构,将应用程序划分为独立的层:Domain(Entities、Use Cases)、Data(Repositories、DataSources)和Presentation(ViewModels、Views)。主要原则 — Dependency Rule:依赖向内指向,外层依赖内层,而非反之。Clean Architecture应用于具有高业务逻辑复杂性的移动开发项目。更多内容 — 见《The Clean Architecture》一书。
要点
Clean Architecture — 由Robert Martin(Uncle Bob)于2012年制定的架构模式。核心思想 — 将应用程序划分为具有严格依赖规则的层:层内代码不知道外部代码。外层(UI、框架、数据库)— 实现细节。内层(业务逻辑、企业规则)— 应用程序的本质。
Clean Architecture的层在移动开发中:1)Domain — Entities(业务对象)和Use Cases(用例);2)Data — RepositoryImpl(仓库实现)、DataSources(网络、数据库、缓存);3)Presentation — ViewModels、Views(Compose/SwiftUI)。Domain — 最内层,没有依赖关系。Data依赖Domain(实现仓库接口)。Presentation依赖Domain(调用Use Cases,订阅结果)。
| 层 | 包含 | 依赖 |
|---|---|---|
| Domain | Entities、Use Cases、Repository Interfaces | 无(纯Kotlin/Swift) |
| Data | RepositoryImpl、DataSources(API、DB、Cache) | Domain、Retrofit、Room、Ktor |
| Presentation | ViewModels、Views、Composables | Domain、Jetpack、SwiftUI |
Dependency Rule — Clean Architecture唯一严格的规则。源代码只能引用自身内部的层或更下层(更靠近中心)。Presentation导入Domain。Domain不导入Data或Presentation。这是通过依赖反转原则(Dependency Inversion Principle)实现的:Domain定义Repository接口,Data实现它。Presentation依赖UseCase抽象,而非具体仓库。
Domain — 应用程序中最稳定的层。Entities — 独立于框架的业务对象:User、Product、Order。Use Cases — 具有一个invoke方法(或Kotlin中的operator fun invoke)的类,实现一个场景:GetUserUseCase、PlaceOrderUseCase、CalculateTotalUseCase。Repository Interfaces — 数据访问抽象,在Domain中定义,在Data中实现。Domain不包含Android SDK、iOS UIKit、Retrofit、Room — 仅纯Kotlin或Swift。
// Entity — 业务对象 (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — 数据抽象 (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — 一个场景 (Domain)
final class GetUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: Int) async throws -> User {
return try await repository.getUser(id: id)
}
}
Use Case — 「一个方法一个类」 — 不是教条,而是实际建议。当Use Case变得更复杂(验证+日志+仓库调用)时,其方法按意义分组:UserUseCase.getUser、UserUseCase.searchUsers、UserUseCase.deleteUser。重要的是 — Use Case不应知道数据来自哪里(网络、数据库、缓存)以及谁在显示它们(Compose、SwiftUI)。在IT Sectr,我们为每个包含业务规则、验证或组合两个来源数据的操作分配一个Use Case。
Domain的纯净性通过在层边界进行DTO映射来实现。Data层接收JSON模型(DTO),将其映射为Domain Entity。Presentation接收Domain Entity,映射为ViewModel(DisplayItem)。Domain Entity从不包含Retrofit、Room、Codable注解 — 这保证了在从Room切换到Realm数据库或从Retrofit替换为Ktor时无需修改该层。
Data Layer — 在Domain中定义的接口的实现。包含RepositoryImpl(实现UserRepository的类)和DataSources(RemoteDataSource — API、LocalDataSource — 数据库、CacheDataSource — SharedPreferences/NSUserDefaults)。Data层依赖Domain(导入仓库接口和Entities)和框架(Retrofit、Room、Ktor、CoreData)。RepositoryImpl对Domain隐藏数据源 — Use Case不知道数据来自网络还是缓存。
// DTO — 网络模型 (Data)
data class UserDto(
@SerializedName("id") val id: Int,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
@SerializedName("email") val email: String
)
// RepositoryImpl — 实现 (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// 尝试从缓存获取
localDataSource.getUser(id)?.let { return it.toDomain() }
// 如果没有 — 从网络加载
val dto = remoteDataSource.fetchUser(id)
val user = dto.toDomain()
localDataSource.saveUser(user)
return user
}
override suspend fun getUsers(): List<User> {
return remoteDataSource.fetchAllUsers().map { it.toDomain() }
}
}
// Mapper — DTO ↔ Domain转换
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Data Layer中的缓存策略:RepositoryImpl首先检查本地存储,如果没有数据 — 从网络加载并保存本地。如果网络不可用 — 返回带有isStale标记的过期数据。Domain中的Use Case不知道此策略 — 通过Repository.getUser(id)接收User。策略的改变(例如每15分钟使缓存失效)不影响Domain和Presentation。
Android中的模块化 — Kotlin Multiplatform允许将Domain移至独立的KMP模块,不依赖Android SDK。Data — 依赖Domain的独立模块。Presentation — 依赖Domain的Android模块。Gradle依赖:domain(pure Kotlin)、data(domain + Retrofit + Room)、app(domain + presentation + Hilt)。这种模块化对大型项目是强制性的 — CI单独构建Domain,Domain的单元测试不需要Android模拟器。
Presentation Layer — Clean Architecture的最外层。包含ViewModels(Android)/ ObservableObject(iOS)和Views(Compose/SwiftUI)。ViewModel调用Use Case,接收结果并将其转换为UI状态(State)。View订阅State并显示。Presentation依赖Domain — 导入Use Cases和Entities。Presentation不导入Data Layer — 数据通过Use Case传入,后者内部使用Repository。
Clean Architecture中的ViewModel不包含业务逻辑 — 调用Use Case。如果Use Case返回User,ViewModel将其转换为UserDisplayItem(name、emailFormatted、avatarUrl)— 纯展示模型。Use Case不知道DisplayItem — 返回Entity。这种分离允许在没有UI的情况下测试Use Case,以及在没有UseCase的情况下(通过mock)测试ViewModel。在IT Sectr,我们严格遵守:Use Case — 业务逻辑,ViewModel — 仅展示,View — 仅显示。
// Use Case (Domain) — 纯业务逻辑
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — 仅展示
class UserViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
val state: StateFlow<UserScreenState> = _state.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_state.value = UserScreenState.Loading
val user = getUserUseCase(id)
val displayItem = UserDisplayItem(
name = user.name,
email = user.email,
initials = user.name.split(" ").joinToString("") { it.first().toString() }
)
_state.value = UserScreenState.Success(displayItem)
}
}
}
data class UserDisplayItem(
val name: String,
val email: String,
val initials: String
)
sealed interface UserScreenState {
data object Loading : UserScreenState
data class Success(val displayItem: UserDisplayItem) : UserScreenState
data class Error(val message: String) : UserScreenState
}
Navigation在Presentation层 — 也是外环的一部分。Clean Architecture不指定导航机制 — 可以是NavController(Compose)、NavigationStack(SwiftUI)、Coordinator(UIKit)或Router(VIPER)。重要的是:导航决策由Presentation做出,但导航不应渗透到Use Case中。Use Case返回结果,ViewModel决定导航到哪个屏幕。在Clean Architecture中,导航是一个可以在不修改Domain的情况下替换的细节。
Android上的Clean Architecture通过Gradle模块实现:domain(pure Kotlin)、data(domain + Retrofit + Room)、presentation(domain + Compose)。文件夹结构:domain/user/User.kt、GetUserUseCase.kt、UserRepository.kt;data/remote/UserRemoteDataSource.kt、local/UserDao.kt、repository/UserRepositoryImpl.kt;presentation/ui/user/UserViewModel.kt、UserScreen.kt。DI(Hilt)连接各层:UserRepositoryImpl绑定到domain模块中的UserRepository接口。
iOS上的Clean Architecture使用SPM或Xcode组而不使用独立模块(由于Xcode的限制)。Domain — 包含不导入UIKit或SwiftUI文件的文件夹。Data — 包含APIClient、CoreDataStack、RepositoryImpl的文件夹。Presentation — 包含ViewModels和SwiftUI Views的文件夹。DI通过构造函数或App中的assembly进行。主要调用 — 通过UseCase.execute()的async/await,并检查MainActor以进行UI更新。
// Data Layer: Remote DataSource (iOS)
final class UserRemoteDataSource {
private let apiClient: APIClient
func fetchUser(id: Int) async throws -> UserDTO {
return try await apiClient.get("/users/\(id)")
}
}
// Repository Implementation (Data)
final class UserRepositoryImpl: UserRepository {
private let remote: UserRemoteDataSource
private let local: UserLocalDataSource
func getUser(id: Int) async throws -> User {
if let cached = try await local.getUser(id) {
return cached
}
let dto = try await remote.fetchUser(id)
let user = dto.toDomain()
try await local.saveUser(user)
return user
}
}
// Presentation: ViewModel + SwiftUI View
@MainActor
final class UserViewModel: ObservableObject {
@Published private(set) var state: UserScreenState = .loading
private let getUserUseCase: GetUserUseCase
func loadUser(id: Int) {
Task {
state = .loading
if let user = try? await getUserUseCase.execute(id: id) {
state = .success(user)
} else {
state = .error("Failed to load")
}
}
}
}
IT Sectr项目中的Clean Architecture — 我们30天以上项目的标准。自2022年起,我们使用带有Kotlin Multiplatform的三层架构用于Android/iOS。Domain — 共享KMP模块,Data — 平台模块(Android上的Retrofit,iOS上的URLSession),Presentation — 原生UI。这使得iOS和Android之间60–80%的业务逻辑代码共享,与两个独立实现相比,开发时间缩短30–40%。
常见问题
最少三层:Domain、Data、Presentation。对于大型项目,会添加Framework(Android SDK/iOS UIKit依赖)和Device(GPS、相机、传感器)。层的数量 — 不是硬性规则,而是便利性问题。重要的是遵守Dependency Rule:依赖向内指向Domain。可以从三层开始,随着项目增长添加层。
是的 — 与MVVM相比增加30–50%,因为分离了仓库接口、Use Cases和映射器。对于简单的CRUD应用程序来说,这过于冗余。Clean Architecture适用于具有复杂业务逻辑的项目,其中可测试性和层隔离比开发速度更重要。对于MVP或原型,请使用MVVM — Clean Architecture会减慢启动速度。
可以,这是常见做法。Use Cases保留在Domain,Presentation使用MVI循环(Intent → Reducer → State)。Data Layer — 相同,Domain — 相同。Presentation中的MVI提供可预测的屏幕状态,Clean Architecture — 业务逻辑隔离。这种组合用于有数十名开发人员的大型项目。
当操作包含业务规则时才需要Use Case:验证、组合两个来源的数据、计算、日志记录、检查访问权限。没有额外逻辑的简单getUser(id)请求可以直接从ViewModel调用Repository。然而,为了架构的一致性,许多团队为Repository的每个公共方法创建Use Case — 这增加了5–10%的代码,但简化了阅读。
Domain:使用mock Repository进行Use Cases的单元测试 — 纯Kotlin/Swift,无需Android SDK。Data:使用mock/fake DataSource进行RepositoryImpl的集成测试。Presentation:使用mock UseCase进行ViewModel测试。得益于Dependency Rule,每个层都可以隔离测试。在IT Sectr,Domain的覆盖率达到95%,Data — 70–80%,Presentation — 60–70%。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。