一起來玩轉Android專案中的位元組碼
作為Android開發,日常寫Java程式碼之餘,是否想過,玩玩class檔案?直接對class檔案的位元組碼下手,我們可以做很多好玩的事情,比如:
- 對全域性所有class插樁,做UI,記憶體,網路等等方面的效能監控
- 發現某個第三方依賴,用起來不爽,但是不想拿它的原始碼修改再重新編譯,而想對它的class直接做點手腳
- 每次寫打log時,想讓TAG自動生成,讓它預設就是當前類的名稱,甚至你想讓log裡自動加上當前程式碼所在的行數,更方便定位日誌位置
- Java自帶的動態代理太弱了,只能對介面類做動態代理,而我們想對任何類做動態代理
為了實現上面這些想法,可能我們最開始的第一反應,都是能否通過程式碼生成技術、APT,抑或反射、抑或動態代理來實現,但是想來想去,貌似這些方案都不能很好滿足上面的需求,而且,有些問題不能從Java檔案入手,而應該從class檔案尋找突破。而從class檔案入手,我們就不得不來近距離接觸一下位元組碼!
JVM平臺上,修改、生成位元組碼無處不在,從ORM框架(如Hibernate, MyBatis)到Mock框架(如Mockio),再到Java Web中的常青樹Spring框架,再到新興的JVM語言 ofollow,noindex">Kotlin的編譯器 ,還有大名鼎鼎的 cglib 專案,都有位元組碼的身影。
位元組碼相關技術的強大之處自然不用多說,而且在Android開發中,無論是使用Java開發和Kotlin開發,都是JVM平臺的語言,所以如果我們在Android開發中,使用位元組碼技術做一下hack,還可以天然地相容Java和Kotlin語言。
今天寫這篇文章,分享自己摸索相關技術過程中的一些雞肋。
這個專案主要使用的技術是Android gradle外掛,Transform,ASM與位元組碼基礎。這篇文章將主要圍繞以下幾個技術點展開:
- Transform的應用、原理、優化
- ASM的應用,開發流,以及與Android工程的適配
- 幾個具體應用案例
所以閱讀這篇文章,讀者最好有Android開發以及編寫簡單Gradle外掛的背景知識。
話不多說,讓我們開始吧。
一、Transform
引入Transform
Transform 是Android gradle plugin 1.5開始引入的概念。
我們先從如何引入Transform依賴說起,首先我們需要編寫一個自定義外掛,然後在外掛中註冊一個自定義Transform。這其中我們需要先通過gradle引入Transform的依賴,這裡有一個坑,Transform的庫最開始是獨立的,後來從2.0.0版本開始,被歸入了Android編譯系統依賴的gradle-api中,讓我們看看Transform在 jcenter 上的歷個版本。

所以,很久很久以前我引入transform依賴是這樣
compile 'com.android.tools.build:transform-api:1.5.0'
現在是這樣
//從2.0.0版本開始就是在gradle-api中了 implementation 'com.android.tools.build:gradle-api:3.1.4'
然後,讓我們在自定義外掛中註冊一個自定義Transform,gradle外掛可以使用java,groovy,kotlin編寫,我這裡選擇使用java。
public class CustomPlugin implements Plugin<Project> { @SuppressWarnings("NullableProblems") @Override public void apply(Project project) { AppExtension appExtension = (AppExtension)project.getProperties().get("android"); appExtension.registerTransform(new CustomTransform(), Collections.EMPTY_LIST); } }
那麼如何寫一個自定義Transform呢?
Transform的原理與應用
介紹如何應用Transform之前,我們先介紹Transform的原理,一圖勝千言

每個Transform其實都是一個gradle task,Android編譯器中的TaskManager將每個Transform串連起來,第一個Transform接收來自javac編譯的結果,以及已經拉取到在本地的第三方依賴(jar. aar),還有resource資源,注意,這裡的resource並非android專案中的res資源,而是asset目錄下的資源。這些編譯的中間產物,在Transform組成的鏈條上流動,每個Transform節點可以對class進行處理再傳遞給下一個Transform。我們常見的混淆,Desugar等邏輯,它們的實現如今都是封裝在一個個Transform中,而我們自定義的Transform,會插入到這個Transform鏈條的最前面。
但其實,上面這幅圖,只是展示Transform的其中一種情況。而Transform其實可以有兩種輸入,一種是消費型的,當前Transform需要將消費型型輸出給下一個Transform,另一種是引用型的,當前Transform可以讀取這些輸入,而不需要輸出給下一個Transform,比如Instant Run就是通過這種方式,檢查兩次編譯之間的diff的。至於怎麼在一個Transform中宣告兩種輸入,以及怎麼處理兩種輸入,後面將有示例程式碼。
為了印證Transform的工作原理和應用方式,我們也可以從Android gradle plugin原始碼入手找出證據,在TaskManager中,有一個方法 createPostCompilationTasks
.為了避免貼篇幅太長的原始碼,這裡附上鍊接
TaskManager#createPostCompilationTasks
這個方法的脈絡很清晰,我們可以看到,Jacoco,Desugar,MergeJavaRes,AdvancedProfiling,Shrinker,Proguard, JarMergeTransform, MultiDex, Dex都是通過Transform的形式一個個串聯起來。其中也有將我們自定義的Transform插進去。
講完了Transform的資料流動的原理,我們再來介紹一下Transform的輸入資料的過濾機制,Transform的資料輸入,可以通過Scope和ContentType兩個維度進行過濾。

ContentType,顧名思義,就是資料型別,在外掛開發中,我們一般只能使用CLASSES和RESOURCES兩種型別,注意,其中的CLASSES已經包含了class檔案和jar檔案

從圖中可以看到,除了CLASSES和RESOURCES,還有一些我們開發過程無法使用的型別,比如DEX檔案,這些隱藏型別在一個獨立的列舉類 ExtendedContentType 中,這些型別只能給Android編譯器使用。另外,我們一般使用 TransformManager 中提供的幾個常用的ContentType集合和Scope集合,如果是要處理所有class和jar的位元組碼,ContentType我們一般使用 TransformManager.CONTENT_CLASS
。
Scope相比ContentType則是另一個維度的過濾規則,

我們可以發現,左邊幾個型別可供我們使用,而我們一般都是組合使用這幾個型別, TransformManager 有幾個常用的Scope集合方便開發者使用。
如果是要處理所有class位元組碼,Scope我們一般使用 TransformManager.SCOPE_FULL_PROJECT
。
好,目前為止,我們介紹了Transform的資料流動的原理,輸入的型別和過濾機制,我們再寫一個簡單的自定義Transform,讓我們對Transform可以有一個更具體的認識
public class CustomTransform extends Transform { public static final String TAG = "CustomTransform"; public CustomTransform() { super(); } @Override public String getName() { return "CustomTransform"; } @Override public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException { super.transform(transformInvocation); //當前是否是增量編譯 boolean isIncremental = transformInvocation.isIncremental(); //消費型輸入,可以從中獲取jar包和class資料夾路徑。需要輸出給下一個任務 Collection<TransformInput> inputs = transformInvocation.getInputs(); //引用型輸入,無需輸出。 Collection<TransformInput> referencedInputs = transformInvocation.getReferencedInputs(); //OutputProvider管理輸出路徑,如果消費型輸入為空,你會發現OutputProvider == null TransformOutputProvider outputProvider = transformInvocation.getOutputProvider(); for(TransformInput input : inputs) { for(JarInput jarInput : input.getJarInputs()) { File dest = outputProvider.getContentLocation( jarInput.getFile().getAbsolutePath(), jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR); //將修改過的位元組碼copy到dest,就可以實現編譯期間干預位元組碼的目的了 FileUtils.copyFile(jarInput.getFile(), dest); } for(DirectoryInput directoryInput : input.getDirectoryInputs()) { File dest = outputProvider.getContentLocation(directoryInput.getName(), directoryInput.getContentTypes(), directoryInput.getScopes(), Format.DIRECTORY); //將修改過的位元組碼copy到dest,就可以實現編譯期間干預位元組碼的目的了 FileUtils.copyDirectory(directoryInput.getFile(), dest); } } } @Override public Set<QualifiedContent.ContentType> getInputTypes() { return TransformManager.CONTENT_CLASS; } @Override public Set<? super QualifiedContent.Scope> getScopes() { return TransformManager.SCOPE_FULL_PROJECT; } @Override public Set<QualifiedContent.ContentType> getOutputTypes() { return super.getOutputTypes(); } @Override public Set<? super QualifiedContent.Scope> getReferencedScopes() { return TransformManager.EMPTY_SCOPES; } @Override public Map<String, Object> getParameterInputs() { return super.getParameterInputs(); } @Override public boolean isCacheable() { return true; } @Override public boolean isIncremental() { return true; //是否開啟增量編譯 } }
可以看到,在transform方法中,我們將每個jar包和class檔案複製到dest路徑,這個dest路徑就是下一個Transform的輸入資料,而在複製時,我們就可以做一些狸貓換太子,偷天換日的事情了,先將jar包和class檔案的位元組碼做一些修改,再進行復制即可,至於怎麼修改位元組碼,就要藉助我們後面介紹的ASM了。而如果開發過程要看你當前transform處理之後的class/jar包,可以到
/build/intermediates/transforms/CustomTransform/下檢視,你會發現所有jar包命名都是123456遞增,這是正常的,這裡的命名規則可以在OutputProvider.getContentLocation的具體實現中找到
public synchronized File getContentLocation( @NonNull String name, @NonNull Set<ContentType> types, @NonNull Set<? super Scope> scopes, @NonNull Format format) { // runtime check these since it's (indirectly) called by 3rd party transforms. checkNotNull(name); checkNotNull(types); checkNotNull(scopes); checkNotNull(format); checkState(!name.isEmpty()); checkState(!types.isEmpty()); checkState(!scopes.isEmpty()); // search for an existing matching substream. for (SubStream subStream : subStreams) { // look for an existing match. This means same name, types, scopes, and format. if (name.equals(subStream.getName()) && types.equals(subStream.getTypes()) && scopes.equals(subStream.getScopes()) && format == subStream.getFormat()) { return new File(rootFolder, subStream.getFilename()); } } //按位置遞增!! // didn't find a matching output. create the new output SubStream newSubStream = new SubStream(name, nextIndex++, scopes, types, format, true); subStreams.add(newSubStream); return new File(rootFolder, newSubStream.getFilename()); }
Transform的優化:增量與併發
到此為止,看起來Transform用起來也不難,但是,如果直接這樣使用,會大大拖慢編譯時間,為了解決這個問題,摸索了一段時間後,也借鑑了Android編譯器中Desugar等幾個Transform的實現,發現我們可以使用增量編譯,並且上面transform方法遍歷處理每個jar/class的流程,其實可以併發處理,加上一般編譯流程都是在PC上,所以我們可以儘量敲詐機器的資源。
想要開啟增量編譯,我們需要重寫Transform的這個介面,返回true。
@Override public boolean isIncremental() { return true; }
雖然開啟了增量編譯,但也並非每次編譯過程都是支援增量的,畢竟一次clean build完全沒有增量的基礎,所以,我們需要檢查當前編譯是否是增量編譯。
如果不是增量編譯,則清空output目錄,然後按照前面的方式,逐個class/jar處理
如果是增量編譯,則要檢查每個檔案的Status,Status分四種,並且對這四種檔案的操作也不盡相同

- NOTCHANGED: 當前檔案不需處理,甚至複製操作都不用;
- ADDED、CHANGED: 正常處理,輸出給下一個任務;
- REMOVED: 移除outputProvider獲取路徑對應的檔案。
大概實現可以一起看看下面的程式碼
@Override public void transform(TransformInvocation transformInvocation){ Collection<TransformInput> inputs = transformInvocation.getInputs(); TransformOutputProvider outputProvider = transformInvocation.getOutputProvider(); boolean isIncremental = transformInvocation.isIncremental(); //如果非增量,則清空舊的輸出內容 if(!isIncremental) { outputProvider.deleteAll(); } for(TransformInput input : inputs) { for(JarInput jarInput : input.getJarInputs()) { Status status = jarInput.getStatus(); File dest = outputProvider.getContentLocation( jarInput.getName(), jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR); if(isIncremental && !emptyRun) { switch(status) { case NOTCHANGED: continue; case ADDED: case CHANGED: transformJar(jarInput.getFile(), dest, status); break; case REMOVED: if (dest.exists()) { FileUtils.forceDelete(dest); } break; } } else { transformJar(jarInput.getFile(), dest, status); } } for(DirectoryInput directoryInput : input.getDirectoryInputs()) { File dest = outputProvider.getContentLocation(directoryInput.getName(), directoryInput.getContentTypes(), directoryInput.getScopes(), Format.DIRECTORY); FileUtils.forceMkdir(dest); if(isIncremental && !emptyRun) { String srcDirPath = directoryInput.getFile().getAbsolutePath(); String destDirPath = dest.getAbsolutePath(); Map<File, Status> fileStatusMap = directoryInput.getChangedFiles(); for (Map.Entry<File, Status> changedFile : fileStatusMap.entrySet()) { Status status = changedFile.getValue(); File inputFile = changedFile.getKey(); String destFilePath = inputFile.getAbsolutePath().replace(srcDirPath, destDirPath); File destFile = new File(destFilePath); switch (status) { case NOTCHANGED: break; case REMOVED: if(destFile.exists()) { FileUtils.forceDelete(destFile); } break; case ADDED: case CHANGED: FileUtils.touch(destFile); transformSingleFile(inputFile, destFile, srcDirPath); break; } } } else { transformDir(directoryInput.getFile(), dest); } } } }
這就能為我們的編譯外掛提供增量的特性。
實現了增量編譯後,我們最好也支援併發編譯,併發編譯的實現並不複雜,只需要將上面處理單個jar/class的邏輯,併發處理,最後阻塞等待所有任務結束即可。
private WaitableExecutor waitableExecutor = WaitableExecutor.useGlobalSharedThreadPool(); //非同步併發處理jar/class waitableExecutor.execute(() -> { bytecodeWeaver.weaveJar(srcJar, destJar); return null; }); waitableExecutor.execute(() -> { bytecodeWeaver.weaveSingleClassToFile(file, outputFile, inputDirPath); return null; }); //等待所有任務結束 waitableExecutor.waitForTasksWithQuickFail(true);
接下來我們對編譯速度做一個對比,每個實驗都是5次同種條件下編譯10次,去除最大大小值,取平均時間
首先,在QQ郵箱Android客戶端工程中,我們先做一次cleanbuild
./gradlew clean assembleDebug --profile
給專案中新增UI耗時統計,全域性每個方法(包括普通class檔案和第三方jar包中的所有class)的第一行和最後一行都進行插樁,實現方式就是Transform+ASM,對比一下併發Transform和非併發Transform下,Tranform這一步的耗時

可以發現,併發編譯,基本比非併發編譯速度提高了80%。效果很顯著。
然後,讓我們再做另一個試驗,我們在專案中模擬日常修改某個class檔案的一行程式碼,這時是符合增量編譯的環境的。然後在剛才基礎上還是做同樣的插樁邏輯,對比增量Transform和全量Transform的差異。
./gradlew assembleDebug --profile

可以發現,增量的速度比全量的速度提升了3倍多,而且這個速度優化會隨著工程的變大而更加顯著。
資料表明,增量和併發對編譯速度的影響是很大的。而我在檢視Android gradle plugin自身的十幾個Transform時,發現它們實現方式也有一些區別,有些用kotlin寫,有些用java寫,有些支援增量,有些不支援,而且是程式碼註釋寫了一個大大的FIXME, To support incremental build。所以,講道理,現階段的Android編譯速度,還是有提升空間的。
上面我們介紹了Transform,以及如何高效地在編譯期間處理所有位元組碼,那麼具體怎麼處理位元組碼呢?接下來讓我們一起看看JVM平臺上的處理位元組碼神兵利器,ASM!
二、ASM
ASM的官網在這裡 asm.ow2.io/ ,貼一下它的主頁介紹,一起感受下它的強大
[圖片上傳失敗...(image-c59976-1544358625290)]
JVM平臺上,處理位元組碼的框架最常見的就三個,ASM,Javasist,AspectJ。我嘗試過Javasist,而AspectJ也稍有了解,最終選擇ASM,因為使用它可以更底層地處理位元組碼的每條命令,處理速度、記憶體佔用,也優於其他兩個框架。
我們可以來做一個對比,上面我們所做的計算編譯時間實驗的基礎上,做如下試驗,分別用ASM和Javasist全量處理工程所有class,並且都不開啟併發處理的情況下,一次clean build中,transform的耗時對比如下

ASM相比Javasist的優勢非常顯著,ASM相比其他位元組碼操作庫的效率和效能優勢應該毋庸置疑的,畢竟是諸多JVM語言欽定的位元組碼生成庫。
我們這部分將來介紹ASM,但是由於篇幅問題,不會從位元組碼的基礎展開介紹,會通過幾個例項的實現介紹一些位元組碼的相關知識,另外還會介紹ASM的使用,以及ASM解析class檔案結構的原理,還有應用於Android外掛開發時,遇到的問題,及其解決方案。
ASM的引入
下面是一份完整的gradle自定義plugin + transform + asm所需依賴,注意一下,此處兩個gradleApi的區別
dependencies { //使用專案中指定的gradle wrapper版本,外掛中使用的Project物件等等就來自這裡 implementation gradleApi() //使用本地的groovy implementation localGroovy() //Android編譯的大部分gradle原始碼,比如上面講到的TaskManager implementation 'com.android.tools.build:gradle:3.1.4' //這個依賴裡其實主要存了transform的依賴,注意,這個依賴不同於上面的gradleApi() implementation 'com.android.tools.build:gradle-api:3.1.4' //ASM相關 implementation 'org.ow2.asm:asm:5.1' implementation 'org.ow2.asm:asm-util:5.1' implementation 'org.ow2.asm:asm-commons:5.1' }
ASM的應用
ASM設計了兩種API型別,一種是Tree API, 一種是基於Visitor API(visitor pattern),
Tree API將class的結構讀取到記憶體,構建一個樹形結構,然後需要處理Method、Field等元素時,到樹形結構中定位到某個元素,進行操作,然後把操作再寫入新的class檔案。
Visitor API則將通過介面的方式,分離讀class和寫class的邏輯,一般通過一個ClassReader負責讀取class位元組碼,然後ClassReader通過一個ClassVisitor介面,將位元組碼的每個細節按順序通過介面的方式,傳遞給ClassVisitor(你會發現ClassVisitor中有多個visitXXXX介面),這個過程就像ClassReader帶著ClassVisitor遊覽了class位元組碼的每一個指令。
上面這兩種解析檔案結構的方式在很多處理結構化資料時都常見,一般得看需求背景選擇合適的方案,而我們的需求是這樣的,出於某個目的,尋找class檔案中的一個hook點,進行位元組碼修改,這種背景下,我們選擇Visitor API的方式比較合適。
讓我們來寫一個簡單的demo,這段程式碼很簡單,通過Visitor API讀取一個class的內容,儲存到另一個檔案
private void copy(String inputPath, String outputPath) { try { FileInputStream is = new FileInputStream(inputPath); ClassReader cr = new ClassReader(is); ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES); cr.accept(cw, 0); FileOutputStream fos = new FileOutputStream(outputPath); fos.write(cw.toByteArray()); fos.close(); } catch (IOException e) { e.printStackTrace(); } }
首先,我們通過ClassReader讀取某個class檔案,然後定義一個ClassWriter,這個ClassWriter我們可以看它原始碼,其實就是一個ClassVisitor的實現,負責將ClassReader傳遞過來的資料寫到一個位元組流中,而真正觸發這個邏輯就是通過ClassWriter的accept方式。
public void accept(ClassVisitor classVisitor, Attribute[] attributePrototypes, int parsingOptions) { // 讀取當前class的位元組碼資訊 int accessFlags = this.readUnsignedShort(currentOffset); String thisClass = this.readClass(currentOffset + 2, charBuffer); String superClass = this.readClass(currentOffset + 4, charBuffer); String[] interfaces = new String[this.readUnsignedShort(currentOffset + 6)]; //classVisitor就是剛才accept方法傳進來的ClassWriter,每次visitXXX都負責將位元組碼的資訊儲存起來 classVisitor.visit(this.readInt(this.cpInfoOffsets[1] - 7), accessFlags, thisClass, signature, superClass, interfaces); /** 略去很多visit邏輯 */ //visit Attribute while(attributes != null) { Attribute nextAttribute = attributes.nextAttribute; attributes.nextAttribute = null; classVisitor.visitAttribute(attributes); attributes = nextAttribute; } /** 略去很多visit邏輯 */ classVisitor.visitEnd(); }
最後,我們通過ClassWriter的toByteArray(),將從ClassReader傳遞到ClassWriter的位元組碼匯出,寫入新的檔案即可。這就完成了class檔案的複製,這個demo雖然很簡單,但是涵蓋了ASM使用Visitor API修改位元組碼最底層的原理,大致流程如圖

我們來分析一下,不難發現,如果我們要修改位元組碼,就是要從ClassWriter入手,上面我們提到ClassWriter中每個visitXXX(這些介面實現自ClassVisitor)都會儲存位元組碼資訊並最終可以匯出,那麼如果我們可以代理ClassWriter的介面,就可以干預最終位元組碼的生成了。
那麼上面的圖就應該是這樣

我們只要稍微看一下ClassVisitor的程式碼,發現它的建構函式,是可以接收另一個ClassVisitor的,從而通過這個ClassVisitor代理所有的方法。讓我們來看一個例子,為class中的每個方法呼叫語句的開頭和結尾插入一行程式碼
修改前的方法是這樣
private static void printTwo() { printOne(); printOne(); }
被修改後的方法是這樣
private static void printTwo() { System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); }
讓我們來看一下如何用ASM實現
private static void weave(String inputPath, String outputPath) { try { FileInputStream is = new FileInputStream(inputPath); ClassReader cr = new ClassReader(is); ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES); CallClassAdapter adapter = new CallClassAdapter(cw); cr.accept(adapter, 0); FileOutputStream fos = new FileOutputStream(outputPath); fos.write(cw.toByteArray()); fos.close(); } catch (IOException e) { e.printStackTrace(); } }
這段程式碼和上面的實現複製class的程式碼唯一區別就是,使用了CallClassAdapter,它是一個自定義的ClassVisitor,我們將ClassWriter傳遞給CallClassAdapter的建構函式。來看看它的實現
//CallClassAdapter.java public class CallClassAdapter extends ClassVisitor implements Opcodes { public CallClassAdapter(final ClassVisitor cv) { super(ASM5, cv); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); } @Override public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); return mv == null ? null : new CallMethodAdapter(name, mv); } } //CallMethodAdapter.java class CallMethodAdapter extends MethodVisitor implements Opcodes { public CallMethodAdapter(final MethodVisitor mv) { super(ASM5, mv); } @Override public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("CALL " + name); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); mv.visitMethodInsn(opcode, owner, name, desc, itf); mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("RETURN " + name); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } }
CallClassAdapter中的visitMethod使用了一個自定義的MethodVisitor—–CallMethodAdapter,它也是代理了原來的MethodVisitor,原理和ClassVisitor的代理一樣。
看到這裡,貌似使用ASM修改位元組碼的大概套路都走完了,那麼如何寫出上面 visitMethodInsn
方法中插入列印方法名的邏輯,這就需要一些位元組碼的基礎知識了,我們說過這裡不會展開介紹位元組碼,但是我們可以介紹一些快速學習位元組碼的方式,同時也是開發位元組碼相關工程一些實用的工具。
在這之前,我們先講講行號的問題
如何驗證行號
上面我們給每一句方法呼叫的前後都插入了一行日誌列印,那麼有沒有想過,這樣豈不是打亂了程式碼的行數,這樣,萬一crash了,定位堆疊豈不是亂套了。其實並不然,在上面visitMethodInsn中做的東西,其實都是在同一行中插入的程式碼,上面我們貼出來的程式碼是這樣
private static void printTwo() { System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); }
無論你用idea還是eclipse開啟上面的class檔案,都是一行行展示的,但是其實class內部真實的行數應該是這樣
private static void printTwo() { System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); }
idea下可以開啟一個選項,讓檢視class內容時,保留真正的行數
開啟後,你看到的是這樣

我們可以發現,17行和18行,分別包含了三句程式碼。
而開啟選項之前是這樣

那麼如何開啟這個選項呢?Mac下 cmd + shift + A
輸入Registry,勾選這兩個選項

其實無論位元組碼和ASM的程式碼上看,class中的所有程式碼,都是先宣告行號X,然後開始幾條位元組碼指令,這幾條位元組碼對應的程式碼都在行號X中,直到宣告下一個新的行號。
ASM code
解析來介紹,如何寫出上面生成程式碼的邏輯。首先,我們設想一下,如果要對某個class進行修改,那需要對位元組碼具體做什麼修改呢?最直觀的方法就是,先編譯生成目標class,然後看它的位元組碼和原來class的位元組碼有什麼區別(檢視位元組碼可以使用javap工具),但是這樣還不夠,其實我們最終並不是讀寫位元組碼,而是使用ASM來修改,我們這裡先做一個區別,bytecode vs ASM code,前者就是JVM意義的位元組碼,而後者是用ASM描述的bytecode,其實二者非常的接近,只是ASM code用Java程式碼來描述。所以,我們應該是對比ASM code,而不是對比bytecode。對比ASM code的diff,基本就是我們要做的修改。
而ASM也提供了一個這樣的類:ASMifier,它可以生成ASM code,但是,其實還有更快捷的工具,Intellij IDEA有一個外掛
Asm Bytecode Outline ,可以檢視一個class檔案的bytecode和ASM code。
到此為止,貌似使用對比ASM code的方式,來實現位元組碼修改也不難,但是,這種方式只是可以實現一些修改位元組碼的基礎場景,還有很多場景是需要對位元組碼有一些基礎知識才能做到,而且,要閱讀懂ASM code,也是需要一定位元組碼的的知識。所以,如果要開發位元組碼工程,還是需要學習一番位元組碼。
ClassWriter在Android上的坑
如果我們直接按上面的套路,將ASM應用到Android編譯外掛中,會踩到一個坑,這個坑來自於ClassWriter,具體是因為ClassWriter其中的一個邏輯,尋找兩個類的共同父類。可以看看ClassWriter中的這個方法getCommonSuperClass,
/** * Returns the common super type of the two given types. The default * implementation of this method <i>loads</i> the two given classes and uses * the java.lang.Class methods to find the common super class. It can be * overridden to compute this common super type in other ways, in particular * without actually loading any class, or to take into account the class * that is currently being generated by this ClassWriter, which can of * course not be loaded since it is under construction. * * @param type1 *the internal name of a class. * @param type2 *the internal name of another class. * @return the internal name of the common super class of the two given *classes. */ protected String getCommonSuperClass(final String type1, final String type2) { Class<?> c, d; ClassLoader classLoader = getClass().getClassLoader(); try { c = Class.forName(type1.replace('/', '.'), false, classLoader); d = Class.forName(type2.replace('/', '.'), false, classLoader); } catch (Exception e) { throw new RuntimeException(e.toString()); } if (c.isAssignableFrom(d)) { return type1; } if (d.isAssignableFrom(c)) { return type2; } if (c.isInterface() || d.isInterface()) { return "java/lang/Object"; } else { do { c = c.getSuperclass(); } while (!c.isAssignableFrom(d)); return c.getName().replace('.', '/'); } }
這個方法用於尋找兩個類的共同父類,我們可以看到它是獲取當前class的classLoader載入兩個輸入的型別,而編譯期間使用的classloader並沒有載入Android專案中的程式碼,所以我們需要一個自定義的ClassLoader,將前面提到的Transform中接收到的所有jar以及class,還有android.jar都新增到自定義ClassLoader中。(其實上面這個方法註釋中已經暗示了這個方法存在的一些問題)
如下
public static URLClassLoader getClassLoader(Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, Project project) throws MalformedURLException { ImmutableList.Builder<URL> urls = new ImmutableList.Builder<>(); String androidJarPath= getAndroidJarPath(project); File file = new File(androidJarPath); URL androidJarURL = file.toURI().toURL(); urls.add(androidJarURL); for (TransformInput totalInputs : Iterables.concat(inputs, referencedInputs)) { for (DirectoryInput directoryInput : totalInputs.getDirectoryInputs()) { if (directoryInput.getFile().isDirectory()) { urls.add(directoryInput.getFile().toURI().toURL()); } } for (JarInput jarInput : totalInputs.getJarInputs()) { if (jarInput.getFile().isFile()) { urls.add(jarInput.getFile().toURI().toURL()); } } } ImmutableList<URL> allUrls = urls.build(); URL[] classLoaderUrls = allUrls.toArray(new URL[allUrls.size()]); return new URLClassLoader(classLoaderUrls); }
但是,如果只是替換了getCommonSuperClass中的Classloader,依然還有一個更深的坑,我們可以看看前面getCommonSuperClass的實現,它是如何尋找父類的呢?它是通過Class.forName載入某個類,然後再去尋找父類,但是,但是,android.jar中的類可不能隨隨便便載入的呀,android.jar對於Android工程來說只是編譯時依賴,執行時是用Android機器上自己的android.jar。而且android.jar所有方法包括建構函式都是空實現,其中都只有一行程式碼
throw new RuntimeException("Stub!");
這樣載入某個類時,它的靜態域就會被觸發,而如果有一個static的變數剛好在宣告時被初始化,而初始化中只有一個RuntimeException,此時就會拋異常。
所以,我們不能通過這種方式來獲取父類,能否通過不需要載入class就能獲取它的父類的方式呢?謎底就在眼前,父類其實也是一個class的位元組碼中的一項資料,那麼我們就從位元組碼中查詢父類即可。最終實現是這樣。
public class ExtendClassWriter extends ClassWriter { public static final String TAG = "ExtendClassWriter"; private static final String OBJECT = "java/lang/Object"; private ClassLoader urlClassLoader; public ExtendClassWriter(ClassLoader urlClassLoader, int flags) { super(flags); this.urlClassLoader = urlClassLoader; } @Override protected String getCommonSuperClass(final String type1, final String type2) { if (type1 == null || type1.equals(OBJECT) || type2 == null || type2.equals(OBJECT)) { return OBJECT; } if (type1.equals(type2)) { return type1; } ClassReader type1ClassReader = getClassReader(type1); ClassReader type2ClassReader = getClassReader(type2); if (type1ClassReader == null || type2ClassReader == null) { return OBJECT; } if (isInterface(type1ClassReader)) { String interfaceName = type1; if (isImplements(interfaceName, type2ClassReader)) { return interfaceName; } if (isInterface(type2ClassReader)) { interfaceName = type2; if (isImplements(interfaceName, type1ClassReader)) { return interfaceName; } } return OBJECT; } if (isInterface(type2ClassReader)) { String interfaceName = type2; if (isImplements(interfaceName, type1ClassReader)) { return interfaceName; } return OBJECT; } final Set<String> superClassNames = new HashSet<String>(); superClassNames.add(type1); superClassNames.add(type2); String type1SuperClassName = type1ClassReader.getSuperName(); if (!superClassNames.add(type1SuperClassName)) { return type1SuperClassName; } String type2SuperClassName = type2ClassReader.getSuperName(); if (!superClassNames.add(type2SuperClassName)) { return type2SuperClassName; } while (type1SuperClassName != null || type2SuperClassName != null) { if (type1SuperClassName != null) { type1SuperClassName = getSuperClassName(type1SuperClassName); if (type1SuperClassName != null) { if (!superClassNames.add(type1SuperClassName)) { return type1SuperClassName; } } } if (type2SuperClassName != null) { type2SuperClassName = getSuperClassName(type2SuperClassName); if (type2SuperClassName != null) { if (!superClassNames.add(type2SuperClassName)) { return type2SuperClassName; } } } } return OBJECT; } private boolean isImplements(final String interfaceName, final ClassReader classReader) { ClassReader classInfo = classReader; while (classInfo != null) { final String[] interfaceNames = classInfo.getInterfaces(); for (String name : interfaceNames) { if (name != null && name.equals(interfaceName)) { return true; } } for (String name : interfaceNames) { if(name != null) { final ClassReader interfaceInfo = getClassReader(name); if (interfaceInfo != null) { if (isImplements(interfaceName, interfaceInfo)) { return true; } } } } final String superClassName = classInfo.getSuperName(); if (superClassName == null || superClassName.equals(OBJECT)) { break; } classInfo = getClassReader(superClassName); } return false; } private boolean isInterface(final ClassReader classReader) { return (classReader.getAccess() & Opcodes.ACC_INTERFACE) != 0; } private String getSuperClassName(final String className) { final ClassReader classReader = getClassReader(className); if (classReader == null) { return null; } return classReader.getSuperName(); } private ClassReader getClassReader(final String className) { InputStream inputStream = urlClassLoader.getResourceAsStream(className + ".class"); try { if (inputStream != null) { return new ClassReader(inputStream); } } catch (IOException ignored) { } finally { if (inputStream != null) { try { inputStream.close(); } catch (IOException ignored) { } } } return null; } }
到此為止,我們介紹了在Android上實現修改位元組碼的兩個基礎技術Transform+ASM,介紹了其原理和應用,分析了效能優化以及在Android平臺上的適配等。在此基礎上,我抽象出一個輪子,讓開發者寫位元組碼外掛時,只需要寫少量的ASM code即可,而不需關心Transform和ASM背後的很多細節。詳見
萬事俱備,只欠寫一個外掛來玩玩了,讓我們來看看幾個應用案例。
應用案例
先拋結論,修改位元組碼其實也有套路,一種是hack程式碼呼叫,一種是hack程式碼實現。
比如修改Android Framework(android.jar)的實現,你是沒辦法在編譯期間達到這個目的的,因為最終Android Framework的class在Android裝置上。所以這種情況下你需要從hack程式碼呼叫入手,比如Log.i(TAG, “hello”),你不可能hack其中的實現,但是你可以把它hack成HackLog.i(TAG, “seeyou”)。
而如果是要修改第三方依賴或者工程中寫的程式碼,則可以直接hack程式碼實現,但是,當如果你要插入的位元組碼比較多時,也可以通過一定技巧減少寫ASM code的量,你可以將大部分可以抽象的邏輯抽象到某個寫好的class中,然後ASM code只需寫呼叫這個寫好的class的語句。
當然上面只是目前按照我的經驗做的一點總結,還是有一些更復雜的情況要具體情況具體分析,比如在實現類似JakeWharton的 hugo 的功能時,在程式碼開頭獲取方法引數名時我就遇到棘手的問題(用了一種二次掃描的方式解決了這個問題,可以移步專案主頁參考具體實現)。
我們這裡挑選OkHttp-Plugin的實現進行分析、演示如何使用Huntet框架開發一個位元組碼編譯外掛。
使用OkHttp的人知道,OkHttp裡每一個OkHttp都可以設定自己獨立的Intercepter/Dns/EventListener(EventListener是okhttp3.11新增),但是需要對全域性所有OkHttp設定統一的Intercepter/Dns/EventListener就很麻煩,需要一處處設定,而且一些第三方依賴中的OkHttp很大可能無法設定。曾經在官方repo提過這個問題的 issue ,沒有得到很好的回覆,作者之一覺得如果是他,他會用依賴注入的方式來實現統一的Okhttp配置,但是這種方式只能說可行但是不理想,後臺在reddit發 帖子 安利自己Hunter這個輪子時,JakeWharton大佬竟然親自回答了,雖然面對大佬,不過還是要正面剛!爭論一波之後,總結一下他的立場,大概如下
他覺得我說的好像這是okhttp的鍋,然而這其實是okhttp的一個feature,他覺得全域性狀態是一種不好的編碼,所以在設計okhttp沒有提供全域性Intercepter/Dns/EventListener的介面。而第三方依賴庫不能設定自定義Intercepter/Dns/EventListener這是它們的鍋。
但是,他的觀點我不完全同意,雖然全域性狀態確實是一種不好的設計,但是,如果要做效能監控之類的功能,這就很難避免或多或少的全域性侵入。(不過我確實措辭不當,說得這好像是Okhttp的鍋一樣)
言歸正傳,來看看我們要怎麼來對OkHttp動刀,請看以下程式碼
public Builder(){ this.dispatcher = new Dispatcher(); this.protocols = OkHttpClient.DEFAULT_PROTOCOLS; this.connectionSpecs = OkHttpClient.DEFAULT_CONNECTION_SPECS; this.eventListenerFactory = EventListener.factory(EventListener.NONE); this.proxySelector = ProxySelector.getDefault(); this.cookieJar = CookieJar.NO_COOKIES; this.socketFactory = SocketFactory.getDefault(); this.hostnameVerifier = OkHostnameVerifier.INSTANCE; this.certificatePinner = CertificatePinner.DEFAULT; this.proxyAuthenticator = Authenticator.NONE; this.authenticator = Authenticator.NONE; this.connectionPool = new ConnectionPool(); this.dns = Dns.SYSTEM; this.followSslRedirects = true; this.followRedirects = true; this.retryOnConnectionFailure = true; this.connectTimeout = 10000; this.readTimeout = 10000; this.writeTimeout = 10000; this.pingInterval = 0; this.eventListenerFactory = OkHttpHooker.globalEventFactory; this.dns = OkHttpHooker.globalDns; this.interceptors.addAll(OkHttpHooker.globalInterceptors); this.networkInterceptors.addAll(OkHttpHooker.globalNetworkInterceptors); }
這是OkhttpClient中內部類Builder的建構函式,我們的目標是在方法末尾加上四行程式碼,這樣一來,所有的OkHttpClient都會擁有共同的Intercepter/Dns/EventListener。我們再來看看OkHttpHooker的實現
public class OkHttpHooker { public static EventListener.Factory globalEventFactory = new EventListener.Factory() { public EventListener create(Call call) { return EventListener.NONE; } };; public static Dns globalDns = Dns.SYSTEM; public static List<Interceptor> globalInterceptors = new ArrayList<>(); public static List<Interceptor> globalNetworkInterceptors = new ArrayList<>(); public static void installEventListenerFactory(EventListener.Factory factory) { globalEventFactory = factory; } public static void installDns(Dns dns) { globalDns = dns; } public static void installInterceptor(Interceptor interceptor) { if(interceptor != null) globalInterceptors.add(interceptor); } public static void installNetworkInterceptors(Interceptor networkInterceptor) { if(networkInterceptor != null) globalNetworkInterceptors.add(networkInterceptor); } }
這樣,只需要為OkHttpHooker預先install好幾個全域性的Intercepter/Dns/EventListener即可。
那麼,如何來實現上面OkhttpClient內部Builder中插入四行程式碼呢?
首先,我們通過Hunter的框架,可以隱藏掉Transform和ASM絕大部分細節,我們只需把注意力放在寫ClassVisitor以及MethodVisitor即可。我們一共需要做以下幾步
1、新建一個自定義transform,新增到一個自定義gradle plugin中
2、繼承HunterTransform實現自定義transform
3、實現自定義的ClassVisitor,並依情況實現自定義MethodVisitor
其中第一步文章講解transform一部分有講到,基本是一樣簡短的寫法,我們從第二步講起
繼承HunterTransform,就可以讓你的transform具備併發、增量的功能。
final class OkHttpHunterTransform extends HunterTransform { private Project project; private OkHttpHunterExtension okHttpHunterExtension; public OkHttpHunterTransform(Project project) { super(project); this.project = project; //依情況而定,看看你需不需要有外掛擴充套件 project.getExtensions().create("okHttpHunterExt", OkHttpHunterExtension.class); //必須的一步,繼承BaseWeaver,幫你隱藏ASM細節 this.bytecodeWeaver = new OkHttpWeaver(); } @Override public void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException { okHttpHunterExtension = (OkHttpHunterExtension) project.getExtensions().getByName("okHttpHunterExt"); super.transform(context, inputs, referencedInputs, outputProvider, isIncremental); } // 用於控制修改位元組碼在哪些debug包還是release包下發揮作用,或者完全開啟/關閉 @Override protected RunVariant getRunVariant() { return okHttpHunterExtension.runVariant; } } //BaseWeaver幫你隱藏了ASM的很多複雜邏輯 public final class OkHttpWeaver extends BaseWeaver { @Override protected ClassVisitor wrapClassWriter(ClassWriter classWriter) { return new OkHttpClassAdapter(classWriter); } } //外掛擴充套件 public class OkHttpHunterExtension { public RunVariant runVariant = RunVariant.ALWAYS; @Override public String toString() { return "OkHttpHunterExtension{" + "runVariant=" + runVariant + '}'; } }
好了,Transform寫起來就變得這麼簡單,接下來看自定義ClassVisitor,它在OkHttpWeaver返回。
我們新建一個ClassVisitor(自定義ClassVisitor是為了代理ClassWriter,前面講過)
public final class OkHttpClassAdapter extends ClassVisitor{ private String className; OkHttpClassAdapter(final ClassVisitor cv) { super(Opcodes.ASM5, cv); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); this.className = name; } @Override public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); if(className.equals("okhttp3/OkHttpClient$Builder")) { return mv == null ? null : new OkHttpMethodAdapter(className + File.separator + name, access, desc, mv); } else { return mv; } } }
我們尋找出 okhttp3/OkHttpClient$Builder
這個類,其他類不管它,那麼其他類只會被普通的複製,而 okhttp3/OkHttpClient$Builder
將會有自定義的MethodVisitor來處理
我們來看看這個MethodVisitor的實現
public final class OkHttpMethodAdapter extends LocalVariablesSorter implements Opcodes { private boolean defaultOkhttpClientBuilderInitMethod = false; OkHttpMethodAdapter(String name, int access, String desc, MethodVisitor mv) { super(Opcodes.ASM5, access, desc, mv); if ("okhttp3/OkHttpClient$Builder/<init>".equals(name) && "()V".equals(desc)) { defaultOkhttpClientBuilderInitMethod = true; } } @Override public void visitInsn(int opcode) { if(defaultOkhttpClientBuilderInitMethod) { if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) { //EventListenFactory mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalEventFactory", "Lokhttp3/EventListener$Factory;"); mv.visitFieldInsn(PUTFIELD, "okhttp3/OkHttpClient$Builder", "eventListenerFactory", "Lokhttp3/EventListener$Factory;"); //Dns mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalDns", "Lokhttp3/Dns;"); mv.visitFieldInsn(PUTFIELD, "okhttp3/OkHttpClient$Builder", "dns", "Lokhttp3/Dns;"); //Interceptor mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "interceptors", "Ljava/util/List;"); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalInterceptors", "Ljava/util/List;"); mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true); mv.visitInsn(POP); //NetworkInterceptor mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "networkInterceptors", "Ljava/util/List;"); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalNetworkInterceptors", "Ljava/util/List;"); mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true); mv.visitInsn(POP); } } super.visitInsn(opcode); } }
首先,我們先找出 okhttp3/OkHttpClient$Builder
的建構函式,然後在這個建構函式的末尾,執行插入位元組碼的邏輯,我們可以發現,位元組碼的指令是符合逆波蘭式的,都是運算元在前,操作符在後。
至此,我們只需要釋出外掛,然後apply到我們的專案中即可。
藉助Hunter框架,我們很輕鬆就成功hack了Okhttp,我們就可以用全域性統一的Intercepter/Dns/EventListener來監控我們APP的網路了。
講到這裡,就完整得介紹瞭如何使用Hunter框架開發一個位元組碼編譯外掛,對第三方依賴庫為所欲為。如果對於程式碼還有疑惑,可以移步專案主頁,參考完整程式碼,以及其他幾個外掛的實現。
總結
這篇文章寫到這裡差不多了,全文主要圍繞Hunter展開介紹,分析瞭如何開發一個高效的修改位元組碼的編譯外掛,以及ASM位元組碼技術的一些相關工作流和開發套路。現在加Android開發群;701740775,可免費領取一份最新Android高階架構技術體系大綱和視訊資料,以及這些年積累整理的所有面試資源筆記。加群請備註簡書領取xx資料