本文解决了支付宝包体积优化方案遗留的一个未解决问题。
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成员:
就是这个成员存储了dexpc信息,那么这个成员是怎样创建的呢?继续查看源码:
可以看到backtrace这个成员并不是new出来的,而是通过Throwable类的native方法nativeFillInStackTrace创建的。继续跟踪源码:
可以看到最终使用的是build_trace_visitor.GetInternalStackTrace的返回结果。
GetInternalStackTrace函数直接返回trace_成员。查看trace_成员:
通过这里的注释结合其他地方的源码分析可知:trace_是一个对象数组,其第一个元素是一个包含了ArtMethod指针和dexpc的数组(前半段为ArtMethod指针,后半段为dexpc,因此它的长度必然为偶数),剩余的元素是ArtMethod所对应的类。这个trace_最终反映到java层就是backtrace对象(这里涉及对象的转换问题,不详细讨论)。到了这里,我们就知道怎样通过backtrace得到调用栈每个栈帧的dexpc了(详见3.2节)。
有了以上知识,我们回到一开始的问题:如何在一个线程中获取其他java线程的调用栈dexpc?
2.2 java线程调用栈dexpc的获取
其实直接调用Thread对象的getStackTrace方法就能在一个线程中获取其他java线程的调用栈,不过返回的结果是StackTraceElement[]类型的对象,包含的是行号信息,而没有dexpc信息。那么我们猜测:是不是系统在getStackTrace方法的内部实现也是先获取dexpc然后再转换为行号呢?如果是这样的话,我们只要想办法获取dexpc这个中间结果就可以了。我们查看getStackTrace的实现:
可以看到调用了VMStack类的native方法getThreadStackTrace,查找其实现:
根据以上函数实现以及Thread::InternalStackTraceToStackTraceElementArray函数的分析,可以知道GetThreadStack这个函数返回的trace对象和上文中提及的trace_具有完全相同的结构,里面就存储有dexpc信息,所以我们上面的猜想是成立的:getStackTrace方法的内部也是先获取dexpc然后再转换为行号。下面只要想办法获取GetThreadStack这个函数的返回值然后取出其中的dexpc即可。一种实现方法是手动调用GetThreadStack这个函数,这个不容易实现:因为GetThreadStack不存在于符号表中,难以定位这个函数的位置;另一种实现方法是hook GetTheadStack这个函数,这样每次调用Thread.getStackTrace都能够获取到中间结果trace,也就获取了dexpc值。但是为了稳定性,我们不想使用任何hook技术,并且难以定位GetThreadStack这个函数。那么到底有没有其他方法呢?
我们换个思路,继续查看GetThreadStack这个函数的实现:
可以看出,如果需要获取调用栈的线程(目标线程)不是当前执行代码的线程,那么需要先暂停目标线程,然后调用CreateInternalStackTrace函数得到trace这个结果,最后再恢复目标线程的执行。所以这里就又有了一个思路:通过系统的SuspendThread类函数暂停线程,然后执行CreateInternalStackTrace函数,最后通过系统的ResumeThread类函数恢复线程。分析so之后发现CreateInternalStackTrace这个函数确实在符号表中,运行时能够找到它的地址。但是并没有找到很友好的暂停/恢复线程的函数(针对release版本的app)。貌似这个思路又行不通了?先别着急,我们这里分析一下为什么获取一个线程的调用栈时必须先暂停它呢?这里再贴一遍CreateInternalStackTrace的源码:
我们可以看到,CreateInternalStackTrace首先通过count_visitor计算目标线程的栈的深度,然后将这个值(depth)告知build_trace_visitor,最后在build_trace_visitor的WalkStack函数中真正构建出trace。因此我们可以得到一个结论:暂停目标线程是因为获取线程调用栈的过程不是一个原子过程,如果获取调用栈的过程中,目标线程仍然在执行,那么就有可能出错(例如count_visitor得到的栈的深度depth为10,此时目标线程仍然在执行,等到build_trace_visitor获取真正的栈帧时可能栈的深度又变成了11)。所以,我们只要能够暂停目标线程即可,不一定要使用系统提供的函数。一种方法就是通过向目标线程发送信号使其暂停。所以这里就得到了一个具体可行的技术实现方案。
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,整体结构如下图