Android字节码插桩采坑笔记

1.写在前面

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

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

简单来讲,我们要实现无埋点对客户端的全量统计。这里的统计概括的范围比较广泛,常见的场景有:

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

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

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

2.开始恶补技术点

2.1 技术点——什么是AOP

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

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

But面向对象的编程天生有个缺点就是分散代码的同时,也增加了代码的重复性。比如我希望在项目里面所有的模块都增加日志统计模块,按照OOP的思想,我们需要在各个模块里面都添加统计代码,但是如果按照AOP的思想,可以将统计的地方抽象成切面,只需要在切面里面添加统计代码就OK了。

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

2.2 技术点——Android打包流程

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

一图顶千言,经过“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插件的教程。文末会给出邮箱。

关于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文件中存储的内容几乎全是程序运行时的必要数据。

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

2.4.4 字节码编织之ASM简介

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

什么是ASM?

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

为什么选择ASM来进行字节码编织?

因为有了前人做的实验,我没有对字节码编织的库进行效率测试。参考网易乐得团队的实验结果:

FrameworkFirst timeLater times
Javassist2575.2
BCEL4735.5
ASM62.41.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 wayvalue
mailweixinjie1993@gmail.com
wechatW2006292
githubhttps://github.com/weixinjie
bloghttps://juejin.im/user/57673c83207703006bb92bf6
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值