
1. 为什么大型项目最终都会倒向Clean Architecture1.1 规模化之后最先崩溃的不是代码是依赖关系我在很多中型、大型Android项目里待过最典型的状态就是一个业务模块动不动几千行网络请求散落在Activity、Fragment、ViewModel的各个角落一个字符串常量被全局共用某个Utils类里堆了几百个静态方法谁也不知道哪些还在用、哪些只是感觉有用所以没删。真正让你崩溃的往往不是单个文件大而是依赖关系失控。比如你改了一个数据返回结构结果有七八个页面同时编译报错你重构了一个网络层接口冒出了十个类要实现方法。这些问题本质上是**谁依赖谁没有规则**。每个人都按自己顺手的方式写代码日积月累之后模块之间的调用关系就像一堆乱麻没人敢动任何一个角。Clean Architecture在我这里的价值不是设计本身有多优雅——它解决的是一个非常现实的问题让大型项目里的上层业务代码不依赖下层实现细节让底层数据来源可以随时被替换让核心业务逻辑脱离Android框架独立运行。1.2 Clean Architecture真正解决的问题不是分层本身很多人一听到Clean Architecture第一反应是我又要多写好几层代码了。这个观点有一定道理但它忽略了一个前提对于一个小项目几千行代码Clean Architecture确实是负担但对于一个千万行级别、二三十人并行开发的项目分层不是增加负担而是降低协作成本。我们团队在落地过程中最核心的一条心法不是你有没有用三层/四层架构而是依赖规则源代码依赖只能从外层指向内层内层不依赖外层。这一条规则才是Clean Architecture的骨架。举一个非常现实的例子如果你的业务层Domain层里直接写OkHttpClient、SharedPreferences、RoomDatabase那么这个业务层就没法在不启动Android设备的情况下测试了。反之如果业务层只依赖接口和纯Kotlin对象它就能在JVM上秒跑单元测试。所以说Clean Architecture解决的核心问题是稳定性核心业务逻辑不随框架或第三方库的变化而重写可测试性业务逻辑可以脱离设备环境单独验证可维护性多人并行开发同一个业务时边界清晰、职责明确。2. 分享一个实际落地中的Clean Architecture分层结构2.1 三层加一核心Data、Domain、Presentation的职责边界我在项目里用的是一种比较务实的变体把经典的Clean Architecture四层收敛为三层层级职责依赖方向典型成员Presentation层用户界面界面渲染、用户交互、视图状态依赖Domain层Activity/Fragment、ViewModel、Compose页面、AdapterDomain层业务核心业务规则、用例编排、数据接口定义谁都不依赖UseCase、Repository接口、纯数据模型、业务异常Data层数据实现网络、数据库、偏好存储、第三方SDK包装实现Domain接口Retrofit API、Room DAO、DataStore、数据映射器重点说一下Domain层最重要的设计它不依赖任何第三方库里的具体实现类也不依赖任何Android框架类。比如我们定义Repository接口时只关心动作不关心数据从哪来、是不是缓存、是不是网络interface UserRepository { suspend fun getUser(id: String): UserEntity suspend fun updateUser(profile: UserProfile): ResultUserEntity }UserEntity是纯Kotlin类不依赖android.os.Parcelable、不依赖Gson注解就是最普通的data class。这样才能保证Domain层在任何环境下都能单独编译、单独测试。Data层则负责把网络返回的UserRemoteModel、数据库里的UserLocalModel统统转换成UserEntity让上层无感知。从数据流向上看整个App的数据流动方向是View / ViewModel - UseCase - Repository接口 - Data层实现网络/缓存/本地 - 返回纯数据模型 - 返回上层渲染每一次跨越层级边界只传递Domain层定义的模型和状态不直接暴露外层实现细节。2.2 目录结构与模块边界设计我们团队一开始没分成多个Gradle Module而是先在单模块项目里按包结构划清了边界。这个阶段的目录结构大概是这样的com.example.app ├── presentation │ ├── main │ ├── profile │ └── common ├── domain │ ├── model │ ├── repository │ ├── usecase │ └── exception └── data ├── remote ├── local ├── repositoryimpl └── mapper先用包结构约束项目、再逐步演化为独立Module是我比较推荐的做法。一上来就拆十几个Gradle Module如果团队对架构规则还没形成共识反而会因为模块间的移动代码成本太高而寸步难行。2.3 依赖方向的强约束如何让团队成员没法写错架构划分完之后最大的挑战是让规则被守住。代码是人写的人总会疲惫、偷懒、或者在不了解规则的情况下提交代码。所以不能只靠口头约定要靠工具和测试去卡。我们引入了类似依赖约束测试的方案写了一个简单的架构测试用例扫描所有源码类校验它们的包路径依赖是否符合Domain层不得引用外层包、Presentation层不得直接引用Data层实现。这个测试在CI上执行任何提交如果破坏了依赖规则构建就会红掉。这类约束测试的好处是新同学不熟悉架构的时候不用到处问这个接口我能直接调用吗机器会告诉他不行。如果你们项目里已经有现成的依赖分析脚本也可以用没有的话写一个基于java.util.jar扫描的测试代码并不复杂关键是把它纳入CI强制校验而不是只在Code Review的时候靠嘴说。3. UseCase是业务逻辑的锚点为什么要单独抽象这一层3.1 业务逻辑被散落在ViewModel/View层是大型项目最快的腐化方式说到业务逻辑很多团队默认的写法是ViewModel里直接调用ViewModelScope调用一个仓库方法拿到数据就赋值给状态同时处理loading、error、保存本地……一行一行全堆在ViewModel里。一开始好像没什么问题但随着业务复杂你会发现同一个操作比如用户下单在A页面和B页面有不同的调用方式某个逻辑加了权限判断、风控校验结果只在一个页面改了另一个页面漏了ViewModel里的方法高度膨胀动一发而牵全身。这里面的根因就是业务逻辑缺少一个独立的沉淀位置。它本该有一个稳定的地方却被UI状态混在一起。UseCase用例就是我选择的沉淀位置。它把一条完整的业务规则封装起来UI层只用调用一个用例方法关心结果就行。页面之间共享逻辑时不需要复制粘贴而是复用同一用例。3.2 UseCase的设计粒度粗了没意义细了过度设计关于UseCase最容易走偏的是粒度问题。有人会把一个超大型UseCase写成聚合根里面做了十件事登录、拉用户信息、拉配置、上报埋点……这其实又回到了功能堆砌完全没有拆分意义。也有人把一个简单查询都拆成两三个UseCase导致类数量爆炸。我们团队的经验参考这几个判断标准一个UseCase只做一件事命名是动词短语比如LoginUseCase、FetchMessagesUseCase如果多个UI场景需要相同业务动作就独立成UseCase如果只是单一场景的临时分支可以先放在ViewModel里等第二个场景需要时再提取组合优于继承遇到复杂流程用多个简单UseCase在Presentation层组合而不是硬造一个超级UseCase。举个例子登录页需要处理三个动作登录、拉取用户资料、请求离线配置。我们不把它们揉成一个大LoginAndFetchEverythingUseCase而是定义LoginUseCase、FetchUserProfileUseCase、FetchConfigUseCase在ViewModel里按业务顺序编排。单个用例短小精悍随时可以组合随时可以单独复用。3.3 事务、调度与状态在UseCase里统一处理协程调度与状态包装大型项目里协程调度如果不统一会散得到处都是Dispatchers.IO。这不是不能用但会造成两个问题一是不容易mock调度器进行测试二是IO密集和CPU密集的线程策略在代码各处不一致。我们的做法是UseCase内部不直接写withContext(Dispatchers.IO)而是在构造时注入调度器默认用Dispatchers.IO。这样在单元测试里可以通过传Dispatchers.Unconfined或者自定TestDispatcher来保证测试可控。同时UseCase的返回值统一使用密封类或自定义Result类型不直接抛裸异常sealed class UiStateout T { data class SuccessT(val data: T) : UiStateT() data class Error(val code: String, val message: String) : UiStateNothing() data object Loading : UiStateNothing() }这样上层处理状态变化的时候模式匹配非常清晰也不会因为不同的异常类型散落一堆if else。在我们项目里这条规则带来的最直观收益是ViewModel里的状态处理从十几个分支降到三四个。4. Repository接口与数据源网络、缓存、本地三种来源如何协同4.1 从直接调API到Repository团队经历过的关键转变我见过很多项目的第一次架构优化就是奔着Clean Architecture去的。但大家最容易忽略的地方不是UseCase而是数据来源抽象。早期代码里ViewModel直接这样调用val user apiService.getUser(id)这本身看起来没毛病可它有三处隐患无法在ViewModel层面做缓存策略控制无法在不启动网络的情况下做界面预览或离线演示换一个网络库、改一个接口域名时所有调用处都要跟着改。Repository接口解决的就是这个数据访问入口问题。UI层和ViewModel只面向UserRepository不关心背后是走网络还是数据库。数据源怎么切换、优先级如何完全由Data层的实现决定。class UserRepositoryImpl( private val remoteDataSource: UserRemoteDataSource, private val localDataSource: UserLocalDataSource ) : UserRepository { override suspend fun getUser(id: String): UserEntity { // 先从缓存读 localDataSource.getUser(id)?.let { return it } // 缓存未命中走网络 val remote remoteDataSource.getUser(id) localDataSource.saveUser(remote) return remote } }这样改完之后后续如果我们要接入先查内存缓存、再查磁盘缓存、最后走网络的多级缓存只需改一个实现所有调用方无需感知。4.2 数据来源切换策略与Cache-Aside模式如果你在学习后端缓存设计一定很熟悉Cache-Aside模式先读缓存缓存不命中就读数据库再把结果写回缓存。这个模式放到Android本地数据源和网络数据源之间同样适用。我们的缓存策略一般是这样列表类数据优先显示本地缓存同时后台拉取新数据刷新页面详情类数据优先网络但显示加载中状态成功后同时写入本地缓存方便下次秒开用户设置等轻量数据直接用DataStore保存不涉及网络的部分只走本地。关键在于缓存策略属于Data层实现细节不能泄露到Domain层接口设计里。Repository的接口定义只表达给我一个用户实体不需要让调用方知道是不是走了缓存。4.3 离线优先与本地数据持久化的一些实操处理很多业务产品都有离线可用的诉求这也依赖Repository抽象。比如消息列表、收藏列表、已下载资源列表这些应该尽量做成离线优先。用Room做本地持久化时我们有一个统一原则Domain层不直接引用Room的Entity也不引用DAO。Data层负责把Entity注解的本地模型转成Domain模型。这样即使将来本地数据库整体替换成SQLDelight或RealmDomain层代码一行都不用动。再补充一个跟Paging有关的经验如果用了Room Paging3页面级分页的细节建议放在Data层封装不要让Domain层直接感知PagingSource的存在。Domain层的UseCase可以返回一个FlowPagingDataT但具体这个PagingData是来自Room还是网络拼接UI层不必知道。5. 大型项目模块化Clean Architecture如何层层分解到Gradle模块5.1 按业务域拆模块还是按层级拆模块项目规模变大后单模块的包结构约束会有天花板编译时间变长、合并冲突变多、模块间隔离变弱。这时候大家会考虑拆Gradle Module。问题来了按层级拆还是按业务域拆我见过有人把core/domain、core/data拆成基础模块再把feature/home、feature/login拆成业务模块。这套思路是对的但不建议一开始就搞太多分层模块。一个比较稳妥的组合是core:common基础工具、通用UI组件、常量core:domainDomain层核心包含用例和接口定义core:dataData层核心实现依赖core:domainfeature:*每个业务Feature单独一个模块依赖core:domain和core:dataapp壳工程负责聚合Feature模块和全局配置。对比一下两种拆法策略优点缺点按层级拆domain/data/presentation三个大模块结构清晰规则好约束业务模块边界不清晰一个feature改需求要动三个模块按业务域拆Feature模块业务内聚并行开发效率高模块间影响小需要更严格的依赖规则约束否则模块依赖会交叉混合拆分core feature兼顾清晰度和业务隔离度初始设计成本高我们最终选择的就是混合拆分核心是core模块的稳定性外围是feature模块的自治。5.2 模块间依赖规则的实例模块化落地中最常见问题是循环依赖。比如feature:home引用了feature:profile去跳转页面同时feature:profile又引用了feature:home里的路由注册表这就直接编译不过。我们的解法是所有跨Feature通信都走core:common中的接口或导航协议层Feature模块之间不允许互相依赖页面跳转统一封装在导航类中不在一个Feature里直接 new 另一个Feature的Activity或ComposableData层实现模块可以依赖多个Feature的Domain接口但Feature模块不能反向依赖Data实现。这样做之后每个Feature模块的编译范围被锁死了改一个Feature不会触发所有模块重编极大提升了本地构建速度。5.3 模块化带来的协作效率提升和编译缓存经验拆模块一个很大收益是Git冲突和编译时间同时下降。以前大家在一个模块里改几十个文件合并分支时痛苦万分拆完之后A同学改feature/profileB同学改feature/home完全不相干合并基本没冲突。编译层面我们开启了Gradle构建缓存和Configuration Cachecore模块本身很少变基本都是增量编译。实测一个中型项目全量编译从五六分钟降到两分钟左右日常调试也有了比较明显的提升。想提醒的是拆模块不是一劳永逸的每拆一个Feature都要把Domain层接口、导航协议、公共依赖打包清楚。否则拆出来的模块截断不干净未来改造成本会翻倍。6. 在存量大型项目里做渐近式重构从传统MVVM迁移的实战路径6.1 先找接缝最便宜的第一步是把数据访问藏到接口后面不是所有团队都有机会从零开始一个项目。更多情况是接手一个历史悠久、逻辑混乱的大型项目这时候强行推倒重写基本等于冒险。我更建议的路线是渐近式重构。第一步别急着重写UI层先找数据访问的接缝。把原来散落在各个ViewModel里的apiService直接访问统一包装到对应的Repository接口里。哪怕Domain层还没有完整建立这一步也能带来立竿见影的效果数据访问的调用点集中了上层代码开始面向接口编程。操作方法很简单选一个边界清晰的业务域比如用户、订单、消息定义XxxRepository接口新建一个XxxRepositoryImpl把散落在各处的网络/数据库调用挪进去用接口替换原有调用点跑全量回归测试确认行为一致。这段工作不需要一次性做完可以按业务域每周逐步推进。刚开始可能觉得只是换了个包装但它为后续的拆分和测试打好了地基。6.2 逐个Feature迁移的顺序与判定标准存量项目重构时最忌讳的是把所有业务一次性全部迁移到新架构。我们团队的经验是按以下优先级来改动频繁的业务先迁比如登录、支付、首页信息流这些每天都在迭代的模块迁移收益最高核心链路先迁用户信息、订单状态、消息推送这类影响面大的链路越早稳定越有利边缘功能最后迁很少改动、逻辑简单的页面可以保持旧模式不必强迫清零。每个Feature的迁移完成标准可以设定为业务核心逻辑已从ViewModel移到Domain层的UseCase数据访问已全部通过Repository接口相关单元测试覆盖了UseCase的核心分支架构约束测试通过。有了这个判定标准重构就有了可验证的完成边界而不是无穷无尽地改。6.3 重构期间的兼容措施接口新旧并存、灰度逻辑在过渡期里新老架构一定会并存。有些页面还在用旧ViewModel直接访问网络有些已经换成了新UseCase。为了不让状态更乱我们约定了几条过渡纪律同一业务域不要新旧模式混用超过两个迭代新代码一律走新架构不允许新增直接调用网络层的代码路径过渡期间允许Deprecated注解标记旧接口但不允许再往旧接口里加业务逻辑。灰度方面我们一般通过配置开关来切换新旧实现。比如同样的业务旧实现和New UseCase在后台配置控制下小流量验证观察崩溃率和耗时数据正常后再全量切换。6.4 迁移后效果的实测对比这些经验听起来可能偏抽象但落到数字上是有说服力的。我们迁移了一个历史悠久的交易流程模块原代码集中在两个大ViewModel里迁移完成后模块的核心代码行数减少了大概40%很多重复的后台逻辑收敛到共享UseCase单元测试从原来的零覆盖提升到接近80%的关键链路覆盖一个涉及数据来源切换的需求改动范围从原来网络层、ViewModel、所有调用页缩小到只改Data层实现和少量配置。数据说明了一点架构工作的回报不在迁移当天而在之后的每一次需求变更里。7. 架构落地中的避坑与团队协同7.1 不要用Clean Architecture阻碍快速原型有一种常见误区新产品、新创意要先做验证结果团队直接套上完整Clean Architecture导致一个简单页面也要写UseCase、Repository接口、Mapper开发速度被拖慢。我们的处理原则是原型验证期可以不走完整架构但一定要在代码里打好接缝。即使暂时不拆Domain层也至少把数据访问封装到接口后面。这样验证通过后后续再把业务逻辑抽到UseCase时改动成本是可控的。7.2 状态管理的归属LiveData在View层Flow用在Domain和Data层很多团队讨论LiveData还是Flow其实在不同层级有不同的答案Data层使用suspend函数或者Flow不做生命周期绑定只关注数据源Domain层同样使用Flow或suspend不依赖任何UI框架Presentation层可以根据使用习惯选择LiveData或StateFlow但建议统一一种避免混合。在我们的实践里UseCase返回Flow是最顺滑的数据源无论来自Room还是网络都是响应式流ViewModel可以很方便地做flatMapLatest等操作。反过来说如果Domain层用LiveData业务逻辑层就被绑死在Android架构组件上没法在JVM测试。7.3 DI框架与组件管理大型项目里依赖注入基本是标配。我们用Hilt主要处理三个层面的依赖Data层把网络服务、本地数据库、DataSource绑定到对应模块Domain层UseCase依赖注入Repository接口Presentation层注入UseCase和ViewModel Factory。Hilt的一个常见坑是把Data层实现直接注入到Presentation层比如在ViewModel里注入OkHttpClient或Room DAO。这会绕过Repository的抽象破坏分层规则。我们的做法是在Code Review规则里明确ViewModel只能直接注入Domain层接口Data层的实现类统一对Domain层隐藏。7.4 测试金字塔落地的实际回报架构落地之后测试这件事就顺理成章了。我们的测试结构大致是Domain层单元测试主战场。UseCase逻辑、状态映射、异常处理100%运行在JVM上秒级执行Data层单元测试/集成测试用MockWebServer验证网络解析和Mapper映射用Room内存数据库验证本地仓储UI测试只覆盖关键链路比如登录、下单、付款跳转等高频且影响面大的场景不追求全覆盖。大型项目里单元测试跑得快、跑得稳才是测试金字塔的核心。Clean Architecture让这段代码天然可测因为它不依赖Android框架。在我个人的实际体验里最有价值的一句话可能是Clean Architecture不是银弹它是一套帮你把依赖关系管理起来的规约。只要团队愿意在写代码前多想一步这个类应该放在哪一层、它可以依赖谁架构就不会腐化。而一旦形成了这样的习惯后续的新功能开发、老代码重构、团队轮转交接都会顺畅非常多。如果你们团队正准备在大型Android项目里推行Clean Architecture我的建议是先从一个频繁变更的业务模块做起花一个迭代把Repository接口和UseCase搭出来跑通完整的测试链路然后再逐步推广。不要等架构设计完美了再动工架构是在真实的业务迭代中逐渐长成的。