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 架构图:一张图看懂依赖关系

下面这张图,是我每次给团队新人讲架构时必画的。它清晰地展示了各层、各模块之间的依赖方向。

Clean Architecture + 多模块依赖关系 :feature:home / :feature:detail (Presentation层) Activity / Fragment / ViewModel / Compose UI 依赖 :core:common, :core:network, :core:database 通过接口调用domain层的UseCase :feature:home / :feature:detail (Domain层) UseCase / Repository接口 / 实体类 纯Kotlin模块,不依赖Android SDK 依赖 :core:common(仅工具类) :feature:home / :feature:detail (Data层) Repository实现 / 数据源(Remote + Local) 依赖 :core:network, :core:database, :core:common 实现domain层定义的Repository接口 依赖 依赖

看到没?箭头都是从外指向内。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