1. 内容整体设计与架构思路拆解
1.1 Clean Architecture到底在解决什么痛点
很多Android项目做到中期,代码就开始“拧巴”了。业务逻辑散落在Activity里、接口回调嵌套成山、一个需求的改动要牵连七八个文件、测试代码根本没法写……这种时候团队往往会想起Clean Architecture。但真到了动手落地,又会发现一个问题:网上的文章讲概念的很多,讲怎么在一个真实的大型项目里把Clean Architecture跑起来的却很少。
先花点时间说清楚Clean Architecture的本质。它最核心的东西不是那五个同心圆,而是依赖规则:源代码的依赖关系只能从外层指向内层,内层完全不知道外层的存在。换句话说,业务核心不该知道自己跑在Android上,也不该知道数据来自网络还是本地数据库,更不该知道UI用的是Compose还是XML。这就是为什么Robert Martin在提出这套架构时反复强调:你用的是Framework,不是Framework用你。
这句话放到Android项目里,翻译得直白一点:你的登录逻辑、购物车计算、订单状态流转,这些应该是一个个纯粹的业务模块,不依赖Activity生命周期,不依赖Context,不依赖Retrofit的注解,也不依赖Room的实体类。好处非常直接——业务模块可以单测,可以脱离Android环境跑,换UI框架不影响核心逻辑,换数据源也不会把业务代码搅得一团糟。
用一个生活化的例子来类比:Clean Architecture就像一家分工明确的餐厅。后厨的核心工作是把菜做好,至于这道菜是装进餐盒供外卖、还是摆盘端给堂食顾客,后厨不关心;食材是当天采购还是冷链配送,后厨也不用管。对应过来就是:业务逻辑(后厨)不关心UI(装盘方式),也不关心数据来源(食材渠道)。听起来很理想,但真落地的时候,层级怎么拆、模块怎么建、依赖怎么注入、模型怎么映射,每一个问题都能把项目拖进泥潭。接下来我会结合自己维护的一个长期迭代的Android项目,把这些细节逐一讲透。
1.2 为什么大型项目比中小项目更需要它
不是所有项目都需要Clean Architecture的,这个得先说明白。对于一个只有三五个页面、一两个月的短期项目,用Clean Architecture相当于用杀鸡刀宰牛,类的数量翻一倍,开发速度明显变慢,纯属自找麻烦。但是项目一旦跨过某个规模阈值,这套架构的收益就会开始体现出来,而且项目越大、活得越久,收益越明显。
我自己判断是否需要引入的标准有三个:第一,业务逻辑是否复杂到需要独立测试,这里的复杂不是说代码量多少,而是分支多、状态多、规则变动频繁;第二,团队是否有多人并行开发,如果几个人同时改一个Activity文件,git冲突会让人崩溃;第三,项目是否有长期迭代的计划,比如规划了一年以上的版本路线。这三条只要中了两条,Clean Architecture就值得认真考虑。
大型项目的核心痛点在于变化的方向太多。UI可能从XML换到Compose,网络库可能从OkHttp换到别的方案,数据库可能从SQLite换到Room,本地缓存策略可能从磁盘换到内存。如果这些变化都直接耦合在业务逻辑里面,每一次技术栈更换都是一次大手术。Clean Architecture的思路就是把这些“会变的东西”赶到外层,把“不太会变的东西”留在内层,通过稳定的接口把它们隔开。这样短期看是多写了几层胶水代码,但长期看每一个技术栈的替换都只是外层局部的修改,风险可控、范围清晰。
当然,它也是有代价的。最大的代价是初期结构设计要花心思,代码量明显增加,入门的同学上手成本变高,而且如果团队成员对架构边界理解不一致,很容易出现“看起来分层了、实际耦合在一起”的半吊子状态。所以用Clean Architecture一定要配合代码评审和依赖约束工具,光靠自觉是不行的。
1.3 和MVVM、MVI到底什么关系
经常有人把Clean Architecture和MVVM、MVI拿来比较,其实它们是不同维度的东西。Clean Architecture是整个应用的宏观架构,回答的是“业务逻辑放在哪里、依赖往哪个方向走”;MVVM和MVI是表现层的模式,回答的是“UI和数据怎么绑定、事件怎么流转”。两者完全不冲突,反而是很好的互补。
在Android生态里,最主流的组合方式是:外层使用MVVM或MVI作为表现层模式,中层使用UseCase承载业务用例,内层使用Repository接口隔离数据来源。ViewModel和StateFlow管理UI状态,UseCase处理具体业务规则,Repository实现类决定数据从网络来还是从缓存来。这样每一层各司其职,不会出现“一个ViewModel里堆了300行业务逻辑”的灾难现场。
还有个容易混淆的概念是Domain层。有人觉得小项目不需要Domain层,直接让ViewModel调Repository就行了;有人则坚持必须有三层。我的观点是,是否引入Domain层取决于业务逻辑的复杂程度。如果业务逻辑就是简单的“取数据填到UI上”,那确实不需要Domain层,硬加一个UseCase就是过度设计;但一旦出现跨数据源的业务规则,比如“计算购物车总价并应用优惠券”、“判断用户是否有权限执行某操作”,这些逻辑放到ViewModel里会污染表现层,放到Repository里又会让数据层变臃肿,这时候独立出来的Domain层就会很舒服。
2. 工程结构落地:模块划分与依赖方向
2.1 包结构怎么分才不容易乱
说到工程结构,第一个绕不开的问题就是:单模块还是多模块。在大型项目里,我强烈建议使用多模块结构,而且要把Clean Architecture的层级边界和Gradle模块边界对齐。这样做的核心价值在于:依赖方向由Gradle层面的模块依赖关系强制约束,违反架构规则的代码连编译都过不了,不依赖代码评审时的人工审查。
拿我现在的项目举例,整体模块结构大概是这样的:
:app:Application入口、全局配置、导航宿主:core:domain:业务模型、Repository接口、UseCase:core:data:Repository实现、网络数据源、本地数据源、缓存策略:core:common:通用工具、扩展函数、基础UI组件:feature:login:登录模块的UI层(Activity、ViewModel):feature:home:首页模块的UI层:feature:order:订单模块的UI层
从依赖方向上看,feature模块依赖core:domain和core:data,core:data依赖core:domain,core:domain不依赖任何其他模块和Android框架。这里有一个关键约束:core:domain模块一定不能依赖core:data和任何UI框架的代码,否则整个依赖规则就被破坏了。
有些项目为了图省事,把domain和data放同一个模块里,只在包层面做了区分。这种做法在中小型项目里问题不大,但在大型项目里很容易失控。因为包层面的分层是“软约束”,程序员今天可以把一个接口实现放到domain包里,明天可以把一个网络请求写进UseCase里,时间一长边界就模糊了。模块层面的隔离是“硬约束”,你想越界,Gradle编译就直接报错。
2.2 每个Layer的职责边界到底怎么画
分层架构最有争议的就是各层职责的划分,尤其在实际项目中,边界不可能像理论那么清晰。根据我自己的经验,下面这套职责划分在Android大型项目中是踩过很多坑之后沉淀下来的:
Domain层(最内层):只放业务模型、Repository接口、UseCase。业务模型应该是纯Kotlin数据类,不继承任何Android框架的类。Repository接口用suspend函数或Flow定义数据操作的能力,但不暴露任何Retrofit、Room相关的类型。UseCase是一个类封装一个业务场景,比如LoginUseCase、GetUserOrdersUseCase。这里最容易犯的错是在接口方法里直接使用DTO或数据源框架的类型,一旦这么做了,Domain层的纯洁性就被破坏了。
Data层(中间层):实现Domain层定义的接口,负责网络请求、数据库读写、本地缓存、文件上传等具体的数据获取工作。数据来源可以有多个,比如RemoteDataSource和LocalDataSource,Repository实现类负责协调它们。这里要注意的是,data层对外暴露的是Domain层的模型,不是DTO也不是数据库实体。API返回的UserDto要在Repository实现内部转换成User,数据库的OrderEntity也要在Repository实现内部映射成Order。这个模型转换工作看起来繁琐,但它是保证业务层不被技术细节污染的关键一堵墙。
Presentation层(最外层):Activity、Fragment、ViewModel、Adapter这些UI相关的东西都放在这层。ViewModel负责把Domain层返回的数据转换成UI状态,处理Loading、Error、Empty这些UI状态,以及处理用户点击事件。UI层可以直接依赖Domain层的模型和接口,但它不应该依赖Data层的具体实现。这样以后就算把网络库从Retrofit换成其他方案,UI层一行代码都不用改。
2.3 Hilt依赖注入如何配合三层结构
依赖注入在Clean Architecture里不只是简化代码,更是依赖方向的控制手段。没有依赖注入的话,想在Domain层的接口和Data层的实现之间加一个“运行时才决定谁是谁”的中间层,操作会非常别扭。
我用的方案是Hilt,因为它在Google生态里支持最好,和ViewModel、Room、Retrofit的集成都很顺滑。但在配置的时候有个很容易踩的坑:依赖注入的绑定关系要创建在正确的地方,不要把所有Provider堆在同一个Module里。
标准的做法是给每层定义自己的Module。Data模块里的Module负责绑定Repository接口和实现类的关系,比如:
@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds @Singleton abstract fun bindLoginRepository(impl: LoginRepositoryImpl): LoginRepository }Presentation层只看到LoginRepository这个接口,完全不知道LoginRepositoryImpl的存在。Hilt在编译期生成依赖图的时候,会把LoginRepositoryImpl注入到需要LoginRepository的地方。这样依赖的方向就从“高层依赖低层”反转成“低层被高层通过接口调用”,实现了传统的控制反转。
用Hilt还有一个需要注意的点:Domain层不要放Hilt注解。前面说了Domain层要保持纯净,如果你在UseCase里写了@Inject,其实问题还不大,因为Hilt注解本身不是Android框架的类;但如果你在Domain层里引入了@Module、@Provides这些注解,就相当于把依赖注入框架绑定到了最内层,以后想换DI框架或者做单元测试,Domain层就变得不干净了。正确做法是在Application或其他地方的Module里构造UseCase,或者直接在ViewModel里用构造函数注入。
3. 实操过程:从一个登录流程走通整个架构
3.1 用户场景如何从UI层抵达Domain层
光说不练是学不会的,我拿一个登录功能来完整走一遍流程。这个功能看起来简单,但刚好能覆盖UI、ViewModel、UseCase、Repository、数据源五层,再加上错误处理和状态流转,可以很清楚地看到Clean Architecture是怎么运作的。
先定义业务场景:用户输入手机号和验证码,点击登录按钮,系统先做本机校验,再调服务端接口换取用户信息,最后把用户信息存到本地。如果验证码错误,要给出错误提示。这个流程的关键是:“本机校验”和“服务端换取用户信息”这两个步骤属于不同的数据来源,在传统写法里很容易揉成一团,但在Clean Architecture里它们各自有明确归属。
UI层是一个LoginFragment,它的职责只有一个:把用户输入传给ViewModel,然后观察ViewModel暴露出来的UI状态。Fragment本身不做逻辑判断,不校验手机号格式,也不决定是否弹出错误提示,这些全丢给ViewModel处理。
class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() fun onLoginClicked(phone: String, code: String) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val result = loginUseCase.execute(phone, code) when (result) { is Result.Success -> _uiState.update { it.copy(isLoading = false, isLoginSuccess = true) } is Result.Error -> _uiState.update { it.copy(isLoading = false, errorMessage = result.message) } } } } }注意ViewModel里只做了三件事:调用UseCase、把结果转成UI状态、把状态暴露给View。校验手机号格式的逻辑不在ViewModel里,登录成功之后要不要跳转页面也不在ViewModel里。
3.2 UseCase封装的业务规则从哪来
接下来是Domain层的LoginUseCase。它的工作是对外提供一个干净的接口,把“登录”这个业务动作完整表达出来,同时把“怎么获取数据”这个细节完全隐藏在Repository后面。
这里有一个设计问题值得单独说:UseCase到底要不要执行“手机号格式校验”。我见过两种做法,一种是把校验放在ViewModel,一种是把校验放在UseCase。我的看法是,只要校验规则是业务规则(而不是单纯的UI交互规则),就应该放UseCase里。手机号格式校验显然属于业务规则,因为服务端也会校验、其他端也要同样的规则,它不能因为UI换了个样式就消失。所以UseCase内部会调用一个校验器,或者把校验逻辑直接写在UseCase里,然后返回一个校验失败的结果。
class LoginUseCase( private val repository: LoginRepository ) { suspend fun execute(phone: String, code: String): Result<User> { if (!PhoneNumberValidator.isValid(phone)) { return Result.Error("手机号格式不正确") } return repository.login(phone, code) } }注意这里我用了Result<User>作为返回值,User是Domain层定义的业务模型。整个UseCase里没有任何Android依赖,不出意外的话,单测可以直接在JVM上跑,不需要模拟器。这在大型项目里是一个极其重要优势:业务逻辑的单测速度快、稳定、CI可以实时跑,不会因为UI自动化测试不稳定而折磨人。
3.3 Repository实现与数据源切换策略
现在看看Data层的LoginRepositoryImpl。它要做的事情包括:调服务端接口获取用户信息、把获取到的DTO转成Domain模型、把结果写到本地数据库或缓存。在这个过程中涉及一个很经典的问题:错误处理在哪个层级捕获。
很多项目会在Repository里直接捕获异常然后封装成自定义的ApiException,这种做法本身没问题,但要注意不要让异常类型泄漏到Domain层。Domain层和UseCase层只应该看到统一的业务类型,比如Result.Error里带一个错误码和消息。网络超时、404、500这种技术相关问题,应该在Repository实现内部转换成业务相关的描述,比如“网络连接不稳定,请检查网络设置”。
class LoginRepositoryImpl( private val remoteDataSource: LoginRemoteDataSource, private val localDataSource: UserLocalDataSource, private val mapper: UserModelMapper ) : LoginRepository { override suspend fun login(phone: String, code: String): Result<User> { return try { val userDto = remoteDataSource.login(phone, code) val user = mapper.fromDto(userDto) localDataSource.cacheUser(user) Result.Success(user) } catch (e: ApiException) { when (e.code) { ERROR_CODE_INVALID_CODE -> Result.Error("验证码错误") ERROR_CODE_PHONE_NOT_REGISTERED -> Result.Error("该手机号尚未注册") else -> Result.Error("登录失败:${e.message}") } } } }这里要专门说一下耗时操作的问题。登录接口是网络请求,必须在IO线程执行,但Repository内部不需要自己处理线程切换。为什么?因为上游的UseCase或ViewModel已经把调用包在协程里了,而且Retrofit本身在封装时已经把网络请求放到了IO线程池里。如果每个Repository都自作主张地加withContext(Dispatchers.IO),嵌套切换会搞得很难看。记住一个原则:线程调度尽量集中在上层,数据源框架自己会处理耗时操作。
3.4 Model映射:最容易做脏的地方
模型映射是我见过的最容易做“脏”的环节。有些人图省事直接在Repository实现里把DTO往业务模型里硬塞,有些人干脆让UseCase直接依赖DTO,美其名曰“减少代码量”,这些我都不推荐。大型项目一定要把模型映射独立出来,用一个Mapper类或者Kotlin扩展函数维护清晰的转换逻辑。
DTO、Domain模型、UIModel三层模型是常态。为什么不能只用一个模型撑到底?因为三者的关注点完全不同。API返回的LoginResponseDto可能包含refreshToken、expiresIn这些服务端特有的字段;Domain层的User只关心业务上的用户名、头像、手机号;UI层的LoginUiState可能还需要一个isAgreedPrivacyPolicy来表示用户是否勾选了隐私协议。如果让一个模型承担所有职责,结果是UI一改接口就改,接口一改数据库就改,改来改去全是雷。
在实际代码里,我习惯让Mapper统一处理模型间的转换:
object UserModelMapper { fun fromDto(dto: LoginResponseDto): User { return User( id = dto.userId, name = dto.nickname, avatarUrl = dto.avatar, phone = dto.mobile ) } }这样的Mapper看起来很简单,但它的价值是把所有“字段重命名”和“类型不一致”的地方集中到一处管理,一旦API字段变了,只改这一个文件,不影响Domain层和UI层。
4. 常见问题与排查技巧实录
4.1 UseCase数量爆炸了怎么办
Clean Architecture最常见的落地问题就是UseCase泛滥。有人严格按照“一个业务动作一个UseCase”的教条来设计,结果一个小型功能页面上来了十好几个UseCase类,看着就头疼,维护成本反而上去了。
我自己在实践过程中逐步调整了策略。原则是:写操作和复杂业务校验独立成UseCase,简单读操作允许合并。比如获取用户资料的请求,如果只是一次简单查询,没有复杂的业务规则,直接让ViewModel依赖Repository接口就行,不需要中间再包一层GetUserProfileUseCase。但如果是“获取订单列表并且根据订单状态做聚合统计”这种有逻辑的场景,就值得单独建一个UseCase。
另外还有一个技巧:把UseCase定义成Kotlin的接口,然后再写实现类,这样在测试时才能方便地mock。但如果项目不大,UseCase本身可以直接用class,因为UseCase通常已经设计成无状态的了,不太需要mock一个接口。
4.2 Repository被“架空”了怎么办
第二种常见问题是Repository实现类退化成了网络接口的纯转发层,里面一行逻辑都没有,只是把RemoteDataSource的返回值原封不动传出去。出现这种情况说明Repository的设计失去了意义——它的价值在于封装数据获取策略,而不只是转发。
真正的Repository应该有策略、有判断、有合并。举个例子:我们项目里用户打开首页时,希望优先展示本地缓存的数据,让页面秒开,然后在后台拉取最新数据,等新数据到达后自动刷新界面。这个逻辑就是Repository的核心价值所在:
class HomeContentRepositoryImpl( private val remoteDataSource: HomeRemoteDataSource, private val localDataSource: HomeLocalDataSource ) : HomeContentRepository { override fun getHomeContent(): Flow<HomeContent> { return flow { localDataSource.getCachedContent()?.let { emit(it) } val latest = remoteDataSource.fetchHomeContent() localDataSource.updateCache(latest) emit(latest) } } }这种Cache-Aside策略拆分到其他地方都不合适,放在Repository里反而非常自然。所以在写Repository的时候,多想想这个接口能不能体现“多个数据来源协调”的价值,如果只是单纯的透传,那不如砍掉这个中间层。
4.3 架构边界被无意间破坏了怎么办
再好的架构设计也扛不住“人人都觉得自己可以改一下”的团队。最常见的一种破坏方式:有人在Domain层的接口里加了@POST注解,直接把Retrofit的注解带进业务层;有人为了图快在UseCase里直接调用网络请求的静态方法;还有人把Room的Entity直接当Domain模型用。
靠代码评审来防止这种问题,效果有限。更好的办法是用Gradle模块依赖配合lint或者编译期检查来强制约束。比如在core:domain模块的build.gradle里,确保它不依赖任何Android框架的库以及Data层的库:
dependencies { implementation project(":core:common") // 只能依赖纯工具模块 // 严禁 implementation project(":core:data") // 严禁 implementation "com.squareup.retrofit2:retrofit:..." }如果core:domain的代码依赖了Retrofit,编译时就会直接报错,这种“物理性”的约束比“人治”可靠得多。另外一个还要注意的点是依赖反过来也不行:feature层不能直接依赖core:data的具体实现类,要用接口注入。如果UI层直接new一个LoginRepositoryImpl,架构约束也名存实亡了。
4.4 模块间编译变慢、开发效率下降怎么办
多模块化配合Clean Architecture会带来一个实际成本:编译时间上升。每次改一行Domain层代码,所有依赖这个模块的上层模块都要重新编译,这在大型项目里可能就是好几分钟的等待。
我的处理经验有几个。第一,尽量做增量编译,AS的Instant Run和Kotlin的增量编译配合好;第二,不要在Domain层塞太多代码,Domain层的修改影响范围是最大的,把一些纯UI状态的封装放到feature模块里,减少Domain层变更的频率;第三,善用模块间的“空debounce”,比如本地开发时用@Preview直接调试UI,不跑真机编译;第四,如果项目真的很大,可以考虑把feature模块和core模块做成独立的Gradle Project,通过二进制依赖来加快构建速度。
5. 团队落地与后续扩展
5.1 新成员如何快速理解这套架构
架构落地最大的阻力往往不是技术问题,而是人的习惯问题。新加入的同事如果以前习惯了在Activity里写逻辑,突然让他在五层结构里加个功能,他会不知道代码该写在哪。
我自己的做法是在项目根目录维护一份简短的架构文档,不写大道理,就写清楚三件事:每层放什么、不能放什么、依赖方向长什么样。尤其要写清楚“反例”,比如“不要在ViewModel里直接调用网络库”、“不要在Repository里写UI逻辑”,这种反例比正向说明更直观。另一个手段是代码评审。前几个月新成员写的Merge Request我会特别关注分层边界,一旦发现问题,直接指出来比事后帮助文档印象深刻得多。
5.2 这套架构为后续演进留了哪些空间
Clean Architecture最让我觉得值得的地方,是它为项目后续演进留足了空间。我们团队在半年内干了两件大事:一是把UI从XML迁移到Jetpack Compose,按传统写法这基本等于重写表现层,但在Clean Architecture的结构下,我们只改了feature模块和少量ViewModel的数据绑定方式,Domain层和Data层的代码几乎没动;二是换了网络层的数据解析方式,从Gson换到了Kotlin Serialization,涉及到的也只是Data层内部的数据源实现和Mapper调整。
如果你的项目正处于一个业务快速扩张、团队不断扩大的阶段,我的建议是:架构设计可以适当激进一点,但“激进”指的是严格遵守分层边界,而不是为了过度设计而设计。我见过有些团队为了证明自己用了Clean Architecture,引入了大量不必要的模式,结果类数量暴涨,开发效率严重下降,最后架构被Team集体抵制。架构还是要服务于业务和团队,它只是手段,不是目的。
最后再分享一个我个人的体会:Clean Architecture的价值不是一朝一夕能看出来的,而是在项目经历了几次“伤筋动骨”的变更之后,你才会真正感激当初那个坚持分层、坚持接口隔离的自己。每当你发现换一个网络库、换一种UI方案、加一套缓存策略都不需要触碰核心业务代码的时候,你就知道这套架构带来的长期收益,已经远大于当初那一点点代码量和设计成本了。