15、多模块项目:模块拆分原则、跨模块依赖、复合构建、版本目录统一管理、模块化插件

说实话,多模块项目这个话题,我每次在团队里讲的时候,都能看到不少开发者的眼神从「这有什么好讲的」变成「原来还有这种操作」。嗯,今天我们就来好好聊聊这个。

我记得刚入行那会儿,接手过一个「超级单体」项目——一个app模块里塞了十几个业务功能,build.gradle文件长达800多行。每次编译都要等5分钟,改一行代码就要重新跑整个项目。后来我痛定思痛,花了两个周末把项目拆成了十几个模块。从那以后,编译时间从5分钟降到了40秒。你想想看,这效率提升有多明显。

模块拆分原则:别为了拆而拆

模块拆分不是越细越好。我见过有人把网络请求封装成一个模块,又把网络请求的拦截器单独拆成一个模块……这就有点过了。

我个人习惯遵循这几个原则:

  • 单一职责:一个模块只做一件事。比如:网络模块只负责网络请求,图片加载模块只负责图片加载。
  • 复用性优先:如果一段代码在两个以上地方用到,就考虑抽成模块。
  • 业务边界清晰:登录模块、支付模块、首页模块……这些业务模块之间尽量不互相依赖。
  • 编译粒度合理:模块太小会导致项目结构臃肿,模块太大又失去了拆分意义。

核心原则:模块拆分的最终目的是「高内聚、低耦合」。说白了,就是让每个模块内部足够独立,对外暴露的接口尽量少。

跨模块依赖:别搞成循环依赖

跨模块依赖是多模块项目里最容易踩坑的地方。我曾经在一个项目里,因为A模块依赖B模块,B模块又依赖C模块,C模块又反过来依赖A模块……结果Gradle直接报了个循环依赖错误,排查了整整一个下午。

常见的依赖方式有三种:

依赖方式 写法 适用场景
api api(project(":core")) 需要对外暴露该依赖的类
implementation implementation(project(":core")) 只在模块内部使用,不对外暴露
compileOnly compileOnly(project(":core")) 编译时需要,运行时不需要

我的建议:能用implementation就别用api。api会把依赖传递出去,容易造成依赖冲突。我见过太多项目因为滥用api导致依赖版本混乱的案例了。

还有一个常见的坑:跨模块的接口定义。比如登录模块需要调用支付模块的某个功能,但又不希望直接依赖支付模块。怎么办?我的做法是:定义一个「公共接口模块」,里面只放接口定义,不包含实现。登录模块依赖这个接口模块,支付模块也依赖这个接口模块并实现接口。这样两个业务模块就解耦了。

复合构建:把多个独立项目组合起来

复合构建(Composite Build)是Gradle 3.1之后引入的功能。说实话,这个功能我用得不算多,但在某些场景下确实很香。

举个例子:你有一个独立的「网络请求库」项目,同时还有一个「主项目」需要使用这个库。传统做法是把网络请求库发布到Maven仓库,然后在主项目里引用。但如果你还在开发网络请求库,每次改完代码都要发布、更新版本号、同步……太麻烦了。

复合构建可以让你直接把网络请求库项目「嵌入」到主项目里:

// settings.gradle.kts
includeBuild("../network-library")

这样,网络请求库的代码改动会立即反映到主项目中。开发调试非常方便。

注意:复合构建里的项目是独立的,它们有自己的build.gradle文件、自己的版本号。如果你在复合构建里改了网络请求库的代码,记得提交到它自己的仓库里。

版本目录统一管理:告别版本号散落一地

这个我太有感触了。以前的项目里,同一个依赖的版本号可能出现在三四个模块的build.gradle里。升级一个依赖版本,要全局搜索替换,漏掉一个就等着编译报错吧。

Gradle 7.0之后引入了版本目录(Version Catalog),用起来真的很爽。

首先,在项目的根目录下创建 gradle/libs.versions.toml 文件:

[versions]
kotlin = "1.9.0"
compose-bom = "2024.01.00"
retrofit = "2.9.0"

[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }

[plugins]
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

然后在模块的build.gradle.kts里引用:

dependencies {
    implementation(libs.retrofit)
    implementation(platform(libs.compose.bom))
}

你看,所有版本号都集中在一个文件里管理。升级版本只需要改这一个文件,所有模块自动同步。我个人习惯把这个文件放在Git里,团队里所有人都用同一套版本号,再也不会出现「你的Retrofit是2.9.0,我的怎么是2.8.0」这种问题了。

模块化插件:把重复的配置抽出来

多模块项目里,每个模块的build.gradle里都有大量重复配置——android闭包、compileSdk、minSdk、targetSdk、依赖声明……我以前数过,一个10个模块的项目,同样的配置重复了10遍。

模块化插件就是解决这个问题的。我写了一个自定义插件,把公共配置全部封装进去:

// buildSrc/src/main/kotlin/com/example/AndroidModulePlugin.kt
class AndroidModulePlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            plugins.apply("com.android.library")
            plugins.apply("org.jetbrains.kotlin.android")

            extensions.configure<LibraryExtension> {
                compileSdk = 34
                defaultConfig {
                    minSdk = 24
                    targetSdk = 34
                }
            }

            dependencies {
                add("implementation", libs.findLibrary("kotlin-stdlib").get())
                add("implementation", libs.findLibrary("compose.ui").get())
            }
        }
    }
}

然后在模块里只需要一行:

plugins {
    id("com.example.android-module")
}

是不是清爽多了?我团队里现在所有模块的build.gradle文件都不超过10行。新同事入职,看到这么简洁的配置都惊呆了。

避坑指南:自定义插件里如果引用了版本目录(libs.xxx),记得在插件代码里使用 libs.findLibrary("xxx") 的方式获取,不要硬编码版本号。否则版本目录就白用了。

知识体系总览

下面这张图是我自己总结的多模块项目核心知识体系,你可以对照着看看自己掌握了多少:

多模块项目 模块拆分原则 • 单一职责 • 复用性优先 • 业务边界清晰 • 编译粒度合理 跨模块依赖 • api / implementation • compileOnly • 接口解耦 复合构建 • includeBuild() • 独立项目嵌入 • 开发调试方便 版本目录统一管理 • libs.versions.toml • 集中管理版本号 • 全局同步更新 模块化插件 • 自定义Plugin • 封装公共配置 • 模块配置精简 避坑指南 • 避免循环依赖 • 慎用api传递 • 插件不要硬编码版本

多模块项目说到底,就是「分而治之」的思想。把大问题拆成小问题,每个小问题独立解决,最后再组合起来。但拆的时候要有章法,不能乱拆。版本目录和模块化插件是帮你「管好」这些模块的工具,复合构建是帮你「用好」外部项目的利器。

好了,这一章的内容就到这里。希望你能把这些原则和方法用在自己的项目里,让构建过程更顺畅、代码更整洁。


公众号:蓝海资料掘金营,微信deep3321