动态模块(Dynamic Feature)概述
说实话,我第一次接触动态模块那会儿,大脑是懵的。心想:不就是个Module嘛,折腾这么多干嘛?后来在Google Play Store上线一个超大应用,包体超过150MB,才真正体会到了什么叫「用户流失」——下载页面还没读完,人已经跑了。动态模块,说白了就是帮你把应用拆成好几块,用户用到哪块,才下载哪块。
我记得有位同事打过一个比方:你请客吃饭,把所有菜都先端上来,结果人没吃完就浪费了。动态模块就是你先上主食,等客人说“来点甜点”,你再把蛋糕送上来。你想想看,这样是不是既省桌面,又省钱?
什么是 Dynamic Feature Module
Dynamic Feature Module,在 Android 里指的是一个独立的功能模块。它跟平时咱们写 app 时新建的那个 module 不一样——它不会被直接打进 APK,而是单独打包成一个 Asset Pack,最后通过 Google Play 的 App Bundle 分成多个 APK 分发给用户。
我从 Android 官方文档里捞了一张术语对照表,方便你理解它们的关系:
| 术语 | 对应解释 |
|---|---|
| Base Module | 核心模块,必须存在,包含 Application 和通用代码 |
| Dynamic Feature Module | 可选功能模块,用户按需下载 |
| On-Demand Delivery | 按需交付模式,由开发者触发下载 |
| Instant-On Delivery | 即时体验模式,无需安装即可使用 |
为什么需要动态模块
其实原因很直接:减少初始安装包体积。在我的一个国际电商项目中,商品详情页、AR试妆、社区直播这三个功能,单独就占了70MB。如果全部塞进基础APK,安装转化率直接降了3个百分点。我们当时用动态模块把它拆开,基础包控制在50MB以内,用户装完第一次打开,再按需拉商品详情和直播,数据立马好了不少。
再说一个我踩过的坑:我曾经把某个推送sdk和对应的UI库直接写在base module里。结果后来产品要求给这个功能增加语音播报,我又加了一个动态模块,发现基础模块里已经包含了完全无关的代码,最后还要重构依赖。避坑指南:别把动态模块需要用的网络库一股脑放进base里,不然拆起来你会哭的。
动态模块的架构关系
一个典型的App Bundle项目长这样:
MyApp/
├── app/ // base module
│ ├── src/main/
│ └── build.gradle
├── feature_profile/ // 用户资料动态模块
│ ├── src/main/
│ └── build.gradle
├── feature_checkout/ // 结算动态模块
│ ├── src/main/
│ └── build.gradle
└── settings.gradle
每个动态模块都需要在 build.gradle 里声明 dynamicFeatures = [':feature_profile', ':feature_checkout'],同时在 base 模块的 manifest 里用 <dist:module> 标签定义交付规则。
下面这张图是我自己画的一个交付流程,看完你应该能很清楚 App Bundle 是怎么把动态模块推给用户的。
动态模块的交付模式
Google Play 提供了三种交付模式,我跟早期版本对比过,现在的 on-demand 和 install-time 已经足够覆盖大多数场景了。来,我列个表:
| 模式 | 说明 | 适用场景 |
|---|---|---|
| install-time | 跟随基础APK一起下载安装 | 核心功能,但希望独立管理版本 |
| on-demand | 用户运行到某个功能时按需下载 | 游戏关卡、AR/VR、支付页面 |
| instant | 无需安装,直接体验 | 即点即玩、试用活动 |
我个人建议:能用 on-demand 就别用 install-time。因为 install-time 虽然名字叫“安装时”,但实际它还是会被打进一个独立的分包里,只不过首装时就下载了——那跟直接放base里其实没太大区别。我在一个工具类App里做过测试,把偏冷门的“PDF导出”功能设为 on-demand,首包小了8MB,而且90%的用户一个月都没用到这个功能,完美省了流量。
动态模块的构建配置
在 feature_profile/build.gradle 里,你会看到这样的声明:
plugins {
id 'com.android.dynamic-feature'
}
android {
namespace 'com.example.feature.profile'
compileSdk 34
defaultConfig {
minSdk 21
}
}
dependencies {
implementation project(':app') // 依赖 base module
implementation 'com.google.android.play:feature-delivery:2.1.0'
}
然后在 app/build.gradle 里加上:
android {
dynamicFeatures = [':feature_profile', ':feature_checkout']
}
对了,别忘了在每个动态模块的 AndroidManifest.xml 里加入 <dist:module>:
<dist:module
xmlns:dist="http://schemas.android.com/apk/distribution"
dist:onDemand="true"
dist:title="@string/feature_profile_title">
<dist:fusing dist:include="true" />
</dist:module>
fusing 属性决定了这个模块能不能被合入兼容的设备(比如有些手机不支持动态分发,就会走 fused APK)。我建议 fusing 保留为 true,不然老设备会直接崩溃。
避坑提醒
⚠️ 我曾经犯过一个低级错误:把 Google Play Services 的一个模块依赖只写在了动态模块里,结果 base module 里也有部分代码间接引用了它,导致运行时 Crash。记住:动态模块的依赖并不会自动传递给 base,反过来却可以。所以最好把所有公共库都放到 base 里,动态模块只负责调用 base 暴露的接口。
💡 一个小经验:如果你使用 Navigation Component,记得把动态模块里的 Fragment 放在 navGraph 里,并在 base 里的 xml 通过 android:name 直接引用。动态模块的 Fragment 包名和 layout 都可以独立,没必要为了动态模块而改变你已有的 MVVM 架构。
什么时候该用动态模块
你想想看,是不是所有功能都要拆?不是的。我个人经验是:
- 低频但巨型的功能:比如游戏里的新手教程(只用一次)、后台埋点 SDK(用到的用户很少)
- 强独立的业务:扫码模块、信用卡绑定页、第三方登录页面
- 实验性的功能:A/B 测试中只想给30%用户看的新 UI
那什么不适合?嗯,全局配置、Application 初始化逻辑、和 base 有大量双向依赖的功能。这些你硬拆,反而会导致模块间通信成本暴涨,测试覆盖率也降下来。我接手过一个项目,他们把整个首页放进了动态模块,结果每次冷启动都要等几秒下载首屏,用户体验反而变差了。所以,别为了拆而拆。
动态模块这玩意儿,说白了是一个权衡的艺术。你省了安装包体积,但多了分发和测试的复杂度。不过一旦你搞清楚了它的基本概念和配置方式,就会发现它其实是 Play 生态里非常棒的特性。后面我会继续深入讲讲如何设计模块间的通信、如何做条件交付,以及 Play Core 的 SDK 怎么用——但今天先把根扎稳。