Android ASM框架詳解

前言

在上篇文章中,咱們以AspectJ爲引子介紹了AOP及其設計思想,傳送門Android AspectJ詳解,咱們用AspectJ能夠方便的實現一些簡單的代碼織入,而不須要關心底層字節碼的實現,而ASM則偏向底層一些,ASM提供的API徹底是面向Java字節碼編程,若是你對Java字節碼的結構和原理不甚瞭解,很難直接上手。html

但正是由於ASM的原理是直接操做字節碼,那麼理論上對字節碼的任意修改,均可以用ASM實現。由於不管是哪一種AOP技術,最終跑在JVM上的都是class字節碼。java

而AspectJ所處的位置更偏向應用層,它將操做字節碼這件事封裝到內部,給外部提供的就是一些篩選切面的註解,並在這個切面下編寫java代碼,最終是經過AspectJ的ajc編譯器實現代碼的織入。android

文中的ASM項目示例戳這裏git

ASM簡介

ASM是一個字節碼操做框架,可用來動態生成字節碼或者對現有的類進行加強。ASM能夠直接生成二進制的class字節碼,也能夠在class被加載進虛擬機前動態改變其行爲,好比方法執行先後插入代碼,添加成員變量,修改父類,添加接口等等。github

ASM官方網站編程

ASM經過訪問者模式依次遍歷class字節碼中的各個部分,並不斷的經過回調的方式通知上層(這有點像SAX解析xml的過程),上層可在業務關心的某個訪問點,修改原有邏輯。segmentfault

之因此能夠這麼作,是由於java字節碼是按照嚴格的JVM規範生成二進制字節流,ASM只是按照這個規範對java字節碼的一次解釋,將晦澀難懂的字節碼背後對應的JVM指令一條條的轉換成ASM API。數組

好比,一句簡單的日誌打印bash

Log.d("tag", " onCreate");
複製代碼

轉換成ASM API將會是下面這樣:app

mv.visitLdcInsn("tag");
mv.visitLdcInsn("onCreate");
mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(POP);
複製代碼

若是你稍懂JVM彙編指令的話,能夠看出大體意思。

  • 加載常量"tag"入棧
  • 加載常量"onCreate"入棧
  • 執行Log的靜態方法d
  • 方法調用出棧

而後咱們經過javap指令查看一下這行代碼對應的JVM彙編指令,以下圖:

字節碼.png

這樣是否是就很清楚了?就是這四條指令,ASM作的就是按照JVM的規範,生成代碼對應的JVM指令並寫入字節碼文件。

Class字節碼結構

上面的例子,用到了javap指令,所以咱們首先須要對java字節碼結構作一個大體的介紹,這樣整個ASM流程最底層的原理就算清楚了。

咱們經過javac指令將一個java源文件編譯成.class的字節碼文件,這個文件直接經過文本編輯器打開將會看到全是16進制的字節碼。

class Demo {
    int i = 0;
	public void test() {
		i += 1;
	}
}
複製代碼

class字節碼.png

class字節碼結構組成結構如圖。

字節碼組成結構.png

各個部分佔用字節大小:

class組成結構.png

其中u一、u二、u四、u8分別表明1個字節、2個字節、4個字節、8個字節的無符號數。無符號數用於描述數字、索引引用、數量值、字符串值。

cp_info、field_info這些以info結尾的是表,一個表由一個或多個元素組成,這裏元素能夠是常量、字段、方法等等。

  • Magic魔數:該項存放了一個 Java 類文件的魔數(magic number)和版本信息。一個 Java 類文件的前 4 個字節被稱爲它的魔數。每一個正確的 Java 類文件都是以 0xCAFEBABE 開頭的,這樣保證了 Java 虛擬機能很輕鬆的分辨出 Java 文件和非 Java 文件。
  • Version:包括主版本號和次版本號,該項存放了 Java 類文件的版本信息,類文件的版本信息讓虛擬機知道如何去讀取並處理該類文件。
  • Constant Pool:該項存放了類中各類文字字符串、類名、方法名和接口名稱、final 變量以及對外部類的引用信息等常量。虛擬機必須爲每個被裝載的類維護一個常量池,常量池中存儲了相應類型所用到的全部類型、字段和方法的符號引用。常量池的大小平均佔到了整個類大小的 60% 左右。
  • Access_flag:該項指明瞭該文件中定義的是類仍是接口(一個 class 文件中只能有一個類或接口),同時還指名了類或接口的訪問標誌,如 public,private, abstract 等信息。
  • This Class:指向表示該類全限定名稱的字符串常量的指針。
  • Super Class:指向表示父類全限定名稱的字符串常量的指針。
  • Interfaces:一個指針數組,存放了該類或父類實現的全部接口名稱的字符串常量的指針。
  • Fields:該項對類或接口中聲明的字段進行了細緻的描述。須要注意的是,fields 列表中僅列出了本類或接口中的字段,並不包括從超類和父接口繼承而來的字段。
  • Methods:該項對類或接口中聲明的方法進行了細緻的描述。例如方法的名稱、參數和返回值類型等。須要注意的是,methods 列表裏僅存放了本類或本接口中的方法,並不包括從超類和父接口繼承而來的方法。
  • Class attributes:該項存放了在該文件中類或接口所定義的屬性的基本信息。

好比按咱們Demo.class字節碼的信息,cafe babe是魔數,按表順序後面跟的四個字節0000 0034是分別是次版本和主版本,轉換成10進制是52.0,查看java虛擬機版本映射關係表,52表示JDK 1.8,也就是該類是用JDK 1.8進行編譯的。

以後的兩個字節0012表示常量池大小,爲十進制的18,因爲常量池常量下標從1開始,也就是有17個常量。

0a00後面的內容就是第一個具體的常量信息。

常量分爲兩類字面量和符號引用

  • 字面量:與Java語言層面的常量概念相近,包含文本字符串、聲明爲final的常量值等。
  • 符號引用:編譯語言層面的概念,包括如下3類:
    • 類和接口的全限定名
    • 字段的名稱和描述符
    • 方法的名稱和描述符

0a對應十進制的10,10表示MethodRef,即方法引用。

字節碼結構的後續內容較多,並非本文重點,再也不展開,除此以外還須要掌握JVM常見的指令,好比aload、invokespecial、ldc等等,感興趣的小夥伴可參考認識 .class 文件的字節碼結構,補充學習。

但在目前,即便咱們不懂這些也不妨礙咱們開發,由於ASM提供了相應工具幫助咱們編寫ASM API代碼,莫慌~~

javap

字節碼嚴格遵照着JVM規範,直接讀字節碼文件是瘋狂的事情,咱們可經過javap指令能夠將字節碼反編譯成易懂的彙編指令。

javap -v Demo.class

-v 表示verbose,將會打印 行號+本地變量表信息+反編譯彙編代碼+常量池等所有信息。

  • javap -l 行號+本地變量表
  • javap -c 反編譯彙編代碼Code區。

在Android Studio中,可經過jclasslib插件查看更清晰。

jclasslib.png

ASM API

ASM經過訪問者模式,將類文件的內容從頭至尾掃描一遍,每次掃描到相應內容時,會回調ClassVisitor內部相應的方法。

常見的visitor以下表。

類型 visitor
Class ClassVisitor
Field FieldVisitor
Method MethodVisitor
Annotation AnnotationVisitor

ClassVisitor的調用順序爲:

visit 
visitSource? 
visitOuterClass? 
( visitAnnotation | visitAttribute )*
( visitInnerClass | visitField | visitMethod )*
visitEnd
複製代碼

MethodVisitor的調用順序爲:

visitAnnotationDefault?
( visitAnnotation | visitParameterAnnotation | visitAttribute )*
( visitCode
    ( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn |
    visitLocalVariable | visitLineNumber )*
visitMaxs )?
visitEnd
複製代碼

完整的訪問順序咱們能夠經過時序圖瞭解:

Visitor時序圖.png

ClassReader/ClassWriter

ClassReader能夠方便地讓咱們對class文件進行讀取與解析,解析到某一個結構就會通知到ClassVisitor的相應方法,好比解析到類方法時,就會回調ClassVisitor.visitMethod方法。

咱們能夠經過更改ClassVisitor中相應結構方法返回值,實現對類的代碼切入,好比更改ClassVisitor.visitMethod()方法的返回值MethodVisitor實例。

經過ClassWriter的toByteArray()方法,獲得class文件的字節碼內容,最後經過文件流寫入方式覆蓋掉原先的內容,實現class文件的改寫。

咱們舉個例子,咱們想爲FragmentActivity這個類的onCreate方法中添加一段日誌打印,能夠按下面的步驟。

//建立ClassReader,傳入class字節碼的輸入流
ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
//建立ClassWriter,綁定classReader
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
//建立自定義的LifecycleClassVisitor,並綁定classWriter
ClassVisitor cv = new LifecycleClassVisitor(classWriter)
//接受一個實現了 ClassVisitor接口的對象實例做爲參數,而後依次調用 ClassVisitor接口的各個方法
classReader.accept(cv, EXPAND_FRAMES)
//toByteArray方法會將最終修改的字節碼以 byte 數組形式返回。
byte[] code = classWriter.toByteArray()
複製代碼

最終code就是修改後的字節碼數組。

咱們能夠將它寫入文件輸出到本地。

File file = new File("Test.class");
FileOutputStream fos = new FileOutputStream(file);
fos.write(classFile);
fos.close();
複製代碼

在Android體系下咱們經過Gradle Transform工具,在java代碼編譯成.class文件以後,.class優化爲.dex文件前將代碼織入。 使用Transform須要開發一個自定義的gradle plugin,plugin的開發不是本文的核心,咱們暫且跳過。

咱們只須要知道在一次transform過程當中,Gradle會將本地工程中編譯的代碼、jar包 / aar包 / 依賴的三方庫中的代碼,做爲輸入源交由咱們的插件處理,這也就是說ASM一樣能夠對工程外部的類進行修改或織入

若是咱們須要在指定的類,指定的方法中織入代碼,須要編寫相應的過濾條件,這也是相比於AspectJ而言不太方便的地方,AspectJ可經過聲明切面註解完成精準的織入。

下面舉個例子,假設咱們想在FragmentActivity的onCreate方法執行前打印一行日誌,能夠這麼作。

建立LifecycleClassVisitor類繼承於ClassVisitor,複寫visitMethod方法。

public class LifecycleClassVisitor extends ClassVisitor implements Opcodes {
    ...

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
        //匹配FragmentActivity
        if ("android/support/v4/app/FragmentActivity".equals(this.mClassName)) {
            if ("onCreate".equals(name) ) {
                //處理onCreate
                return new LifecycleOnCreateMethodVisitor(mv);
            }
        }
        return mv;
    }
}
複製代碼

訪問到onCreate這個方法時,咱們須要繼續自定義一個MethodVisitor,告訴ASM你想如何處理這個方法。

根據上述的訪問時序圖咱們知道,在方法訪問開始時會回調MethodVisitor的visitCode方法,所以咱們複寫此方法後將會在onCreate方法開頭織入代碼。

public class LifecycleOnCreateMethodVisitor extends MethodVisitor {
    ...
    @Override
    public void visitCode() {
        super.visitCode();
        //方法執行前插入
        mv.visitLdcInsn("tag");
        mv.visitLdcInsn("onCreate start");
        mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
    }

    @Override
    public void visitInsn(int opcode) {
        //方法執行後插入
        if (opcode == Opcodes.RETURN) {
            mv.visitLdcInsn("tag");
            mv.visitLdcInsn("onCreate end");
            mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
            mv.visitInsn(POP);
        }
        super.visitInsn(opcode);

    }

    @Override
    public void visitEnd() {
        super.visitEnd();
        //warn 若想在方法最後織入代碼,寫在這裏是無效的
    }
}
複製代碼

這裏值得注意的是若想在方法最後織入代碼,寫在visitEnd方法內是無效的,回調它的時候類已經訪問結束了。 咱們只能迂迴解決,咱們知道方法執行結束前都會有一個return指令,若是你的方法返回值爲void,那編譯成字節碼時會默認補上一個return指令。 return指令根據返回對象的類型不一樣,會有不一樣的指令,好比:

  • areturn 返回值類型爲對象類型
  • ireturn 返回值類型爲int
  • lreturn 返回值類型爲long

因爲咱們知道onCreate方法的返回值就是空,因此咱們只須要捕獲這個return指令就能夠了。 這裏的指令範圍很是廣,好比加減乘除、條件判斷、aload等等,這些指令常量被封裝到Opcodes類中。

訪問者模式爲指令提供的回調就是visitInsn方法,所以就有了上面visitInsn方法的代碼。

因爲在方法先後插入代碼這種需求很常見,而上述模板代碼寫起來又太難看,所以ASM還提供了一個AdviceAdapter類,對一些常見的切面作了二次封裝。

若是咱們用AdviceAdapter編寫上述代碼會變得更直觀清爽。

public class OnCreateMethodAdapter extends AdviceAdapter {
    ...

    @Override
    protected void onMethodEnter() {
        super.onMethodEnter();
        //方法開頭織入代碼
        mv.visitLdcInsn("tag");
        mv.visitLdcInsn("onCreate start");
        mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);

    }

    @Override
    protected void onMethodExit(int opcode) {
        //方法末尾織入代碼
    }

}
複製代碼

ok,到這裏咱們以在某個方法先後織入一段代碼的例子講完了,ASM能實現關於字節碼的任何修改,其中涉及的API能夠十分複雜,對於好比修改類名、添加方法等,最好經過查閱ASM官方文檔完成開發。

ASM Bytecode Outline插件

考慮到直接使用ASM API編寫JVM指令比較困難,所以官方提供了一個插件幫助咱們完成API的編寫。

asm_outline.png

咱們只須要先在任意位置編寫須要織入的java代碼,而後即可經過這個插件生成對應的ASM代碼,愛了愛了...

ASM的優缺點

雖然ASM很強大,但若是你使用了AspectJ以後再開看ASM,就會發現有一些新的問題。

  • 過濾類和方法須要硬編碼,且不夠靈活,須要對插件進行二次封裝,而在AspectJ中已經封裝好了切面表達式。
  • 很難實如今方法調用先後織入新的代碼,而在AspectJ中一個call關鍵字就解決了。

不過,ASM優勢更加明顯:

  • 因爲直接操做的是字節碼,所以相比其餘框架效率更高。
  • 從ASM5開始已經支持Java8的部分語法,好比lamabda表達式。
  • 由於ASM偏向底層,不少其餘的上層框架也以ASM做爲其底層操做字節碼的技術棧,好比Groovy、cglib。

AOP相關參考舊文:談談Android AOP技術方案

參考文章

  1. 大話Java字節碼指令
  2. 認識 .class 文件的字節碼結構
  3. Java字節碼指令
  4. 經過javap命令分析java彙編指令
  5. ASM 3.0 介紹
  6. ASM Core Api 詳解
  7. Java字節碼加強技術探索
相關文章
相關標籤/搜索