一、背景
随着业务不断迭代更新,App的大小也在快速增加,2019年~2022年期间一度超过了117M,期间我们也做了部分优化如图1红色部分所示,但在做优化的同时面临着新的增量代码,包体积一直持续上升。包体积直接或间接地影响着下载转化率、安装时间、磁盘空间等重要指标,所以投入精力发掘更深层次的安装包体积优化是十分必要的。根据谷歌商店的内部数据,APK体积每减少10M,平均可增加~1.5%的下载转化率,如图2所示:
图1 京东金融Android版本2019-2022体积变化过程 (红色部分是期间做的部分优化,但是很快就反弹回去了)
图2 谷歌商店应用转化率增加幅度 / 10M [1]
因此2022年9月开始我们针对金融APP进行了瘦身专项整治,在不考虑增量的情况,无删减业务代码的情况下实现从117M瘦身至74M,在本次安装包瘦身过程中我们遇到了不少坑,同时也积累了些经验,在此分享给大家。
二、APK分析
接下来我们会简单分析下 Apk内各组成部分,以及 Apk 作为 ZIP,其标准结构是什么样的,为包瘦身的目标设定及任务拆解提供数据支撑。
2.1 APK内容分析
图3 APK 结构
- classes.dex APK 中可能包含一个或多个 classes.dex 文件,应用程序内的 Java/Kotlin 源码最终会以字节码的方式存在于 classes.dex 文件中。
- resources.arsc aapt工具在编译资源会将一些资源或者资源索引打包成resources.arsc。
- res/ 源码工程中 res 目录下除了 values 外的资源文件,这些文件路径同时会记录在 resources.arsc 中。
- lib/ nativeLibraries,即源码工程 jni 目录下的 so 文件,二级目录为 NDK支持的 ABI。
- assets/ 与 res/ 资源目录不同,assets/ 下的资源文件不会在 resources.arsc 中生成查询条目,且 assets/ 下的资源目录可完全自定义,在程序中通过 AssetManager 对象来获取。
- META-INF/ 该文件夹下主要包含 CERT.SF 和 CERT.RSA 签名文件, 以及 MANIFEST.MF 清单文件。
- AndroidManifest.xml 应用清单文件,用于描述应用基本信息,主要包括应用包名、应用id、应用组件、所需权限、设备兼容性等。
2.2 SDK大小分析
通过我们自研的能效提升平台Pandora[7],可以直观地看到SDK的大小,如图4所示:
图4 SDK大小排序(包含版本号)
图5 SDK中包含的SO库列表及大小
根据SDK分析后结合业务,来判断哪些业务适合做插件化,进而直观的降低包体积。
2.3 ZIP结构分析
可以用zipinfo命令输出压缩包中每个文件的详细信息日志,用法:zipinfo -l --t --h test.apk > test.txt
输出的日志文件打开如图6所示,每个文件的压缩信息一行,包括文件名、原始大小、压缩后大小等指标:
图6 APK内文件信息大小
对以上日志信息进行逐行解析,根据解混淆后的文件名路径、文件类型进行归类统计,即可得出Apk的总览信息,包括各类型文件的数量、总大小、单一文件大小等指标,并建立文件大小索引。
三、瘦身实践
整体实施路径如图7所示,主要分为:
- 常规技术方案,通过Gradle插件(代码无侵入、自动化)在编译时期完成APP瘦身;
- 进阶技术方案,将部分业务线差别性的通过插件化或者SO动态下载的方式就行改造,业务改造的越多,收益越高;
- 业务优化方案,针对业务线的数据埋点,生成访问UV进行排名,将UV较低的业务线反馈架构委员会,评估是否可以进行下线或者通过进阶技术方案(2)进行改造,进而减小包体积。
图7 整体实施路径
3-1 常规技术方案
3-1-1 图片处理
经过上述的APP的剖析,得出占用体积第一大的还是图片,因此将APP所有含SDK内所有图片在编译打包过程中通过瘦身任务自动完成图片优化处理,整体优化方案如图8所示:
图8 图片优化方案
1.多 DPI 优化:
Android 为了适配各种不同分辨率或者模式的设备,为开发者设计了同一资源多个配置的资源路径,app 通过 resource 获取图片资源时,自动根据设备配置加载适配的资源,但这些配置伴随着的问题就是高分辨率的设备包含低分辨率的无用图片或者低分辨率的设备包含高分辨率的无用图片。
一般情况下,针对国内应用市场,App 为了减少包大小,会选用市场占有率最高的一套 dpi(google 推荐 xxhdpi)兼容所有设备。而针对海外应用市场的 APP,大多会通过 AppBundle 打包上传至 Google Play,能够享受动态分发 dpi 这一功能,不同分辨率手机可以下载不同 dpi 的图片资源,因此我们需要提供多套 dpi 来满足所有设备。在项目中,我们的图片有的只有一套 dpi,有的有多套 dpi,针对上述两种场景,我们分别在打包时合并资源、复制资源,减少了包大小。
2.转换为webp格式:
WebP是谷歌提供的一种支持有损压缩和无损压缩的图片文件格式,而且可以提供比JPEG或PNG更好的压缩。在Android 4.0(API level 14)中支持有损的WebP图像,在Android 4.3(API level 18)和更高版本中支持无损和透明的WebP图像
因此:我们采用插件在编译时期仅保留针对图片通过Google提供的shell程序进行格式转换,转换成功删除旧的图片,进而达到APK瘦身的效果
3.png压缩
Pngquant是一个好用的png压缩工具,可以进行有损图片压缩的命令行工具,因此在1和2处理结束后,可以使用Pngquant进行二次压缩,达到更优的图片瘦身。
3-1-2 R文件内联优化
DEX里是Java/Kotlin 源码编译后的字节码文件,对DEX的优化其实就是怎么优化字节码文件,DEX中包含大量的资源索引R文件,这里主要讲下如何通过资源ID内联后进行R文件删除,达到APK瘦身的目的:
R文件瘦身的可行性分析
日常开发阶段,在主工程中通过R.xx.xx的方式引用资源,经过编译后R类引用对应的常量会被编译进class中。
setContentView(2131427356);
这种变化叫做内联,内联是java的一种机制(如果一个常量被标记为static final,在java编译的过程中会将常量内联到代码中,减少一次变量的内存寻址)。非主工程中,R类资源ID以引用的方式编译进class中,不会产生内联。
setContentView(R.layout.activity_main);
产生这种现象的原因是AGP打包工具导致的。具体细节,大家可以去查阅一下android gradle plugin在R文件上的处理过程。结论:R类id内联后程序可运行,但并非所有的工程都会自动产生内联现象,我们需要通过技术手段在合适的时机将R类id内联到程序中,内联完成后,由于不再依赖R类文件,则可以将R类文件删除,在应用正常运行的同时,达到包瘦身目的,如图9所示,在编译完成后会产生大量的R文件:
图9 项目R文件生成示意
整体方案如图10所示:
图10 R文件优化流程
注意事项:在替换阶段一定要加入二次检查,防止替换完,运行时出现ResourceNotFind异常,如下所示:
try {
int value = RManager.checkInt(type, name);
}catch (Exception e){
String errorMsg = "resource is not found(I),className="+className+",fieldName="+owner+"."+name;
throw new ResourceNotFoundException(errorMsg);
}
try {
int[] value = RManager.checkIntArray(type, name);
}catch (Exception e){
String errorMsg = "resource is not found(I[]),className="+className+",fieldName="+owner+"."+name;
throw new ResourceNotFoundException(errorMsg);
}
3-1-3 AndResGuard进行资源混淆
1.资源加载过程分析
开发过程中我们通过aapt生成的R.java中的常量来使用资源,而在编译之后使用常量的地方都会被替换为常量的值,如下所示:
final View layout = inflater.inflate(2131165182, container, false);
也就是说我们通过Resource使用一个int数值来查找使用资源。那么Resource是怎么通过int数值找到具体的资源呢?我们解压apk可以看到里面有个resources.arsc文件,这个文件也是由aapt生成,文件中保存着资源id和资源key的映射关系,Resource就是按照这个映射关系找到资源的。
2.resources.arsc:
图11是resources.arsc的里存储的映射关系,resources.arsc可以理解为一个资源映射数据库,根据ID映射其中具体的路径和名称。
图11 resources.arsc解析
通过解压APK后,将资源文件名进行短链处理比如res/layout/hello.xml转换为r/l/a.xml后,然后更改resources.arsc对应的value值,达到整体的瘦身效果。
AndResGuard[5]是微信推出资源优化工具,它的基本思想类似于 ProGuard 中的混淆,可以实现以上方案。
3-1-4 7zip压缩
7zip命令解释:
-t:指定压缩类型,支持7z, xz, split, zip, gzip, bzip2, tar, ....
-m:指定压缩算法,默认是Deflate
具体流程如下:
第一步:使用7z命令将未签名包解压到指定目录:7za x ${未签名包} -o${7z解压目录}
第二步:首先通过7z命令对解压目录进行全部压缩:7za a -tzip -mx9 ${目标7z文件名} ${7z解压目录}
第三步:获取存储类型文件,通过Android SDK中的aapt命令获取压缩方式为Stored的文件列表:aapt l -v ${未签名包}
第四步:更新存储类型文件,通过7z命令将存储类型文件更新到第二步操作中生成的7zip安装包:7za a -tzip -mx0 ${目标7z文件名} ${存储类型文件目录}
3-1-5 配置CPU架构
根据不同的CPU架构,构建不同的类型的安装包,目前主流设备都是64位机器,因此安卓市场上主要投放的是依据arm64-v8a编译构建的安装包
ndk {
abiFilters arm64-v8a
}
3-1-6 arsc 压缩
resources.arsc 的压缩体积收益很高,但对其进行压缩会影响启动速度和内存指标。原因是:系统在加载 arsc 文件时,若 arsc 文件未压缩,可使用 mmap 进行内存映射;若 arsc 文件被压缩了,则需要将其解压缩后读取到RAM 缓冲区,会增加内存使用,也会拖慢启动速度。
官方出于同样的考虑,从 targetSdkVersion>=30后不能用这种方式 开始强制要求resources.arsc ,否则会直接安装失败,因此本文不在展开阐述。
3-1-7 国际化语言处理
京东金融App目前仅在国内市场运营,但是接入的大量SDK中加入了几十种语言一样,导致整个体积变大,经过评估可以通过配置 resConfigs 去除无用的语言资源。
defaultConfig {
resConfigs "zh","en"
}
3-1-8 shrinkResources
shrinkResources:编译过程中用来检测并删除无用资源文件,也就是没有引用的资源
minifyEnabled:用来开启删除无用代码,比如没有引用到的代码,所以如果需要知道资源是否被引用就要配合minifyEnabled使用,只有两者都为true时才会起到真正的删除无效代码和无引用资源的目的。
其作用是将未被引用的资源文件替换为一个体积很小的格式文件(仍存在占位体积,同时保留了该资源条目,所以 resources.arsc 体积并不会减少),可通过 res/raw/keep.xml 文件配置 shrinkMode 和白名单。
buildTypes {
release {
// 不显示Log
buildConfigField "boolean", "LOG_DEBUG", "false"
//混淆
minifyEnabled true
// 移除无用的resource文件
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig sign.release
}
}
3-1-9 编码约束
- 尽量少用枚举类型,因为枚举在编译成字节码后,会增加大量体积,如图12所示(22行代码编译后字节码是86行)
图12 枚举类型编译后的字节码对比
- 删除不必要的LOG日志输出
3-2 进阶技术方案
SO库动态下载和插件化技术,本质上都属于动态下载的一个范畴,两个方案可以在业务中长期持续使用,在具体使用过程中如何选择,如图13所示:
图13 业务如何选择进阶方案
3-2-1 SO库动态加载
APP中有部分业务不适合做插件化改造,经过拆解发现其中的SO库占比很大,因此可以考虑采用动态下载的方式进行改造,进而实现减小体积。
SO库加载的两种方式
第一种方式我们直接把SO库下载并放到指定目录就可以
第二种方式是通过环境变量设置的目录中进行加载SO库,因此我们需要追加指定的目录到环境变量中,就可以正常加载SO库
System.load("{安全路径}/libxxx.so")
System.load("xxx")
1、如何设置APP中SO库的环境变量位置(借鉴Tinker):
final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
final Object dexPathList = pathListField.get(classLoader);
final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");
List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
if (origLibDirs == null) {
origLibDirs = new ArrayList<>(2);
}
final Iterator<File> libDirIt = origLibDirs.iterator();
while (libDirIt.hasNext()) {
final File libDir = libDirIt.next();
if (folder.equals(libDir)) {
libDirIt.remove();
break;
}
}
origLibDirs.add(0, folder);
final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
if (origSystemLibDirs == null) {
origSystemLibDirs = new ArrayList<>(2);
}
final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
newLibDirs.addAll(origLibDirs);
newLibDirs.addAll(origSystemLibDirs);
final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);
final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);
final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
nativeLibraryPathElements.set(dexPathList, elements);
2、如何删除指定SO库和整个加载流程,如图14所示:
图14 SO库删除和加载流程
3-2-2 插件化
什么是插件化:
插件化是将一个Apk根据业务功能拆分成不同的子Apk(也就是不同的插件),每个子Apk可以独立编译打包,最终发布上线的是集成后的Apk。在Apk使用时,每个插件是动态加载的,插件也可以进行热修复和热更新。
- 宿主:主App可以用来加载插件也成为Host
- 插件:插件App,被宿主加载的App,可以跟普通的App一样的Apk文件
什么形式的业务适合插件化改造:
- 业务相对独立,与宿主App解耦彻底
- 改造成本低,收益相对较高
- 占用体积较大
经过一些列评估,视频营业符合以上几点,改造后的效果如图15所示:
图15 视频营业厅插件化改造后效果
3-3 业务优化方案
随着业务越来越多,一些陈旧的业务UV越来越低,因此制定了一套业务下线优化流程,如图16所示:
图16 业务优化方案流程
四、管控
瘦身方案的实施很重要,后续的管控不反弹更重要,我们一边做瘦身治理,另一边探索常态化的管控机制,最终沉淀了一套管控规范和管控机制。管控的目的不是限制业务迭代或者新增代码,而是怎么做到在有限的代码中实现其功能,提升工程师日常编码中的瘦身意识。
4.1 SDK接入规范
为防止SDK无序扩张,制定了SDK准入规范,在保证功能的前提下严控SDK体积大小,最大程度控制APP体积反弹。
4.2 管控流程
图17 管控流程
根据增加内容、删除内容、增大内容、减小内容、重复文件、代码治理等资源文件的变动情况结合治理管控规范等进行治理,打包构建完成会跟历史版本就行差量对比,获取变化的内容来评估是否具有优化空间,并给出优化目标,待优化后重新构建打包集成。
五、成果与后续规划
5.1 成果
通过以上措施,京东金融Android版本经过两个季度5个版本的迭代,从117M到现在的74M(图18),整体一直维持在可控的范围内。同时在接下来的版本迭代中,我们会将APK瘦身常态化,始终维持包体积在可控的范围内。
图18 金融APP瘦身成果
5.2 后续规划
持续技术手段优化:
业务的不断堆积迭代,总会产生一些无用的资源,所以安装包瘦身要定期清理这些无用文件和代码;
做好各个版本的监控,对比版本之间的差异,发现可以在不影响业务情况下,使用技术手段优化。
线上管控平台搭建:
前期采用线下的管控治理,实施起来有点耗时,后续我们会完善线上管控平台的搭建,与整个App发布构建平台进行融合,形成流水线的机制,做好管控。
小结:安装包瘦身的探索还有很长的路走,本文也只是列举了一些常用的瘦身方案,对于庞大的项目除了优化外,还有做好项目之间的治理,持续对APP进行体积优化,提升用户体验。
【参考资料】
[1] 包大小与安装转化率 https://medium.com/googleplaydev/shrinking-apks-growing-installs-5d3fcba23ce2
[2] ProGuard https://www.guardsquare.com/proguard
[3] R8https://r8.googlesource.com/r8
[4] ProGuard与R8对比https://www.guardsquare.com/blog/proguard-and-r8
[5] AndResGuardhttps://github.com/shwenzhang/AndResGuard
[6] AGPhttps://developer.android.com/studio/releases/gradle-plugin
[7] Pandora:基于去中心化技术的研发、测试阶段能效提升工具