從 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,包含了幾個完整的字節碼插樁實踐案例,讀者可以一起參照