我参与过几个从 MVP 一路演进到 MVVM 的 Android 老项目,代码量上来之后,真正让人头疼的往往不是某一个功能的复杂度,而是整个模块之间的依赖关系。Clean Architecture 这个概念在 Android 圈子里热度一直很高,但说实话,能在百人级协作的大型项目里真正落地并保持不腐化的并不多。这篇文章我会结合自己在多个大型项目的实操经验,把这个架构方案拆开揉碎,说说哪些东西值得抄,哪些东西该扔,以及如果你决定引入 Clean Architecture,应该从哪里动手。
先说清楚,这篇文章不是《Clean Architecture》那本书的读后感。我会直接用 Android 项目的真实场景来讲,包括模块怎么分、接口怎么定、UseCase 的粒度怎么把握、Hilt 的依赖注入怎么接,以及最后最常见的坑。无论你是准备重构现有项目,还是新项目要做技术选型,这篇文章应该都能让你少踩几个坑。
1. 为什么要做架构分层:先弄明白 Clean Architecture 到底解决什么问题
1.1 大型 Android 项目的真实痛点
当项目里的 Java/Kotlin 文件超过两千个,参与开发的 Android 工程师超过两位数时,代码库的维护难度是指数级上升的。我见过不少项目最终演变成这样:一个 Activity 里塞了几千行业务逻辑,一个 Repository 变成"上帝类",改一个字段要牵连四五个模块,编译时间越来越长,新同事入职三个月还没搞清楚某个数据到底是从哪来的。
这些问题不是靠 Code Review 或者代码规范能根治的。Code Review 只能管住代码风格和明显的逻辑漏洞,管不住架构边界。当业务方提了一个新的需求,开发同学下意识地把逻辑写在 Activity 里,然后"以后再说",等以后真来了,代码已经盘根错节,动哪里都疼。
回到根子上,问题出在缺少清晰的架构约束。Clean Architecture 之所以被引入到 Android 项目里,不是为了追新,而是因为当"业务复杂度"和"团队协作规模"同时上升时,你需要一套显式的规则来约束代码的走向,让依赖关系可被检查和感知,而不是靠每个人的自觉。
1.2 核心思想不是那几张圆图,而是依赖规则
很多介绍 Clean Architecture 的文章会画同心圆图,把 Entity、Use Case、Interface Adapter、Framework 分成四层。图确实好看,但落到 Android 工程里,我强烈建议你不要照抄四层,而是压缩成三层:Domain 层(纯业务逻辑)、Data 层(数据来源)、Presentation 层(UI 与平台交互)。
分层架构的根基不是物理上的隔离,而是那条铁律:依赖只能从外层指向内层,内层不依赖外层。Domain 层的代码不 import 任何 Android SDK 的类,不依赖 Retrofit、Room、Hilt,它只关心业务规则本身。Presentation 层可以依赖 Domain 层,Data 层也可以依赖 Domain 层,但 Domain 层谁都不许依赖。
这样做有几个实际收益。第一,Domain 层的纯 Kotlin 代码可以被 JVM 单元测试直接跑起来,不依赖模拟器,测试速度极快。第二,业务规则的核心逻辑不会因为 UI 的改版、数据库的迁移、网络库的替换而被动重写。第三,团队协作时,每个人负责的边界非常清晰,不会出现两个人同时改同一个文件还互相冲突的尴尬。
用一句话总结:分层不是目的,依赖方向的正确才是目的。你画多少层圆圈都不重要,重要的是你能否保证内层不感知外层的存在。
2. 模块划分与包结构设计:大型项目落地的第一关
2.1 多模块 Gradle 工程是 Clean Architecture 的物理基础
在几个模块组成的工程里谈 Clean Architecture,就像在一间大开间里谈独立办公室——多少有点勉强。大型项目建议至少拆成:domain、:data、:presentation三个 Gradle 模块。
模块拆分的本质不是让编译并行,而是用工程结构强制约束依赖方向。:domain模块的build.gradle里只依赖 Kotlin 标准库,不依赖 Android Gradle Plugin(AGP)相关的库,那 Domain 层想依赖 Android 的Context都做不到,架构约束就变成了物理约束,比任何 Code Review 规则都硬。
我在实际操作时,build.gradle.kts大概长这样:
// :domain 模块 plugins { id("java-library") id("org.jetbrains.kotlin.jvm") } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") }注意,:domain模块甚至可以不加com.android.library插件,它就是纯 JVM 模块,好处是编译速度极快,BadBoy 插件也没法往里塞 Android 依赖。
:data模块需要依赖网络库、数据库、SharedPreferences 等具体的数据源实现,但它依赖的接口(也就是 Port)是从:domain模块引入的。:presentation或者按功能拆分的各个:feature-xxx模块,则依赖:domain和:data。
2.2 如果项目已经很大,拆不动模块怎么办
现实往往很骨感。很多老项目根本做不到一次拆成几个 Gradle 模块,这时候你至少要在单模块内用包结构强制分层。
举一个我实际用过的包结构:
com.example.app ├── domain │ ├── model │ ├── repository │ ├── usecase │ └── di ├── data │ ├── local │ ├── remote │ ├── repositoryimpl │ └── mapper └── presentation ├── ui ├── viewmodel └── adapter单模块内分包的约束力比 Gradle 模块弱不少,因为 Java/Kotlin 的包访问控制在同模块内基本是透明的,你可以用ArchUnit之类的工具在 CI 里跑规则,禁止domain包下的类 importpresentation包下的类。我建议新项目直接上多模块,老项目分批把核心业务抠出来,先抠domain模块,收益最大、工作量相对可控。
2.3 分层与分包的功能边界
不管你是多模块还是单模块分包,每个层的职责要明确,尤其是边界上的"灰色地带"归谁管要提前说死。我的约定如下:
- Domain 层:业务实体、UseCase、Repository 接口、异常定义。
- Data 层:网络 API、数据库 DAO、SharedPreferences 封装、Repository 接口的实现类、DTO/VO 与实体之间的 Mapper。
- Presentation 层:Activity、Fragment、Adapter、ViewModel、UI State 的定义。
这个约定看起来很简单,执行起来最容易出问题的就是 Mapper 和 DTO 到底放哪。我的答案是:DTO 放 Data 层,Entity 放 Domain 层,Mapper 放 Data 层。因为只有 Data 层需要关心"网络字段名叫user_name,内存里叫userName"这种映射问题,Domain 层和 Presentation 层都不应该接触到 DTO,否则你的架构边界就已经破了。
3. 核心细节:Port、UseCase、Repository 在 Android 中的落地姿势
3.1 你需要 Port 吗——接口应该定义在哪一层
有个热词问"Clean Architecture 中需要 Port 么",这确实是个好问题。在原著里面,Port 指的是连接内外层之间的接口,比如数据库 Port、外部服务 Port。在 Android 世界里,最常见的 Port 就是Repository 接口。
我的建议是:Repository 接口要定义在 Domain 层,具体实现在 Data 层。为什么?因为 Domain 层的 UseCase 需要依赖 Repository,但它不能依赖具体的实现。如果 UseCase 直接依赖一个 Retrofit 的 API 接口,那你所有业务逻辑就和网络库绑死了,测试时还得 mock Retrofit 的复杂结构。
举个常见例子,定义在:domain模块的仓库接口:
interface AuthRepository { suspend fun login(phone: String, password: String): Result<AuthSession> suspend fun getCachedSession(): AuthSession? }然后在:data模块里写它的实现:
class AuthRepositoryImpl( private val authApi: AuthApi, private val sessionStorage: SessionStorage, private val mapper: AuthMapper ) : AuthRepository { override suspend fun login(phone: String, password: String): Result<AuthSession> { return try { val dto = authApi.login(LoginRequest(phone, password)) val session = mapper.fromDto(dto) sessionStorage.save(session) Result.success(session) } catch (e: Exception) { Result.failure(e) } } override suspend fun getCachedSession(): AuthSession? { return sessionStorage.load() } }那么问题来了:是不是所有 Repository 一开始都要定义接口?我个人的经验是不要过度抽象。如果你只有一个网络实现,那 RepositoryImpl 直接实现一个接口,接口只暴露 Domain 层需要的方法,这是合理的。但如果你有两个数据来源需要切换(比如缓存与网络、A/B 测试中的两个服务端),那 Port 的价值就能显现出来——UseCase 只面向接口编程,切换具体实现只需要在 DI 配置处换一个实现类。
3.2 UseCase 的粒度:一个类一个用例
UseCase 在不少项目里很容易演变成"过度设计的重灾区"。我曾经见过一个项目里,一个 UserUseCase 类里塞了十几个方法,美其名曰"统一用户相关操作",结果这个类成了一个巨大的上帝类。
UseCase 的正确姿势应该是:一个类只负责一个业务动作,类名直接表达意图。比如LoginUseCase、FetchHomeFeedUseCase、UpdateUserProfileUseCase。它的内部结构也很简单,就做三件事:调用仓库接口、做必要的业务校验或裁剪、把结果返回给上层。
来看一个完整的 UseCase 示例:
class GetUserProfileUseCase( private val userRepository: UserRepository, private val sessionManager: SessionManager ) { suspend operator fun invoke(userId: String): UserProfile { require(userId.isNotBlank()) { "userId must not be blank" } val session = sessionManager.currentUserId() val profile = if (userId == session) { userRepository.getMyProfile() } else { userRepository.getUserProfile(userId) } return profile } }你可能会问,为什么 UseCase 里还能有require这种校验?我的回答是,业务层的入参校验属于业务规则的一部分,放这里没问题。但如果是"手机号格式错误,提示用户重新输入"这类 UI 层交互逻辑,就要放到 Presentation 层交给 ViewModel 处理。
使用 UseCase 时,我个人习惯用operator fun invoke让类可以像函数一样被调用,代码会简洁很多。ViewModel 里就不再直接 依赖多个 Repository,而是依赖一个或几个 UseCase,语义清楚,测试也方便——mock UseCase 比 mock Repository 更简单。
还要提示一个坑:如果 UseCase 只做了一行委托,比如直接调用repository.getXxx()然后返回,那这个 UseCase 大概率是多余的。不要为了套用架构而制造无意义的类。真正的业务规则不够复杂时,UseCase 可以晚点引入,不要强行拆。
3.3 Repository 的职责不只是"网罗数据源"
Repository 在 Clean Architecture 中的角色是 Domain 层的数据入口。它隔离了数据来源的复杂度:上层不关心数据是从网络拿的、从数据库读的,还是跑了一段复杂的算法生成的。
很多初学者以为 Repository 就是"把 Retrofit 调一下然后返回",这是误解。真正有经验的 Repository 实现会处理三件事:
- 数据来源的优先级与回退(比如先读缓存,缓存过期再走网络)
- 多数据源的数据合并与一致性维护
- 面向可测试性的资源注入
拿缓存策略举例:
class HomeFeedRepositoryImpl( private val localDataSource: HomeFeedLocalDataSource, private val remoteDataSource: HomeFeedRemoteDataSource, private val mapper: HomeFeedMapper ) : HomeFeedRepository { override suspend fun getHomeFeed(): HomeFeed { val cached = localDataSource.getCachedFeed() if (cached != null && !isExpired(cached.timestamp)) { return mapper.toEntity(cached) } val remote = remoteDataSource.fetchFeed() localDataSource.saveFeed(mapper.toDto(remote)) return mapper.toEntity(remote) } }注意,这里的isExpired是 Repository 内部的业务策略。如果你把缓存判断逻辑放到 UseCase 里,那就意味着所有调用方都得知道"缓存会过期"这个细节,封装就很不到位了。
4. 依赖注入与数据流设计:让架构真正跑起来
4.1 Hilt 在 Clean Architecture 中的配置技巧
架构里各个模块的实例怎么创建,最佳方案是依赖注入。Android 官方推荐 Hilt,它基于 Dagger 的编译期注解处理,稳且可控。
Hilt 的配置有几个关键点容易被忽略。第一,@Binds是用在接口和实现类上,@Provides则是用在你无法修改构造方法的第三方类上。绑定 Repository 接口时,应该用@Binds:
@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository @Binds @Singleton abstract fun bindHomeFeedRepository(impl: HomeFeedRepositoryImpl): HomeFeedRepository }第二,各个模块的@Module类不要全都堆在同一个包下面,应该分开放。:data模块的 DI 放:data模块内,:domain模块尽量不放 Hilt 注解,让 Domain 层保持纯 JVM 属性。这样演进的后期如果想把 Domain 做成跨平台模块,成本就低很多。
第三,ViewModel 的注入要用@HiltViewModel,并把SavedStateHandle用起来。大型项目里 Activity 重建是高频场景,SavedStateHandle能帮你把 UI 状态绑到进程级,这比自己在 ViewModel 里维护 Bundle 干净。
4.2 单向数据流的走向设计
Clean Architecture 里数据是怎么流动的,我个人在实践中强烈推荐加一层"单向数据流"的约束。具体形式是:UI 触发事件 -> ViewModel 调用 UseCase -> UseCase 调用 Repository -> Repository 返回数据或异常 -> ViewModel 加工成 UI State -> UI 通过 StateFlow 渲染。
用代码来展示 ViewModel 这一层的典型写法:
@HiltViewModel class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() fun login(phone: String, password: String) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } loginUseCase(phone, password) .onSuccess { session -> _uiState.update { it.copy(isLoading = false, isLoginSuccess = true) } } .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = e.toUserMessage()) } } } } }这个模式的价值在于,数据流的方向唯一,状态可预测,逻辑可单测。UI 层不会直接拿 Repository 的返回结果做逻辑分支,所有的业务判读都收敛在 ViewModel 里。大型项目里如果出现"UI 层直接调用 UseCase 然后又在本层做短路逻辑",架构就会迅速退化成面条式代码。
4.3 线程调度放在哪一层
Clean Architecture 落地时,协程的线程调度经常引发争论。我的做法是:UseCase 是 suspend 函数,不指定线程,默认在调用方所在的上下文中执行。ViewModel 层用viewModelScope.launch默认的 Main 调度器,遇到耗时操作,由 Repository 内部用withContext(Dispatchers.IO)切线程。
这样分层的好处是 UseCase 对线程无感知,想并发测试还是同步测试都方便。如果你在 UseCase 里写死了Dispatchers.IO,以后想改调度策略,等于每个 UseCase 都要动一遍。
5. 实操中的常见问题与避坑指南
5.1 架构退化的三大信号
就算起步时严格按照 Clean Architecture 分层,项目迭代几个月后也容易出现"退化"。我总结过三个信号,只要出现任何一个,就说明架构约束已经松动了。
第一个信号是 Domain 层开始出现 Android 依赖,比如在 UseCase 里直接用android.util.Log打日志、依赖Context,或者引入 Gson 解析 JSON 数据。这说明团队没有守住"Domain 是纯 Kotlin"的底线,继续下去 Domain 层会和平台耦合越来越深。
第二个信号是 Presentation 层开始直接依赖 Data 层。比如 ViewModel 里直接 new 一个 RepositoryImpl,而不是通过 DI 拿接口;或者布局文件里直接取数据库字段名。一旦绕过 UseCase 和 Repository 接口,业务逻辑就散落到 UI 各处了。
第三个信号是 Data 层的 DTO 被传到 Presentation 层。很多同事为了省事,直接把HomeFeedDTO从 Repository 里返回出去,在 UI 上直接绑定字段。短期内能快,但哪天后端改字段名,你的 UI 也得跟着改,而且编译期根本查不出这种问题,只在运行时崩溃,排查成本极高。
一旦发现这些信号,我建议立刻在 CI 加一层 ArchUnit 规则,从物理上禁止这种非法依赖。下面是一段 ArchUnit 的规则示例:
@Test fun domain_should_not_depend_on_android() { val domainClasses = ClassFileImporter() .importPackages("com.example.app.domain") .filter { !it.name.endsWith("Kt") } val rule = NoClassesThat() .resideInAPackage("com.example.app.domain..") .should() .dependOnClassesThat() .resideInAnyPackage("android..", "retrofit2..", "com.squareup.okhttp3..") rule.check(domainClasses) }5.2 使用 Clean Architecture 时最常见的"过度设计"
架构师容易犯的毛病是把所谓的"标准结构"生搬硬套。比如每层都强制加一个 Mapper、每个用例都必须有 Input/Output、每个 Repository 都必须抽象接口,不管实际情况是否真的需要。
我见过最离谱的项目,是几十个 UseCase,每个 UseCase 就一行代码,把 Repository 里的方法原样透传。这种东西除了增加文件数量和编译时间,没有任何价值。架构设计衡量的标准是"当前和可预见的未来是否需要",而不是"教科书上有没有"。
如果你在做需求评估时发现这个功能很简单,直接把 Repository 注入到 ViewModel 就能解决问题,那么在没有明确扩展需求之前,不必强行加一个 UseCase。等业务逻辑变复杂了,再抽 UseCase 也不迟。增量演进好过一步到位,代码库和团队都需要适应期。
5.3 Hilt 循环依赖和编译期错误
依赖注入虽然解耦,但 Hilt 配置出错的时候特别折磨人。最常见的是循环依赖。比如你给AuthRepositoryImpl注入了UserLocalDataSource,而UserLocalDataSource又依赖AuthRepository,Dagger 在编译期就会给你一个大大的报错。
遇到这种 DAG 循环,第一步不是改代码,而是先审视你的依赖设计是不是有问题。通常来说,循环依赖的根源在于两个模块职责没有切干净。解决办法是引入一个更底层的依赖,比如把SessionStorage抽出来,让两个模块都依赖它。不要用@Qualifier或者Lazy<K>这类 hack 方式绕过,那样只是把运行时炸弹延后了。
5.4 测试金字塔在分层架构里的体现
很多团队做测试只做 UI 测试和部分单元测试,但 Clean Architecture 分层的好处是你能在 Domain 层做大量纯逻辑测试,速度飞快,还不依赖设备。
实际操作中,我通常把测试分成三层:
| 测试层级 | 范围 | 测试对象 | 速度 |
|---|---|---|---|
| Domain 单元测试 | UseCase、业务规则、实体不变量 | 纯 JVM,无 Android 依赖 | 极快 |
| Data 集成测试 | Repository 实现、Mapper 映射 | 需要 Mock WebServer 或内存数据库 | 较快 |
| UI 测试 | 关键流程的端到端验证 | Compose test / Espresso | 慢 |
举个例子,针对上面那个LoginUseCase,我可以直接 mockAuthRepository,验证入参校验、账号类型判断、异常映射这些逻辑,不需要启动模拟器。这样 1 分钟能跑几百个用例,长期维护很有底。
6. 这套架构在实际项目中还可以怎么扩展
6.1 向跨平台演进
Clean Architecture 的 Domain 层是纯 Kotlin/JVM 时,你就拥有了一个天然的跨平台业务内核。以后如果团队要复用业务逻辑到 iOS(Kotlin Multiplatform)或者后端(Ktor),Domain 层可以直接平移过去,这是一个非常大的长期红利。所以哪怕现在只是纯 Android 项目,我也建议你守住 Domain 层"零 Android 依赖"的底线,这是最重要的可迁移资产。
6.2 与 Compose 的搭配
如果你的新项目用的是 Jetpack Compose,那么 Presentation 层的实践可以进一步细化为"单 Activity 的 UI 状态集中管理"。ViewModel 只暴露 StateFlow,Compose 通过collectAsStateWithLifecycle收集状态。Clean Architecture 的分层仍然适用,而且因为组合式函数的粒度更细,UI 层可以直接拆成更小粒度的组件,每个组件只消费自己关心的状态,整体代码会更清晰。
6.3 对团队协作的效率影响
我还想多聊两句团队层面的事。在大型项目里,架构方案不只是技术方案,某种意义上也是团队协作的契约。有了清晰的分层,新同学上手时会更快——他只需要搞清楚自己改的代码应该落在哪一层,以及它跟相邻层之间怎么交互,其余的不需要关心。代码 Review 时,大家讨论的焦点也会更集中,不会被"这行代码为什么不放这里"这个低级问题耗掉精力。
当然也提醒一句,架构是约束,不是枷锁。实际项目里总是会有"这里为了赶时间能不能捅个窟窿"的情况,我的建议是建立"例外流程",让例外可以被记录、被追踪,而不是假装没有发生过。Clean Architecture 要能在大型项目里活下来,靠的不是完美主义,而是让所有人知道"什么时候必须守规则,什么时候可以借路,借路之后怎么还债"。
最后说点我个人的体会——架构方案的成败,多半不在方案本身,而在落地过程中的克制。不要以架构的名义把代码复杂化,不要为了贴一个 Clean Architecture 的标签而制造大量没有实际价值的中间层,更不要指望一个架构方案解决团队的所有工程问题。Clean Architecture 在 Android 大型项目中最有价值的产出,是让代码的依赖方向始终朝着"业务核心"收敛,让核心业务逻辑不随着 UI 框架、网络库、数据库的变迁而被反复重写。守住这一条,哪怕你在实现细节上做得不是那么完美,架构的整体骨架也不会歪。