前言
在上篇文章中,我們以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通過訪問者模式依次遍歷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匯編指令,如下圖:
這樣是不是就很清楚了?就是這四條指令,ASM做的就是按照JVM的規范,生成代碼對應的JVM指令并寫入字節碼文件。
Class字節碼結構
上面的例子,用到了javap指令,因此我們首先需要對java字節碼結構做一個大致的介紹,這樣整個ASM流程最底層的原理就算清楚了。
我們通過javac指令將一個java源文件編譯成.class的字節碼文件,這個文件直接通過文本編輯器打開將會看到全是16進制的字節碼。
class Demo {
int i = 0;
public void test() {
i += 1;
}
}
class字節碼結構組成結構如圖。
各個部分占用字節大小:
其中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插件查看更清晰。
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
完整的訪問順序我們可以通過時序圖了解:
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的編寫。
我們只需要先在任意位置編寫需要織入的java代碼,然后便可通過這個插件生成對應的ASM代碼,愛了愛了...
ASM的優缺點
雖然ASM很強大,但如果你使用了AspectJ之后再開看ASM,就會發現有一些新的問題。
- 過濾類和方法需要硬編碼,且不夠靈活,需要對插件進行二次封裝,而在AspectJ中已經封裝好了切面表達式。
- 很難實現在方法調用前后織入新的代碼,而在AspectJ中一個call關鍵字就解決了。
不過,ASM優點更加明顯:
- 由于直接操作的是字節碼,因此相比其他框架效率更高。
- 從ASM5開始已經支持Java8的部分語法,比如lamabda表達式。
- 因為ASM偏向底層,很多其他的上層框架也以ASM作為其底層操作字節碼的技術棧,比如Groovy、cglib。