解决支付宝包体积优化的遗留问题:运行时获取dexpc
本文解决了支付宝包体积优化方案遗留的一个未解决问题。
1 问题背景
1.1 安卓包体积优化
精简安卓应用的包体积是提升其质量的重要手段之一。安卓应用的安装包(apk文件)中dex保存的是应用的代码,占有可观的体积。如果能够将这一部分的体积减小,那么无疑会有效地减小安装包的体积。dex中的debugitem主要保存着两类信息:1.方法的参数和局部变量信息。2.行号信息。删除debugitem后不会影响代码的执行效果,但是会导致无法正确得到调用栈对应的源码行号。显然,丢失了行号信息,对于开发者是非常不方便的(比如修复崩溃问题或做性能优化时都需要源码行号)。那么是否存在一种方法,能够在删除了dex中的debugitem后仍然能够正确获取调用栈对应的源码行号呢?
1.2 支付宝的方案
支付宝对于上面的行号丢失问题提出了一个解决方案:编译打包时将dex的dexpc(指令集偏移)与源码行号的映射关系记录下来存储到服务端,这样只需要在客户端获取调用栈中每个栈帧的dexpc然后在服务端映射成真正的源码行号即可。具体来讲,在客户端处理调用栈的情况分为两种:
1.java崩溃后Throwable对象代表的调用栈。对于这种情况直接反射Throwable对象的stackTrace(或backtrace)成员,然后经过一系列操作即可得到每个栈帧的dexpc(原文有描述,下文也会详细讲解)
2.一些需要主动获取调用栈的情况(例如:当达到一定条件时,性能监控模块会获取线程的调用栈)。对于这种情况的解决方法是修改dex文件:“只保留一小块debugitem,让系统查找行号的时候指令集行号和源文件行号保持一致,这样就什么都不用做,任何监控上报的行号都直接变成了指令集行号”(注:这个方法其实也能应用于第一种情况)。
对于修改dex的方法,原文也说“踩过很多坑”,例如AOT变慢的问题等,虽然最后都解决了,却并没有给出详细的解决过程。另外,原文没有提到另一个必须要解决的问题:如何区分同一个类中的同名方法。因为按照原文,上报上来的调用栈的每个栈帧只有文件名、类名、方法名和dexpc,如果一个类中存在多个同名方法,是无法确定到底是哪一个方法的。
那么我们能否不修改dex文件而在运行时获取dexpc呢?其实原文本来是想在运行时通过hook获取dexpc的,不过最终因为“兼容性和hook的点太多”而作罢。所以这里要对新的方案做如下要求:
1.在“运行时”获取dexpc
2.不使用任何hook技术
3.能够区分类中的同名方法
2 技术研究
要想在运行时获取dexpc,其实只要能够在一个线程中获取另外一个java线程调用栈的dexpc即可:
1.java崩溃后仍然反射Throwable对象的成员即可。
2.如果是获取本java线程的调用栈,那么直接new一个Throwable对象然后反射其成员即可。
为了便于以后的分析研究,我们查看一下Throwable对象中是怎样存储dexpc的。(注:为了便于描述,下文全部以安卓7.0源码为例进行分析)。
2.1 Throwable中dexpc的获取
查看Throwable.java源码,可以看到其backtrace成员:
有了以上知识,我们回到一开始的问题:如何在一个线程中获取其他java线程的调用栈dexpc?
2.2 java线程调用栈dexpc的获取
其实直接调用Thread对象的getStackTrace方法就能在一个线程中获取其他java线程的调用栈,不过返回的结果是StackTraceElement[]类型的对象,包含的是行号信息,而没有dexpc信息。那么我们猜测:是不是系统在getStackTrace方法的内部实现也是先获取dexpc然后再转换为行号呢?如果是这样的话,我们只要想办法获取dexpc这个中间结果就可以了。我们查看getStackTrace的实现:
3 技术实现
技术方案的产物是一个安卓sdk:给定一个Throwable或Thread对象,sdk可以获取到这个Throwable或Thread的调用栈(每个栈帧包括所在文件名、类名、方法名以及dexpc和方法参数类型)。下面以Thread为例介绍一下sdk的原理。
3.1 sdk的初始化
sdk首先获取Thread类中的nativePeer成员这个Field;之后通过读取libart.so文件找到函数CreateInternalStackTrace(符号通常为_ZNK3art6Thread24CreateInternalStackTraceILb0EEEP8_jobjectRKNS_33ScopedObjectAccessAlreadyRunnableE)并记录其地址。
3.2 Thread对象调用栈的获取
首先通过反射得到目标Thread对象中的nativePeer成员对象(下称targetPtr),然后向目标线程发送信号令其暂停,此时就可以安全地获取其调用栈:将targetPtr作为参数,执行CreateInternalStackTrace函数,得到的返回结果即为backtrace;然后让目标线程从信号处理函数中返回即恢复了线程的执行。
这个backtrace对象就存储了目标线程调用栈的所有信息。下面从backtrace对象中取出所有栈帧信息:将backtrace强转为Object[]类型的对象(下称backtraceArr);取backtraceArr的第一个元素first;将first强转为int[]类型(32位运行情况下)或long[]类型(64位运行情况下)的对象(下称firstIL);取出firstIL这个数组的后半段的数据即为所有栈帧的dexpc值(dexpc与栈帧一一对应);firstIL数组的前半段是ArtMethod指针,结合backtraceArr的后半段代表的class信息可以得到此方法对象(下称method),然后将method传递到java层记录其所在文件名、类名、方法名、所有参数类型即可(注意:这里的method有可能是构造方法即Constructor的对象)。
通过上述过程就得到了Thread对象的调用栈,并且每个栈帧都记录了方法的参数类型,从而能够支持类中的同名方法。
为了简便起见,上面描述的过程与实际处理过程稍有差别,我已经将实现过程封装为一个sdk,并对不同的安卓版本进行了适配。
4 方案的兼容情况
支持的cpu架构:arm,arm64
支持的安卓版本:Android4.4-Andoird9
附录 此方案的dex精简整体架构
整个dex精简方案命名为dextrip(dex strip),上面描述的客户端sdk为libdextip,整体结构如下图