Android字节码插桩采坑笔记

1.写在前面

俗话说“任何技术都是脱离了业务都将是空中楼阁”。最开始有研究字节码插桩技术冲动的是咱们接入了一款统计类的SDK(这里我就不具体说是哪款了)。他们的套路是第三方开发者须要接入他们的插件(Gradle Plugin),而后即可以实现无埋点进行客户端的全量数据统计(全量的意思是包括页面打开速度、方法耗时、各类点击事件等)。当时因为需求排期比较急,一直没有时间研究他们的实现方式。春节假期,我实在难以控制体内的求知欲,经过查资料以及反编译他们的代码终于找到了技术的本源——字节码插桩。正好公司这段时间要继续搞一套统计系统,为了避免侵入原有的项目架构,我也打算使用字节码插桩技术来实现。so写这篇文章的目的是将预研期的坑share一下,避免更多小伙伴入坑~html

先简要描述一下接下来咱们要干什么

简单来说,咱们要实现无埋点对客户端的全量统计。这里的统计归纳的范围比较普遍,常见的场景有:java

  • 页面(Activity、Fragment)的打开事件
  • 各类点击事件的统计,包括但不限于Click LongClick TouchEvent
  • Debug期须要统计各个方法的耗时。注意这里的方法包括接入的第三方SDK的方法。
  • 待补充

要实现这些功能须要拥有哪些技术点呢?

  • 面向切面编程思想(AOP)
  • Android打包流程
  • 自定义Gradle插件
  • 字节码编织
  • 结合本身的业务实现统计代码
  • 没了。。。

2.开始恶补技术点

2.1 技术点——什么是AOP

AOP(Aspect Oriented Program的首字母缩写)是一种面向切面编程的思想。这种编程思想是相对于OOP(ObjectOriented Programming即面向对象编程)来讲的。说破大天,我们要实现的功能仍是统计嘛,大规模的重复统计行为是典型的AOP使用场景。因此搞懂什么是AOP以及为何要用AOP变得很重要android

先来讲一下你们熟悉的面向对象编程:面向对象的特色是继承、多态和封装。而封装就要求将功能分散到不一样的对象中去,这在软件设计中每每称为职责分配。实际上也就是说,让不一样的类设计不一样的方法。这样代码就分散到一个个的类中去了。这样作的好处是下降了代码的复杂程度,使类可重用。git

But面向对象的编程天生有个缺点就是分散代码的同时,也增长了代码的重复性。好比我但愿在项目里面全部的模块都增长日志统计模块,按照OOP的思想,咱们须要在各个模块里面都添加统计代码,可是若是按照AOP的思想,能够将统计的地方抽象成切面,只须要在切面里面添加统计代码就OK了。github

切面图
其实在服务端的领域AOP已经被各路大佬玩的风生水起,例如Spring这类跨时代的框架。我第一次接触AOP就是在自学Spring框架的的时候。最多见实现AOP的方式就是代理。

2.2 技术点——Android打包流程

既然想用字节码插桩来实现无埋点,对Android的打包流程老是要了解一下的。否则我们怎么系统何时会把Class文件生成出来供咱们插桩呢?官网的打包流程不是那么的直观。因此一块儿来看一下更直观的构建流程吧。web

Android打包流程
一图顶千言,通过“Java Compiler步骤”,系统便生成了.class文件。这些class文件通过dex步骤再次转化成Android识别的.dex文件。既然咱们要作字节码插桩,就必须hook打包流程,在dex步骤以前对class字节码进行扫描与从新编织,而后将编织好的class文件交给dex过程。这样就实现了所谓的无埋点。那么问题来了,咱们怎么知道系统已经完成了“Java Compiler”步骤呢?这就引出下一个技术点——自定义Gradle插件。

2.3 技术点——自定义Gradle插件

接着2.2小节的问题,咱们怎么知道打包系统已经完成“Java Compiler”步骤?即便知道打包系统生成了class字节码文件又怎么Hook掉该流程在完成自定义字节码编织后再进行“dex”过程呢?原来,对于Android Gradle Plugin 版本在1.5.0及以上的状况,Google官方提供了transformapi用做字节码插桩的入口。说的直白一点经过自定义Gradle插件,重写里面transform方法就能够在“Java Compiler”过程结束以后 “dex”过程开始以前得到回调。这正是字节码从新编织的绝佳时机。编程

关于怎样定义Gradle插件值得参考的资源

由于本文重点讲字节码插桩的技术流程,强调从面上覆盖这套技术所涉及到的技术点,因此关于自定义插件的内容不展开讲解了。按照上面推荐的资源本身基本能够跑通自定义Gradle插件的流程。若是你们自定义插件的详细内容请联系我,若是有必要我能够出一篇自定义Gradle插件的教程。文末会给出邮箱。api

关于transform值得参考的资源:

  • 官方文档
  • 滴滴插件化项目VirtualApk,该项目中的virtualapk-gradle-plugin就是利用这个插桩入口将插件的资源与宿主的资源进行剥离,防止宿主apk与插件apk资源冲突。详见该项目里面StripClassAndResTransform类。

2.4 技术点——字节码编织

字节码的相关知识是本文的核心技术点数组

2.4.1 什么是字节码

Java 字节码(英语:Java bytecode)是Java虚拟机执行的一种指令格式。通俗来说字节码就是通过javac命令编译以后生成的Class文件。Class文件包含了Java虚拟机指令集和符号表以及若干其余的辅助信息。Class文件是一组以8位字节为基础单位的二进制流,哥哥数据项目严格按照顺序紧凑的排列在Class文件之中,中间没有任何分隔符,这使得整个Class文件中存储的内容几乎全是程序运行时的必要数据。android-studio

由于Java虚拟机的提供商有不少,其具体的虚拟机实现逻辑都不相同,可是这些虚拟机的提供商都严格遵照《Java虚拟机规范》的限制。因此一份正确的字节码文件是能够被不一样的虚拟机提供商正确的执行的。借用《深刻理解Java虚拟机》一书的话就是“代码编译的结果从本地机器码转变成字节码,是存储格式发展的一小步,确实编程语言发展的一大步”。

2.4.2 字节码的内容

字节码内容

这张图是一张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;
复制代码

2.4.3 虚拟机字节码执行引擎知识

执行引擎是虚拟机最核心的组成部分之一。本篇仍然控制版面,避免长篇大论的讨论具体内容而忽略须要解决的问题的本质。下面咱们重点讨论一下Java的运行时内存布局:

虚拟机的内存能够分为堆内存与栈内存。堆内存是全部线程共享的,栈内存则是线程私有的。下图为虚拟机运行时数据区

运行时数据区
这里重点解释一下栈内存。Java虚拟机栈是线程私有的,它描述的是Java方法执行的内存模型:每一个方法在执行的同时会建立一个栈帧用于存局部变量表、操做数栈、动态连接、方法返回地址等信息。每个方法从调用到执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。每个栈帧都包含了局部变量表、操做数栈、动态连接、方法返回地址和一些额外的附加信息。在编译成class文件后,栈帧中须要多大的局部变量表和多深的操做数栈已经保存在字节码文件(class文件)的code属性中,所以一个栈帧须要分配多少内存,不会受到程序运行的影响,只会根据虚拟机的具体实现不一样。一个线程中的方法调用链可能会很长,即有不少栈帧。对于一个当前活动的线程中,只有位于线程栈顶的栈帧才是有效的,称为当前栈帧(current stack Frame),这个栈帧关联的方法称为当前方法(current method),栈帧的概念图以下:
解释一下上图相关概念:

  • 局部变量表:局部变量表是一组变量存储空间,用于存储方法参数(就是方法入参)和方法内部定义局部变量。局部变量表的容量以容量槽为最小单位(slot)。虚拟机经过索引的定位方式使用局部变量表,索引值的范围为0到局部变量的最大slot值,在static方法中,0表明的是“ this”,即当前调用该方法的引用(主调方),其他参数从1开始分配,当参数列表中的参数分配完后,就开始给方法内的局部变量分配。用Android的click方法举个栗子:
public void onClick(View v) {
                
            }
复制代码

这个方法的局部变量表的容量槽为:

Slot Number value
0 this
1 View v
  • 操做数栈:操做数栈又被称为操做栈,它是一个后入先出的栈结构。当一个方法刚开始执行时,操做数栈里是空的,在方法的执行过程当中,会有各类字节码指令向操做数栈中写入和提取内容,也就是出栈和入栈的过程。例如,在执行字节码指令iadd(两个int类型整数相加)时要求操做数栈中最接近栈顶的两个元素已经存入两个int类型的值,而后执行相加时,会将这两个int值相加,而后将相加的结果入栈。具体的字节码操做指令能够参考维基百科,也能够参考国内巴掌的文章

2.4.4 字节码编织之ASM简介

恶补完前面的知识点,终于到了最后的一步。怎样对字节码进行编织呢?这里我选了一个强大的开源库ASM。

什么是ASM?

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者加强既有类的功能。ASM 能够直接产生二进制 class 文件,也能够在类被加载入 Java 虚拟机以前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的全部元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,可以改变类行为,分析类信息,甚至可以根据用户要求生成新类。

为何选择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的内容就介绍到这里,具体怎么使用你们参考项目代码或者本身研究一波文档就行了。

3.项目实战

咱们以Activity的开启为切面,对客户端内全部Activity的onCreate onDestroy进行插桩。建议先clone一份demo项目

3.1 新建Gradle插件

按照2.3小节的内容,聪明的你必定能很快新建一个Gradle插件并能跑通流程吧。若是你的流程没跑通能够参考项目源码。

须要注意的点:

注意点1:

项目中须要将Compile的地址换成你的本机地址,不然编译会失败。须要改动的文件有traceplugin/gradle.properties中的LOCAL_REPO_URL属性。

以及跟项目下的build.gradle文件中的maven地址

3.2 完善自定义插件,添加扫描与修改逻辑

例如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类

  • 重写里面的visit方法以便筛选哪些类须要插桩,例如筛选全部继承自Activity的类才插桩。
  • 重写visitMethod方法以便筛选当前类哪些方法须要插桩。例如筛选全部onCreate方法才插桩。 具体注释见代码:
/**
 * 对继承自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小节已经介绍过了。

3.3 完善自定义统计工具,实现最终数据统计

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方法插桩插进去的呀。

3.4 本身运行一下Demo & Enjoy

项目代码

看下项目的效果,统计代码已经被成功注入。

项目效果

4. 其余的小Tips

  • 字节码插桩是面向整个应用的插桩,若是咱们只想插某一个函数的桩应该怎么办呢?例如我只想插MainActivity的onCreate函数,而不想插其余Activity的onCreate。这时候可使用自定义注解来解决。方案是自定义一个注解,在想统计的方法上打上这个注解,在ASM的ClassVisitor类中重写visitAnnotation方法来肯定要不要插桩。怎样自定义注解能够看个人这篇博文
  • 若是想插不一样的桩该怎么办呢?例如我既想统计Activity的生命周期函数又想统计View的Click事件。讲道理这块个人经验不够丰富,个人方案比较low,我是经过在ClassVisitor中判断当前类的名字、当前类的父类名字、当前类实现了哪些接口、以及当前类方法的名字来判断的,比较臃肿。小伙伴们有什么好的想法能够留言或联系我

写在最后

因为这篇博文所涉及到的知识点比较多,不少地方我可能没有展开写的比较糙。若是写的有什么问题但愿你们及时提出来,一块儿学习,一块儿进步。

参考资源


About Me

contact way value
mail weixinjie1993@gmail.com
wechat W2006292
github https://github.com/weixinjie
blog https://juejin.im/user/57673c83207703006bb92bf6