28、Jetpack架构实战:项目架构搭建、Clean Architecture、多模块化
好,终于到了实战环节。
前面我们聊了那么多Jetpack组件,ViewModel、LiveData、Room、Navigation……说实话,光看单个组件,你可能会觉得“也就那样”。但真正让这些组件发光发热的,是它们组合在一起,形成一个清晰、可维护、可测试的架构。
今天我就带你走一遍,一个真实项目该怎么搭架子。我会用Clean Architecture的思路,配合多模块化,把Jetpack组件嵌进去。嗯,这套方案我在好几个线上项目里都用过,踩过坑,也填过坑,今天全盘托出。
核心目标:让代码分层清晰、依赖可控、测试友好、团队协作不打架。
28.1 为什么需要Clean Architecture?
说白了,就是不想让业务逻辑和Android框架绑死。
你想想看,如果Activity里直接写了网络请求、数据库操作、业务判断……那这个Activity基本就废了。换个人来维护,得从头读到尾才能改一个需求。而且单元测试?别想了,你得跑模拟器才能测。
Clean Architecture的核心思想就一句话:依赖方向从外向内,内层不知道外层的存在。
我习惯把项目分成三层:
- data层:管数据来源,网络、本地数据库、文件等。
- domain层:纯业务逻辑,不依赖任何Android框架。
- presentation层:UI和状态管理,用ViewModel+LiveData/Flow。
这里有个关键点:domain层是纯Kotlin/Java模块,不依赖Android SDK。这样你可以在纯JVM环境下跑单元测试,爽得很。
我的习惯:domain层里只放UseCase和Repository接口。具体实现全扔给data层。这样换数据源(比如从Retrofit换成Ktor)时,domain层纹丝不动。
28.2 多模块化:不只是为了编译快
很多人觉得多模块就是为了加快编译速度。其实不止。
多模块化最大的好处是强制解耦。模块之间通过接口通信,谁都不能直接new对方的类。我曾经在一个单模块项目里,看到工具类被十几个页面引用,改一个方法要全局搜索……那叫一个酸爽。
我推荐的分模块方式:
- :app:壳工程,只做依赖注入和页面组装。
- :core:network:网络层,封装Retrofit。
- :core:database:数据库层,封装Room。
- :core:common:公共工具类、扩展函数。
- :feature:home:首页模块,包含自己的data、domain、presentation。
- :feature:detail:详情模块,同理。
每个feature模块内部,再按Clean Architecture分层。这样每个模块都是一个“小项目”,独立开发、独立测试。
注意:模块之间不要循环依赖。我见过有人把common模块搞得越来越大,最后所有模块都依赖它,一改common全项目重编。common应该只放真正通用的东西,比如Log工具、日期格式化。业务相关的工具类,放到各自feature模块里。
28.3 架构图:一张图看懂依赖关系
下面这张图,是我每次给团队新人讲架构时必画的。它清晰地展示了各层、各模块之间的依赖方向。
看到没?箭头都是从外指向内。presentation依赖domain,domain依赖data?不对,domain不依赖data。domain只定义接口,data去实现。这就是依赖反转。
28.4 实战:搭建一个首页模块
光说不练假把式。我们拿首页模块举个例子。
第一步:定义domain层的UseCase和Repository接口
// domain层,纯Kotlin模块
interface HomeRepository {
suspend fun getFeaturedItems(): List<Item>
}
class GetFeaturedItemsUseCase(
private val repository: HomeRepository
) {
suspend operator fun invoke(): Result<List<Item>> {
return try {
Result.success(repository.getFeaturedItems())
} catch (e: Exception) {
Result.failure(e)
}
}
}
第二步:在data层实现Repository
// data层,依赖core:network和core:database
class HomeRepositoryImpl(
private val api: HomeApi,
private val dao: ItemDao
) : HomeRepository {
override suspend fun getFeaturedItems(): List<Item> {
// 先尝试网络
return try {
val response = api.getFeatured()
dao.insertAll(response.map { it.toEntity() })
response.map { it.toDomain() }
} catch (e: Exception) {
// 网络失败,走缓存
dao.getAll().map { it.toDomain() }
}
}
}
第三步:在presentation层用ViewModel调用UseCase
// presentation层
class HomeViewModel(
private val getFeaturedItemsUseCase: GetFeaturedItemsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
loadFeatured()
}
private fun loadFeatured() {
viewModelScope.launch {
getFeaturedItemsUseCase().collect { result ->
_uiState.value = result.fold(
onSuccess = { HomeUiState.Success(it) },
onFailure = { HomeUiState.Error(it.message) }
)
}
}
}
}
你看,ViewModel里没有直接new Repository,也没有直接调网络。它只依赖一个UseCase,UseCase只依赖接口。这样换数据源、换实现,ViewModel完全不用改。
避坑指南:我曾经在ViewModel里直接传Repository接口,后来发现单元测试时还得mock整个Repository。改成UseCase后,测试粒度更细,每个UseCase只做一件事,mock起来轻松多了。
28.5 依赖注入:用Hilt把一切串起来
模块分好了,接口定义好了,谁来把它们组装起来?
我推荐用Hilt。它和Jetpack全家桶配合得最好。
// 在core:network模块中
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideHomeApi(retrofit: Retrofit): HomeApi {
return retrofit.create(HomeApi::class.java)
}
}
// 在feature:home模块中
@Module
@InstallIn(ViewModelComponent::class)
object HomeModule {
@Provides
fun provideHomeRepository(impl: HomeRepositoryImpl): HomeRepository {
return impl
}
@Provides
fun provideGetFeaturedItemsUseCase(
repository: HomeRepository
): GetFeaturedItemsUseCase {
return GetFeaturedItemsUseCase(repository)
}
}
这样,Hilt会自动把HomeRepositoryImpl注入到HomeRepository接口的位置,再把GetFeaturedItemsUseCase注入到ViewModel里。你只需要在ViewModel上用@HiltViewModel注解就行。
注意:每个模块的Hilt Module要放在对应的模块内,不要全堆在:app模块里。否则模块化就失去意义了——你改一个模块,还得去:app里改依赖配置。
28.6 多模块化带来的测试优势
说实话,单模块项目也能写单元测试,但多模块化让测试变得理所当然。
因为domain层不依赖Android,你可以直接在JVM上跑测试,不用等模拟器启动。我团队现在要求每个UseCase都必须有单元测试,覆盖率80%以上。因为测试成本低,大家也愿意写。
// 测试domain层的UseCase
class GetFeaturedItemsUseCaseTest {
private val repository = mock<HomeRepository>()
private val useCase = GetFeaturedItemsUseCase(repository)
@Test
fun `when repository returns data, then return success`() = runTest {
// given
val items = listOf(Item(1, "Test"))
`when`(repository.getFeaturedItems()).thenReturn(items)
// when
val result = useCase()
// then
assertTrue(result.isSuccess)
assertEquals(items, result.getOrNull())
}
}
这个测试不需要Android环境,不需要Room数据库,不需要Retrofit。纯Kotlin,跑起来飞快。
28.7 总结一下这套架构的好处
| 维度 | 单模块+混乱架构 | 多模块+Clean Architecture |
|---|---|---|
| 编译速度 | 改一行,全量编译 | 只编译改动的模块 |
| 测试难度 | 需要模拟器,慢 | 纯JVM测试,快 |
| 团队协作 | 容易冲突,互相影响 | 各模块独立开发 |
| 技术栈替换 | 牵一发动全身 | 只换对应模块 |
嗯,这套架构不是一天建成的。我建议你从一个小功能开始,比如把首页拆成独立模块,慢慢体会模块化带来的好处。等你习惯了,就再也回不去了。
公众号:蓝海资料掘金营,微信deep3321