写点什么

京东金融 Android 瘦身探索与实践

  • 2023-03-27
    北京
  • 本文字数:6680 字

    阅读完需:约 22 分钟

作者:京东科技 冯建华

一、背景

随着业务不断迭代更新,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.arscaapt 工具在编译资源会将一些资源或者资源索引打包成 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 所示,主要分为:


1.常规技术方案,通过 Gradle 插件(代码无侵入、自动化)在编译时期完成 APP 瘦身;


2.进阶技术方案,将部分业务线差别性的通过插件化或者 SO 动态下载的方式就行改造,业务改造的越多,收益越高;


3.业务优化方案,针对业务线的数据埋点,生成访问 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 {7z 解压目录}


第二步:首先通过 7z 命令对解压目录进行全部压缩:7za a -tzip -mx9 {7z 解压目录}


第三步:获取存储类型文件,通过 Android SDK 中的 aapt 命令获取压缩方式为 Stored 的文件列表:aapt l -v ${未签名包}


第四步:更新存储类型文件,通过 7z 命令将存储类型文件更新到第二步操作中生成的 7zip 安装包:7za a -tzip -mx0 {存储类型文件目录}

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] ProGuardhttps://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:基于去中心化技术的研发、测试阶段能效提升工具

发布于: 刚刚阅读数: 3
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
京东金融Android瘦身探索与实践_App_京东科技开发者_InfoQ写作社区