從 Lambda 表示式到位元組碼插樁

語言: CN / TW / HK

theme: hydrogen highlight: androidstudio


我正在參加「掘金·啟航計劃」

位元組碼插樁是如今 Android 開發中非常普遍的一種技術手段,其應用範圍非常廣泛,涉及各種業務強關聯或者和業務無關的領域,例如:無痕埋點、隱私合規檢測、耗時方法統計、效能檢測、雙擊防抖等

我之前就寫過幾篇文章,介紹了幾種通過 ASM 實現位元組碼插樁的案例

本篇文章再來詳細介紹下在實現位元組碼插樁的過程中,我們難免會遇到的一個難點,也即從 Java 8 開始支援的一個新語法:Lambda 表示式,再以此擴充套件介紹向 Lambda 表示式進行位元組碼插樁的大致思路

匿名內部類

我們知道,如果想要宣告某個介面或者抽象類的例項的話,我們可以不顯式宣告實現類,而是可以直接採取 匿名內部類 的方式來得到其例項物件

看一個小例子

```java public class Lambda {

private void lambda() {
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("Hello World!");
        }
    };
}

} ```

Lambda.java檔案編譯為位元組碼

java javac Lambda.java

最終會生成兩個 class 檔案:Lambda$1.classLambda.class

Lambda$1.class 可以很明確地就看出其實現了 Runnable 介面,是編譯器自動生成的實現類。從 Lambda.class 檔案也可以明確看出,lambda 方法中 new 的物件指向的也是 Lambda$1。所以說,對於程式碼中的匿名內部類,編譯器會自動為其生成一個實現類,包含了其原有的內部邏輯:System._out_.println("Hello World!"),並將原有的匿名內部類自動替換為該具體的實現類

Lambda 表示式

由於 Runnable 介面屬於函式式介面,因此我們可以將上述程式碼轉化為 Lambda 表示式,再來看其位元組碼會有什麼變化

```java public class Lambda {

private void lambda() {
    Runnable runnable = () -> System.out.println("Hello World!");
}

} ```

最終只會生成一個 class 檔案:Lambda.class

前後兩份位元組碼檔案主要的差異點在於:

  • 匿名內部類宣告的 Runnable 變數,最終會指向一個具體的介面實現類,在位元組碼中也可以看到有明確的宣告類例項並呼叫其構造方法的過程,對應 new、dup、aload_0、invokespecial 等指令。該實現類的 run 方法中包含了所要執行的程式碼塊
  • Lambda 語法宣告的 Runnable 變數,這個操作對應的是 invokedynamic、astore_1 等指令。Runnable 所要執行的程式碼塊是在自動生成的靜態方法 lambda$lambda$0()

從這可以推匯出一個結論:在編譯階段,Lambda 表示式並不會生成相應的實現類,Lambda 語法的實現機制有別於匿名內部類

當中的重點就在於 invokedynamic 指令了,Java 目前一共包含五種位元組碼呼叫指令

| 指令 | 作用 | | --------------- | ------------------------------------------------------------ | | invokevirtual | 呼叫例項方法 | | invokestatic | 呼叫靜態方法 | | invokeinterface | 呼叫介面方法 | | invokespecial | 呼叫特殊例項方法,包括例項初始化方法、父類方法 | | invokedynamic | 由使用者引導方法決定,執行時動態解析出呼叫點限定符所引用的方法 |

在編譯期間生成的 class 檔案中,前四種指令通過常量池(Constant Pool)已經固定了目標方法的符號資訊,包括 類和介面的全侷限定名、欄位的名稱和描述符、方法的名稱和描述符 等,執行階段就可以依靠該符號資訊直接定位到具體的方法從而直接呼叫

而 invokedynamic 是在 Java 7 中新增的位元組碼呼叫指令,作為 Java 支援動態型別語言的改進之一,在 Java 8 開始應用,Lambda 表示式底層就依靠該指令來實現。invokedynamic 指令在常量池中並沒有包含其目標方法的具體符號資訊,儲存的是 BootstapMethod 資訊,在執行時再來通過引導方法機制動態確定方法的所屬者和型別

而不管怎麼說,在編譯過後,Lambda 表示式所對應的 物件型別、要呼叫的方法的簽名信息、要執行的程式碼塊 等資訊依然是要被儲存在位元組碼中的。進一步檢視 Lambda.class 的詳細位元組碼資訊,看這些資訊是儲存在哪裡

可以看到,第十八行的 invokedynamic 指令就包含了 Runnable 介面和 run 方法完整的簽名信息:run:()Ljava/lang/Runnable,同時指向了第四十一行的 BootstapMethods 區域, 當中會通過 invokestatic 指令去呼叫 LambdaMetafactory 的靜態方法 metafactory(),通過該方法在記憶體中來生成關聯的介面實現類

同時,Method arguments 所列出的引數有三個:

  • 原始方法泛型擦除後的方法簽名信息,也即 run 方法
  • Lambda 表示式原本所要執行的程式碼塊,也即自動生成的靜態方法 lambda$lambda$0() 的簽名信息,當中包含了 Lambda 表示式原本所要執行的程式碼塊
  • 原始方法泛型擦除前的方法簽名信息,也即 run 方法。由於 run 方法不包含泛型,所以和第一個引數的簽名信息一樣

可以通過反射呼叫 lambda$lambda$0()方法來驗證該方法是否真的存在,執行以下程式碼就會發現 Hello World! 列印了兩次

```java public class Lambda {

private void lambda() {
    Runnable runnable = () -> System.out.println("Hello World!");
    runnable.run();
}

public static void main(String[] args) {
    Lambda lambda = new Lambda();
    lambda.lambda();
    try {
        Lambda.class.getDeclaredMethod("lambda$lambda$0").invoke(null);
    } catch (Throwable throwable) {
        throwable.printStackTrace();
    }
}

} ```

所以說,Lambda 表示式所對應的 物件型別(Runnable 介面)、要呼叫的方法的簽名信息(run 方法)、要執行的程式碼塊(lambda$lambda$0 方法) 等資訊都是有被儲存下來的,這樣在執行時才能構成一個完整的呼叫鏈

Lambda 的狀態

假如一個 Lambda 表示式沒有 "捕獲" 任何外部例項物件,該表示式無需依賴於外部例項物件就可以單獨執行,那麼該 Lambda 表示式可以稱為是 “無狀態的";假如使用到了外部例項物件,那麼該 Lambda 表示式就是 “有狀態的”

例如,在上述例子中,Runnable Lambda 表示式最終會對應一個自動生成的靜態方法 lambda$lambda$0(),用於儲存所要執行的程式碼邏輯。因為該表示式並沒有使用到任何例項物件,所以可以以靜態方法的形式存在。而 “有狀態的” Lambda 表示式對應的方法將是例項方法

例如,以下四個 Lambda 表示式,因為只有 runnable1 使用到了例項變數,因此也只有它會生成一個例項方法。此外,runnable3 捕獲的是區域性變數,該變數和具體例項無關,因此也將對應一個靜態方法,並將捕獲的區域性變數作為引數傳入

```java public class Lambda {

private static String log1 = "Hello World!";

private String log2 = "Hello World!";

private void lambda() {
    Runnable runnable0 = () -> System.out.println(log1);
    Runnable runnable1 = () -> System.out.println(log2);
    Runnable runnable2 = () -> System.out.println("Hello World!");
    String log = "Hello World!";
    Runnable runnable3 = () -> System.out.println(log);
}

} ```

Android Lambda

知道標準 Java 平臺是如何處理 Lambda 表示式後,再來講下 Android 平臺是如何支援 Lambda 表示式的,因為 Android 的 Lambda 和 Java 的 Lambda 並不等同

雖然每一個 Android 應用程序都對應一個 Java 虛擬機器,但 Android 虛擬機器並不完全遵循 Java 虛擬機器標準, Java-Bytecode(JVM 位元組碼)是不能直接執行在 Android 系統上的,需要轉換成 Android-Bytecode(Dalvik / ART 位元組碼),而 Dalvik / ART 並不支援 invokedynamic 指令,導致目前 Android 系統對 Java 8 以及更高版本的 JDK 支援得並不徹底。某些 Java API 也只有高版本系統才可以使用,例如,LocalDateTime.now() 至少要 Android 8.0 的系統才可以使用

為了能夠支援 Java 8,目前 AGP 是通過在 D8/R8 將 class 檔案編譯成 dex 檔案的過程中,對位元組碼進行轉換來實現的,這個轉換過程稱為 desugar,也即 脫糖

desugar 操作就用於將某些 Android 系統目前還不支援的語法糖還原為簡單的 基礎語法結構 。例如,上述的 Runnable Lambda 表示式經過 desugar 之後,就會被轉換為具體的實現類,並將生成的實現類直接寫入到 dex 檔案中,就如同普通的匿名內部類一樣,因此也就不存在相容性問題了,從而保證了 Lambda 表示式也能夠在 Android 低版本系統上正常執行

位元組碼插樁

由於 Android APK 編譯流程中 Transform 和 desugar 兩個操作的先後順序問題,就給我們的位元組碼插樁帶來了一點點困擾

舉個例子。我曾經通過位元組碼插樁的方式為專案實現了一個全域性的 雙擊防抖 功能。簡單來說,我通過位元組碼插樁的方式來為整個專案中所有使用了 OnClickListener 的回撥方法中都插入了一段邏輯程式碼,該段邏輯程式碼會對前後兩次點選的時間進行判斷,如果判斷到時間小於某個閾值的話就直接 return

就像以下程式碼所示

```kotlin //插樁前 view.setOnClickListener(object : View.OnClickListener { override fun onClick(view: View) { //TODO } })

//插樁後 view.setOnClickListener(object : View.OnClickListener { override fun onClick(view: View) { if (!ViewDoubleClickCheck.canClick(view)){ return } //TODO } }) ```

Kotlin 中的 object : View.OnClickListener 就相當於 Java 中的匿名內部類,在編譯階段就會直接生成具體的實現類,因此可以很直接地通過 View.OnClickListener 介面和 onClick 方法兩者的籤資訊名定位到需要插入程式碼的位置,難度不大

比較麻煩的是 Lambda 表示式

kotlin view.setOnClickListener { //TODO }

由於 AGP 的 Transform 流程是在 desugar 之前執行的,Transform 時還未生成各個 Lambda 表示式的具體實現類,所以此時的 Lambda 表示式還對應著 invokedynamic 指令,我們無法直接 “看到” Lambda 表示式對應的程式碼塊,因此也不能簡單地通過簽名信息就定位到目標方法

想要解析 Lambda 表示式,就還是要依靠上文介紹的 BootstapMethods,通過 BootstapMethods 來找到出目標方法

對於上述 Lambda 表示式,通過 ASM 框架,在位元組碼層面上我們能夠獲取到的資訊有:

  • 該表示式包含一條 invokedynamic 指令,對應 ASM 中的 InvokeDynamicInsnNode
  • invokedynamic 指令中包含了要生成的介面例項的簽名信息,即 invokedynamic 指令中標明瞭要生成的是 OnClickListener 物件,且包含一個 onClick 方法,所以此時就可以通過遍歷專案全域性的 InvokeDynamicInsnNode 的 name 和 desc 兩個屬性,來查詢到和 OnClickListener Lambda 表示式關聯的 InvokeDynamicInsnNode
  • 上文已經講到,invokedynamic 指令指向了位元組碼中的 BootstapMethod 區域,而 BootstapMethod 中已經標明瞭三個入參引數,第二個引數指向的是編譯期間自動生成的方法,當中就包含了 onClick 方法應該執行的程式碼塊。這三個引數就對應 InvokeDynamicInsnNode 的 bsmArgs 屬性,所以通過 bsmArgs 我們就能夠知道 onClick 方法最終要呼叫的方法的簽名信息,通過向該方法插入需要的邏輯就可以實現插樁了

所以說,對於匿名內部類,我們的插樁思路是向 OnClickListener 介面的實現類的 onClick 方法插入程式碼;對於 Lambda 表示式,我們的插樁思路可以改為向其自動生成的方法插入程式碼,兩者的最終效果都是一樣的

對應具體程式碼

第一步。需要先遍歷每一個 MethodNode 包含的所有指令 instructions,找出 name 和 desc 都符合的 InvokeDynamicInsnNode。此處之所以通過 endsWith 而非 equals 來篩選 desc,是因為 Lambda 有可能引用了外部例項,此時外部例項就會成為 OnClickListener 實現類的構造引數,那麼 desc 就會變成類似於 (Lgithub/leavesczy/asm/MainActivity;)Landroid/view/View$OnClickListener; 這樣的形式,所以需要通過 endsWith 來進行篩選

```kotlin val dynamicInsnNodes = methodNode.filterLambda { val nodeName = it.name val nodeDesc = it.desc nodeName == "onClick" && nodeDesc.endsWith("Landroid/view/View\$OnClickListener;") }

fun MethodNode.filterLambda(filter: (InvokeDynamicInsnNode) -> Boolean): List { val mInstructions = instructions ?: return emptyList() val dynamicList = mutableListOf() mInstructions.forEach { instruction -> if (instruction is InvokeDynamicInsnNode) { if (filter(instruction)) { dynamicList.add(instruction) } } } return dynamicList } ```

第二步。找到需要插樁的 Lambda 表示式後,拿到 Method arguments 的第二個引數 it.bsmArgs[1],該值就對應編譯器要自動生成的方法,再通過該方法的簽名信息 nameWithDesc 從 methods 中篩選出對應的 MethodNode,通過向該方法植入程式碼就可以實現插樁了

```kotlin val shouldHookMethodList = mutableSetOf()

dynamicInsnNodes.forEach { val handle = it.bsmArgs[1] as? Handle if (handle != null) { val nameWithDesc = handle.name + handle.desc val method = methods.find { it.nameWithDesc == nameWithDesc }!! shouldHookMethodList.add(method) } } ```

第三步。此步驟就要來向目標方法植入程式碼了,但還有個問題需要先解決。由於 匿名內部類 和 Lambda 表示式 都有可能引用到了外部例項物件,因此經過 desugar 後,就會像以下程式碼所示,MainActivity 成為 OnClickListener 實現類的構造引數,該實現類再通過 MainActivity 物件來呼叫目標方法。這使得我們需要先知道 View 物件是作為當前方法的第幾個引數,取到值後才能去呼叫 ViewDoubleClickCheck 進行防抖檢查

```java public final class MainActivity extends AppCompatActivity {

/* access modifiers changed from: protected */
@Override // androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, androidx.fragment.app.FragmentActivity
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    ((TextView) findViewById(R.id.tvViewDoubleClickCheck)).setOnClickListener(new MainActivity$$ExternalSyntheticLambda0(this));
}

/* access modifiers changed from: private */
/* renamed from: onCreate$lambda-0  reason: not valid java name */
public static final void m60onCreate$lambda0(MainActivity this$0, View view) {
    if (ViewDoubleClickCheck.canClick(view)) {
        //TODO
    }
}

}

public final / synthetic / class MainActivity$$ExternalSyntheticLambda0 implements View.OnClickListener { public final / synthetic / MainActivity f$0;

public /* synthetic */ MainActivity$$ExternalSyntheticLambda0(MainActivity mainActivity) {
    this.f$0 = mainActivity;
}

public final void onClick(View view) {
    MainActivity.m60onCreate$lambda0(this.f$0, view);
}

} ```

此外,點選事件不單單侷限於 setOnClickListener 方法,RecyclerView 的每一個 item 的點選事件也需要進行防抖檢查,這種情況也一樣需要解析出 View 物件是作為當前方法的第幾個引數

kotlin val clickDemoAdapter = ClickDemoAdapter() clickDemoAdapter.setOnItemClickListener(object : OnItemClickListener { override fun onItemClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) { if (ViewDoubleClickCheck.canClick(view)) { //TODO } } })

因此,我們需要先計算出 viewArgumentIndex,通過 viewArgumentIndex 執行 ALOAD 操作載入到 View 物件,之後才能去呼叫 ViewDoubleClickCheck

kotlin private fun hookMethod(modeNode: MethodNode) { val argumentTypes = Type.getArgumentTypes(modeNode.desc) //計算 View 物件是方法的第幾個入參引數 val viewArgumentIndex = argumentTypes?.indexOfFirst { it.descriptor == "Landroid/view/View;" } ?: -1 if (viewArgumentIndex >= 0) { val instructions = modeNode.instructions if (instructions != null && instructions.size() > 0) { val list = InsnList() //引用 View 物件 list.add( VarInsnNode( Opcodes.ALOAD, getVisitPosition( argumentTypes, viewArgumentIndex, modeNode.isStatic ) ) ) //去呼叫 ViewDoubleClickCheck 的 canClick 方法 list.add( MethodInsnNode( Opcodes.INVOKESTATIC, config.formatDoubleCheckClass, config.doubleCheckMethodName, config.doubleCheckMethodDescriptor ) ) val labelNode = LabelNode() list.add(JumpInsnNode(Opcodes.IFNE, labelNode)) list.add(InsnNode(Opcodes.RETURN)) list.add(labelNode) instructions.insert(list) } } }

結尾

本文相關的程式碼都上傳到了 Github:ASM_Transform,包含了幾個完整的位元組碼插樁實踐案例,讀者可以一起參照