Android大型项目Clean Architecture落地:模块划分、依赖注入与分层实践
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层面的模块依赖关系强制约束违反架构规则的代码连编译都过不了不依赖代码评审时的人工审查。拿我现在的项目举例整体模块结构大概是这样的:appApplication入口、全局配置、导航宿主:core:domain业务模型、Repository接口、UseCase:core:dataRepository实现、网络数据源、本地数据源、缓存策略:core:common通用工具、扩展函数、基础UI组件:feature:login登录模块的UI层Activity、ViewModel:feature:home首页模块的UI层:feature:order订单模块的UI层从依赖方向上看feature模块依赖core:domain和core:datacore:data依赖core:domaincore: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和LocalDataSourceRepository实现类负责协调它们。这里要注意的是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: StateFlowLoginUiState _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): ResultUser { if (!PhoneNumberValidator.isValid(phone)) { return Result.Error(手机号格式不正确) } return repository.login(phone, code) } }注意这里我用了ResultUser作为返回值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): ResultUser { 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(): FlowHomeContent { 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方案、加一套缓存策略都不需要触碰核心业务代码的时候你就知道这套架构带来的长期收益已经远大于当初那一点点代码量和设计成本了。