App Bundle 架构:基础模块、特征模块、动态模块的划分与作用

做过大型 Android 项目的朋友应该都有体会——随着业务膨胀,app 模块越来越臃肿,编译时间越来越长,最终用户下载的 APK 也大得吓人。App Bundle 加动态交付,就是为了解决这些问题来的。但要想用好它,得先搞懂它的架构:基础模块、特征模块、动态模块,这三者到底怎么划分?各自承担什么角色?

这一章咱们就把它掰开揉碎,讲清楚。我刚开始接触这个体系时也走过弯路,希望下面的经验能帮你少踩几个坑。

为什么一定要拆分模块?

说白了,不拆分的话,你只能把整个 App 当一个整体来交付。用户下载 100 MB,可能 80% 的功能他根本用不上。而做了模块化拆分之后,你可以做到:

  • 按需编译——改哪个模块就编译哪个,不用每次都全量构建
  • 按需交付——用户只下载他需要的功能代码和资源
  • 团队并行开发——每个模块可以独立迭代,互不阻塞

我见过很多团队一上来就拆了十几个模块,结果发现构建反而更慢了。原因很简单——模块划分没有分层,所有模块平铺在一起,互相引用,乱成一团。所以,理解三层模块架构是第一步。

基础模块 —— 应用的基石

基础模块(Base Module)是整个 App Bundle 的底座。它包含那些所有其他模块都离不开的东西:

  • 核心框架代码:网络层、图片加载、日志、工具类等
  • 公共资源:主题、通用样式、基础字符串、字体等
  • 底层依赖:Dagger / Hilt、协程、AndroidX 等第三方库
  • Application 类:初始化逻辑、崩溃收集、埋点基座

这里有个关键点:基础模块在用户设备上是永远存在的,它不会被按需卸载。所以,别把任何业务逻辑塞进来。我之前重构一个项目时,发现上一个团队把登录逻辑也放进了基础模块,理由是这个功能“太基础了”……结果每个模块都依赖了登录相关的类,导致后面想拆分登录模块时,牵一发动全身。嗯,这就是典型的基础模块膨胀问题。

我的习惯:基础模块只放“跨模块公用的技术基础设施”,不放任何业务逻辑。判断标准很简单——如果某个功能有一天可能被替换或独立交付,它就不属于基础模块。

特征模块 —— 功能的容器

特征模块(Feature Module)承载的是具体的业务功能。比如首页、搜索、个人中心、商品详情……每个都是一个特征模块。它们的特点是:

  • 功能内聚——一个模块只做一件事
  • 依赖基础模块——可以使用基础模块提供的能力
  • 可独立开发——每个模块由专门的团队维护

特征模块的划分原则,我建议按业务功能维度来切,而不是按技术层来切。举个例子:你可能会想把所有的网络请求放在一个“网络模块”里,把所有的 UI 放在一个“UI 模块”里——这样做其实是在按技术层拆分,结果就是每个业务功能横跨多个模块,改一个需求要同时改好几个地方。更合理的做法是按业务功能切,比如“购物车模块”包含自己的 UI、ViewModel、网络请求、数据模型,它是一个完整的垂直切片。

我在项目中遇到过一个典型场景:用户反馈“个人中心”页面打开很慢。因为是独立的特征模块,我们直接针对这个模块做了懒加载优化,完全不影响其他功能。如果当时没拆分,可能就得动整个 App 的启动流程了。

注意:特征模块之间不要互相依赖。如果模块 A 需要用到模块 B 里的类,那说明拆分得不够干净。正确的做法是通过接口下沉到基础模块,或者用依赖注入来解决。

动态模块 —— 按需交付的利器

动态模块(Dynamic Module)是 App Bundle 动态交付体系里的王牌。它跟特征模块最大的区别在于:动态模块可以按条件交付——用户不需要的时候,根本不会下载它。

常见的适合做成动态模块的场景:

  • 低频功能:比如帮助中心、用户协议、活动页面
  • 特定机型/地区:比如某地区的支付方式、某机型的专属功能
  • 付费功能:用户购买后才下载的功能模块
  • 大型资源包:如 AR 模型、高清壁纸、语音包

我曾经踩过一个坑:把一个动态模块里引用了基础模块的某个内部类,结果构建时没报错,但运行时直接崩溃。原因是动态模块在下载后是独立的 split APK,它跟基础模块的 class loader 是不同的。所以 动态模块只能访问基础模块的公开 API,不能依赖内部实现。从那以后,我要求团队在基础模块里给动态模块提供专门的接口层,用 @Provides 或者 @Inject 来解耦。

动态模块的几个关键配置(在 build.gradle 中):

// 动态模块的 build.gradle
plugins {
    id 'com.android.dynamic-feature'
}

android {
    // 必须指定分发条件
    // 例如:仅在美国地区分发
    // 或在用户点击"下载"时才触发安装
    ...
}

dependencies {
    // 动态模块必须依赖基础模块
    implementation project(':app')
    // 可以依赖特征模块,但不建议
    // 最佳实践是只依赖基础模块
}

三大模块的协作关系

理解了每个模块的职责,接下来最关键的是搞清楚它们如何协作。我画了一张架构图,帮你把这三层的关系理清楚:

App Bundle 三大模块架构 基础模块 特征模块 动态模块 核心代码 公共资源 功能内聚 独立迭代 按需加载 条件交付 依赖与交付关系 基础模块(始终存在)← 特征模块(始终交付)← 动态模块(按条件交付) 箭头表示“依赖于”,动态模块只能依赖基础模块的公开 API

从图中可以看到,基础模块在最底层,提供基础设施;特征模块在中间层,依赖基础模块来实现具体业务;动态模块在最上层,按条件交付给用户。箭头方向是依赖方向——动态模块依赖特征模块和基础模块,特征模块依赖基础模块。反过来不行。

模块划分的最佳实践

讲了这么多理论,最后总结一下我在实际项目中沉淀下来的几条原则:

维度 基础模块 特征模块 动态模块
核心职责 技术基础设施 业务功能实现 按需交付的扩展功能
是否始终交付 ✅ 是 ✅ 是 ❌ 按条件
依赖关系 无外部依赖 依赖基础模块 依赖基础模块(+ 特征模块可选)
构建影响 改动影响所有模块 改动只影响自身 改动只影响自身

另外,还有几条实战经验:

  • 基础模块要瘦——只放“不变”的东西,任何可能变化或替换的能力都不要放进去。我见过一个基础模块里塞了 5 种图片加载库,说是为了兼容不同场景……结果每个特征模块都跟着膨胀。
  • 特征模块要纯——一个特征模块只做一个业务功能。如果发现一个模块里既有“购物车”又有“订单详情”,那就该拆了。
  • 动态模块要稳——因为动态模块是运行时下载的,网络状况、存储空间、用户权限都会影响交付成功率。所以动态模块的代码要足够健壮,能处理下载失败、安装失败等异常情况。
我曾经吃过一次大亏:把一个动态模块的核心逻辑写成直接在 Application.onCreate() 里初始化,结果模块还没下载完就触发了初始化,导致空指针崩溃。正确的做法是在使用动态模块的功能时才触发安装和初始化,用 SplitInstallManager 来管理生命周期。

小结

这一章我们聊了 App Bundle 的三层模块架构:基础模块是地基,提供公共能力;特征模块是墙体,承载业务功能;动态模块是阁楼,按需搭建。三者的划分核心在于职责分离和依赖方向——基础模块在最底层,动态模块在最上层,依赖关系单向流动。