点击上方“刘望舒”,选择“星标”
多点在看,就是真爱
作者: 琉克 | 来源:公众号 mPaaS
1
前言
本章节我们将围绕《支付宝 App 构建优化解析》另启新系列,细分拆解客户端在“代码管理”、“证书管理”、“版本管理”、“构建打包”等维度的具体实现方案展开讨论,带领大家进一步了解支付宝在 App 构建模块下的持续优化。
本节将主要记录通过对支付宝 Android 包大小进行压缩,来改善运行效率和质量。
2
背景
包大小的重要性已经不需要多说,包大小直接影响用户的下载,留存,甚至部分厂商预装强制要求必须小于一定的值。但是随着业务的迭代开发,应用会越来越大,安装包会不停的膨胀,因此包大小缩减是一个长期持续的治理过程。
3
方案介绍
支付宝关于包大小优化,支付宝一直在这个方向上努力,因此我们引入了很多方案,比如 “proguard 代码混淆”,“图片从 png 到 tinypng 到 webp”,“引入 7zip 压缩方案”等。本方案是有别于上面这些常规的方案,是通过直接删 dex 中的无用信息,达到支付宝包大小瞬间减小 2.1M 的目的,并且不影响整个的运行逻辑和性能,甚至还能降低一点运行内存。
在讲详细方案前得稍微说说整个 Java 系的调试逻辑。JVM 运行时加载的是 .class 文件,Android 为了使包大小更紧凑,并且运行更高效发明了 dalvik 和 art 虚拟机,两种虚拟机运行的都是 .dex 文件(当然 art 虚拟机还可以同时运行 oat 文件,不在本文章讨论范围)。所以 dex 文件里面信息的内容和 class 文件包含的信息是完全一致的,不同的是 dex 文件对 class 中的信息做了去重,一个 dex 包含了很多的 class 文件,并且在结构上有比较大的差异,class 是流式的结构,dex 是分区结构,各个区块间通过 offset 索引。后面就只提 dex 的结构,不再提 class 的结构。dex 的结构可以用下面这张图表示:
dex 文件的结构其实非常清晰,分几个大块,header 区,索引区,data 区,map 区。本优化方案优化删除的就是 data 区中的 debugItems 区域。
首先得知道 debugItem 里面存了什么?里面主要包含两种信息:
函数的参数变量和所有的局部变量
所有的指令集行号和源文件行号的对应关系有什么用呢?
第一点很明显,既然叫 debugItem,那么肯定就是 debug 的时候用。我们平时在用 IDE 进行断点和单步调试的时候都会用到这个区域。
第二点作用那就是上报 crash 或者主动获取调用堆栈的时候用。因为虚拟机真正执行的时候是执行的指令集,上报堆栈会上报 crash 的对应源文件行号,此时正是通过这个 debugItem 来获取对应的行号,可以用下面的截图比较直观的了解:
上图是一个比较常见的 crash 信息,红框中的行号便是通过查找这个 debugItem 来获取的。
在支付宝的场景下,debug 包有 4-5M,release 包有 3.5M 左右,占 dex 文件大小的比例在 5.5% 左右,和 google 官方的数据是一致的。如果能把这部分直接去掉,是不是很诱人!
显然不能,如果去掉了,那所有上报的 crash 信息就会没有行号,所有的行号都会变成 -1,会被喷的找不到北。其实在 proguard 的时候就是有配置可以去掉或保留这个行号信息,-keep SourceFile, LineNumberTable 就是这个作用,为了方便定位问题,基本所有的开发都保留了这个配置。所以,方案的核心思路就是去掉 debugItem,同时又能让 crash 上报的时候能拿到正确的行号。至于 IDE 调试,这个比较好解决,我们只要处理 release 包就行了,debug 包不处理。
4
方案一
核心思路也比较简单,就是行号查找离线化,让本来存放在 App 中的行号对应关系提前抽离出来存放在服务端,crash 上报的时候通过提前抽离的行号表进行行号反解,解决 crash 信息上报无行号,无法定位的问题。思路虽然简单,实现的时候还是有点复杂,推动上线也比较曲折,方案经过几次调整,大概的方案可以用下面一张图来抽象:
如上图,核心点有四个:
修改 proguard:利用 proguard 来删除 debugItem (去掉 -keep lineNumberTable),在删除行号表之前 dump 出一个临时的 dex。
修改 dexdump:把临时的 dex 中的行号表关系 dump 成一个 dexpcmapping 文件(指令集行号和源文件行号映射关系),并存至服务端。
hook app runtime 的 crash handler,把 crash 时的指令集行号上报到反解平台。
反解平台通过上报指令集行号和提前准备好 dexpcmapping 文件反解出正确的行号。
上面这套方案大概花了两个多星期,撸出了整个 demo,其它几个改造点都不是很难,难点还是在指令集行号的上报。我们知道所有的 crash 最终都是会有一个 throwable 对象,里面保存了整个堆栈信息,经过反复的阅读源码和尝试,发现我要的指令集行号其实也在这个对象里面。可以用下面一幅简单的图示意:
在打印 crash 堆栈信息前,每个 throwable 都会调用art虚拟机提供的一个 jni 方法,返回一个内部的对象叫 stackTrace 保存在 Throwable 对象中,这个 stackTrace 对象里面保存的便是整个方法的调用栈,当然也包括指令集行号,后续获取实际的堆栈信息时会再调用一个 art 的 jni 方法,把这个 stackTrace 方法丢过去,底层通过这个 stackTrace 对象中的指令集行号反解出正式的源文件行号。好了,其实很简单,反射获取下这个 Throwable 中的 stackTrace 对象,拿到指令集行号,然后,上报。这里要注意的一个点,比较恶心,每个虚拟机的实现都不一样,首先内部对象的名字,有些叫 stackTrace,有些叫 backstrace,然后这个内部对象的类型也非常有,有些是 int 数组,有些是 long 数组,有些是对象数组,但是都会有这个指令集行号,需要针对不同的虚拟机版本使用不同的方法去解析这个对象,大概要兼容4种虚拟机,4.x, 5.x, 6.x, 7.x,7.x 虚拟机之后的就统一了。
5
方案二
上面这套方案其实挺完美的,没有什么兼容性问题,删除是直接利用 proguard,获取指令集行号直接在 java 层获取,不需要各种 hook,如果只需要处理 crash 的上报,方案一足够了,但是在支付宝有很多场景是远远不够的。比如:
上面这些 case 都会涉及到堆栈信息,方案一中通过反射调用 throwable 中的 stackTrace 内部对象根本搞不定,需要换种方法。最开始的思路是尝试 hook art 虚拟机,每天翻源码,看看可以 hook 的点,最后还是放弃了,一个是担心兼容性问题,另一个是 hook 的点太多,比较慌。最后换了一种思路,尝试直接修改 dex 文件,保留一小块 debugItem,让系统查找行号的时候指令集行号和源文件行号保持一致,这样就什么都不用做,任何监控上报的行号都直接变成了指令集行号,只需修改 dex 文件。可以用下面的示意图表示:
如上图:本来每一个方法都会有一个 debugInfoItem,每一个 debuginfoItem 里面都有一个指令集行号和源文件行号的映射关系,我做的修改其实非常简单,就是把多余的 debugInfoItem 全部删掉了,只留了一个 debugInfoItem,所有的方法都指向同一个 debugInfoItem,并且这个 debugInfoItem 中的指令集行号和源文件行号保持一致,这样不管用什么方式来查行号,拿到的都是指令集行号。其中也踩过很多坑,其实光留一个 debugInfoItem 是不够的,要兼容所有虚拟机的查找方式,需要对 debugInfoItem 进行分区,并且 debugInfoItem 表不能太大,遇到过一个坑就是 androidO 上进行 dex2oat 优化的时候,会频繁的遍历这个 debugInfoItem,导致 AOT 编译比较慢,最后都通过 debugInfoItem 分区解决了。这个方案比较彻底,不用改 proguard,也不用 hook native。不过如果只需要处理 crash 的行号问题,那还是首推方案一,这个方案改动有点大,前期也是每天研究 dex 的文件结构,抠每一个细节,有比较大的把握时才敢改。
6
小结
目前该方案已经在支付宝正式上线,前面经过好几轮的外灰验证,还是比较稳定的。支付宝整体包大小减少了 2.1M 左右,真实的 dex 大小减少 3.5M 左右。
通过本节内容,我们初步了解了支付宝在 Android 客户端持续压缩包大小所积累的思考和实践。由于篇幅限制,很多技术要点我们无法一一展开。而相应的技术内核,我们同样应用在了 mPaaS 并对外输出,欢迎大家上手体验:
https://tech.antfin.com/docs/2/49549
关于 Android 端包大小压缩的设计思路和具体实践,同样期待你们的反馈,欢迎一起探讨交流。
---------- END ----------
推荐文章
不会查看系统源码,还搞什么Android?
Flutter 美团的实践及原理
为什么阿里禁止在 foreach 循环里进行元素的 remove/add 操作
分享大前端、Java、Android等技术,
关注职业发展和行业动态。