Android字节码框架ByteX [method_call_opt] 源码分析

Posted 化作孤岛的瓜

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android字节码框架ByteX [method_call_opt] 源码分析相关的知识,希望对你有一定的参考价值。

前言

ByteX 是字节团队推出的一个基于 Gradle Transform Api 和 ASM 的字节码插件平台。

Github:GitHub - bytedance/ByteX: ByteX is a bytecode plugin platform based on Android Gradle Transform API and ASM. 字节码插件开发平台

近期在学习研究字节码相关的技术,所以会整理一个系列文章着重分析ByteX各种插件的实现原理和思想。

阅读本文需要初步了解ASM技术,如果不了解也影响不大。

目录

前言

插件介绍

简单的实现方法移除

 [method_call_opt] 源码分析

1.找到目标方法和结束位置

2.找到起始点位置

总结

插件介绍

[method_call_opt]插件属于ByteX的插件之一,顾名思义,旨在用来干净地删除某些方法调用,如Log.d等一些非业务必须的冗余代码,大概能给抖音带来1w2k处修改,数百kb的缩减。

简单的实现方法移除

在正式的分析之前,笔者也曾自己通过ASM实现简单的方法调用移除MethodRemovePlug.java,代码太长就不贴了。

基于Core API实现,原理是重写MethodVisitor的visitMethodInsn方法,在查找到需要移除的方法位置,提前return,实现抹除本次方法调用的目的。

首先举个栗子:

我们实现一个最简单的方法,打印一行日志:

    public static void tryRemoveMethod(Context context) 
        Log.d("tag","A");
    

通过AS插件ASM Bytecode Viewer,我们可以看到它的字节码是这样的:

 		L0
    LINENUMBER 94 L0
    LDC "tag"
    LDC "A"
    INVOKESTATIC android/util/Log.d (Ljava/lang/String;Ljava/lang/String;)I
    POP

主要有几个步骤:

3:将"tag"压入操作数栈

4:将"A"压入操作数栈

5:将操作数栈中的两个元素弹出作为参数调用Log.d,结果再压入操作栈中

6:弹栈,方法调用到此为止,栈也随之清空。

在经过MethodRemovePlug.java中字节码处理后,查看它的class文件:

public static void tryRemoveMethod(Context context) 
        String var10000 = "tag";
        String var10001 = "A";
    

可以看到打印Log日志的一行已经没有了,再看一下字节码:

  L0
    LINENUMBER 94 L0
    LDC "tag"
    LDC "A"
    POP

可以看到,与之前相比,INVOKESTATIC android/util/Log.d (Ljava/lang/String;Ljava/lang/String;)I 这一句已经被移除掉了,基本实现了对Log.d方法的移除。

但是这样做有一个最大的问题就是,参数定义的操作还留存着,并没有被删掉,依然很占大小。

 [method_call_opt] 源码分析

主要实现源码在MethodCallClassVisitor中实现,代码上千行这里就不贴出来了。

MethodCallClassVisitor是ClassVisitor的子类,重写visitMethod方法将MethodVisitor替换为MethodCallOptMethodVisitor。

MethodCallOptMethodVisitor继承自MethodNode,MethodNode是ASM中的Tree api,比起Core api,抽象出了类和方法节点,更加适合做插桩操作。

总体思想可以概括为通过以下几个步骤

  1. 找到目标方法

  2. 找到终止指令位置startIndex(有返回值即POP位置,没有的话就是方法调用的位置)

  3. 找到起始指令位置endIndex(即向上逆查找到参数定义的起始位置)

  4. 删除startIndex - endIndex之间所有的指令,实现完全干净的方法移除。

1.找到目标方法和结束位置

我们要删除一个方法,那么首先要从项目中茫茫多的

具体的实现从MethodCallClassVisitor 92行开始:

int index = instructions.size() - 1;
List<List<AbstractInsnNode>> optimizedIns = new ArrayList<>();

instructions是整个方法的字节码节点集合

这里定义了一个index下标,意图从instructions的尾部开始倒序遍历

一个optimizedIns数组,用来储存后面需要删除的指令。

另外还特别定义了一个mParamsStack栈用来存储后续用到的操作符:

    private final Stack<Type> mParamsStack = new Stack<>();

接下来是一个while循环(96~201),大概意思如下:

while (index >= 0) 
	AbstractInsnNode node = instructions.get(index);
	if(node节点是方法调用指令)
  	1.通过开发者传入的owner,name,desc筛选并找到需要过滤的方法
    2.判断该方法的返回值,必须是void或者返回值没有被使用,不然会影响其他业务逻辑,不可进行移除操作
    3.如果是静态方法,将方法的owner(this)类型入栈,否则将方法的desc类型入栈
    int succeedIndex = optimize(index);
    4.通过optimize方法逆向回溯找到方法的起始节点,optimize后文分析
    5.当前index理论上就是结束节点了,如果返回值不是void则index需要+1,因为有返回值的方法后面需要POP弹栈操作,需要把这一行也给囊括进来
		6.这里还需要一个特殊处理,因为在移除一段字节码操作符以后,如果上下相连连续两个FRAME操作符,会导致MethodWriter
    的visitFrame爆一个IllegalStateException,所以这里需要向下查找下一个FRAME节点位置,作为结束节点,然后找到上一个FRAME节点
    位置,并作为新的起始节点。
    7.将起始节点和结束节点之间所有的操作符储存起来。
	index--;

需要说明下第2点,如何判断返回值没有被使用?这里是通过观察下一个操作符是否是POP或者POP2来判断的,因为如果其他地方要用到这个返回值,就不会直接弹栈。

第六点,ASM源码中异常的原文:

if a frame is visited just after another one, without any
 instruction between the two (unless this frame is a Opcodes#F_SAME frame, in which case it
 is silently ignored).

说明两个frame中一定要有操作符,不能直接相连。

储存起来待删除的节点后,在209行中:

instructions.remove(node);

通过instructions直接遍历删除对应的节点。

2.找到起始点位置

找到起始点位置,即前文提到的optimize(index)方法。该方法是本插件实现的核心,也是最难的部分。

整体代码如下:

if (mParamsStack.size() == 0) 
    return index;

if (index <= 0) 
    throw new MethodCallOptException("Can not match the method call params:index=" + index);

final int next = index - 1;
final AbstractInsnNode node = instructions.get(next);
if (node.getOpcode() < 0) 
    if (node instanceof LineNumberNode || node instanceof LabelNode) 
        //LABEL or LINENUMBER...
        //过滤标签和行号
        return optimize(next);
     else 
        //frame
        return SKIP_INDEX_FRAME;
    

Type pop1, pop2, pop3, pop4, pop5, pop6;
final Type type = mParamsStack.peek();
//boolean byte char short ->int
switch (node.getOpcode()) 
	236~1031行..
  对应各种节点的操作

可以看到,主要是通过index节点从结尾向前回溯遍历,在过滤了标签和行号以后,通过一个大型的switch case囊括了几乎所有的操作符。

可以概括为:

在指令的入栈或者出栈操作时,进行反向操作,并存储在我们自己定义的栈mParamsStack中,因为在前文中已经将方法的执行类型做了入栈操作,所以这里mParamsStack的初始大小是1.

接下来进行递归操作,当mParamsStack的大小成为0是,说明我们已经找到了方法的起始节点。

为了方便理解optimize方法,这里同样以上文的栗子来举例:

	L0
    LINENUMBER 94 L0
    LDC "tag"
    LDC "A"
    INVOKESTATIC android/util/Log.d (Ljava/lang/String;Ljava/lang/String;)I
    POP
  • 第一次循环

进入到optimize方法入口,当前mParamsStack元素为[Ljava/lang/String,Ljava/lang/String]

当前下标index为12,其对应字节码操作为INVOKESTATIC android/util/Log.d (Ljava/lang/String;Ljava/lang/String;)I

这个时候找到上一个Node,通过node.getOpcode()可知对应的是LDC,是字节码中的 LDC "A"

执行case:

case Opcodes.LDC:
     LdcInsnNode ldcInsnNode = (LdcInsnNode) node;
     if ..
     else if (ldcInsnNode.cst instanceof String && typeOf(Type.getType(String.class), type)) 
                    //lcd string
                    pop();
                    return optimize(next);
      else ...

因为LDC是把元素压栈,所以这里执行反向操作POP,然后递归进入下一次循环

pop之后的mParamsStack元素为[Ljava/lang/String]

  • 第二次循环

通过index--找到上一个节点,还是LDC,对应字节码中的LDC "tag",继续执行循环1中的操作

pop之后当前mParamsStack为空

  • 跳出循环

此时返回index = 2,通过查找操作符集合

instructions.get(2)

可知对应的是LDC "tag"这一行,因此已经找到了目标方法的起始位置。

运行剩下的逻辑,结束以后,可以看到class文件以及变成了:

public static void tryRemoveMethod(Context context) 

再看看字节码:

原来的方法相关的参数以及调用指令已经被完全删干净了。

总结

总体说来实现的逻辑和思路其实是很清晰的,主要是通过定义一个栈来进行回溯查找操作(其中反转栈的思想有点像leetcode的题用栈实现队列),来实现在尽可能不影响业务逻辑的情况下达到字节码缩减的目的,但是诸多的细节和对所有操作符的兼容处理,也令人感受到原作者的匠心与辛勤。

以上是关于Android字节码框架ByteX [method_call_opt] 源码分析的主要内容,如果未能解决你的问题,请参考以下文章

Android字节码框架ByteX [method_call_opt] 源码分析

Android 插件化Hook 插件化框架 ( 创建插件应用 | 拷贝插件 APK | 初始化插件包 | 测试插件 DEX 字节码 )

反射之Method如何获取字节码对象中的方法

通过字节码获取到的方法

Android开发 Error:The number of method references in a .dex file cannot exceed 64K.

Android AOP编程之AspectJ