title: Android AOP之字节码插桩
author: 陶超
description: 实现数据收集SDK时,为了实现非侵入的,全量的数据采集,采用了AOP的思想,探索和实现了一种Android上AOP的方式。本文基于数据收集SDK的AOP实现总结而成。
categories: Android
date: 2017/02/11
tags:javascript
本篇文章基于《网易乐得无埋点数据收集SDK》总结而成,关于网易乐得无埋点数据采集SDK的功能介绍以及技术总结后续会有文章进行阐述,本篇单讲SDK中用到的Android端AOP的实现。java
随着流量红利时代过去,精细化运营时代的开始,网易乐得开始构建本身的大数据平台。其中,客户端数据采集是第一步。传统收集数据的方式是埋点,这种方式依赖开发,采集时效慢,数据采集代码与业务代码不解藕。android
为了实现非侵入的,全量的数据采集,AOP成了关键,数据收集SDK探索和实现了一种Android上AOP的方式。web
面向切向编程(Aspect Oriented Programming),相对于面向对象编程(ObjectOriented Programming)而言。
OOP的精髓是把功能或问题模块化,每一个模块处理本身的家务事。但在现实世界中,并非全部问题都能完美得划分到模块中,有些功能是横跨并嵌入众多模块里的,好比下图所示的例子。编程
上图是一个APP模块结构示例,按照照OOP的思想划分为“视图交互”,“业务逻辑”,“网络”等三个模块,而如今假设想要对全部模块的每一个方法耗时(性能监控模块)进行统计。这个性能监控模块的功能就是须要横跨并嵌入众多模块里的,这就是典型的AOP的应用场景。api
AOP的目标是把这些横跨并嵌入众多模块里的功能(如监控每一个方法的性能) 集中起来,放到一个统一的地方来控制和管理。若是说,OOP若是是把问题划分到单个模块的话,那么AOP就是把涉及到众多模块的某一类问题进行统一管理。数组
咱们在开发无埋点数据收集是一样也遇到了不少须要横跨并嵌入众多模块里的场景,这些场景将在第二章(AOP应用情景)进行介绍。下面咱们调研下Android AOP的实现方式。网络
AOP从实现原理上能够分为运行时AOP和编译时AOP,对于Android来说运行时AOP的实现主要是hook某些关键方法,编译时AOP主要是在Apk打包过程当中对class文件的字节码进行扫描更改。Android主流的aop 框架有:app
除此以外,还有一些非框架的可是能帮助咱们实现 AOP的工具类库:框架
Dexposed,Xposed的缺陷很明显,xposed须要root权限,Dexposed只对部分系统版本有效。
与之相比aspactJ没有这些缺点,可是aspactJ做为一个AOP的框架来说对于咱们来说过重了,不只方法数大增,并且还有一堆aspactJ的依赖要引入项目中(这些代码定义了aspactJ框架诸如切点等概念)。更重要的是咱们的目标仅仅是按照一些简单的切点(用户点击等)收集数据,而不是将整个项目开发从OOP过渡到AOP。
AspactJ对于咱们想要实现的数据收集需求过重了,可是这种编译期操做class文件字节码实现AOP的方式对咱们来讲是合适的。
所以咱们实现Android上AOP的方式肯定为:
在具体讲解实现技术以前,先看一下无埋点数据收集需求遇到的三个须要AOP的场景。
下面举出数据收集SDK经过修改字节码进行AOP的三个应用情景,其中情景一和二的字节码修改是方法级别的,情景三的字节码修改是指令级别的。
收集页面数据时发现有些fragment是但愿看成页面来看待,而且计算pv的(如首页用fragmen实现的tab)。而fragment的页面显示/隐藏事件须要根据:
onResume()
onPause()
onHiddenChanged(boolean hidden)
setUserVisibleHint(boolean isVisibleToUser)复制代码
这四个方法综合得出。
也就是说当项目中任一一个Fragment发生如上状态变化,咱们都要拿到这个时机,并上报相关页面事件,也就是对Fragment的这几个方法进行AOP。
作法是:
假设咱们有一个Fragment1(空类,内部什么代码也没有)
public class Fragment1 extends Fragment {}复制代码
通过扫描修改字节码后变为:
public class Fragment1 extends Fragment {
@TransformedDCSDK
public void onResume() {
super.onResume();
Monitor.onFragmentResumed(this);
}
@TransformedDCSDK
public void onPause() {
super.onPause();
Monitor.onFragmentPaused(this);
}
@TransformedDCSDK
public void onHiddenChanged(boolean var1) {
super.onHiddenChanged(var1);
Monitor.onFragmentHiddenChanged(this, var1);
}
@TransformedDCSDK
public void setUserVisibleHint(boolean var1) {
super.setUserVisibleHint(var1);
Monitor.setFragmentUserVisibleHint(this, var1);
}
}复制代码
注:
点击事件是分析用户行为的一个重要事件,Android中的点击事件回调大可能是View.OnClickListener的onClick方法(固然还有一部分是DialogInterface.OnClickListener或者重写OnTouchEvent本身封装的点击)。
也就是说当项目中任一一个控件被点击(触发了OnClickListener),咱们都要拿到这个时机,并上报点击事件。也就是对View.OnClickListener的onClick方法进行AOP。作法是:
假设有个实现接口的类
public class MyOnClickListener implements OnClickListener {
public void onClick(View v) {
//此处表明点击发生时的业务逻辑
}
}复制代码
通过扫描修改字节码后变为:
public class MyOnClickListener implements OnClickListener {
@TransformedDCSDK
public void onClick(View v) {
if (!Monitor.onViewClick(v)) {
//此处表明点击发生时的业务逻辑
}
}
}复制代码
注:
弹窗显示/关闭事件,固然弹窗的实现能够是Dialog,PopupWindow,View甚至Activity,这里仅以Dialog为例。
当项目中任意一个地方弹出/关闭Dialog,咱们都要拿到这个时机,即对Dialog.show/dismiss/hide这几个方法进行AOP。作法是:
假设项目中有一个代码(例如方法)块以下,其中某处调用了dialog.show()
某个方法 {
//其余代码
dialog.show()
//其余代码
}复制代码
通过扫描修改字节码后变为
某个方法 {
//其余代码
Monitor.showDialog(dialog)
//其余代码
}复制代码
注:Monitor.showDialog除了调用dialog.show()还进行一些数据收集逻辑
第二章 (AOP应用情景)简单地列举了AOP在三种应用情景中达到的效果,下面介绍AOP的实现,实现的大体流程以下图所示:
关键有如下几点:
A、字节码插桩入口(图3-1 中1,3两个环节)。
咱们知道Android程序从Java源代码到可执行的Apk包,中间有(但不止有)两个环节:
咱们要想对字节码进行修改,只须要在javac以后,dex以前对class文件进行字节码扫描,并按照必定规则进行过滤及修改就能够了,这样修改事后的字节码就会在后续的dex打包环节被打到apk中,这就是咱们的插桩入口(更具体的后面还会详述)。
B、bytecode manipulate(上图3-1 中第二个环节),这个环节主要作:
最后B步骤修改过字节码的class文件,将连同资源文件,一块儿打入Apk中,获得最终能够在Android平台能够运行的APP。
下面分别就插桩入口和ASM字节码操做两个方面进行详述。
如 第三章(AOP实现概述)所述,咱们在Android 打包流程的javac以后,dex以前得到字节码插桩入口。
完整的Android 打包流程以下图所示:
说明:
图4-1中“dex”节点,表示将class文件打包到dex文件的过程,其输入包括1.项目java源文件通过javac后生成的class文件以及2.第三方依赖的class文件两种,这些class文件都是咱们进行字节码扫描以及修改的目标。
具体来讲,进行图4-1中dex任务是一个叫dx.jar的jar包,存在于Android SDK的sdk/build-tools/22.0.1/lib/dx.jar目录中,经过相似 :
java dx.jar com.android.dx.command.Main --dex --num-threads=4 —-output output.jar input.jar复制代码
的命令,进行将class文件打包为dex文件的步骤。
从上面的演示命令能够看出,dex任务是启动一个java进程,执行dx.jar中com.android.dx.command.Main类(固然对于multidex的项目入口可能不是这个类,这个再说)的main()方法进行dex任务,具体完成class到dex转化的是这个方法:
private static boolean processClass(String name,byte[] bytes) {
//内容省略
}复制代码
方法processClass的第二个参数是一个byte[],这就是class文件的二进制数据(class文件是一种紧凑的8位字节的二进制流文件, 各个数据项按顺序紧密的从前向后排列, 相邻的项[包括字节码指令]之间没有间隙),咱们就是经过对这个二进制数据进行扫描,按照必定规则过滤以及字节码修改达到第二部分所描述的AOP情景。
那么咱们怎么得到插桩入口呢?
对于Android Gradle Plugin 版本在1.5.0及以上的状况,Google官方提供了transformapi用做字节码插桩的入口。此处的Android Gradle Plugin 版本指的是build.gradle dependencies的以下配置:
compile 'com.android.tools.build:gradle:1.5.0'复制代码
此处1.5.0即为Android Build Gradle Plugin 版本。
关于transform api如何使用就不详细介绍了,
可自行查看API,
参考热修复项目Nuwa的gradle插桩插件(使用transfrom api实现)
那么对于Android Build Gradle Plugin 版本在1.5.0如下的状况呢?
下面咱们介绍一种不依赖transform api而得到插桩入口的方法,暂且称为 hook dx.jar吧。
提示:具体使用能够考虑综合这两种方式,首先检查build环境是否支持transform api(反射检查类com.android.build.gradle.BaseExtension是否有registerTransform这个方法便可)而后决定使用哪一种方式的插桩入口。
hook dx.jar 便是在图4-1中的dex步骤进行hook,具体来说就是hook 4.1节介绍的dx.jar中com.android.dx.command.Main.processClass方法,将这个方法的字节码更改成:
private static boolean processClass(String name,byte[] bytes) {
bytes=扫描并修改(bytes);// Hook点
//原有逻辑省略
}复制代码
注:这种方式得到插桩入口也可参见博客《APM之原理篇》
如何在一个标准的java进程(记得么?dex任务是启动一个java进程,执行dx.jar中com.android.dx.command.Main类的main()方法进行dex任务)中对特定方法进行字节码插桩?
这就须要运用Java1.5引入的Instrumentation机制。
java Instrumentation指的是能够用独立于应用程序以外的代理(agent)程序来监测和协助运行在JVM上的应用程序。这种监测和协助包括但不限于获取JVM运行时状态,替换和修改类定义等。
Instrumentation 的最大做用就是类定义的动态改变和操做。
方式一(java 1.5+):
开发者能够在一个普通 Java 程序(带有 main 函数的 Java 类)运行时,经过 – javaagent 参数指定一个特定的 jar 文件(agent.jar)(包含 Instrumentation 代理)来启动 Instrumentation 的代理程序。例如:
java -javaagent agent.jar dex.jar com.android.dx.command.Main --dex …........复制代码
如此,则在目标main函数执行以前,执行agent jar包指定类的 premain方法 :
premain(String args, Instrumentation inst)复制代码
方式二(java 1.6+):
VirtualMachine.loadAgent(agent.jar)
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(jarFilePath, args);复制代码
此时,将执行agent jar包指定类的 agentmain方法:
agentmain(String args, Instrumentation inst)复制代码
关于上述代码中出现的agent.jar?
这里的agent就是一个包含一些指定信息的jar包,就像OSGI的插件jar包同样,在jar包的META-INF/MANIFEST.MF中添加以下信息:
Manifest-Version: 1.0
Agent-Class: XXXXX
Premain-Class: XXXXX
Can-Redefine-Classes: true
Can-Retransform-Classes: true复制代码
这个jar包就成了agent jar包,其中Agent-Class指向具备agentmain(String args, Instrumentation inst)方法的类,Premain-Class指向具备premain(String args, Instrumentation inst)的类。
关于premain(String args, Instrumentation inst)?
第二个参数,Instumentation 类有个方法
addTransformer(ClassFileTransformer transformer,boolean canRetransform)复制代码
而一旦为Instrumentation inst添加了ClassFileTransformer:
ClassFileTransformer c=new ClassFileTransformer()
inst.addTransformer(c,true);复制代码
那么之后这个jvm进程中再有任何类的加载定义,都会出发此ClassFileTransformer的transform方法
byte[] transform( ClassLoader loader,String className,Class classBeingRedefined,ProtectionDomain protectionDomain,byte[] classfileBuffer)throwsIllegalClassFormatException;复制代码
其中,参数byte[] classfileBuffer是类的class文件数据,对它进行修改就能够达到在一个标准的java进程中对特定方法进行字节码插桩的目的。
完整流程以下图所示:
注:apply plugin: 'bytecodeplugin'中的bytecodeplugin是咱们用于字节码插桩的gradle插件
A. 经过任意方式(as界面内点击/命令gradle build等)都会启动图4-2所描述的build流程。
B. 经过Java Instrumentation机制,为得到插桩入口,对于apk build过程进行了两处插桩(即hook),图4-2中标红部分:
在build进程,对ProcessBuilder.start()方法进行插桩
ProcessBuilder类是J2SE 1.5在java.lang中新添加的一个新类,此类用于建立操做系统进程,它提供一种启动和管理进程的方法,start方法就是开始建立一个进程,对它进行插桩,使得经过下面方式启动dx.jar进程执行dex任务时:
java dex.jar com.android.dx.command.Main --dex …........复制代码
增长参数-javaagent agent.jar,使得dex进程也可使用Java Instrumentation机制进行字节码插桩
在dex进程
对咱们的目标方法com.android.dx.command.Main.processClasses进行字节码插入,从而实现打入apk的每个项目中的类都按照咱们制定的规则进行过滤及字节码修改。
C. 图4-2左侧build进程使用Instrumentation的方式时以前叙述过的VirtualMachine.loadAgent方式(方式二),dex进程中的方式则是-javaagent agent.jar方式(方式一)。
由此,咱们得到了进行字节码插桩的入口,下面咱们就使用ASM库的API,对项目中的每个类进行扫描,过滤,及字节码修改。
在这一部分咱们以第二部分描述的情景二的应用场景为例,对View.OnClickListener的onClick方法进行字节码修改。在实践bytecode manipulation时须要一些关于字节码以及ASM的基础知识须要了解。所以本部分组织结构以下:
ASM是一个java字节码操纵框架,它能被用来动态生成类或者加强既有类的功能。ASM 能够直接产生二进制 class 文件,也能够在类被加载入 Java 虚拟机以前动态改变类行为。相似功能的工具库还有javassist,BCEL等。
那么为何选择ASM呢?
ASM与同类工具库(这里以javassist为例)相比:
A. 较难使用,API很是底层,贴近字节码层面,须要字节码知识及虚拟机相关知识
B. ASM更快更高效,Javassist实现机制中包括了反射,因此更慢。下表是使用不一样工具库生成同一个类的耗时比较
Framework | First time | Later times |
---|---|---|
Javassist | 257 | 5.2 |
BCEL | 473 | 5.5 |
ASM | 62.4 | 1.1 |
C. ASM库更增强大灵活,好比能够感知细到字节码指令层次(第二部分情景三中的场景)
总结起来,ASM虽然不太容易使用,可是功能强大效率高值得挑战。
关于ASM库的使用能够参考手册,下面对其API进行简要介绍:
ASM(core api) 按照visitor模式按照class文件结构依次访问class文件的每一部分,有以下几个重要的visitor。
按照class文件格式,按次序访问类文件每一部分,以下:
public abstract class ClassVisitor {
public ClassVisitor(int api);
public ClassVisitor(int api, ClassVisitor cv);
public void visit(int version, int access, String name,
String signature, String superName, String[] interfaces); public void visitSource(String source, String debug);
public void visitOuterClass(String owner, String name, String desc); AnnotationVisitor visitAnnotation(String desc, boolean visible); public void visitAttribute(Attribute attr);
public void visitInnerClass(String name, String outerName,
String innerName, int access);
public FieldVisitor visitField(int access, String name, String desc,
String signature, Object value);
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions); void visitEnd();
}复制代码
与之对应的class文件格式为:
重点看ClassVisitor的以下几个方法:
其余方法可参考前面推荐的ASM手册,下面介绍一下负责访问方法的MethodVisitor。
按如下次序访问一个方法:
visitAnnotationDefault?
( visitAnnotation | visitParameterAnnotation | visitAttribute )*
( visitCode
( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn | visitLocalVariable | visitLineNumber )*
visitMaxs )?
visitEnd复制代码
注:上述出现的“*”表示出现“0+”次,“?”表示出现“0/1”次。 含义可类比正则式元字符。
下面说明几个比较关键的visit方法:
简单介绍了asm库后,因为使用ASM还须要对字节码有必定的了解,故在实践以前再介绍一些关于字节码的基础知识:
关于字节码,有如下概念定义比较重要:
类android.widget.AdapterView.OnItemClickListener的全限定名为:
android/widget/AdapterView$OnItemClickListener复制代码
如图5-2所示,在class文件中类型 boolean用“Z”描述,数组用“[”描述(多维数组可叠加),那么咱们最多见的自定义引用类型呢?“L全限定名;”.例如:
Android中的android.view.View类,描述符为“Landroid/view/View;”
2.方法描述符的组织结构为:
(参数类型描述符)返回值描述符复制代码
其中无返回值void用“V”代替,举例:
方法boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) 的描述符以下:
(Landroid/widget/ExpandableListView;Landroid/view/View;IJ)Z复制代码
jvm执行引擎用于执行字节码,以下图
如图5-3所示,纵向来看有三个线程,其中每个线程内部都有一个栈结构(即一般所说的“堆栈”中的虚拟机栈),栈中的每个元素(一帧)称为一个栈帧(stack frame)。栈帧与咱们写的方法一一对应,每一个方法的调用/return对应线程中的一个栈帧的入栈/出栈。
方法体中各类字节码指令的执行都在栈帧中完成,下面介绍下栈帧中两个比较重要的部分:
boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id)复制代码
刚进入此方法时,局部变量表的槽位状态以下:Slot Number | value |
---|---|
0 | this |
1 | ExpandableListView parent |
2 | View v |
3 | int groupPosition |
4 | long id |
例如,方法体中有语句以下:
1+1复制代码
##
对上图中三个步骤的详细说明:
ASM的ClassVisitor对全部类的class文件进行扫描,在visit方法中获得当前类实现了哪些接口,判断这些接口中是否包含全限定名为“android/view/View$OnClickListener”的接口。若是有,证实当前类是View.OnClickListener,进行步骤二,不然终止扫描;
ClassVisitor每扫描到一个方法时,在visitMethod中进行以下断定:
若是所有断定经过,则证实本次扫描到的方法是View.OnClickListener的onClick方法,而后将
将扫描逻辑交给MethodVisitor,进行字节码的修改(步骤三)。
假设待修改的onClick方法以下:
public void onClick(View v) {
System.out.println("test");//表明方法中原有的代码(逻辑)
}复制代码
修改以后须要变成:
public void onClick(View v) {
if(!Monitor.onViewClick(v)) {
System.out.println("test");//表明方法中原有的代码(逻辑)
}
}复制代码
即:
进入方法以后先执行Monitor.onViewClick(v)(里面是数据收集逻辑),而后根据返回值决定是执行原有onClick方法内的逻辑,仍是说直接返回。下面是修改以后onClick方法的字节码:
public onClick(Landroid/view/View;)V
ALOAD 1//插入的字节码,将index为1的局部变量(入参v)压入操做数栈
INVOKESTATIC com/netease/lede/bytecode/monitor/Monitor.onViewClick (Landroid/view/View;)Z//插入的字节码,调用方法Monitor.onViewClick(v),将返回值(true/false)压入操做数栈
IFEQ L0//插入的字节码,若是操做数栈栈顶为0(if条件为false),则跳转到lable L0,执行原有逻辑
RETURN//插入的字节码,上条指令判断不知足(即操做数栈栈顶为1(true)),直接返回
L0
LINENUMBER 11 L0
FRAME SAME
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
LDC "test"
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L1
LINENUMBER 12 L1
RETURN
L2
LOCALVARIABLE this Lcom/netease/caipiao/datacollection/bytecode/ViewOnclickListener; L0 L2 0
LOCALVARIABLE v Landroid/view/View; L0 L2 1
MAXSTACK = 2//操做数栈最大为2
MAXLOCALS = 2//局部变量表最大为2复制代码
如上图所示,插入的字节码主要是前面四行(图中已经用注释的形式作了标记),图中的字节码指令能够参照下表:
字节码指令 | 说明 | 指令入参 |
---|---|---|
ALOAD | 将引用类型的对象从局部变量表load到操做数栈 | 局部变量表index |
INVOKESTATIC | 调用类方法(即静态方法) | 1.类全限定名 2.方法描述符 |
INVOKEVIRTUAL | 调用对象方法 | 1.类全限定名 2.方法描述符 |
IFEQ | 检查操做数栈栈定位置是否为0 | 跳转Lable(栈顶为0时跳转) |
RETURN | 无返回值返回(操做数栈无弹栈操做) | |
IRETURN | 返回int值(操做数栈将栈顶int值弹栈) | |
GETSTATIC | 获取类字段(静态成员变量) | 1.类全限定名,2.字段类型描述符 |
LDC | 从常量池取int,float,String等常量到操做数栈顶 | 常量值 |
MAXSTACK | 操做数栈最大容量(javac编译时肯定) | |
MAXLOCALS | 局部变量表最大容量(javac编译时肯定) |
具体插入的代码是字节码代码的前四行,逻辑比较简单:
注:值得注意的是MAXSTACK,MAXLOCALS 两个值在javac生成的class文件就已经固定,即,栈内存大小已经肯定(有别于堆内存能够在运行时动态申请/释放)。
如此,通过上述三个步骤,咱们完成了第二部分情景二描述的AOP实践。
文章写的比较长,下面对主要的几点进行总结:
首先介绍了AOP的概念,已及在Android平台的主流框架,面对无埋点数据收集的需求,这些现有的都不太合适所以须要本身动手实现,
而后,简单列举了无埋点数据收集SDK中须要AOP的应用情景
最后介绍了实现的技术细节,主要有两点: