從《Android熱更新方案 Robust》一文可知,美團熱更新使用的是 Instant Run 的方案。本文將著重于分享美團熱更新方案中沒講到的部分,包含以下幾個方面:
作為云服務提供廠商,需要提供給客戶 SDK,SDK 發布后同樣要考慮 Bug 修復問題。此處將介紹作為 SDK 發布者的熱更新方案選型,即為什么使用美團方案&Instant Run 方案。
美團方案實現的大致結構;
ASM 插樁的過程,字節碼導讀,以及遇到的各種坑。
方案選擇
我們公司提供即時通訊服務,同時需要提供給用戶方便集成的即時通訊 SDK,每次 SDK 發布的同時也面臨 SDK 發布后緊急 Bug 的修復問題。現在市面上的熱更新方案普遍不適用 SDK 提供方使用。以阿里的 AndFix 和微信的 Tinker 為例,都是直接修改并合成新的 Apk。這樣做對于普通 App 沒有問題,但是對于 SDK 提供方是不可以的,SDK 發布者不能夠直接修改 Apk,這個事情只能由 App 開發者來進行。
Tinker 方案如圖:
女媧(Nuwa)方案,由大眾點評 Jason Ross 實現并開源,其在 classLoader 過程中,將自己的修改后的 patch 類所在的dex, 插入到 dex 隊列前面,這樣在 classloader 按照類名字加載的時候會優先加載 patch 類。
女媧方案如圖:
Nuwa 方案有一個條件約束,就是每個類都要插樁,插入一個類的引用,并且這個被引用類需要打包到單獨的 dex 文件中,這樣保證每個類都沒有被打上 CLASS_ISPREVERIFIED 標志。
具體詳細描述在早期的 hotpatch 方案:《Android App 熱補丁動態修復技術介紹》。
作為 SDK 提供者,只能提供 jar 包給用戶,無法約束用戶的 dex 生成過程,所以 Nuwa 方案無法直接應用。女媧方案是開源的,而且其中提供了 ASM 插樁示例,對于后面應用美團方案有很好參考意義。
美團& Instant Run 方案
美團方案也就是 Instant Run 的方案基本思路就是在每個函數都插樁,如果一個類存在 Bug,需要修復,就將插樁點類的changeRedirect 字段從 null 值變成 patch 類。基本原理在美團方案中有講述,但是美團文中沒有講最重要的一個問題,就是如何在每一個函數前面插樁,下面會詳細講一下。Patch 應用部分,這里忽略,因為是 Java 代碼,大家可以反編譯 Instant Run.jar,看一下大致思路,基本都能寫出來。
插樁
插樁的動作就是在每一個函數前面都插入 PatchProxy.isSupport...PatchProxy.accessDisPatch 這一系列代碼(參看美團方案)。插樁工作直接修改 class 文件,因為這樣不影響正常代碼邏輯,只有最后打包發布的時候才進行插樁。
插樁最常用的是 asm.jar。接下來的部分需要用戶先了解 asm.jar 的大致使用流程。了解這個過程最好是找個實例實踐一下,光看介紹文章是看不懂的。
ASM 有兩種方式解析 class 文件,一種是 core API, “provides an event based representation of classes”,類似解析 XML 的 SAX 的事件觸發方式、遍歷類以及類的字段,類中的方法,在遍歷的過程中會依次觸發相應的函數,比如遍歷類函數時,觸發 visitMethod(name, signature...),用戶可以在這個方法中修改函數實現。
另外一種是 tree API, “provides an object based representation”,類似解析 XML 中的 DOM 樹方式。本文中,這里使用了 core API 方式。asm.jar 有對應的 manual asm4-guide.pdf,需要仔細研讀,了解其用法。
使用 asm.jar 把 java class 反編譯為字節碼
反編譯為字節碼對應的命令是
java -classpath "asm-all.jar" org.jetbrains.org.objectweb.asm.util.ASMifier State.class
這個地方有一個坑,官方版本 asm.jar 在執行 ASMifier 命令的時候總是報錯,后來在 Android Stuidio 的目錄下面找一個 asm-all.jar 替換再執行就不出問題了。但是用 asm.jar 插樁的過程,仍然使用官方提供的 asm.jar。
插入前代碼:
class State {
long getIndex(int val) { return 100;
}
}
ASMifier 反編譯后字節碼如下:
mv = cw.visitMethod(0, "getIndex", "(I)J", null, null);mv.visitCode();mv.visitLdcInsn(new Long(100L));mv.visitInsn(LRETURN);mv.visitMaxs(2, 2);mv.visitEnd();
插樁后代碼:
long getIndex(int a) { if ($patch != null) { if (PatchProxy.isSupport(new Object[0], this, $patch, false)) { return ((Long) com.hyphenate.patch.PatchProxy.accessDispatch(new Object[] {a}, this, $patch, false)).longValue();
}
} return 100;
}
ASMifier 反編譯后代碼如下:
mv = cw.visitMethod(ACC_PUBLIC, "getIndex", "(I)J", null, null);mv.visitCode();mv.visitFieldInsn(GETSTATIC, "com/hyphenate/State", "$patch", "Lcom/hyphenate/patch/PatchReDirection;");Label l0 = new Label();mv.visitJumpInsn(IFNULL, l0);mv.visitInsn(ICONST_0);mv.visitTypeInsn(ANEWARRAY, "java/lang/Object");mv.visitVarInsn(ALOAD, 0);mv.visitFieldInsn(GETSTATIC, "com/hyphenate/State", "$patch", "Lcom/hyphenate/patch/PatchReDirection;");mv.visitInsn(ICONST_0);mv.visitMethodInsn(INVOKESTATIC, "com/hyphenate/patch/PatchProxy", "isSupport", "([Ljava/lang/Object;Ljava/lang/Object;Lcom/hyphenate/patch/PatchReDirection;Z)Z", false);mv.visitJumpInsn(IFEQ, l0);mv.visitIntInsn(BIPUSH, 1);mv.visitTypeInsn(ANEWARRAY, "java/lang/Object");mv.visitInsn(DUP);mv.visitIntInsn(BIPUSH, 0);mv.visitVarInsn(ILOAD, 1);mv.visitMethodInsn(INVOKESTATIC, "java/lang/Integer", "valueOf", "(I)Ljava/lang/Integer;", false);mv.visitInsn(AASTORE);mv.visitVarInsn(ALOAD, 0);mv.visitFieldInsn(GETSTATIC, "com/hyphenate/State", "$patch", "Lcom/hyphenate/patch/PatchReDirection;");mv.visitInsn(ICONST_0);mv.visitMethodInsn(INVOKESTATIC, "com/hyphenate/patch/PatchProxy", "accessDispatch", "([Ljava/lang/Object;Ljava/lang/Object;Lcom/hyphenate/patch/PatchReDirection;Z)Ljava/lang/Object;", false);mv.visitTypeInsn(CHECKCAST, "java/lang/Long");mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Long", "longValue", "J", false);mv.visitInsn(LRETURN);mv.visitLabel(l0);mv.visitFrame(Opcodes.F_SAME, 0, null, 0, null);mv.visitLdcInsn(new Long(100L));mv.visitInsn(LRETURN);mv.visitMaxs(4, 2);mv.visitEnd();
對于插樁程序來說,需要做的就是把差異部分插樁到代碼中。
需要將全部入參傳遞給 patch 方法,插入的代碼因此會根據入參進行調整,同時也要處理返回值.
可以觀察上面代碼,上面的例子顯示了一個 int 型入參 a,裝箱變成 Integer,放在一個 Object[] 數組中,先后調用isSupport 和 accessDispatch,傳遞給 patch 類的對應方法,patch 返回類型是 Long,然后調用 longValue,拆箱變成 long 類型。
對于普通的 Java 對象,因為均派生自 Object,所以對象的引用直接放在數組中;對于 primitive 類型(包括 int, long, float….)的處理,需要先調用 Integer, Boolean, Float 等 Java 對象的構造函數,將 primitive 類型裝箱后作為 object 對象放在數組中。
如果原來函數返回結果的是 primitive 類型,需要插樁代碼將其轉化為 primitive 類型。還要處理數組類型,和 void 類型。Java 的 primitive 類型在 Java Virtual Machine Specification 中有定義。
這個插入過程有兩個關鍵問題,一個是函數 signature 的解析,另外一個是適配這個參數變化插入代碼。下面詳細解釋下:
@Overridepublic MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
這個函數是 asm.jar 訪問類函數時觸發的事件,desc 變量對應 Java jni 中的 signature,比如這里是'(I)J', 需要解析并轉換成 primitive 類型、類、數組、void。這部分代碼參考了 Android 底層的源碼libcore/luni/src/main/java/libcore/reflect,和 Sun Java 的 SignatureParser.java,都有反映了這個遍歷過程。
關于 Java 字節碼的理解,匯編指令主要是看 Java bytecode instruction listings。
理解 Java 字節碼,需要理解 JVM 中的棧的結構。JVM 是一個基于棧的架構。方法執行的時候(包括 main 方法),在棧上會分配一個新的幀,這個棧幀包含一組局部變量。這組局部變量包含了方法運行過程中用到的所有變量,包括 this 引用,所有的方法參數,以及其它局部定義的變量。對于類方法(也就是 static 方法)來說,方法參數是從第 0 個位置開始的(+微信關注網絡世界),而對于實例方法來說,第 0 個位置上的變量是 this 指針。(引自:Java 字節碼淺析)
分析中間部分字節碼實現:
com.hyphenate.patch.PatchProxy.accessDispatch(new Object[] {a}, this, $patch, false))
對應字節碼如下,請對照 Java bytecode instruction listings 中每條指令觀察對應棧幀的變化,下面注釋中''中表示棧幀中的內容。
mv.visitIntInsn(BIPUSH, 1); # 數字 1 入棧,對應 new Object[1]數組長度 1。 棧:[1]mv.visitTypeInsn(ANEWARRAY, "java/lang/Object"); # ANEWARRY:count(1) → arrayref, 棧:[arr_ref]mv.visitInsn(DUP); # 棧:[arr_ref, arr_ref]mv.visitIntInsn(BIPUSH, 0); # 棧:[arr_ref, arr_ref, 0]mv.visitVarInsn(ILOAD, 1); # 局部變量位置 1 的內容入棧, 棧:[arr_ref, arr_ref, 0, a]mv.visitMethodInsn(INVOKESTATIC, "java/lang/Integer", "valueOf", "(I)Ljava/lang/Integer;", false); # 調用 Integer.valueOf, INVOKESTATIC: [arg1, arg2, ...] → result, 棧:[arr_ref, arr_ref, 0, integerObjectOf_a]mv.visitInsn(AASTORE); # store a reference into array: arrayref, index, value →, 棧:[arr_ref]mv.visitVarInsn(ALOAD, 0); # this 入棧,棧:[arr_ref, this]mv.visitFieldInsn(GETSTATIC, "com/hyphenate/State", "$patch", "Lcom/hyphenate/patch/PatchReDirection;"); #$patch 入棧,棧:[arr_ref, this, $patch]mv.visitInsn(ICONST_0); #false 入棧, # 棧:[arr_ref, this, $patch, false]mv.visitMethodInsn(INVOKESTATIC, "com/hyphenate/patch/PatchProxy", "accessDispatch", "([Ljava/lang/Object;Ljava/lang/Object;Lcom/hyphenate/patch/PatchReDirection;Z)Ljava/lang/Object;", false); # 調用 accessDispatch, 棧包含返回結果,棧:[longObject]
熟悉上面的字節碼以及對應的棧幀變化,也就掌握了插樁過程。
坑
ClassVisitor.visitMethod()中 access 如果是 ACC_SYNTHETIC 或者 ACC_BRIDGE,插樁后無法正常運行。ACC_SYNTHETIC 表示函數由 JAVAC 自動生成的,enum 類型就會產生這種類型的方法,不需要插樁,直接略過。因為觀察到模版類也會產生 ACC_SYNTHETIC,所以插樁過程跳過了模版類。
ClassVisitor.visit()函數對應遍歷到類觸發的事件,access 如果是 ACC_INTERFACE 或者 ACC_ENUM,無需插樁。簡單說就是接口和 enum 不涉及方法修改,無需插樁。
靜態方法的實現和普通類成員函數略有出入,對于匯編程序來說,本地棧的第一個位置,如果是普通方法,會存儲 this 引用,static 方法沒有 this,這里稍微調整一下就可以實現的。
不定參數由于要求連續輸入的參數類型相同,被編譯器直接變成了數組,所以對本程序沒有造成影響。
大小
插樁因為對每個函數都插樁,反編譯后看實際上增加了大量代碼,甚至可以說插入的代碼比原有的代碼還要多。但是實際上最終生成的 jar 包增長了大概 20% 多一點,并沒有想的那么多,在可接受范圍內。因為 class 所占的空間不止是代碼部分,還包括類描述、字段描述、方法描述、const-pool 等,代碼段只占其中的不到一半。可以參考 The class File Format。
討論
前面代碼插樁的部分和美團熱更文章中保持一致,實際上還有些細節還可以調整。isSupport 這個函數的參數可以調整如下:
if (PatchProxy.isSupport(“getIndex”, "(I)J", false)) {
這樣能減小插樁部分代碼,而且可以區分名字相同的函數。
PatchProxy.isSupport 最后一個參數表示是普通類函數還是 static 函數,這個是方便 Java 應用 patch 的時候處理。
源碼地址:https://github.com/easemob/empatch