当前位置: 首页 > 科技观察

支付宝安卓包体积极度压缩

时间:2023-03-20 14:10:58 科技观察

前言本章我们将围绕《支付宝 App 构建优化解析》推出新的系列,在“代码管理”、“证书管理”、“版本管理”、“构建”中对客户端进行细分和拆解packaging”等,探讨Dimension的具体实施方案,带领大家进一步了解支付宝在App积木下的持续优化。本节将主要记录通过压缩支付宝安卓包大小对运行效率和质量的提升。后台包大小的重要性就不用多说了。包大小直接影响用户的下载和留存,有些厂商预装强制要求必须小于一定值。但是随着业务的迭代发展,应用会越来越大,安装包也会不断膨胀,所以包体积缩减是一个长期的治理过程。解决方案支付宝也一直在优化包大小的方向努力,我们推出了很多解决方案。例如:proguard代码混淆,图片从png到tinypng再到webp,引入7zip压缩方案等。这个方案和上面的常规方案不同。通过直接删除dex中无用的信息,支付宝包的大小可以瞬间减少2.1M,而且不会影响整个运行逻辑和性能,甚至可以减少一点点运行。记忆。解决方案介绍介绍在说详细的解决方案之前,不得不稍微说一下整个Java系统的调试逻辑。.class文件在JVM运行时加载。为了让包体积更紧凑,运行更高效,Android发明了dalvik和art虚拟机。两台虚拟机都运行.dex文件(当然art虚拟机也可以同时运行.oat文件,不在本文讨论范围)。因此,dex文件中的信息内容与class文件中包含的信息内容是完全一样的。不同的是类中的信息在dex文件中进行了去重。一个dex包含很多class文件,结构比较大。不同的是class是streaming结构,dex是partition结构,每个block都是通过offset来索引的。后面只说dex的结构,class的结构就不说了。dex的结构可以用下图表示:dex文件的结构其实很清晰,分为几个大块,header区,index区,data区,map区。本次优化方案优化删除了数据区中的debugItems区。为什么要使用debugItem?首先,你需要知道debugItem中存放的是什么。它主要包含两种信息:函数的参数变量和所有的局部变量。指令集行号与源文件行号的对应关系是什么?道理其实很明显。既然叫debugItem,调试的时候一定要用到。我们在使用IDE进行断点和单步调试的时候,一般都会用到这个区域。第二个作用是上报crash或者主动获取调用栈,因为虚拟机真正执行的时候,执行的是指令集,reportstack会上报crash对应的源文件行号。这时候就是通过这个debugItem来获取对应的行号,可以通过下面的截图更直观的理解:上图是一个比较常见的crash信息,红框中的行号是通过搜索获取??的这个调试项。debugItem有多大?以支付宝为例,调试包4-5M,发布包约3.5M,约占dex文件大小的5.5%,与谷歌官方数据一致。直接去掉这部分岂不是很诱人!debugItem可以直接去掉吗?显然不是,如果去掉,所有上报的crash信息都会没有行号,所有行号都会变成-1,这样就会被喷的找不到北。其实在proguard中,有一个配置可以去掉或者保留这个行号信息。-keepSourceFile,LineNumberTable就是这个函数。为了方便定位问题,基本上所有的开发都保留了这个配置。所以,解决方案的核心思想就是去掉debugItem,同时让报crash的时候能够获取到正确的行号。至于IDE调试,这个比较容易解决。我们只需要处理release包,debug包不处理。方案一的核心思想也比较简单,就是离线查找行号,让原本保存在app中的行号对应关系提前提取出来,保存在服务器上。报crash时,提前提取行号表。反解决方案,解决crash信息报告没有行号,无法定位的问题。想法虽然简单,但实现起来还是有点复杂,推广到线下也颇为曲折。计划经过多次调整。删除debugItem(remove-keeplineNumberTable),在删除行号表之前dump一个临时dex。修改dexdump,将临时dex中的行号表关系dump成一个dexpcmapping文件(指令集行号与源文件行号的映射关系),保存到服务器。Hookapp运行时的crashhandler,将crash发生时指令集的行号上报给反解平台。反解码平台可以通过上报指令集的行号并提前准备好dexpcmapping文件,反解码出正确的行号。上述方案从推出整个demo用了两周多的时间。其他修改点不难。难点在于指令集行号的报告。我们知道所有的崩溃最终都会有一个throwable对象,这个对象存储了整个堆栈信息。看了源码反复尝试,发现我要的指令集行号其实就在这个对象中。可以用下面这个简单的图来说明:在打印崩溃堆栈信息之前,每个throwable都会调用art虚拟机提供的一个jni方法,返回一个名为stackTrace的内部对象保存在Throwable对象中,stackTrace对象保存里面是整个方法的调用栈,当然也包括指令集行号。后面获取到实际的栈信息时,会调用一个artjni方法,把stackTrace方法抛过去。底层会使用stackTrace对象中的指令集行号解码官方源文件行号。嗯,其实很简单。通过反射获取这个Throwable中的stackTrace对象,获取指令集的行号,然后上报。这里要注意的一点是恶心。每个虚拟机的实现是不同的。首先是内部对象的名称,有的叫stackTrace,有的叫backstrace,然后这个内部对象的类型也是千差万别,有的是int数组,有的是long数组,有的是object数组,但他们都有这个指令集行号。对于不同的虚拟机版本,需要使用不同的方法来解析这个对象。大概兼容4种虚拟机,4.x、5.x、6.x、7.x,7.x之后虚拟机统一。方案2上面的方案其实已经很完美了,没有兼容性问题,删除是直接使用proguard,直接在java层获取指令集的行号,没有各种hook。如果只需要处理崩溃报告,方案1就够了,但是支付宝里面有很多场景是远远不够的。例如:内存异常时的性能、CPU、调用栈。本机崩溃时的Java调用堆栈。以上案例都会涉及到堆栈信息。方案一中,通过反射调用throwable中的stackTrace内部对象是根本不可能的,需要另一种方法。最初的想法是尝试hook艺术虚拟机,每天查看源码,看看能hook到什么点,最后放弃了。一是担心兼容性问题,二是hook点太多,看得我心慌。最后改主意,尝试直接修改dex文件,保留一个小的debugItem,这样系统查找行号的时候,指令集的行号和源文件的行号一致,所以那什么都不用做,监控上报的任何行号直接变成指令集的行号,只需要修改dex文件即可。可以用下面的示意图来表示:如上图所示:本来每个方法都会有一个debugInfoItem,每个debugInfoItem都有一个指令集行号和源文件行号的映射关系。我做的修改其实很简单,就是把多余的debugInfoItem都删掉,只留下一个debugInfoItem,所有方法都指向同一个debugInfoItem,这个debugInfoItem中的指令集行号和源文件行号一致,所以不管用什么方法查行号,拿来的都是指令集行号。也有很多陷阱。事实上,只留一个debugInfoItem是不够的。为了兼容所有虚拟机的搜索方式,需要对debugInfoItem进行分区,并且debugInfoItem表不能太大。遇到的一个坑是在androidO上的dex2oat优化,有时会频繁遍历debugInfoItem,导致AOT编译慢,最后通过debugInfoItem分区解决。这个方案比较彻底,不用改proguard,不用hooknative。但是,如果只需要处理崩溃的行号,那么推荐第一种方案。这个解决方案有一点大的变化。前期每天研究dex的文件结构,把每一个细节都挖出来,等自己比较有把握的时候才敢改。总结目前,该方案已正式上线支付宝。经过几轮国外灰验证,比较稳定。支付宝整体压缩包大小减少约2.1M,真实dex大小减少约3.5M。通过本节,我们初步了解了支付宝在安卓客户端是如何通过包大小压缩来提高App的运行效率和质量的。关于Androidpacketsize压缩的设计思路和具体做法,也期待大家的反馈,欢迎一起讨论交流。