俗话说“任何技术都是脱离了业务都将是空中楼阁”。最开始有研究字节码插桩技术冲动的是咱们接入了一款统计类的SDK(这里我就不具体说是哪款了)。他们的套路是第三方开发者须要接入他们的插件(Gradle Plugin),而后即可以实现无埋点进行客户端的全量数据统计(全量的意思是包括页面打开速度、方法耗时、各类点击事件等)。当时因为需求排期比较急,一直没有时间研究他们的实现方式。春节假期,我实在难以控制体内的求知欲,经过查资料以及反编译他们的代码终于找到了技术的本源——字节码插桩。正好公司这段时间要继续搞一套统计系统,为了避免侵入原有的项目架构,我也打算使用字节码插桩技术来实现。so写这篇文章的目的是将预研期的坑share一下,避免更多小伙伴入坑~html
简单来说,咱们要实现无埋点对客户端的全量统计。这里的统计归纳的范围比较普遍,常见的场景有:java
AOP(Aspect Oriented Program的首字母缩写)是一种面向切面编程的思想。这种编程思想是相对于OOP(ObjectOriented Programming即面向对象编程)来讲的。说破大天,我们要实现的功能仍是统计嘛,大规模的重复统计行为是典型的AOP使用场景。因此搞懂什么是AOP以及为何要用AOP变得很重要android
先来讲一下你们熟悉的面向对象编程:面向对象的特色是继承、多态和封装。而封装就要求将功能分散到不一样的对象中去,这在软件设计中每每称为职责分配。实际上也就是说,让不一样的类设计不一样的方法。这样代码就分散到一个个的类中去了。这样作的好处是下降了代码的复杂程度,使类可重用。git
But面向对象的编程天生有个缺点就是分散代码的同时,也增长了代码的重复性。好比我但愿在项目里面全部的模块都增长日志统计模块,按照OOP的思想,咱们须要在各个模块里面都添加统计代码,可是若是按照AOP的思想,能够将统计的地方抽象成切面,只须要在切面里面添加统计代码就OK了。github
既然想用字节码插桩来实现无埋点,对Android的打包流程老是要了解一下的。否则我们怎么系统何时会把Class文件生成出来供咱们插桩呢?官网的打包流程不是那么的直观。因此一块儿来看一下更直观的构建流程吧。web
接着2.2小节的问题,咱们怎么知道打包系统已经完成“Java Compiler”步骤?即便知道打包系统生成了class字节码文件又怎么Hook掉该流程在完成自定义字节码编织后再进行“dex”过程呢?原来,对于Android Gradle Plugin 版本在1.5.0及以上的状况,Google官方提供了transformapi用做字节码插桩的入口。说的直白一点经过自定义Gradle插件,重写里面transform方法就能够在“Java Compiler”过程结束以后 “dex”过程开始以前得到回调。这正是字节码从新编织的绝佳时机。编程
由于本文重点讲字节码插桩的技术流程,强调从面上覆盖这套技术所涉及到的技术点,因此关于自定义插件的内容不展开讲解了。按照上面推荐的资源本身基本能够跑通自定义Gradle插件的流程。若是你们自定义插件的详细内容请联系我,若是有必要我能够出一篇自定义Gradle插件的教程。文末会给出邮箱。api
字节码的相关知识是本文的核心技术点数组
Java 字节码(英语:Java bytecode)是Java虚拟机执行的一种指令格式。通俗来说字节码就是通过javac命令编译以后生成的Class文件。Class文件包含了Java虚拟机指令集和符号表以及若干其余的辅助信息。Class文件是一组以8位字节为基础单位的二进制流,哥哥数据项目严格按照顺序紧凑的排列在Class文件之中,中间没有任何分隔符,这使得整个Class文件中存储的内容几乎全是程序运行时的必要数据。android-studio
由于Java虚拟机的提供商有不少,其具体的虚拟机实现逻辑都不相同,可是这些虚拟机的提供商都严格遵照《Java虚拟机规范》的限制。因此一份正确的字节码文件是能够被不一样的虚拟机提供商正确的执行的。借用《深刻理解Java虚拟机》一书的话就是“代码编译的结果从本地机器码转变成字节码,是存储格式发展的一小步,确实编程语言发展的一大步”。
这张图是一张java字节码的总览图。一共含有10部分,包含魔数,版本号,常量池,字段表集合等等。一样本篇文章不展开介绍具体内容请参考这篇博文,有条件的同窗请阅读《深刻理解Java虚拟机》一书。我如今读了两遍,每次读都有新的感悟。推荐你们也读一下,对本身的成长很是有好处。
关于字节码几个重要的内容:
Class文件中使用全限定名来表示一个类的引用,全限定名很容易理解,即把类名全部“.”换成了“/”
例如
android.widget.TextView
复制代码
的全限定名为
android/widget/TextView
复制代码
描述符的做用是描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符的规则,基本数据类型(byte char double float int long short boolean)以及表明无返回值的void类型都用一个大写字符来表示,对象类型则用字符“L”加对象的全限定名来表示,通常对象类型末尾都会加一个“;”来表示全限定名的结束。以下表
标志字符 | 含义 |
---|---|
B | 基本类型byte |
C | 基本类型char |
D | 基本类型double |
F | 基本类型float |
I | 基本类型int |
J | 基本类型long |
S | 基本类型short |
Z | 基本类型boolean |
V | 特殊类型void |
L | 对象类型,例如Ljava/lang/Object |
对于数组类型,每个维度将使用“[”字符来表示 例如咱们须要定义一个String类型的二维数组
java.lang.String[][]
将会被表示成
[[java/lang/String;
int[]
将会被表示成
[I;
复制代码
用描述符来描述方法时,按照先参数列表后返回值的顺序进行描述。参数列表按照参数的顺序放到一组小括号“()”以内。举几个栗子:
void init()
会被描述成
()V
void setText(String s)
会被描述成
(Ljava/lang/String)V;
java.lang.String toString()
会被描述成
()Ljava/lang/String;
复制代码
执行引擎是虚拟机最核心的组成部分之一。本篇仍然控制版面,避免长篇大论的讨论具体内容而忽略须要解决的问题的本质。下面咱们重点讨论一下Java的运行时内存布局:
虚拟机的内存能够分为堆内存与栈内存。堆内存是全部线程共享的,栈内存则是线程私有的。下图为虚拟机运行时数据区
public void onClick(View v) {
}
复制代码
这个方法的局部变量表的容量槽为:
Slot Number | value |
---|---|
0 | this |
1 | View v |
恶补完前面的知识点,终于到了最后的一步。怎样对字节码进行编织呢?这里我选了一个强大的开源库ASM。
ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者加强既有类的功能。ASM 能够直接产生二进制 class 文件,也能够在类被加载入 Java 虚拟机以前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的全部元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,可以改变类行为,分析类信息,甚至可以根据用户要求生成新类。
由于有了前人作的实验,我没有对字节码编织的库进行效率测试。参考网易乐得团队的实验结果:
Framework | First time | Later times |
---|---|---|
Javassist | 257 | 5.2 |
BCEL | 473 | 5.5 |
ASM | 62.4 | 1.1 |
经过上表可见,ASM的效率更高。不过效率高的前提是该库的语法更接近字节码层面。因此上面的虚拟机相关知识显得更加剧要。
这个库也没什么可展开描述的,值得参考的资源:
为了快速上手ASM,安利一个插件[ASM Bytecode Outline]。这里须要感谢巴掌的文章。ASM的内容就介绍到这里,具体怎么使用你们参考项目代码或者本身研究一波文档就行了。
咱们以Activity的开启为切面,对客户端内全部Activity的onCreate onDestroy进行插桩。建议先clone一份demo项目。
按照2.3小节的内容,聪明的你必定能很快新建一个Gradle插件并能跑通流程吧。若是你的流程没跑通能够参考项目源码。
须要注意的点:
项目中须要将Compile的地址换成你的本机地址,不然编译会失败。须要改动的文件有traceplugin/gradle.properties中的LOCAL_REPO_URL属性。
例如demo项目中的TracePlugin.groovy就是扫描的入口,经过重写transform方法,咱们能够得到插桩入口,将对Class文件的处理转化成ASM处理。
public class TracePlugin extends Transform implements Plugin<Project> {
void apply(Project project) {
def android = project.extensions.getByType(AppExtension);
//对插件进行注册,添加插桩入口
android.registerTransform(this)
}
@Override
public String getName() {
return "TracePlugin";
}
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
@Override
public Set<QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
public boolean isIncremental() {
return false;
}
@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs,
TransformOutputProvider outputProvider, boolean isIncremental)
throws IOException, TransformException, InterruptedException {
println '//===============TracePlugin visit start===============//'
//删除以前的输出
if (outputProvider != null)
outputProvider.deleteAll()
//遍历inputs里的TransformInput
inputs.each { TransformInput input ->
//遍历input里边的DirectoryInput
input.directoryInputs.each {
DirectoryInput directoryInput ->
//是不是目录
if (directoryInput.file.isDirectory()) {
//遍历目录
directoryInput.file.eachFileRecurse {
File file ->
def filename = file.name;
def name = file.name
//这里进行咱们的处理 TODO
if (name.endsWith(".class") && !name.startsWith("R\$") &&
!"R.class".equals(name) && !"BuildConfig.class".equals(name)) {
ClassReader classReader = new ClassReader(file.bytes)
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
def className = name.split(".class")[0]
ClassVisitor cv = new TraceVisitor(className, classWriter)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
FileOutputStream fos = new FileOutputStream(
file.parentFile.absolutePath + File.separator + name)
fos.write(code)
fos.close()
}
}
}
//处理完输入文件以后,要把输出给下一个任务
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY)
FileUtils.copyDirectory(directoryInput.file, dest)
}
input.jarInputs.each { JarInput jarInput ->
/**
* 重名名输出文件,由于可能同名,会覆盖
*/
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
File tmpFile = null;
if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
JarFile jarFile = new JarFile(jarInput.file);
Enumeration enumeration = jarFile.entries();
tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_trace.jar");
//避免上次的缓存被重复插入
if (tmpFile.exists()) {
tmpFile.delete();
}
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile));
//用于保存
ArrayList<String> processorList = new ArrayList<>();
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement();
String entryName = jarEntry.getName();
ZipEntry zipEntry = new ZipEntry(entryName);
//println "MeetyouCost entryName :" + entryName
InputStream inputStream = jarFile.getInputStream(jarEntry);
//若是是inject文件就跳过
//重点:插桩class
if (entryName.endsWith(".class") && !entryName.contains("R\$") &&
!entryName.contains("R.class") && !entryName.contains("BuildConfig.class")) {
//class文件处理
jarOutputStream.putNextEntry(zipEntry);
ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
def className = entryName.split(".class")[0]
ClassVisitor cv = new TraceVisitor(className, classWriter)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
jarOutputStream.write(code)
} else if (entryName.contains("META-INF/services/javax.annotation.processing.Processor")) {
if (!processorList.contains(entryName)) {
processorList.add(entryName)
jarOutputStream.putNextEntry(zipEntry);
jarOutputStream.write(IOUtils.toByteArray(inputStream));
} else {
println "duplicate entry:" + entryName
}
} else {
jarOutputStream.putNextEntry(zipEntry);
jarOutputStream.write(IOUtils.toByteArray(inputStream));
}
jarOutputStream.closeEntry();
}
//写入inject注解
//结束
jarOutputStream.close();
jarFile.close();
}
//处理jar进行字节码注入处理 TODO
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
if (tmpFile == null) {
FileUtils.copyFile(jarInput.file, dest)
} else {
FileUtils.copyFile(tmpFile, dest)
tmpFile.delete()
}
}
}
println '//===============TracePlugin visit end===============//'
}
复制代码
上述TracePlugin.groovy文件完成了字节码与ASM的结合,那具体怎么修改字节码呢?新建继承自ClassVisitor的Visitor类
/**
* 对继承自AppCompatActivity的Activity进行插桩
*/
public class TraceVisitor extends ClassVisitor {
/**
* 类名
*/
private String className;
/**
* 父类名
*/
private String superName;
/**
* 该类实现的接口
*/
private String[] interfaces;
public TraceVisitor(String className, ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}
/**
* ASM进入到类的方法时进行回调
*
* @param access
* @param name 方法名
* @param desc
* @param signature
* @param exceptions
* @return
*/
@Override
public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature,
String[] exceptions) {
MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
methodVisitor = new AdviceAdapter(Opcodes.ASM5, methodVisitor, access, name, desc) {
private boolean isInject() {
//若是父类名是AppCompatActivity则拦截这个方法,实际应用中能够换成本身的父类例如BaseActivity
if (superName.contains("AppCompatActivity")) {
return true;
}
return false;
}
@Override
public void visitCode() {
super.visitCode();
}
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
return super.visitAnnotation(desc, visible);
}
@Override
public void visitFieldInsn(int opcode, String owner, String name, String desc) {
super.visitFieldInsn(opcode, owner, name, desc);
}
/**
* 方法开始以前回调
*/
@Override
protected void onMethodEnter() {
if (isInject()) {
if ("onCreate".equals(name)) {
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESTATIC,
"will/github/com/androidaop/traceutils/TraceUtil",
"onActivityCreate", "(Landroid/app/Activity;)V",
false);
} else if ("onDestroy".equals(name)) {
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESTATIC, "will/github/com/androidaop/traceutils/TraceUtil"
, "onActivityDestroy", "(Landroid/app/Activity;)V", false);
}
}
}
/**
* 方法结束时回调
* @param i
*/
@Override
protected void onMethodExit(int i) {
super.onMethodExit(i);
}
};
return methodVisitor;
}
/**
* 当ASM进入类时回调
*
* @param version
* @param access
* @param name 类名
* @param signature
* @param superName 父类名
* @param interfaces 实现的接口名
*/
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
this.className = name;
this.superName = superName;
this.interfaces = interfaces;
}
}
复制代码
注意:
若是你对ASM用的并非那么熟练,别忘了ASM Bytecode Outline插件。上面TraceVisitor.java中的onMethodEnter方法内部代码即是从ASM Bytecode Outline生成直接拷贝过来的。至于这个插件怎么使用2.4.4小节已经介绍过了。
demo项目中app/TraceUtil.java类是用来统计的代码,项目中我只是在onCreate与onDestroy时弹出了一个Toast,你彻底能够把这两个函数执行的时间记录下来,实现统计用户在线时长等逻辑。TraceUtils.java代码以下:
/**
* Created by will on 2018/3/9.
*/
public class TraceUtil {
private final String TAG = "TraceUtil";
/**
* 当Activity执行了onCreate时触发
*
* @param activity
*/
public static void onActivityCreate(Activity activity) {
Toast.makeText(activity
, activity.getClass().getName() + "call onCreate"
, Toast.LENGTH_LONG).show();
}
/**
* 当Activity执行了onDestroy时触发
*
* @param activity
*/
public static void onActivityDestroy(Activity activity) {
Toast.makeText(activity
, activity.getClass().getName() + "call onDestroy"
, Toast.LENGTH_LONG).show();
}
}
复制代码
看到这里有人会有疑问,这个TraceUtil的onActivityCreate与onActivityDestroy是何时被执行的?固然是经过TraceVisitor的visitMethod方法插桩插进去的呀。
看下项目的效果,统计代码已经被成功注入。
因为这篇博文所涉及到的知识点比较多,不少地方我可能没有展开写的比较糙。若是写的有什么问题但愿你们及时提出来,一块儿学习,一块儿进步。
参考资源
contact way | value |
---|---|
weixinjie1993@gmail.com | |
W2006292 | |
github | https://github.com/weixinjie |
blog | https://juejin.im/user/57673c83207703006bb92bf6 |