Android ASM框架詳解

前言

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

但正是因為ASM的原理是直接操作字節碼,那么理論上對字節碼的任意修改,都可以用ASM實現。因為無論是哪種AOP技術,最終跑在JVM上的都是class字節碼。

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

文中的ASM項目示例戳這里

ASM簡介

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

ASM官方網站

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

之所以可以這么做,是因為java字節碼是按照嚴格的JVM規范生成二進制字節流,ASM只是按照這個規范對java字節碼的一次解釋,將晦澀難懂的字節碼背后對應的JVM指令一條條的轉換成ASM API。

比如,一句簡單的日志打印

Log.d("tag", " onCreate");

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

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

其中u1、u2、u4、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。

參考文章

  1. 大話Java字節碼指令
  2. 認識 .class 文件的字節碼結構
  3. Java字節碼指令
  4. 通過javap命令分析java匯編指令
  5. ASM 3.0 介紹
  6. ASM Core Api 詳解
  7. Java字節碼增強技術探索
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,559評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,581評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,922評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,096評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,639評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,374評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,591評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,789評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,322評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,554評論 2 379

推薦閱讀更多精彩內容

  • 前言 很早之前就寫過面向切面的編程思想,主要學習了AOP的思想(參考:AOP簡介)以及使用 AspectJ 實現簡...
    Whyn閱讀 10,836評論 4 40
  • 引言 什么是 ASM ? ASM 是一個 Java 字節碼操控框架。它能被用來動態生成類或者增強既有類的功能。AS...
    Chauncey_Chen閱讀 1,502評論 0 6
  • 第6章類文件結構 6.1 概述 6.2 無關性基石 6.3 Class類文件的結構 java虛擬機不和包括java...
    kennethan閱讀 956評論 0 2
  • 概述 虛擬機的類加載機制:虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終...
    tomas家的小撥浪鼓閱讀 979評論 0 4
  • 1. 概述 AOP(面向切面編程)的概念現在已經應用的非常廣泛了,下面是從百度百科上摘抄的一段解釋,比較淺顯易懂 ...
    lijiankun24閱讀 17,991評論 4 33