多模块项目架构基础
做Android开发这么多年,我见过太多「一个app/module搞定一切」的项目。刚开始确实爽,但等团队扩展到十几个人、代码量超过50万行……那酸爽,谁经历谁知道。今天咱们就来聊聊多模块架构到底是什么、怎么拆、怎么配。
什么是多模块架构
说白了,就是把一个app拆成多个独立的Gradle模块。每个模块有自己清晰的职责,可以单独编译、单独测试。举个例子:你的购物车功能就算出Bug,也不会影响用户登录流程。
单模块 vs 多模块对比
| 维度 | 单模块 | 多模块 |
|---|---|---|
| 编译时间 | 全量编译,改一行代码跑10分钟 | 增量编译,只编译改动模块 |
| 团队并行 | 一次只能一个人改核心逻辑 | 每个人独立负责一个模块 |
| 测试粒度 | 全量测试,慢且容易误报 | 模块内单元测试,快准狠 |
| 代码复用 | 依赖复制粘贴 | 模块间aar引用,天然复用 |
| 重构风险 | 改一个地方,全工程崩溃 | 接口隔离,影响范围可控 |
我个人习惯至少拆成三个层级:基础层(网络、工具库)、业务层(用户、商品)、展现层(UI模块)。你想想看,这样做回归测试时,只需要mock掉下层依赖,上层几乎不需要改代码。
模块划分原则
拆模块不是拍脑袋。我踩过一个大坑:曾经把一个「数据处理」模块搞成了大杂烩,里面有网络请求、数据库读写、甚至还有部分UI逻辑。结果半年后没人敢动它,因为改个数据库字段要重新跑200个测试用例。
几条硬原则,记住了能保命:
- 单一职责 —— 一个模块只做一件事。比如 `:network` 只负责API调用,`:database` 只负责本地存储。
- 依赖方向向内 —— 核心业务模块不能依赖UI模块。反例:`:order` 模块里引用了 `:checkout` 的UI类,导致无法单独测试。
- 抽象接口 —— 模块间通过接口通讯,而不是直接引用实现类。嗯,这块和后面的依赖注入关系紧密。
- 可独立编译 —— 每个模块都能 `./gradlew :feature_login:assembleDebug` 跑通。这样CI就能并行构建。
Gradle构建配置
多模块项目的根 `settings.gradle.kts` 会变成这样:
// settings.gradle.kts
rootProject.name = "MyMultiModuleApp"
include(":app")
include(":core:network")
include(":core:database")
include(":core:common")
include(":feature:login")
include(":feature:product")
include(":feature:checkout")
每个模块的 `build.gradle.kts` 里,我们需要声明自己的依赖和插件。我建议所有模块统一用 version catalog 管理版本号,避免版本冲突:
// libs.versions.toml
[versions]
retrofit = "2.9.0"
room = "2.6.1"
[libraries]
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
小技巧:每个模块的 `build.gradle.kts` 里尽量避免直接写版本号。用 `libs.xxx` 引用,这样升级依赖时一个文件搞定。
模块间依赖声明
模块之间怎么依赖?最常用的方式是在 `build.gradle.kts` 里写:
// :feature:login/build.gradle.kts
dependencies {
implementation(project(":core:network"))
implementation(project(":core:database"))
testImplementation(project(":core:testing")) // 测试工具模块
}
这里有个细节:`implementation` 和 `api` 的区别。用 `api` 会把依赖透传出去,容易造成依赖泄漏。我一般只用 `implementation`,除非明确要暴露接口给下游。
警告:千万不要在 `:app` 模块里直接依赖所有业务模块的细节类。我曾经接手过一个项目,`app/build.gradle.kts` 里列了20多个 `implementation project`,每次clean build半小时。正确做法是:`:app` 只依赖一个路由模块,由它动态调配。
依赖关系如果画成图,大概是下面这样:
多模块项目依赖关系图
你看这个图,从上到下是 `app → feature → core → library`。每个层级只依赖下面一层,绝不反向。这样当你修改 `:core:network` 时,只需要重新编译上层两个模块,而不会波及到不相关的 `:feature:product`(如果它没有直接依赖网络)。
依赖注入如何与多模块协同
前面说了模块间通过接口隔离,那具体怎么实现?我强烈建议用 Hilt 或 Dagger 来做依赖注入。举个例子,`:feature:login` 需要一个网络接口,它不直接 new 一个 `Retrofit` 实例,而是通过构造函数注入:
// :feature:login/src/main/java/.../LoginViewModel.kt
class LoginViewModel @Inject constructor(
private val authApi: AuthApi // 接口,定义在 :core:network
) : ViewModel() {
// ...
}
// :core:network/src/main/java/.../AuthApi.kt
interface AuthApi {
suspend fun login(username: String, password: String): Result<User>
}
然后在 `:core:network` 模块里提供真正的实现,并在 Hilt 模块中绑定:
// :core:network/src/main/java/.../NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
}
这样做的好处是什么?回归测试时可以轻松替换掉 `AuthApi` 的实现。我在做回归测试时,直接在测试模块里提供一个 fake 版:
// :core:testing/src/main/java/.../FakeAuthApi.kt
class FakeAuthApi @Inject constructor() : AuthApi {
override suspend fun login(username: String, password: String): Result<User> {
return if (username == "test" && password == "123") {
Result.success(User("test", "_abc"))
} else {
Result.failure(IllegalStateException("Invalid credentials"))
}
}
}
然后在 Espresso 测试里用 Hilt 的测试规则注入这个 fake 实现,不用改任何业务代码。这部分我们后面章节会细讲。
个人经验:我建议把 `:core:testing` 模块单独拎出来,里面只放 fake 实现和测试工具类。这样所有业务模块的测试都可以共用,而且不会污染正式代码。记得用 `testImplementation` 或者 `androidTestImplementation` 来引用它。
嗯,到这里你应该对多模块的基本概念、划分原则、Gradle配置、依赖声明以及和DI的配合有了整体认识。这就像搭乐高,模块拆得好,后面写测试、做回归才能轻松愉悦。下一块内容咱们就进入具体的依赖注入模拟实战。