ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Clean Architecture在Android大型项目中的落地实践与避坑指南

Clean Architecture在Android大型项目中的落地实践与避坑指南 我参与过几个从 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 PluginAGP相关的库那 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): ResultAuthSession 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): ResultAuthSession { 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 接口时应该用BindsModule 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: StateFlowLoginUiState _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又依赖AuthRepositoryDagger 在编译期就会给你一个大大的报错。遇到这种 DAG 循环第一步不是改代码而是先审视你的依赖设计是不是有问题。通常来说循环依赖的根源在于两个模块职责没有切干净。解决办法是引入一个更底层的依赖比如把SessionStorage抽出来让两个模块都依赖它。不要用Qualifier或者LazyK这类 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 时你就拥有了一个天然的跨平台业务内核。以后如果团队要复用业务逻辑到 iOSKotlin Multiplatform或者后端KtorDomain 层可以直接平移过去这是一个非常大的长期红利。所以哪怕现在只是纯 Android 项目我也建议你守住 Domain 层零 Android 依赖的底线这是最重要的可迁移资产。6.2 与 Compose 的搭配如果你的新项目用的是 Jetpack Compose那么 Presentation 层的实践可以进一步细化为单 Activity 的 UI 状态集中管理。ViewModel 只暴露 StateFlowCompose 通过collectAsStateWithLifecycle收集状态。Clean Architecture 的分层仍然适用而且因为组合式函数的粒度更细UI 层可以直接拆成更小粒度的组件每个组件只消费自己关心的状态整体代码会更清晰。6.3 对团队协作的效率影响我还想多聊两句团队层面的事。在大型项目里架构方案不只是技术方案某种意义上也是团队协作的契约。有了清晰的分层新同学上手时会更快——他只需要搞清楚自己改的代码应该落在哪一层以及它跟相邻层之间怎么交互其余的不需要关心。代码 Review 时大家讨论的焦点也会更集中不会被这行代码为什么不放这里这个低级问题耗掉精力。当然也提醒一句架构是约束不是枷锁。实际项目里总是会有这里为了赶时间能不能捅个窟窿的情况我的建议是建立例外流程让例外可以被记录、被追踪而不是假装没有发生过。Clean Architecture 要能在大型项目里活下来靠的不是完美主义而是让所有人知道什么时候必须守规则什么时候可以借路借路之后怎么还债。最后说点我个人的体会——架构方案的成败多半不在方案本身而在落地过程中的克制。不要以架构的名义把代码复杂化不要为了贴一个 Clean Architecture 的标签而制造大量没有实际价值的中间层更不要指望一个架构方案解决团队的所有工程问题。Clean Architecture 在 Android 大型项目中最有价值的产出是让代码的依赖方向始终朝着业务核心收敛让核心业务逻辑不随着 UI 框架、网络库、数据库的变迁而被反复重写。守住这一条哪怕你在实现细节上做得不是那么完美架构的整体骨架也不会歪。
返回列表