一、Clean Architecture 与 Jetpack 的化学反应
说实话,我刚接触 Clean Architecture 那会儿,觉得这东西太「重」了。一个简单的列表页,要拆出四五个层,写一堆接口和实现类。但后来在几个大项目里被坑过几次——业务逻辑散落在 Activity 和 Fragment 里,想改个数据源得翻遍整个项目——我才明白,架构不是用来炫技的,是用来兜底的。
Clean Architecture 的核心思想就一句话:依赖方向从外向内,内层不知道外层的存在。在 Android 里,这个「内」就是你的业务逻辑和领域模型,「外」就是 Framework、数据库、网络这些容易变的东西。
我个人的分层习惯是这样的:
- data 层:Repository 实现、数据源(本地/远程)、DTO
- domain 层:UseCase、Repository 接口、领域模型
- presentation 层:ViewModel、UI State、Composable 或 View
你可能会问:domain 层真的有必要吗?我的回答是:如果你的项目超过 10 个页面,或者有多个数据源切换的需求,domain 层就是你的「保险丝」。它让业务逻辑可以脱离 Android Framework 单独测试,这在后期维护时能省下大量时间。
Jetpack 组件在 Clean Architecture 中的位置
Jetpack 组件不是架构本身,而是帮你实现架构的工具。举个例子:
- ViewModel 负责管理 UI 状态,它属于 presentation 层
- Room 是 data 层的本地数据源实现
- Navigation 是纯 UI 层的路由工具
- Hilt 贯穿所有层,负责依赖注入
嗯,这里要注意:不要把 Jetpack 组件直接塞进 domain 层。domain 层应该保持纯 Kotlin,不依赖任何 Android 框架。我见过有人把 LiveData 用在 UseCase 里,结果单元测试时还得 mock Android 环境,这就违背了 Clean Architecture 的初衷。
二、Repository 模式与数据源切换
Repository 模式说白了就是「数据源的中介者」。UI 层不关心数据是从网络来的,还是从本地数据库来的,它只认 Repository 这个接口。
我在项目中遇到过这样一个场景:App 的首页需要展示用户信息,优先从缓存读取,缓存过期了再请求网络。如果用传统写法,每个 ViewModel 里都要写一遍缓存逻辑,代码重复不说,改起来还容易漏。
用 Repository 模式就简单了:
class UserRepositoryImpl @Inject constructor(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override fun getUser(): Flow<Result<User>> {
return localDataSource.getUser()
.onStart {
// 先尝试从本地拿
val cached = localDataSource.getCachedUser()
if (cached == null) {
// 本地没有,从网络拉
val remote = remoteDataSource.fetchUser()
localDataSource.saveUser(remote)
}
}
}
}
数据源切换的常见策略:
- 网络优先:适合实时性要求高的场景(如股票行情)
- 缓存优先:适合展示类场景(如用户资料)
- 缓存优先 + 网络刷新:先展示旧数据,后台拉新数据更新 UI
你想想看,如果哪天产品经理说「我们要把本地缓存从 Room 换成 DataStore」,你只需要改 localDataSource 的实现,Repository 的接口和调用方完全不用动。这就是 Repository 模式的价值。
三、单元测试与 Instrumented 测试
测试这件事,我踩过的坑比写过的代码还多。刚开始做单元测试时,我恨不得每个方法都测一遍,结果测试代码比业务代码还多,维护起来痛不欲生。后来我学乖了:只测核心逻辑,不测框架行为。
单元测试:domain 层是重点
domain 层的 UseCase 和 Repository 接口,是单元测试的最佳对象。因为它们不依赖 Android 环境,跑起来飞快。
class GetUserUseCaseTest {
private val repository = mock<UserRepository>()
private val useCase = GetUserUseCase(repository)
@Test
fun `当用户存在时,返回用户信息`() = runTest {
// 准备
val user = User(id = "1", name = "张三")
whenever(repository.getUser("1")).thenReturn(flowOf(Result.success(user)))
// 执行
val result = useCase("1").first()
// 验证
assertTrue(result.isSuccess)
assertEquals("张三", result.getOrNull()?.name)
}
}
我曾经犯过的错:在单元测试里测试 Room 数据库的查询语句。Room 的 SQL 编译和运行依赖 Android 环境,单元测试跑不了,必须用 Instrumented 测试。后来我把数据访问逻辑单独抽出来,用 Instrumented 测试覆盖,domain 层的单元测试只验证业务逻辑,分工明确,测试速度也上来了。
Instrumented 测试:验证真实环境
Instrumented 测试跑在真机或模拟器上,适合测试 Room DAO、Navigation 跳转、Compose UI 交互等。我建议用 Hilt 的测试注解来管理依赖,避免手动创建复杂的对象图。
@HiltAndroidTest
class UserDaoTest {
@Inject
lateinit var userDao: UserDao
@Test
fun insertAndQuery() {
val user = UserEntity(id = "1", name = "李四")
userDao.insert(user)
val result = userDao.getUser("1")
assertEquals("李四", result?.name)
}
}
嗯,这里有个小技巧:Instrumented 测试的数据库最好用 Room.inMemoryDatabaseBuilder,测试结束后自动销毁,不会污染真机数据。
四、模块化与动态特性模块
模块化这件事,我见过两种极端:一种是所有代码写在一个 module 里,编译一次 10 分钟;另一种是拆了 50 多个 module,结果依赖关系乱成一团,改个公共类要重新编译 20 个 module。
我的建议是:按功能边界拆分,不要按层拆分。什么意思呢?
- 把「登录」拆成一个 module,里面包含登录的 data、domain、presentation
- 把「首页」拆成一个 module,同样包含自己的三层
- 公共的 UI 组件、网络库、工具类放在 core module 里
这样每个功能 module 都是自包含的,修改登录模块不会影响首页模块,编译速度也能提升不少。
动态特性模块:按需加载
动态特性模块(Dynamic Feature Module)是 Google Play 提供的按需下载功能。适合那些不是所有用户都会用到的功能,比如「高级设置」、「活动页面」等。
使用动态特性模块时,要注意两点:
- Navigation 要支持跨 module 跳转:可以用 Deep Link 或者 Navigation 的动态路由
- 依赖注入要统一:Hilt 的
@InstallIn可以跨 module 注册组件,但要注意 component 的 scope
我个人的模块化推荐结构:
- app:主模块,负责组装和启动
- core:网络、数据库、UI 组件、工具类
- feature:login:登录功能
- feature:home:首页功能
- feature:profile:个人中心(可做成动态特性模块)
你想想看,如果个人中心只有 30% 的用户会点进去,把它做成动态特性模块,就能让另外 70% 的用户安装包体积减少 5MB。对于大厂来说,这 5MB 可能就是 1% 的转化率提升。
知识体系总览
下面这张图概括了本章的核心内容,从架构分层到测试策略,再到模块化方案,你可以把它当作一个快速索引。
这张图从左到右展示了从架构设计到测试验证的完整链路。你可以看到,Clean Architecture 是骨架,Repository 模式是血肉,测试是保障,模块化是工程化手段。四者缺一不可。
最后分享一个我自己的经验:不要为了用架构而用架构。如果你的项目只有两三个页面,写一个 Activity 搞定反而更快。架构的价值在项目规模变大、人员变多时才会体现出来。什么时候开始重构?当你改一个需求需要动 5 个文件的时候,就是时候引入 Clean Architecture 了。
公众号:蓝海资料掘金营,微信 deep3321