导言 Android App Bundle 是 Android 新推出的一种官方发布格式,可让您以更高效的方式开发和发布应用。企业微信基于 App Bundle 采用低入侵、业务代码基本零重构的技术方案,实现了全业务模块采用动态模块(dynamic feature)开发。最后展示并行编译方案,进一步加速持续集成。
是 Android 新推出的一种官方发布格式,可让您以更高效的方式开发和发布应用。和国内开发者已经熟知的 Kotlin 开发语言、Android Studio IDE 工具、Android JetPack API 最佳实践一起,组成了现代 Modern Android Development,值得我们研究和结合项目实践。
它的核心是 Google Play 应用分发渠道和 Android Split APKs 运行时分包加载机制,以更小的应用提供优质的使用体验,从而提升安装成功率并减少卸载量。发布包从 .apk 转换为 .aab 过程轻松便捷,无需重构代码即可开始获享较小应用的优势。
Android-App-Bundle-Delivery
从 2021 年下半年开始,Google 要求新应用需要使用 Android App Bundle 才能在 Google Play 中发布。大小超过 150 MB 的新应用必须使用 Play Feature Delivery 或 Play Asset Delivery。可查看官方文档[1]了解更多。
经过5年的高速发展,已经成为远程办公不可或缺的通信工具,目前服务超过550万企业客户,并与超过4亿微信用户连接。企业微信存在多行业(教育版、金融版、医疗版、零售版、政务版等等)、多角色(管理员、服务商、开发者、员工)、多身份(企业、团队)等大量复杂因素,形成了一套超复杂的大型软件系统。随着版本快速迭代,Android 客户端迅速膨胀为超大型 App。
我们也遇到了超大型 App 通常会存在的问题:
企业微信采用多种技术结合的模块化开发,逐步解决这些问题。在 2019 年,我们调研和使用 Android App Bundle 解决 Google Play 渠道包 64bit 版本发布问题后,又对模块化开发流程做了进一步改进。
先给出动态的演进过程,让大家有一个宏观的概念,再对不同阶段的技术方案做一些概述。
企业微信模块化开发演进
阶段一:基础库模块复用 Gradle 构建工具引入,改变了模块的组织形式,从包依赖管理变成模块依赖管理,从单 Project 结构变成多 Project 结构。实现了库模块代码边界隔离。
阶段二:模块分层重构 强调模块化开发职责,定义出 app / module / api / library 分层依赖结构,通过 api 通信和控制反转,将 app 拆小为业务 module,app 改为壳工程用于集成。我们参考了《微信 Android 模块化架构重构实践》 经验,实现了业务代码边界隔离。
阶段三:模块分组重构 Android App Bundle 和动态模块 feature 引入,改变了发布形式,从单体式应用 app.apk 变为 base.apk + split.apk 分包式应用。更小的初始化安装包,更严格的依赖边界、代码边界、资源边界隔离,更灵活的部署方案。
Android App Bundle 描述非常恰当:
提升工程速度 将应用功能作为独立模块进行设计、构建、调试和测试,并在准备就绪后将其添加到主应用中。您不再需要一整支工程团队来处理具有大量复杂代码的同一个单体式应用,因而可以减少合并冲突和中断。 缩短编译时间 使用 Gradle 的 Android Studio 编译系统针对模块化应用进行了优化,因此编译速度比较大的单体式应用要快得多。
相似的两个描述,都是加快速度、减少时间,含义却不一样。
在旧的模块化开发中,工程类型只有应用(application)和库模块(library)2种类型,在新的模块化开发中,增加了第3种动态模块(dynamic feature)类型。动态模块依赖基础模块(base),能独立、更快地研发:
speed-up-engineering-velocity
动态模块有2个难以平衡的问题:
要充分发挥动态模块独立、快速的优势,这要求企业微信模块化实现:
Gradle 编译系统在效率上的提升,主要体现在3个方面:
编译加速在实践中,通常是这2类思路:
在模块化后,会带来并行和缓存效率的提升:
faster-build-times
但是根据开发的朴素经验,并行会产生额外调度开销、并发甚至死锁问题,而缓存有命中率和时效性问题。超过阈值后,效率反而会降低。这在模块化开发中同样适用:缓存大量不命中时,并行数剧增、大量消耗 CPU 和内存资源,当资源耗尽时性能急剧降低。
多工程改造为支持动态模块,分包式多 .apk 更能充分发挥并行、缓存的优势,但这更要求企业微信模块化解决计算资源消耗的问题:
团队规模处在不同时期,采用不同版本周期和迭代方式会对模块化效用产生比较大影响。我们对比这2种迭代模式:
上述2个模式,可明显发现主干开发模式有利于 merge、提升工程速度,Git-Flow 模式有利于缓存、缩短编译时间。
企业微信迭代周期快,采用的是主干开发模式。由于缺少 Git-Flow 的隔离,并行开发会导致:
阶段三重构目的就是,通过增强模块隔离性、分包动态加载,从模块化设计改进和实现支持,提升并行、缓存效率,降低模块依赖数、减少并发修改影响。
Android App Bundle 具有无需重构代码、转换过程轻松便捷的优点,因此要求我们在实现转换原模块化开发模式过程中,同样也要保持这样的优点:
先分析 Android App Bundle 相对于 APK 编译,在开发阶段的最重要区别:
编译关键任务
依赖处理
资源编译
代码编译
Android App Bundle 会在 base 检查依赖打包是否冲突:
通过编译时预检查,避免了运行时加载重复 .dex,确保逻辑一致性。
Gradle 通过依赖配置 Configuration [2]管理一组类型的依赖关系,比如开发者常见的,阻断向上传递依赖的 implementation、只编译不打包的 compileOnly 等:
dependencies { implementation(Deps.Lib.kotlin_stdlib_jdk8) compileOnly(Deps.Mockito.wechat_media_codec)}
它们可通过组合方式进行多继承,分组和复用依赖配置资源:
configurations { create("modularImplementation").apply { getByName("implementation")?.extendsFrom(this) }}
fun DependencyHandler.modularImplementation(dependencyNotation: Any): Dependency? = add("modularImplementation", dependencyNotation)
比如在 Android Gradle Plugin 中的 Release 版本变种配置,可明显观察区分了编译时和运行时分类:
Android App Bundle 收集和检查依赖冲突的就是 runtimeClasspath,可参看 FeatureSplitTransitiveDepsWriterTask.kt 代码片段:
override fun configure(task: FeatureSplitTransitiveDepsWriterTask) { ... task.runtimeJars = variantScope.getArtifactCollection( AndroidArtifacts.ConsumedConfigType.RUNTIME_CLASSPATH, ...
compileClasspath 会决定代码编译是否通过,而 runtimeClasspath 会决定是否加入打包。我们通过在 gradle 全局的依赖管理里,按业务规则集中过滤处理依赖冲突:
subprojects { configurations.all { resolutionStrategy.eachDependency { if(Env.isBuildBundle){ skipDependencyIfNeeded(...) // useTarget(Skipped) // because(reason) } ...
原项目工程结构和依赖配置无需修改,对业务开发和其他插件透明,达到我们解决依赖的目的,同时在 gradle scan 里可以查看到裁减依赖的原因,方便 debug:
Android App Bundle 在打包的时候会把 feature AndroidManifest.xml 文件合并到 base,但是却不会把 AndroidManifest.xml 依赖的资源一起合并到 base。这样就会导致编译时出现 base AndroidManifest.xml 依赖的 feature 资源找不到的错误:
经过对系统包管理相关代码的分析,其实 Android App Bundle 运行时 feature 的组件配置也是会生效的,并且优先级要高于 base 中的 AndroidManifest.xml 配置。只有 application 标签的属性和 application 标签外面的配置是以 base 中的配置生效。
具体的生效情况如下图:
也就是说,只要 feature 中有组件的清单配置,base 中有无组件的清单配置并不会影响apk的运行效果。而 application 和 uses-permission 的配置比较固定、修改本来就很少,可以把 application 和 uses-permission 的配置复制到 base 的 AndroidManifest.xml,这样就能够解决问题了。采用复制另一个重要原因是 feature 可能由于命中缓存、不会转换为 Project 参与编译,所以没采用编译任务来合并。
base manifest 要合并的 feature manifest 信息保存在 processDebugManifest 任务的 featureManifests
中,可参看 ProcessApplicationManifest.java 代码片段:
task.featureManifests = variantScope.getArtifactCollection( METADATA_VALUES, MODULE, METADATA_FEATURE_MANIFEST); ...if (featureManifests != null) { providers.addAll( computeProviders( featureManifests.getArtifacts(), InternalArtifactType.METADATA_FEATURE_MANIFEST));}
我们只需要调用 featureManifests.getArtifacts().clear()
把其要合并的信息清空即可阻断合并 feature 的 AndroidManifest.xml。
Android 的资源编译会经历资源收集、分配资源id、编译链接几个重要流程:
资源编译流程
资源编译错误主要集中编译链接 (Link) 过程:
造成链接失败原因是:
verifyReleaseResources
任务做轻量的链接探测,预防运行时因为缺少引用的资源导致异常。遗憾的是,在大型 app 重构过程中可能会关掉它以加速进度,遗留部分未彻底解耦的资源引用,它们可能在这时报错。verifyReleaseResources
的检查能力,而且保持了高效编译速度,在日常重构和开发中防微杜渐。“不重构代码或资源”在这里失效,所以我们是“基本不重构”。不过这里的重构是正向和有益的,我们提供了快速处理的一般方法:
1.通过 mock 各种类型资源、快速重构为新模块化开发,并统计资源被引用范围
<?xml version="1.0" encoding="utf-8"?><resources>
<item name="title" type="id"/> <item name="video_clip_slider_selected" type="raw"/> <attr format="reference|color" name="textColor"/> <item name="back_icon_normal" type="drawable"/> <item name="has_send" type="string"/>
</resources>
2.高频引用的公共资源,按照官方推荐下沉到 base 使用
3.低频引用的业务资源,按照业务归属重构、解耦
而代码和资源密切相关的文件就是 R.java,它在每个模块中广泛引用,通常是 <模块包名>.R.tt.nnnn 格式。
在处理完 Android App Bundle 依赖和资源编译改造后,由于模块包名发生了变化,代码编译会有大片大片对 R.java 引用报错:
为了使读者对问题认识有个延续性,我们先概要介绍下 R.java 在 Android 开发中的发展历史。
R.java 发展历史
工程规模扩大
编译工具成熟、Google 对开发生态控制力增强,促进应用生产方式转变和更易扩大规模。
not_namespaced_r_ 实现思路有2种:组合或继承。但模块可以有多个依赖:
而 Java 不支持多继承:
Java 多继承语法错误
R.java 最终方案采用了组合,final 常量还可以内联优化运行时性能。但递归的方式引起了代码行数剧增,编译性能骤降。
编译性能优化
动态模块编译的 R.java 和这2个优化方向各有相似之处:
R.java 编译方案 | 文件数 | 字段重复 | 隔离 | 全包名引用 |
---|---|---|---|---|
方向一:阻断递归 | 最少 | 不重复 | 隔离 | 是 |
方向二:字节码 | 多 | 重复 | 不隔离 | 否 |
动态模块 | 少 | 重复 | 平级隔离 | 部分 |
回顾了 R.java 演进历史,我们想要集中优点、消除缺点:
因为每个 feature 仅依赖一个 base,以前不适用的继承方案,这时完美的适用了。 R 文件的产生都是在 processDebugResources,在任务结束后再做简单处理:
资源、代码对称覆写
到此,我们完成了全部业务模块改造为动态模块所需的编译工作。依次解决了依赖冲突、AndroidManifest.xml 合并失败、资源、代码编译失败等问题。
新的模块化开发在运行时还存在2类问题:
资源 id 错乱容易理解,举例说明交叉引用报 NPE。在代码中使用 findViewById() 获取视图对象,假设 feature A,feature B 和 base 3个模块都各自在不同的 layout.xml 资源里定义了相同 @+id/title, 组成了这3类运行时调用关系:
可能出现的结果:
findViewById | A 资源 | B 资源 | base 资源 |
---|---|---|---|
A 代码 | 正常 | NPE | NPE |
B 代码 | NPE | 正常 | NPE |
base 代码 | NPE | NPE | 正常 |
这2类运行时问题我们通过替代 aapt2 解决:
aapt2 透明替换
从 Android studio 3.2 开始,AAPT2 的来源为 google()[6] Maven 库里的发布包:com.android.tools.build:aapt2
我们基于开源主干增加所需要的特性修改,重新发布替换掉 Maven 依赖即可:
subprojects { configurations.all { resolutionStrategy.eachDependency { if(Env.isBuildBundle && requested.name == "aapt2"){ useTarget("com.tencent.wework.tools.build:aapt2:2.3.3.1-wecomponent-6051327") } ...
aapt2 修改兼容性
官方已经提供了一个较佳的 hack 点:
// A custom delegate that generates compatible pre-O IDs for use with feature splits.// Feature splits use package IDs > 7f, which in Java (since Java doesn't have unsigned ints)// is interpreted as a negative number. Some verification was wrongly assuming negative values// were invalid.//// This delegate will attempt to masquerade any '@id/' references with ID 0xPPTTEEEE,// where PP > 7f, as 0x7fPPEEEE. Any potential overlapping is verified and an error occurs if such// an overlap exists.//// See b/37498913.class FeatureSplitSymbolTableDelegate : public DefaultSymbolTableDelegate
相对简单,篇幅有限不多说明。
dist:module
配置并不影响普通 apk 编译,在按编译环境使用不同插件、倒置依赖关系即可。base 需要特别判断 dynamicModules
面向不同人群和场景,我们支持4种研发模式:
4种研发模式
不同团队的发布形式和研发流程不同,这里仅重点描述下企业微信已使用成熟的 Debug 开发流程:
企业微信 Debug 开发流程
Android App Bundle 技术生态在国内仍不完善,国内外对比:
生态位 | 国外 | 国内 | 问题 | 解决方案 |
---|---|---|---|---|
开发体系 | MAD 体系 | 自研体系 | 需要重构、改造不断适应官方技术栈 | 超大型项目一般都具备模块化开发,本文提出一种轻量级重构方案,在企业微信实践 |
应用渠道 | Google Play Delivery | 华为/应用宝等应用市场 | 开发者不能控制用户获取应用的方式,市场提供的系统安装支持不完整 | 自建发布系统 CDN 下发 |
运行环境 | 原生 ROM | 厂商 ROM | 系统缺少对 splitapk 分包运行的统一支持 | 插件化框架,如基于 App Bundle 的开源方案 iqiyi/Qigsaw |
资源优化 | split 配置 | resguard | 大型项目在使用 App Bundle 时重复资源才是重灾区 | .aab 中间件提供了二次修改的可能,如基于 resgaurd 的开源方案 bytedance/AabResGuard |
代码优化 | d8 / r8 | 自研 | 大型项目 release 编译速度到混淆、dex merge 阶段就成瓶颈了 | 本文提出一种并行编译方案,为 proguard 配置预分配、进一步加速提供可能 |
回顾超大型 App 通常会存在的问题,企业微信采用多种技术结合的模块化开发,逐步解决:
问题 | 解决方案 |
---|---|
业务模块多,代码、资源隔离度低,依赖关系复杂 | 模块分层,梳理了职责和依赖关系;模块分组,强制不相关依赖、资源、类编译隔离 |
编译效率低 | 多种缓存(.aar / .apk)加速,本地、远程并行加速 |
包体积大,国内外应用商店渠道包代码分化 | 技术栈更新,低成本切换、使用基于 App Bundle 的 APK 合成方案 |
历史代码量大,难于重构 | 低入侵、业务代码基本零重构方案 |
代码工程结构不适应人员组织结构发展 | 动态模块独立开发,4种研发方式满足团队内外合作 |
harrisonwu(吴洪春) / 腾讯 Android 工程师
renpengtian(田仁鹏) / 腾讯 Android 高级工程师
waylonhuang(黄玮) / 腾讯 Android 高级工程师
tagorewang(王涛) / 腾讯 Android 高级工程师、企业微信 Android 模块化负责人
均来自企业微信 Android 团队。Android 模块化开发仍在优化,欢迎加入我们一起补充国内生态位缺少的解决方案。
企业微信客户端团队,包括 iOS、Andrroid、Windows、Mac、Web 五大平台。我们重视跨平台技术框架的研发,各类原创技术专利,截止去年,仅数十人的技术团队在近3年内提交技术专利百余项。团队招聘优秀技术人才,岗位分布在成都、广州、深圳。欢迎在官网投递简历。
可在 hr.tencent.com 搜索企业微信相关岗位,或者扫码联系 HR
[1]
官方文档: https://developer.android.com/platform/technology/app-bundle
[2]
依赖配置 Configuration : https://docs.gradle.org/current/userguide/declaring_dependencies.html
[3]
android.content.pm.PackageParser#parsePackage: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/content/pm/PackageParser.java;bpv=1;bpt=1;l=1057?gsn=parsePackage&gs=kythe%3A%2F%2Fandroid.googlesource.com%2Fplatform%2Fsuperproject%3Flang%3Djava%3Fpath%3Dandroid.content.pm.PackageParser%239bdc30a4a5ef088db38c1edea5644b6fb075ec3fd6224d4372620ef9ca96cb29
[4]
android.content.pm.PackageParser#parseSplitApplication: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/content/pm/PackageParser.java;drc=master;bpv=1;bpt=1;l=3966?gsn=parseSplitApplication&gs=kythe%3A%2F%2Fandroid.googlesource.com%2Fplatform%2Fsuperproject%3Flang%3Djava%3Fpath%3Dandroid.content.pm.PackageParser%2374922f0b4c87441d4a27c24d2d68e6a3c269a12dbd4d9bc585ce84714ef09852
[5]
--stable-ids: https://developer.android.com/studio/command-line/aapt2
[6]
google(): https://dl.google.com/dl/android/maven2/
[7]
aosp-android-9.0.0-r59-aapt2: https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-9.0.0_r59/tools/aapt2/
[8]
FeatureSplitSymbolTableDelegate: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/tools/aapt2/cmd/Link.cpp;bpv=1;bpt=1;l=175?q=FeatureSplitSymbolTableDelegate&ss=android%2Fplatform%2Fsuperproject&gsn=FeatureSplitSymbolTableDelegate&gs=kythe%3A%2F%2Fandroid.googlesource.com%2Fplatform%2Fsuperproject%3Flang%3Dc%252B%252B%3Fpath%3Dframeworks%2Fbase%2Ftools%2Faapt2%2Fcmd%2FLink.cpp%23X7zI0WZo9YoEo05MFLqlo2cE4VK5wJRKMPJR6SrWUsg