Android字节码框架ByteX [method_call_opt] 源码分析
Posted 化作孤岛的瓜
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android字节码框架ByteX [method_call_opt] 源码分析相关的知识,希望对你有一定的参考价值。
前言
ByteX 是字节团队推出的一个基于 Gradle Transform Api 和 ASM 的字节码插件平台。
近期在学习研究字节码相关的技术,所以会整理一个系列文章着重分析ByteX各种插件的实现原理和思想。
阅读本文需要初步了解ASM技术,如果不了解也影响不大。
目录
插件介绍
[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,抽象出了类和方法节点,更加适合做插桩操作。
总体思想可以概括为通过以下几个步骤
-
找到目标方法
-
找到终止指令位置startIndex(有返回值即POP位置,没有的话就是方法调用的位置)
-
找到起始指令位置endIndex(即向上逆查找到参数定义的起始位置)
-
删除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 字节码 )
Android开发 Error:The number of method references in a .dex file cannot exceed 64K.