动态模块(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 是怎么把动态模块推给用户的。

动态模块交付流程 开发者构建 App Bundle 上传至 Play Console Google Play 分发 用户下载 Base APK 用户触发功能入口 请求动态 Feature APK Play 动态交付 SDL 下载 APK 安装并加载 Feature 注意:动态模块下载需要用户主动触发或由开发者在后台请求,系统会自动完成安装,无需额外权限

动态模块的交付模式

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 怎么用——但今天先把根扎稳。