Dynamic Feature Module 上线一年后的真实体验

Dynamic Feature Module 上线一年后的真实体验

Dynamic Feature Module 上线一年后的真实体验


「Dynamic Feature Module 上线一年后的真实体验」


去年 Q2 我们把一个 28MB 的安装包拆成了 base + 3 个 dynamic feature module,当时团队内部的预期很乐观:用户冷启动只下载 12MB 核心逻辑,其他功能按需拉取,Play Store 评分里"安装包太大"的抱怨应该能降下来。一年过去,数据确实有些变化,但和官方文档里描述的"无缝用户体验"差距不小。这篇文章把踩过的坑、测过的数据、以及现在回头看应该怎么做,如实记录下来。


拆包前的幻觉:官方 Sample 太干净了


Google 的 sample 项目 DynamicFeatures 在 GitHub 上跑起来非常顺滑。SplitInstallManager 几行代码就能请求模块,SplitInstallSessionState 的状态流转清晰得像状态机教科书。我照着 sample 写了第一版代码,本地测试通过,alpha 渠道发布,然后就在真实用户数据上栽了跟头。


问题出在 sample 用的是 com.google.android.play:core:1.10.0,这个版本在 2022 年已经被标记为 deprecated,但文档迁移路径写得模糊。真正该用的是 com.google.android.gms:play-feature-delivery:16.0.0 这个新的 artifact,属于 Google Play Services 的一部分而非独立的 core library。这个迁移不是简单的包名替换——SplitInstallManager 的初始化方式变了,错误码重新定义了,最坑的是 play-feature-delivery 16.0.0 在 2023 年 8 月之前的版本有个已知 bug:如果用户设备上的 Google Play Services 版本低于 22.30.15,调用 startInstall() 会静默失败,返回 STATUS_UNKNOWN_ERR 但回调不会触发 onFailure()


我们线上 0.8% 的崩溃率来自这个场景,用户点击功能入口后无限转圈。Firebase Crashlytics 抓到的堆栈是 java.lang.IllegalStateException: SplitInstallSessionState session not found,根源是 session ID 在 Play Services 内部丢失了。修复方案是强制要求 com.google.android.gms:play-services-base:18.2.0 以上版本,并在调用前用 PackageManager.getPackageInfo("com.google.android.gms", 0) 检查版本号,低于阈值时降级到本地兜底逻辑。这段代码现在还在我们的 FeatureDeliveryManager 里,注释写着 "TODO: remove after Play Services 24.x penetration > 95%",但看 Google 的 distribution dashboard,这个目标至少要到 2025 年中。


模块划分的边界:比技术问题更难的是产品决策


我们拆的三个模块按功能域划分:直播模块(live_stream)、电商模块(ecommerce)、社交小游戏(social_games)。这个划分在架构评审会上全票通过,上线后才发现 ecommerce 模块的按需安装率只有 34%,意味着 66% 的用户在首次打开 App 后的 7 天内根本没有触发购买行为,但他们在第一次冷启动时已经下载了 base 模块里预留的电商接口定义和 DTO 类——这些类为了跨模块通信不能放在 ecommerce 内部,结果 base 模块膨胀了 4.3MB。


更麻烦的是 social_games。小游戏引擎用的是 Cocos2d-x,so 文件有 8MB,拆成 dynamic feature 后首次启动游戏需要下载 12MB 资源包。我们做了预加载逻辑:用户进入社交首页时后台静默请求 social_games 模块,但这个策略在低端机上触发大量 ANR。Android Vitals 里 social_games 相关的 Input dispatching timed out 堆栈指向 SplitInstallManager.startInstall() 的同步调用,实际上这个 API 是异步的,ANR 的真正原因是我们在主线程做了模块存在性检查:


// 错误的写法,主线程阻塞
val installedModules = SplitInstallManagerFactory.create(context).installedModules
if ("social_games" !in installedModules) {
    // 跳转下载提示页
}

installedModules 内部实现需要读取 /data/user/0/package/splitcompat/ 目录下的 metadata 文件,在三星 Galaxy A12(Android 11,2GB RAM)上这个 IO 操作平均耗时 1.2 秒,足够触发 InputDispatcher ANR。修复是把这个检查移到 LifecycleScope.launch(Dispatchers.IO),但代价是用户点击游戏入口后需要等几百毫秒才能确定是直接进入还是跳转下载页,这个延迟在 200ms 以上时用户流失率上升 7%。


后来我们重构了模块边界,把接口定义层抽成独立的 api 模块(仍然打包在 base 里),实现层放 dynamic feature。这个改动花了两周,因为 api 模块必须是 Java Library 而非 Android Library,不能用 Context 也不能依赖 R 文件,所有资源 ID 要通过接口常量传递。一个典型的坑是 ecommerce 里的商品详情页需要展示品牌色,原来用 R.color.brand_primary,现在要在 EcommerceColors 接口里定义 const val BRAND_PRIMARY = 0xFF5722,然后 feature 模块里用 Color.parseColor 或者自定义 View 时手动设置。这种代码现在散落在各个 feature 模块里,维护成本不低。


版本兼容的深坑:Android 5.x 的 SplitCompat


官方文档说 Dynamic Delivery 支持 API 21+,但有个隐藏前提:Android 5.0 和 5.1(API 21-22)需要启用 SplitCompat。启用方式是在 Application.attachBaseContext() 里调用 SplitCompat.install(this),看起来很简单,直到我们在 Moto G(Android 5.0.2)上测试发现应用直接 crash:


java.lang.RuntimeException: Unable to instantiate application com.example.App:
java.lang.ClassNotFoundException: Didn't find class "com.example.App" on path:
DexPathList[[zip file "/data/app/com.example-1/base.apk"],nativeLibraryDirectories=[...]]

原因是 SplitCompat 需要反射修改 BaseDexClassLoaderpathList 字段,但 Android 5.0 的 ART 对 dex2oat 的处理和 5.1 有差异,SplitCompat.install() 在某些 ROM 上会破坏主 APK 的 classloader 链。Google Issue Tracker 上 #181355229 记录了这个问题,标记为 fixed in play-feature-delivery 16.0.1,但我们升级到 16.0.1 后仍然在特定设备复现。最终的 workaround 是判断 Build.VERSION.SDK_INT == 21 时跳过 SplitCompat,直接拒绝 dynamic feature 功能,引导用户去网页版。这个决策让产品很不满意,但稳定性优先。


Android 6.0(API 23)有个更隐蔽的问题:如果用户从 SD 卡作为 adoptable storage 使用,SplitCompat 的模块安装路径可能在 getExternalFilesDir() 返回的目录下,而某些国产 ROM(2018-2019 年的 OPPO ColorOS 3.x)对这个路径的权限管理有 bug,导致已下载的 feature 模块在应用重启后"消失"——installedModules 返回空集,但文件实际存在。我们的遥测数据显示这个场景占 0.3% 的 DAU,复现条件苛刻,没有通用修复方案,只能增加本地缓存的模块版本号记录,在状态不一致时强制重新下载。


下载策略的博弈:WiFi-only 不是万能药


Play Store 的默认行为是 WiFi 下自动下载 deferred install 的模块,这个策略在发达国家没问题,但在我们的主要市场(东南亚、拉美)需要重新考量。印尼用户的 WiFi 普及率 61%,但公共 WiFi 的连通质量参差不齐,很多实际上是运营商热点,有流量限制或时间限制。我们在 SplitInstallRequest 里设置了 deferredInstall 期望后台静默完成,结果用户反馈"游戏打不开"的工单里,有 23% 是因为模块下载在后台失败但没有任何通知。


SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION 这个状态在文档里描述为"需要用户确认大文件下载",但实际触发条件不透明。我们测试下来,单个模块超过 10MB 时大概率触发,但 8MB 的模块在某些运营商网络下也会触发,似乎和 Google Play 服务端对网络类型的判断有关。用户看到的是一个系统弹窗,标题是"需要下载额外内容",按钮是"下载"和"取消",取消后 session 进入 CANCELED 状态,下次再触发需要重新请求。这个弹窗的文案不可定制,和我们的品牌语言不一致,转化率数据很难看。


我们后来放弃了 deferred install,全部改成按需即时下载,并在应用内做了自定义的下载进度 UI。代价是代码复杂度上升:需要监听 SplitInstallSessionStatus.DOWNLOADING 的进度回调,处理 FAILED 状态的重试逻辑,还要在下载过程中禁止用户退出页面(否则 session 可能被系统回收)。一个未公开的行为是:如果用户在下载过程中按 home 键把应用切后台,30 秒后 SplitInstallManager 的回调可能不再触发,但 getSessionStates() 返回的状态仍然是 DOWNLOADING。我们加了轮询机制,每 5 秒查询一次 session 状态,这个轮询在低端机上又成了电量消耗点。


构建系统的暗面:BundleTool 和 CI 时间


Dynamic Feature Module 要求用 Android App Bundle(AAB)格式发布,本地调试可以用 bundletool build-apks --local-testing 生成包含所有模块的 APK set。但 CI 流程里这个步骤把打包时间从 4 分钟拉到了 11 分钟,因为 bundletool 需要为每个模块组合生成独立的 APK,复杂度是 O(2^n)。我们 3 个 feature 模块理论上产生 8 种组合,加上 abi 和屏幕密度拆分,最终的 APK set 有 47 个文件。


更麻烦的是自动化测试。Espresso 测试跑在 connected device 上时,需要先用 bundletool install-apks 把本地测试 APK set 装到设备,这个命令在 Mac Mini CI 节点上经常超时,因为 USB 调试传输 200MB+ 的文件到低端测试机需要 3-5 分钟。我们尝试过 bundletool extract-apks --device-spec 只提取匹配当前设备的 APK,但 device-spec JSON 的生成又依赖 adb shell getprop,整个流程脆弱得像多米诺骨牌。


一个节省时间的技巧是:在 build.gradleandroid 块里配置 bundle.language.enableSplit = false,因为我们只支持中英双语,语言拆分节省的空间不到 200KB,但能让 APK set 的文件数减半。abidensity 的拆分我们也关掉了,因为应用本身有自适应图标和 vector drawable,密度拆分的收益有限。这些配置在官方文档里被推荐为"优化下载大小",但实际对构建速度的改善更明显。


feature 模块和 app 模块的依赖关系检查是另一个耗时点。Gradle 的 lint 任务会检查 feature 模块不能反向依赖 app 模块,但错误提示经常指向错误的行号。我们有个 case 是 social_games 里误用了一个只在 app 模块定义的 AnalyticsTracker 类,lint 报错说 Dependency cycle detected between modules,实际问题是 import 了错误的包。这种诊断成本在模块数量增加时线性上升,现在 3 个模块还能忍,如果当初拆成 6 个,估计每周要浪费几小时在这种问题上。


线上数据:那些文档不会告诉你的数字


拆包一年后,我们的核心数据如下。安装包大小从 28MB 降到 12MB(base),但首次安装后的 24 小时内平均额外下载 8.4MB,所以用户实际获得的价值是延迟下载而非减少下载。Play Store 的"安装包太大"负面评论确实减少了,但"功能加载慢"的新评论出现了,整体评分变化不显著(4.3 到 4.4,统计上可能无关)。


更关键的是卸载率。我们预期小安装包能降低预装即卸载的比例,但实际数据显示:安装后 1 小时内卸载率从 18% 降到 15%,7 日留存从 34% 升到 36%——这些改善可能和拆包无关,因为同期我们做了 onboarding 流程优化。真正和 dynamic feature 强相关的指标是"模块下载完成率":直播模块 89%,电商模块 34%,社交游戏 61%。电商模块的低完成率是因为购买行为本身低频,用户可能安装 App 两周后才第一次逛商城,那时 deferredInstall 的后台策略已经被我们关掉,需要即时下载,而用户在没有明确购买意图时不愿意等 4MB 下载。


一个意外的发现是:在 Android 10+ 设备上,SplitInstallManager 的下载速度明显慢于直接走应用内更新(In-app Updates)的下载速度。同样的 10MB 文件,Play Feature Delivery 平均耗时 22 秒,In-app Updates 的 AppUpdateManager 只要 8 秒。两者底层都走 Google Play 的下载服务,但 Feature Delivery 似乎用了不同的 CDN 节点或限速策略。我们在 Issue Tracker 上开了 case,Google 工程师回复说"这是预期行为,feature delivery 优先考虑后台带宽占用",但没有给出具体的技术细节。


内存占用方面,加载 dynamic feature 后的 RSS 增长比预期高。social_games 模块加载后,应用总内存从 180MB 涨到 310MB,其中 45MB 是 Cocos2d-x 的 so 文件映射,剩下的增长来自模块自己的 DEX 加载。Android 的 DexClassLoader 每个 DEX 文件会映射到内存,feature 模块的 DEX 即使和 base 模块共享依赖库,也不能复用 base 的 classloader 中已经加载的类——这是 SplitCompat 的隔离机制导致的。我们尝试用 android:extractNativeLibs="true" 让 so 文件解压到文件系统而非直接从 APK 内存映射,但 Android 6.0+ 的 behavior change 让这个属性在 extractNativeLibs="false" 时反而更省空间,逻辑很绕,最终没有改动。


现在回头看的决策点


如果重新做这个拆分,我会把电商模块留在 base 里。4.3MB 的接口定义和 DTO 膨胀不值得,真正的实现代码只有 2.1MB,而且购买转化路径不能容忍任何下载延迟。直播模块拆出来是正确的,因为直播功能本身需要大量 so 文件(美颜、编解码),且用户进入直播间前有明确的"点击直播 tab"行为,可以在这个点击时预加载。社交游戏的拆分决策最纠结:Cocos2d-x 的 so 文件确实大,但游戏场景的即时性要求很高,现在的妥协方案是首次安装时 base 模块里带一个极简的 H5 游戏引擎作为降级,等 social_games 下载完成后再热切换到原生实现——这个方案增加了 800KB base 大小,但把"游戏打不开"的客诉从 12% 降到了 3%。


Google 在 2023 I/O 上推了 Play Asset Delivery 作为游戏资源分发的新方案,和 Dynamic Feature Module 有重叠但定位不同。我们评估过迁移,结论是对于非游戏应用(或者混合应用),Dynamic Feature Module 仍然是唯一选择,但 Asset Deliveryfast-follow 模式(安装后自动下载,用户无需打开应用)比 deferredInstall 更可控。这个模式目前只对游戏开放,非游戏应用申请需要联系 Google 商务,流程不透明。


play-feature-delivery 的最新版本是 16.1.0,2024 年 1 月发布,release note 里提到"修复了某些设备上模块安装后需要重启应用才能加载的问题"。这个问题我们遇到过,在 Pixel 6 Android 14 上复现,表现为 Class.forName("com.example.social_games.GameActivity") 抛出 ClassNotFoundException,但 SplitInstallManager.installedModules 明确包含 social_games。当时的 workaround 是捕获异常后引导用户重启应用,现在应该可以去掉这个逻辑了,但我们还没升级验证——每次升级 Google Play Services 相关的库,都需要在 20+ 台测试设备上跑回归,成本不低。


一个具体的调试技巧


最后分享一个花了一下午才定位的问题。某天 CI 上所有涉及 social_games 的集成测试突然失败,错误是 android.content.res.Resources$NotFoundException: Resource ID #0x7f0a0001,这个 ID 对应 social_games 模块里的一个布局文件。本地调试完全正常,只有 CI 的 Firebase Test Lab 设备上复现。


根源是 bundletool 的版本差异。CI 用的是 1.13.1,本地是 1.15.1。旧版本在生成 standalone APK(用于 pre-L 设备)时,对 feature 模块的资源 ID 分配有 bug,导致 0x7f0a0001 这个 ID 在 base 模块和 feature 模块里冲突。Firebase Test Lab 的某些虚拟设备运行的是 Android 5.1,走的是 standalone APK 路径,而本地调试用的 Android 13 设备走 split APK 路径,不受这个 bug 影响。升级 bundletool 到 1.15.2 后修复。这个 case 说明 Dynamic Feature 的测试矩阵必须覆盖 API 21 的 standalone 路径,不能只测新设备。


类似的,资源合并时的 tools:keep 规则也需要为每个 feature 模块单独配置。ProGuard/R8 的代码收缩在 feature 模块边界上行为特殊:androidx.navigation.dynamicfeaturesDynamicNavHostFragment 用反射加载 feature 模块里的 fragment 类,如果 shrink 规则没保留这些类,release 包会崩溃,但 debug 包因为 minifyEnabled false 不会暴露问题。我们在每个 feature 模块的 proguard-rules.pro 里加了 -keep class * extends androidx.fragment.app.Fragment,这个规则看起来粗暴,但比逐个维护 fragment 类名可靠。


一年下来,Dynamic Feature Module 确实帮我们解决了安装包大小的问题,但引入的复杂度远超最初估计。官方文档的"快速开始"只覆盖了 happy path,真实世界的设备碎片化、网络环境差异、产品功能耦合,让每个"简单"决策都有长尾的技术债务。现在团队里负责这块的同事已经换了两轮,代码里的 workaround 注释越来越多,有些连我自己都忘了当初为什么加。如果 Google 能把 SplitCompat 的稳定性再夯实一些,把错误码和状态机的文档补全一些,这个技术方案的维护成本还能再降一截,但目前看优先级不高——他们的重心显然在 Jetpack Compose 和 Kotlin Multiplatform 上,Dynamic Delivery 的更新节奏已经慢下来了。

这几个 GitHub 仓库,帮我省了不少时间 2026-05-28
Gradle 构建慢的问题,有人找到了新解法 2026-05-28

评论区