Android 插桩技术原理和使用

插桩

插桩是什么?你在开发中有用过插桩的技术吗?

所谓的插桩就是在代码编译期间修改已有的代码或者生成新代码。

插桩具体在编译的哪个流程介入呢?

插桩的作用与场景

  • 代码生成
  • 代码监控
  • 代码修改
  • 代码分析

Java 源文件方式

类似 AndroidAnnotation/APT(Annotation Processing Tool),可以在代码编译期解析注解,并且生成新的 Java 文件,减少手动的代码输入。 这些代码生成的场景,它们生成的都是 Java 文件,是在编译的最开始介入。典型的有 Greendao、ButterKnife

上图是我们项目内在用的 ORM 映射数据库 Greendao。可见 build 目录下有很多 *.java 后缀的文件,build一般都是放置编译生成后的产物,很显然这些文件就是在我们 build 时候通过注解处理器产生的 Java 文件。

字节码

对于代码监控、代码修改以及代码分析这三个场景,一般采用操作字节码的方式。可以操作“.class”的 Java 字节码,也可以操作“.dex”的 Dalvik 字节码,这取决于我们使用的插桩方法,相对于 Java 文件方式,字节码操作方式功能更加强大,应用场景也更广,但是它的使用复杂度更高。

Java 字节码

对于 Java 平台,Java 虚拟机运行的是 Class 文件,内部对应的是 Java 字节码。

Dalvik 字节码

Android 这种嵌入式平台,为了优化性能,Android 虚拟机运行的是 Dex 文件。dex 我们可以理解为 Android 为移动设备(受限于早年的手机配置远低于 PC) 研发的 class 的压缩格式。Android SDK 工具包里面有 dx 工具可以将 class 文件打包成 dex。又由 Android 虚拟机的 PathClassLoader 装载到内存中。

身边经历过的案例

火箭兔

之前的项目工程,有 Java + kotlin + Flutter 混编,还大量应用了一些注解框架例如:ButterKnife 、Dagger、Eventbus 导致编译的耗时非常感人。时常可能在代码调试的时候只是想增加一行 Log 日志,但是编译花费的时间可能在 3~5 分钟

使用火箭兔进行增量编译示例:

我们可以看到上图我们修改了一个 Java 文件和一个资源文件。增量编译重启 run 起 apk 的时间仅仅只花了 10s。当然这种增量编译的实现方式实现起来除了要求插桩的技术以外你还需要了解很多其他的诸如编译原理、脚本语言等综合方面的知识,实现成本不低。但是如果能在团队中推广使用用的人越多 ROI (Return on Investment)也就越高。

全局水印

类似于钉钉、飞书这类办公软件,都有防截图的需求。现在甲方就需求在 APP 的所有 activity 以及 dialog 上加上全局水印,例如带上员工的名称工号等,一个 APP 常规来讲页面加上弹窗可能有上百个。好,我们人多加班加点干,但是应用内的第三方的 Activity ,和 Dialog 你怎么办呢?

通过 AspectJ 的插桩技术我们把几天的工作量可能还容易出错漏的需求,短短几个小时就完成了。掌握了插桩的技术再能想到好的思路(Hook点),能极大的提高我们的工作效率。

插桩方案的对比

AspectJ

AspectJ 的作为一个老牌的插桩框架优点是 1 成熟稳定 2 使用简单。但是 AspectJ 的缺点是,由于其基于规则,所以其切入点相对固定,对于字节码文件的操作自由度以及开发的掌控度就大打折扣。还有就是如果我们要实现对所有方法进行插桩,代码注入后的性能也是我们需要关注的一个重要的点,我们希望只插入我们想插入的代码,而AspectJ会额外生成一些包装代码,对性能以及包大小有一定影响。AspectJX

Javassist

Javassist 源代码级 API 比 ASM 中实际的字节码操作更容易使用。Javassist 在复杂的字节码级操作上提供了更高级别的抽象层。Javassist 源代码级 API 只需要很少的字节码知识,甚至不需要任何实际字节码知识,因此实现起来更容易、更快。Javassist使用反射机制,这使得它比运行时使用 Classworking 技术的ASM慢。Javassist

ASM

相比 AspectJ,ASM 更加直接高效。但是对于一些复杂情况,我们可能需要使用另外一种 Tree API 来完成对 Class 文件更直接的修改,因此这时候你要掌握一些必不可少的 Java 字节码知识,ASM 的特点是功能强大操作灵活,但是上手的难度也会比 AspectJ 更难,但是它能获得更好的性能,更适合大面积的插桩场景。ASM

插桩实战

ASM

我们先对 ASM 的三个重要的角色有个了解,他们分别是:

  • ClassReader
  • ClassVisitor
  • ClassWirter

ClassReader

我们通过上文的内容大概了解到,ASM 插桩对字节码进行修改。这肯定有个读取 Class 字节码的过程。那么 ClassReadr 就是这个读取器,他提供了对字节码的读取的方法。

ClassVisitor

ClassVisitor 是 ASM 插桩的核心,因为字节码的插桩修改就是在这一个步骤进行。ClassVisitor 是基于 访问者模式

ClassWirter

顾名思义,这应该是 ASM 提供的对字节码修改完以后,将修改完的内容进行写入的工具。写入的对象包括上面读取的对象都可以是字节码数组或者他们包装了一层的字节码流(Strem)

了解了上面的 ASM 的三个核心 API 的左右下面我们来进行一个小案例的使用实践:

ASM API 实践

class Printer {
    public static void main(String[] args) {
        b();
    }

    private static void b() {
        long s = System.currentTimeMillis();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long e = System.currentTimeMillis();
        System.out.println("executed time : " + (e - s) + " :ms");
    }
}

我们在排查一些问题的时候,可能需要去根据某个方法的耗时时间来做定位和判断。一个或者几个方法我们可以通过像上文一样手动去编写。但是我需要给数百个方法或者整个应用所有的方法全部添加这个时候靠人力手动是不现实的,这个时候如何去解决?

依赖包引入
implementation group: 'org.ow2.asm', name: 'asm-commons', version: '9.2'

读取目标字节流

        FileInputStream fis = new FileInputStream("/Users/macbook/Documents/thunder/ASMDemo/app/build/intermediates/javac/debug/classes/com/thunder/asmdemo/zxm31/Printer.class");
        ClassReader classReader = new ClassReader(fis);

插入

我们先看一段伪代码或者是未完成的代码:

具体的实现我们放在后面,先了解主干流程。

写出修改后的字节码

        FileOutputStream fos = new FileOutputStream("/Users/macbook/Documents/thunder/ASMDemo/app/build/intermediates/javac/debug/classes/com/thunder/asmdemo/zxm31/Printer.class");
        ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES);
        fos.write(classWriter.toByteArray());
        fos.flush();
        fos.close();

Visitor

上文我们先暂时没有实现 accept 方法,这里我们完成它细节的实现:

  //真正改变字节码的插桩的核心`
        classReader.accept(new TimeClassVisitor(ASM9, classWriter), ClassReader.EXPAND_FRAMES);

accept 方法接受一个 ClassVisitor 的一个入参:

    static class TimeClassVisitor extends ClassVisitor {

        public TimeClassVisitor(int api, ClassVisitor classVisitor) {
            super(api, classVisitor);
        }
    }

ClassVisitor 内部有很多方法可以重写,我们的需求是需要对方法进行插入。这里我们来实现方法相关的函数。

        @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            return super.visitMethod(access, name, descriptor, signature, exceptions);
        }

visitMethod 会分析字节码把字节码内所有的方法给我们输出出来,我们来验证一下:

方法的构造、入口方法、b 方法一目了然,但是 ClassVisitor 只是提供了对类里面的元素的方法,我想具体的将我们自己的代码插入方法体怎么做呢?

我注意到 visitMethod 的返回值是 MethodVisitor ,没有错就是它:

我们来看下 MethodVisitor 内部提供了各种对方法体操作的 API ,但是针对我们这样在方法前后进行代码插入统计的需求,commons 包已经给我们提供了更简便的子类实现 AdviceAdapter,来查看一下它的继承关系:

查看一下里面和 method 相关的方法

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值