引言

当咱们触摸一个新知识点时,一般会有两个疑问,它是什么?它有什么用?

所以在一开始呢,就需求先答复这两个问题。

ASM 是什么

简略一句话总结:ASM 是一个 字节码操作库

这句话解读下来有三点:

  1. 字节码:ASM 操作的方针是字节码,字节码即 JVM 履行的一种指令格局,它既可以来自 Java 代码编译,也可以来自于 Kotlin、Grovvy 代码编译,只要是符合 JVM 规范的字节码即可。
  2. 操作:即增修正查。ASM 可以修正已有类的字节码,或许直接生成二进制格局的类。
  3. 库:ASM 是一个东西类库,开箱即用。

ASM 依靠库体积小、字节码操作速度快,这也是它有别于其他字节码操作库的原因。

ASM 有什么用

ASM 的运用极其广泛,Java 中 Lambda 表达式调用点 的生成、反射的动态完成,Android 中 BuildConfig 类的生成等,都是经过 ASM 来完成了。

除此之外,一些 Android 的质量优化框架 Booster、ByteX、Matrix 等,也都是运用 ASM 来操作字节码。了解这些 APM 库的完成原理,也是需求咱们首要熟悉 ASM 的运用。

这其实也我共享 ASM 的原因之一,还有一个原因是网上关于 ASM 的材料较少,很少能拿来即跑的。

这次共享的重点 不在于讲解 ASM 怎样运用,而在于了解 ASM 有什么用。

ASM 的两类 API

ASM 供给了两类 Api,一类是 Core Api,一类是 Tree Api。

// Core API
implementation "org.ow2.asm:asm:9.4"
// Tree API
implementation "org.ow2.asm:asm-tree:9.4"

Core Api 是以事件回调的方式拜访字节码,这种办法占用内存小、拜访速度快;而 Tree Api 是把整个字节码全部读到内存,占用内存大,但 Api 运用简略、可以很好的符合函数式编程。

ASM 示例

读取 ArrayList 类

先来一个开胃菜,运用 Tree Api 读取 ArrayList 类,输出其前两个特点名和办法名:

private fun readArrayListByTreeApi() {
  // 1. 从类的全限定名、或字节数组、或二进制字节流中读取字节码
  val classReader = ClassReader(ArrayList::class.java.canonicalName)
  // 2. 以 ClassNode 方式表示字节码
  val classNode = ClassNode(Opcodes.ASM9)
  classReader.accept(classNode, ClassReader.SKIP_CODE)
  classNode.apply {
    println("name: $name\n")
    // 3. 读取特点
    fields.take(2).forEach {
      println("field: ${it.name} ${Modifier.toString(it.access)} ${it.desc} ${it.value}")
     }
    println()
    // 4. 读取办法
    methods.take(2).forEach {
      println("method: ${it.name} ${Modifier.toString(it.access)} ${it.desc}")
     }
   }
}
​
// 输出
name: java/util/ArrayList
​
field: serialVersionUID private static final J 8683452581122892189
field: DEFAULT_CAPACITY private static final I 10
​
method: <init> public (I)V
method: <init> public ()V
输出特定办法耗时

接下来便是网上举的最多的一个比方,输出办法的耗时。咱们以 ASM 来完成相似 hugo 的功用,输出以注解 @MeasureTime 标记的办法的耗时,修正前的 Java 文件如下:

public class MeasureMethodTime {
​
  @MeasureTime
  public void measure() {
    try {
      Thread.sleep(2000);
     } catch (InterruptedException e) {
      throw new RuntimeException(e);
     }
   }
}

希望修正后的 class 文件如下:

public class MeasureMethodTimeTreeClass {
  public MeasureMethodTimeTreeClass() {
   }
​
  @MeasureTime
  public void measure() {
    long var3 = System.currentTimeMillis();
​
    long var5;
    try {
      Thread.sleep(2000L);
     } catch (InterruptedException var7) {
      RuntimeException var10000 = new RuntimeException(var7);
      var5 = System.currentTimeMillis();
      System.out.println(var5 - var3);
      throw var10000;
     }
​
    var5 = System.currentTimeMillis();
    System.out.println(var5 - var3);
   }
}

运用 Tree Api 操作中心代码如下:

classNode.methods.forEach { methodNode ->
  // 该办法的注解列表中包括 @MeasureTime
  if (methodNode.invisibleAnnotations?.map { it.desc }
      ?.contains(Type.getDescriptor(MeasureTime::class.java)) == true) {
    val localVariablesSize = methodNode.localVariables.size
    // 在办法的第一个指令之前刺进 System.currentTimeMillis()
    val firstInsnNode = methodNode.instructions.first
    methodNode.instructions.insertBefore(firstInsnNode, InsnList().apply {
      add(MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J"))
      add(VarInsnNode(Opcodes.LSTORE, localVariablesSize + 1))
     })
​
    // 在办法 return 指令之前刺进
    methodNode.instructions.filter {
      it.opcode.isMethodReturn()
     }.forEach {
      methodNode.instructions.insertBefore(it, InsnList().apply {
        add(MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J"))
        // 留意,Long 是占两个局部变量槽位的,所以这儿要较之前 +3,而不是 +2
        add(VarInsnNode(Opcodes.LSTORE, localVariablesSize + 3))
        add(FieldInsnNode(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"))
        add(VarInsnNode(Opcodes.LLOAD, localVariablesSize + 3))
        add(VarInsnNode(Opcodes.LLOAD, localVariablesSize + 1))
        add(InsnNode(Opcodes.LSUB))
        add(MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V"))
       })
     }
   }
}

运用 Tree Api 非常简单完成,由于整个类都现已读取结束,每个办法的局部变量表的大小现已确定了,新增局部变量只需求在后面追加即可。运用 Core Api 稍许费事一些,不过 ASM 也供给了 LocalVariablesSorter 类,为了方便生成局部变量获取其索引

或许有的同学会问,Kotlin 现已供给了 measureTimeMillis 这种顶层函数来防止手动核算耗时,那这个示例的实践意义在哪呢?

在做发动优化时,一个关键的过程是获取发动阶段的 Trace 文件,但 Systrace 默许的监控点大多都是体系调用,这就需求咱们在每个办法进口和出口处,自动加上 Trace#beginSection 和 Trace#endSection,以生成以下 Trace 文件:

ASM 应用与实践

删去办法里面的日志句子

修正前的 Java 源文件:

public class DeleteLogInvoke {
​
  public String print(String name, int age) {
    System.out.println(name);
    String result = name + ": " + age;
    System.out.println(result);
    System.out.println("Delete current line.");
    System.out.println("name = " + name + ", age = " + age);
    System.out.printf("name: %s%n", name);
    System.out.println(String.format("age: %d", age));
    return result;
   }
}

希望修正后的 class 文件:

public class DeleteLogInvokeCoreClass {
  public DeleteLogInvokeCoreClass() {
   }
​
  public String print(String var1, int var2) {
    String var3 = var1 + ": " + var2;
    return var3;
   }
}

处理思路是:删去 GETSTATIC out 和 INVOKEVIRTUAL println 之间的一切指令。

或许又有同学会问为啥不用 Proguard 呢?Proguard 供给了 assumenosideeffects 来 移除日志代码:

-assumenosideeffects class android.util.Log {
  public static boolean isLoggable(java.lang.String, int);
  public static int v(...);
  public static int i(...);
  public static int w(...);
  public static int d(...);
  public static int e(...);
}

原因是,Proguard 无法删去一些隐式的中心调用。

对于以下这段 Kotlin 代码:

Log.i("MainActivity", "onCreate: $packageName")

生成的 Dex 字节码如下:

    invoke-virtual {p0}, Landroid/content/Context;->getPackageName()Ljava/lang/String;
    move-result-object p1
    const-string v0, "onCreate: "
    invoke-static {v0, p1}, kotlin.jvm.internal.Intrinsics.stringPlus(Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;
    move-result-object p1
    const-string v0, "MainActivity"
    invoke-static {v0, p1}, Landroid/util/Log;->i(Ljava/lang/String;Ljava/lang/String;)I

删之后,发现中心的 getPackageName 调用和字符串拼接调用依然存在:

    invoke-virtual {p0}, Landroid/content/Context;->getPackageName()Ljava/lang/String;
    move-result-object p1
    const-string v0, "onCreate: "
    invoke-static {v0, p1}, Landroidx/constraintlayout/widget/R$id;->kotlin.jvm.internal.Intrinsics.stringPlus(Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;

其实 Proguard 供给了 assumenoexternalsideeffects、assumenoexternalreturnvalues 来删去这些中心调用,但在 R8 (在 AGP 3.4及其以上,R8 是 默许编译器 了)上现已不支持该配置了:

> Task :app:minifyReleaseWithR8
WARNING: R8: Ignoring option: -assumenoexternalsideeffects
WARNING: R8: Ignoring option: -assumenoexternalreturnvalues

而运用 ASM 则可以彻底删去洁净。

线程重命名

该示例是来源于 Booster – 线程重命名。

线程办理一直是非常头痛的工作,运用在发动时就或许初始化了几十上百个线程在跑。开发者面对的问题有:

  1. 或许存在低优先级的子线程抢占 CPU,导致主线程 UI 响应能力下降、主线程空等子线程的锁等,过多的资源竞赛意味着过多的资源都浪费在了线程调度上
  2. 不可控的线程创立或许导致 OOM
  3. 线程命名默许是以 Thread-{N} 方式命名,不知道线程是在哪个模块哪个类创立的,不利于问题排查

经过对线程重命名 — 为线程名添加当前类名的前缀,当 APM 东西上报反常信息或对线程进行采样时,收集到的线程信息对于排查问题十分有协助。

修正前的 Java 源代码:

public class ThreadReName {
    public static void main(String[] args) {
        // 不带线程称号
        new Thread(new InternalRunnable()).start();
        // 带线程称号
        Thread thread0 = new Thread(new InternalRunnable(), "thread0");
        System.out.println("thread0: " + thread0.getName());
        thread0.start();
        Thread thread1 = new Thread(new InternalRunnable());
        // 设置线程名字
        thread1.setName("thread1");
        System.out.println("thread1: " + thread1.getName());
        thread1.start();
    }
}

修正后的 class 文件如下:

public class ThreadReNameTreeClass {
    public ThreadReNameTreeClass() {
    }
    public static void main(String[] var0) {
        (new ShadowThread(new InternalRunnable(), "sample/ThreadReNameTreeClass#main-Thread-0")).start();
        ShadowThread var1 = new ShadowThread(new InternalRunnable(), "thread0", "sample/ThreadReNameTreeClass#main-Thread-1");
        System.out.println("thread0: " + var1.getName());
        var1.start();
        ShadowThread var2 = new ShadowThread(new InternalRunnable(), "sample/ThreadReNameTreeClass#main-Thread-2");
        var2.setName(ShadowThread.makeThreadName("thread1", "sample/ThreadReNameTreeClass#main-Thread-3"));
        System.out.println("thread1: " + var2.getName());
        var2.start();
    }
}

输出:

thread0: sample/ThreadReNameTreeClass#main-Thread-1#thread0
thread1: sample/ThreadReNameTreeClass#main-Thread-3#thread1

这种替换体系调用的做法,运用也比较多。比方替换体系默许的 SharedPreferences 完成以防止或许的卡顿、ANR;替换办法调用为体系类或第三库的代码兜底等。

留一个思考题,Toast 在 Android 7.1 上存在抛 BadTokenException 的溃散情况,咱们简化一下这个体系 bug,如果有以下代码:

public class ReplaceMethodInvoke {
    public static void main(String[] args) {
        // throw NPE
        new Toast().show();
    }
}
public class Toast {
    private String msg = null;
    public void show() {
        System.out.println("Toast: " + msg + ", msg.length: " + msg.length());
    }
}

该怎么防止其在运行时溃散呢?

序列化查看

该示例是来源于 ByteX – 序列化查看。

对于 Java 的序列化,有以下几条规矩需求恪守(也便是 IDEA Inspections 里的几条规矩):

  1. 完成了 Serializable 的类未供给 serialVersionUID 字段
  2. 完成了 Serializable 的类包括非 transient、static 的字段,这些字段并未完成 Serializable 接口
  3. 未完成 Serializable 接口的类,包括 transient、serialVersionUID 字段
  4. 完成了 Serializable 的非静态内部类,它的外层类并未完成 Serializable 接口

对于以下 Java 代码:

public class SerializationCheck implements Serializable {
    private ItemBean1 itemBean1;
    private ItemBean2 itemBean2;
    private transient ItemBean3 itemBean3;
    private String name;
    private int age;
    static class ItemBean1 {
    }
    static class ItemBean2 implements Serializable {
    }
    static class ItemBean3 {
    }
}

需求查看出来并输出:

Attention: Non-serializable field 'itemBean1' in a Serializable class [sample/SerializationCheckCoreClass]
Attention: This [sample/SerializationCheckCoreClass] class is serializable, but does not define a 'serialVersionUID' field.

这品种静态剖析的能力,运用场景也比较多。比方查看是否存在调用不存在的字段或办法、隐私合规 Api 调用监测等等。

ASM 不能做什么

经过上面的讲述,相信你现已了解了 ASM 的强壮之处了,似乎 ASM 无所不能,但比较于 ASM 能做什么,可以认识到它不能做什么,有时候会显得更加重要。所以,这一末节用来讨论下 ASM 不能做什么。

如果依然用一句话总结,那便是 不支持动态剖析或许说是运行时剖析。

咱们来看一个比方,比方咱们想检测 Android 项目中有哪些 assets 资源未被运用。

一个清楚明了的思路是:

  1. 收集项目中一切的 AAR 包括的 assets 资源名
  2. 在 Transform 阶段,check 以下调用,收集一切已运用的 assets 资源名
context.getAssets().open("fileName.json")

两个成果集一对比,就可以知道哪些 assets 资源未被运用了。

如果真是这样的调用,其实还可以经过静态剖析拿到,由于 ldc 指令可以拿到对应的 value 即 “fileName.json”,可是如果这个文件名是 办法的入参或出参,那就没有办法了,由于只有在运行时才能知道详细的值是什么。

Matrix#UnusedAssetsTask 给出的方案是:查找 smali 文件中引证字符串常量的指令,判别引证的字符串常量是否某个 assets 文件的称号。

总结

最后总结一下,ASM 的运用场景有:

  1. Static Analysis 静态剖析:序列化查看、查看是否存在调用不存在的字段或办法、隐私合规 Api 调用监测等等,都归于该运用范畴。
  2. AOP 面向切面编程:输出特定办法耗时、线上代码覆盖率监测等都归于该运用范畴。
  3. Hook:对原有代码逻辑做增强,一般完成手段有反射和静态/动态署理;线程重命名、点击防手抖、修正第三方库或体系 bug 等都归于该运用范畴。

不在 ASM 的运用范畴里:动态剖析。

更多

  1. ASM-Task
  2. Chapter 4. The class File Format
  3. 内部共享的 PPT