Clean Architecture这个概念由Robert C. Martin提出,核心思想只有一句话:业务逻辑不应该依赖任何外部细节,包括UI框架、数据库、网络库。放到Android场景里,就是把Activity从“什么都干”的上帝类中解放出来,让它只负责展示数据。本文将结合实际项目经验,聊聊这套架构在Android上的落地方式,包括分层设计、依赖注入、目录结构,以及一些容易踩的坑。

一、为什么Android项目需要Clean Architecture
先看一个典型的问题代码。很多项目早期的Activity大概长这样:在里面直接创建Retrofit实例发请求,拿到结果后直接更新UI,同时还要把数据存进Room数据库,再处理各种异常分支。这样的代码能跑,但随着业务膨胀,一个Activity轻松超过两千行,改一个小需求要在网络解析、数据库实体、UI刷新之间来回跳转,牵一发动全身。
问题的根源在于依赖方向错了。业务逻辑本应是项目最稳定的部分,却依赖了最不稳定的部分——UI组件和第三方库。Retrofit换个OkHttp,Room换成别的ORM,都会波及业务代码。Clean Architecture的解决思路是把代码切成若干同心圆层,依赖只能从外圈指向内圈,内圈对外圈的存在一无所知。这样一来,框架和工具都变成可替换的插件,业务逻辑成为整个应用的核心资产。
对Android开发者来说,这套架构带来的最直接收益有两个:一是可测试性,业务逻辑不依赖Android SDK,可以用纯JVM单元测试覆盖,跑起来毫秒级,不需要模拟器;二是多人协作的边界清晰,数据层、领域层、表现层可以并行开发,接口定义好之后互不阻塞。
二、三层结构与依赖规则的落地
在Android实践中,通常把Clean Architecture简化为三层:表现层、领域层、数据层。领域层包含UseCase和领域模型,是纯Kotlin代码,不依赖任何Android库;数据层负责实现Repository接口,封装Room、Retrofit等具体技术;表现层就是我们熟悉的Activity、Fragment和ViewModel。
这里有个关键点容易搞混:Repository的接口定义在领域层,实现放在数据层。这就是依赖倒置在架构层面的应用——内层定义规则,外层提供实现。配合Hilt或Koin这类依赖注入框架,表现层通过ViewModel调用UseCase,UseCase通过接口操作数据源,整个依赖链条始终由外向内。
下面用一个获取用户信息的例子展示完整链路。先定义领域模型和Repository接口,注意这个文件里没有任何Android或第三方依赖:
// domain/model/User.kt —— 纯Kotlin数据类
data class User(
val id: Long,
val name: String,
val email: String
)
// domain/repository/UserRepository.kt —— 只定义接口
interface UserRepository {
suspend fun getUserById(id: Long): User
}
// domain/usecase/GetUserUseCase.kt —— 业务逻辑
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Long): User {
// 这里可以放业务规则,比如参数校验、缓存策略
require(id > 0) { "用户ID必须大于0" }
return repository.getUserById(id)
}
}
接着是数据层的实现。这里的实体类UserEntity是Room的表结构,和领域模型User是两个不同的类,中间通过mapper函数转换。有人觉得这层转换多余,但它换来的好处很实在:数据库表结构变化不会污染业务层,网络字段和业务字段解耦后,后端接口改动的影响被限制在数据层内部。
// data/repository/UserRepositoryImpl.kt
class UserRepositoryImpl @Inject constructor(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUserById(id: Long): User {
val cached = localDataSource.getUser(id)
if (cached != null) return cached.toDomain()
return remoteDataSource.fetchUser(id).also {
localDataSource.saveUser(it)
}.toDomain()
}
}
// mapper函数放在数据层
private fun UserEntity.toDomain() = User(id, name, email)
最后是表现层。ViewModel持有UseCase而不是Repository,Activity只观察ViewModel暴露的状态:
class UserViewModel @Inject constructor(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: Long) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
_uiState.value = try {
UserUiState.Success(getUserUseCase(id))
} catch (e: Exception) {
UserUiState.Error(e.message ?: "加载失败")
}
}
}
}
三、项目目录结构与模块化建议
单module项目可以按包分层组织:domain、data、presentation三个顶层包,各自内部再按功能细分。这种组织方式简单直观,适合中小型项目。当项目规模继续扩大,再考虑拆分成Gradle多模块::app、:feature-xxx、:domain、:data-xxx等。多模块的好处是依赖关系由Gradle强制约束——:domain模块的build.gradle里不写任何Android库依赖,编译器直接保证它不会误用框架API,这比靠团队口头约定可靠得多。
推荐的模块划分方式是::domain为纯Kotlin库模块;:data依赖:domain并实现接口;各feature模块依赖:domain,通过依赖注入拿到:data的实现。如果项目用了Compose,表现层可以进一步按feature组织,每个feature自带ViewModel和Composable,配合Hilt的multi-module支持做得很顺。
需要提醒的是,模块拆分不是越细越好。每个模块都有构建配置的维护成本,小项目拆出七八个模块,改一次公共代码要重新编译一串依赖,反而降低开发效率。经验值是:团队超过五人、编译时间明显变长时再动手拆分。
四、常见的坑与过度设计
Clean Architecture被诟病最多的就是“样板代码太多”。一个简单的展示页要走ViewModel、UseCase、Repository、DataSource四层,新手很容易产生怀疑。对此我的建议是按业务复杂度伸缩:一个纯展示页完全可以ViewModel直接调Repository,等业务规则积累起来再抽UseCase;只有当某个操作包含真正的业务规则(比如下单前的库存校验、多数据源合并)时,UseCase才有存在价值。
第二个常见错误是领域模型和接口泄漏。有人图省事让UseCase直接返回Room的Entity,或者Repository接口参数用了Retrofit的ResponseBody,这样一来依赖规则形同虚设,框架替换的成本又回来了。写代码时可以做个简单自检:domain包下import语句里凡是出现android.、retrofit2.、androidx.room.开头的,基本都是违规。
第三个坑是单向数据流执行不彻底。有的ViewModel既暴露StateFlow又暴露一堆public方法供外部改状态,UI层随时可能拿到不一致的数据。正确做法是UI只读状态、通过事件方法触发变更,状态流转全部收敛在ViewModel内部,配合Activity重建时状态恢复才不会出问题。
总的来说,Clean Architecture的价值不在于照搬几个类和目录,而在于接受“业务逻辑不依赖框架”这条原则。理解了这一点,无论项目用MVVM还是MVI,用的是View体系还是Compose,都能搭出经得起业务迭代考验的结构。
Clean ArchitectureAndroid架构MVVM修改时间:2026-09-03 03:26:44