模块化设计:Gradle多模块配置、模块间通信、资源隔离

模块化设计,说白了就是把一个庞大的应用拆成多个独立的小模块。每个模块各司其职,互不干扰。我刚开始做架构时,也踩过不少坑——一个模块改动,整个项目都要重新编译,那滋味可不好受。

今天我们就来聊聊模块化的三个核心问题:Gradle多模块怎么配、模块间怎么通信、资源怎么隔离。嗯,这三个问题搞定了,你的项目架构基本就稳了。

一、Gradle多模块配置

先说说多模块的目录结构。我个人习惯这样组织:

MyApp/
├── app/                    # 主应用模块
├── common/                 # 公共基础模块
├── module_login/           # 登录模块
├── module_home/            # 首页模块
├── module_mine/            # 我的模块
├── lib_network/            # 网络库模块
├── lib_ui/                 # UI组件库模块
└── settings.gradle.kts     # 模块注册文件

settings.gradle.kts 中注册模块:

rootProject.name = "MyApp"
include(":app")
include(":common")
include(":module_login")
include(":module_home")
include(":module_mine")
include(":lib_network")
include(":lib_ui")

每个模块的 build.gradle.kts 要统一管理依赖版本。我建议用 版本目录(Version Catalog) 的方式,而不是到处写死版本号。为什么?因为升级依赖时,你只需要改一个地方。

gradle/libs.versions.toml 中定义:

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

[libraries]
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }

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

然后在模块中引用:

dependencies {
    implementation(libs.retrofit.core)
    implementation(libs.retrofit.gson)
}
小技巧: 每个模块的 build.gradle.kts 尽量保持简洁。公共配置可以抽取到 build-logic 模块中,用 convention plugins 统一管理。这样新加模块时,几行代码就搞定了。

二、模块间通信

模块间通信,这是个老生常谈的问题。你想想看,登录模块怎么告诉首页模块「用户登录成功了」?直接依赖?那模块化就白做了。

我常用的方案有三种:

方案 适用场景 优点 缺点
接口下沉 模块间有明确的调用关系 类型安全,编译期检查 需要定义公共接口模块
事件总线 一对多、松耦合的场景 解耦彻底,使用灵活 运行时风险,不易追踪
路由框架 页面跳转、跨模块调用 支持URL跳转,拦截器强大 需要额外引入框架

接口下沉是我最推荐的方式。具体做法是:

  1. common 模块中定义接口
  2. 业务模块实现接口
  3. 通过 ServiceLoader 或依赖注入获取实现

举个例子:

// common模块中定义
interface ILoginService {
    fun isLoggedIn(): Boolean
    fun getUserId(): String
}

// login模块中实现
class LoginServiceImpl : ILoginService {
    override fun isLoggedIn(): Boolean {
        return SPUtils.getBoolean("is_login", false)
    }
    override fun getUserId(): String {
        return SPUtils.getString("user_id", "")
    }
}

// home模块中使用
class HomeViewModel @Inject constructor(
    private val loginService: ILoginService
) {
    fun checkLoginStatus() {
        if (loginService.isLoggedIn()) {
            // 展示用户信息
        }
    }
}
核心原则: 模块之间只能依赖接口,不能依赖实现。接口定义在公共模块中,实现藏在各自模块里。这样替换实现时,其他模块完全无感知。

事件总线适合那些「通知一下就行」的场景。比如用户退出登录,多个模块都需要清理缓存。我用的是 SharedFlow,比传统的 EventBus 更轻量,也更 Kotlin 化。

// 定义事件
object Events {
    data class LogoutEvent(val reason: String)
    data class RefreshEvent(val pageId: Int)
}

// 发送事件
fun postLogout() {
    eventBus.emit(Events.LogoutEvent("user_manual"))
}

// 接收事件
fun observeLogout() {
    viewModelScope.launch {
        eventBus.collect { event ->
            when (event) {
                is Events.LogoutEvent -> handleLogout(event.reason)
                else -> {}
            }
        }
    }
}
注意: 事件总线虽然方便,但别滥用。我曾经在一个项目里看到几十个事件满天飞,调试起来简直噩梦。我的建议是:能用接口解决的,就别用事件。事件只用于「通知」场景,不要用于「请求-响应」场景。

三、资源隔离

资源隔离,说白了就是防止模块间的资源命名冲突。你想想看,两个模块都定义了一个 ic_launcher.png,编译时到底用哪个?

我遇到过最坑的一次:登录模块和首页模块都定义了 color_primary,结果首页的按钮颜色莫名其妙变了。排查了半天,才发现是资源覆盖了。

解决方案其实很简单:给每个模块加资源前缀

build.gradle.kts 中配置:

android {
    namespace = "com.example.module_login"
    resourcePrefix = "login_"
}

这样,登录模块的资源文件必须用 login_ 开头,比如 login_ic_avatar.pnglogin_color_primary。如果某个资源忘了加前缀,编译时会直接报错。嗯,这招很管用。

除了命名隔离,还有资源目录隔离。每个模块的资源放在自己的 src/main/res 下,不要共享。公共资源(比如主题、通用图标)放在 common 模块中。

目录结构示例:

module_login/
└── src/
    └── main/
        ├── res/
        │   ├── drawable/
        │   │   └── login_ic_avatar.png
        │   ├── layout/
        │   │   └── login_activity.xml
        │   ├── values/
        │   │   ├── login_colors.xml
        │   │   └── login_strings.xml
        │   └── ...
        └── ...
避坑指南: 我曾经在资源隔离上吃过亏——某个模块的 strings.xml 里定义了 app_name,结果覆盖了主模块的配置。从那以后,我要求所有模块的字符串资源都必须带模块前缀,比如 login_app_namehome_title。虽然名字长了点,但安全。

最后说说资源引用。模块间引用资源时,要通过 R 类来访问。但注意,如果模块 A 依赖了模块 B,模块 A 可以直接用 R.b.xxx。如果模块 A 不依赖模块 B,那就需要通过接口或事件来传递资源 ID。

举个例子:

// common模块定义接口
interface IResourceProvider {
    fun getLoginAvatar(): Drawable
    fun getLoginHint(): String
}

// login模块实现
class LoginResourceProvider : IResourceProvider {
    override fun getLoginAvatar(): Drawable {
        return ContextCompat.getDrawable(context, R.drawable.login_ic_avatar)!!
    }
    override fun getLoginHint(): String {
        return context.getString(R.string.login_hint)
    }
}

// home模块使用
class HomeViewModel @Inject constructor(
    private val resourceProvider: IResourceProvider
) {
    fun getAvatar() = resourceProvider.getLoginAvatar()
}

这样做的好处是:资源的使用者和提供者完全解耦。你换模块、换资源,都不影响其他模块。


好了,模块化设计的三个核心问题就聊到这里。总结一下:

  • Gradle配置:用版本目录统一管理依赖,用 convention plugins 抽取公共配置
  • 模块间通信:优先用接口下沉,事件总线只用于通知场景
  • 资源隔离:加资源前缀,公共资源放 common 模块,通过接口传递资源

这些经验都是我在实际项目中一点点积累的。刚开始做模块化时,我也觉得麻烦,但坚持下来后,项目的可维护性、编译速度、团队协作效率都提升了一大截。嗯,值得投入。