模块化项目结构设计原则:分层模块、功能模块、公共模块划分

很多人刚开始接触模块化时,第一反应就是「把所有东西拆散」。拆完之后呢?依赖乱成一锅粥,改一个公共类要重新编译大半个项目……说白了,模块化不是拆分,而是分层。我自己的习惯是:先想清楚三个维度——分层、功能、公共。这三者缺一不可。

1. 为什么必须分层?

先看一个反例。我以前接手过一个项目,里面有一个 `common` 模块,里面既有网络请求的工具类,又有业务上的「用户帮助弹窗」,还有数据库 dao 层代码。你猜后面发生了什么?每次改弹窗样式,整个 common 都要重新打包,而且因为 common 被所有模块依赖,牵一发动全身。

分层的核心逻辑是:每一层只做一件事,并且只依赖比它更「底」的层。举个例子:

  • 基础层(Base Layer):纯 Java/Kotlin 工具类,例如字符串、日期、加密。不能有任何 Android 依赖。
  • 数据层(Data Layer):网络请求、数据库、本地缓存。可以依赖基础层,但绝不能直接引用 Activity、Fragment。
  • 业务层(Business Layer):具体的业务逻辑,比如用户登录、商品展示。只依赖数据层和基础层。
  • 展示层(Presentation Layer):UI + 页面路由。依赖业务层。

这样做的好处很明显——你改数据层的数据库表结构,展示层完全不需要重新编译。嗯,我在一个日活百万的 App 里实践过这套,编译速度提升了 30% 以上。

2. 功能模块:按业务边界拆分

分层是纵向的,功能模块是横向的。比如你的 App 有登录、支付、个人中心,就应该各自独立成模块。为什么?因为一个团队通常负责一块业务,功能模块之间不应该互相知道对方的内部实现。

我通常按以下原则划分:

  1. 每个功能模块都有自己的「对外接口」(比如一个 LoginApi 接口)。其他模块只能通过接口调用,不能直接 new 实现类。
  2. 功能模块之间的依赖必须显式声明。例如支付模块需要用户信息,那就让支付模块依赖登录模块提供的 UserService 接口,而不是直接依赖整个登录模块的实现。
  3. 粒度适中。如果一个功能模块的代码量不到 3 个 Activity,我建议先合并到相邻模块里。模块太多,AGP 的配置和编译反而更慢。
一个小技巧: 用 Gradle 里的「api」和「implementation」来控制依赖传播。我习惯把对外接口用 api 暴露,实现类全用 implementation。这样别的模块改实现逻辑,不会触发下游模块重编。

3. 公共模块:复用但不绑架

公共模块是最容易被滥用的一层。我见过一个公共 UI 模块,里面放了 200 多个自定义 View,结果业务模块为了用一个 Button 样式,不得不依赖一整个 UI 模块。

我的建议是:公共模块应该只包含「纯通用」的东西。比如:

  • UI 公共库:只有那些确实被 3 个以上功能模块共用的控件,比如统一的错误提示弹窗、加载动画。
  • 网络公共库:封装好的 Retrofit + OkHttp 客户端,但不要在里面塞业务拦截器。
  • 工具公共库:Utils 类,务必保持无状态。

还有一个容易踩的坑——公共模块不能反向依赖功能模块。我记得有一次,为了快速解决问题,我在公共模块里加了一个「根据通知类型跳转到不同页面」的方法,结果公共模块依赖了所有功能模块的页面类,彻底打破了单向依赖。后来花了两周重构才把循环依赖拆干净……

4. 模块间依赖关系:一张表说清楚

模块类型 可以依赖哪些模块 不能依赖哪些模块
基础层 无(自己就是最底层) 任何 Android 组件、任何功能模块
数据层 基础层 业务层、展示层、功能模块
业务层 数据层、基础层 展示层
功能模块(如登录) 业务层、数据层、基础层 其他功能模块的具体实现
公共模块 基础层(必要时可依赖数据层) 任何功能模块、展示层
展示层(App壳) 所有层(但需通过接口依赖功能模块) 无,但它只做组装,不写业务逻辑

5. 实战:在 AGP 里如何配置模块化结构?

结构设计好了,得落实到 Gradle 脚本里。我以常见单 App + 多个 library 模块为例:

// 在 settings.gradle.kts 中
include(":app")
include(":core:base")
include(":core:data")
include(":core:network")
include(":feature:login")
include(":feature:payment")
include(":common:ui")
include(":common:util")

每个模块的 build.gradle.kts 里声明依赖时,务必遵守前面的依赖规则:

// feature/login/build.gradle.kts
dependencies {
    implementation(project(":core:data"))      // 可以
    implementation(project(":common:util"))    // 可以
    implementation(project(":core:base"))      // 可以
    // 不要这样:
    // implementation(project(":feature:payment"))  // ❌ 功能模块互依
}

另外,我记得还有一个关键点——尽量让公共模块的版本号独立于业务模块。这样在 AGP 做增量编译时,公共模块的缓存更稳定。

警告: 不要为了「解耦」而在 Gradle 里用 runtimeOnly 或 compileOnly 来隐藏依赖。这些配置会让 AGP 在编译时跳过某些检查,运行时才暴露问题。我吃过这个亏,线上 crash 了才发现一个功能模块的依赖在 release 构建中被剪掉了。

6. 模块结构总览图

下面这张图概括了我上面讲的所有分层、模块划分和依赖方向。你可以把它贴在工位上,每次加新模块时对照着看。

模块化项目结构设计 App 展示层(app 壳) 只组装,不写业务 功能模块(按业务边界拆分) 登录模块 支付模块 个人中心 业务层 & 数据层 业务逻辑模块 数据层模块 网络/存储模块 公共模块(UI组件、工具库) 纯通用,无业务上下文 基础层(纯工具、无 Android 依赖) 字符串、日期、加密等 箭头方向表示「依赖方向」:上层模块可以依赖下层模块,反之不行 功能模块之间只能通过接口依赖,不能直接依赖具体实现

7. 我自己踩过的坑

写到最后,分享几个我真实遇到过的问题,希望你能绕开:

  • 「公共模块膨胀」:我曾经把整个图片加载库的封装放进了公共模块,结果功能模块为了压缩体积想换成别的图片库,公共模块成了卡点。后来我改成每个功能模块自己声明图片库依赖,公共模块只保留一个 `ImageLoader` 接口。
  • 「跨模块资源冲突」:两个功能模块都用到了同一个字符串,我直接在公共模块里定义了。后来产品要改其中一个,才发现没法单独改——因为是同一个 `R.string.xxx`。教训:资源按模块命名空间隔离,比如 `login_xxx`、`payment_xxx`。
  • 「AGP 编译顺序导致的奇怪错误」:有一次我把公共模块的版本号写死了,业务模块依赖它时用了 `+`,结果打包时 AGP 解析了不同的版本,运行时类找不到。后来我统一用 `libs.versions.toml` 管理版本,再没出过这种问题。
总结一句话: 模块化不是「砍一刀劈两半」,而是「画几个圈,再定好哪个圈能碰哪个圈」。分层保稳定,功能模块保业务,公共模块保复用——三者平衡,你的项目才能越做越清爽。