寫個更牛逼的Transform | Plugin 進階教程

語言: CN / TW / HK

連結:

https://juejin.cn/post/6916304559602139149

蝦哥的文章內容不是那麼循序漸進,很多同學表示不好理解。但是文章內容都是極其不錯的,對於走效能優化路線的同學,部落格都有極高的借鑑價值,而且他本人也在不斷進步,從他的文章和開源專案的迭代中可以學習到很多精深的技巧。

文章主要要演示的程式碼都在github上  https://github.com/Leifzhang/AndroidAutoTrack

還是我那個90年的老安卓,這算是一篇自吹自擂的裝逼爽文。首先我寫這個 AndroidAutoTrack Demo 的原因很簡單,我就單純覺得很好玩,然後同時其實對於自己的技術水平是會有成長的。我最近下班在優化以前寫的自動化埋點。我看過很多文章介紹這個,但是我覺得都是一些入門相關的,很難有一些更深入一點的文章。

Plugin 外掛或者說 Transform ,我個人覺得說難其實也不難,但對於新入門的人來說,這個東西非常的不友善,gralde 官方資料都是英文的,然後 Gralde Plugin 的編寫除錯又比較繁瑣,如果中間碰到了什麼問題,如果沒人帶你一把,你也走進死衚衕了。

所以並不是各位看下文章就能完全搞懂這個的,我個人覺得如果沒有人帶你的情況下,你基本很難學會這個東西。我的專案內有如何通過構 ComposeBuilding 簡單方便的寫一個 Gradle Plugin 的方式,我的上一篇文章也有對應的介紹。協程 路由 元件化 1+1+1>3 | 掘金年度徵文

https://juejin.cn/post/6908232077200588814

開始我的表演

一個合格的 Transform 外掛是需要增量編譯的功能的,我拿以前增編的文章的資料給大家做個比較好了。

全量編譯的情況下

二次增量編譯情況下

我們拋開別的Task,同樣一個 Transform 全量編譯的耗時是2784ms,而程式碼變更增量編譯的情況下只有68ms。其中的差距之大也值得各位去把增量編譯給寫出來了。

當專案後期程式碼持續不斷的增加之後,不可避免的 Transform 變多了,只要有任意的一個 Transform 不是增量的,就會導致整個編譯 Transform 過程都變成全量。這個以前我也介紹過,其實有很多系統的 Transform 任務,比如 Shrink Dex 合併等等。

你們想一想哦,一個人優化了1分半的編譯時間的話,那麼如果團隊人員一多,那麼豈不就是Kpi美滋滋。

如何去實現一個增編

有興趣的大佬們可以看下這個地址  https://github.com/Leifzhang/AndroidAutoTrack/tree/master/Plugin/BasePlugin/src/main/java/com/kronos/plugin/base

首先我將 Transform 流程進行了一次抽象,主要是因為我比較懶,同樣程式碼和功能如果要讓我複製黏貼好兩遍其實我都不樂意。所以我先對這部分程式碼進行了梳理,整理出來兩個部分,第一就是檔案的複製拷貝,第二就是檔案的 ASM 操作。其中我覺第一部分的程式碼是可以進行整合的,然後就是下面的邏輯了。

先從 Transform 開始吧,簡單的說 Transform 就是一個輸入檔案集合 Collection<TransformInput> 一個輸出檔案集合 TransformOutputProvider 的過程。我們先讀取原始的 class jar ,然後我們自己對其進行加工之後生成好另外一部分 class jar ,最後把這個 Transform 的輸出產物當作下一個 Transform 的輸入產物。當 class+jar 輸入的時候,我會先把整個流資料進行一次 copy 操作,然後對這個 temp 檔案進行加工,如果 asm 操作完了,我們就將檔案進行覆蓋操作。

而在增量編譯的情況下,輸入流就會發生輕微的變更, TransformInput 會告訴我們其中變更的類是什麼,其中變更被定義為三種,無論是 Jar 還是 Class 都是一樣的。

  1. NOTCHANGED   當前檔案不需要處理跳過就行了。

  2. ADDED、CHANGED   因為我們都是先用 temp 然後覆蓋當前檔案,所以採用同樣的處理方式。

  3. REMOVED   刪除當前資料夾下的該歷史檔案。

所以當增編被呼叫的情況下,我們只是對於上述這四種不同的操作符號進行不同的處理就好了,只要幾個 if else 就能搞定了。

而一般我們在使用 asm 的時候,我們都只會操作 Class 檔案,然後根據 class 的檔名+路徑對其進行一次簡單的判斷,當前類是不是我們需要做插樁或者掃描操作的,然後我們會讀取這個檔案 byte 陣列,之後在完成 asm 操作之後返回一個 byte 陣列,之後覆蓋掉原始檔案。那麼其實我在這裡就對其進行了第一次的抽象, asm 操作被我定義成了一個介面。

package com.kronos.plugin.base

interface TransformCallBack {
fun process(className: String, classBytes: ByteArray?): ByteArray?
}

這個介面只負責接受一個檔名和一個 byte 陣列,然後方法結束返回一個 byte 陣列就行了。如果 byte 陣列非空的情況下,代表當前類被進行了位元組碼修改操作,然後我們只要把這個檔案進行一次覆蓋操作就可以了。進行了這個抽象,我們就可以把上面的檔案操作和 ASM 操作進行一次整合, sdk 使用者只需要對這個介面負責就好了。

那麼剩下來我們需要做的就是對這部分檔案的寫入進行封裝了。我是怎麼做的呢?我參考了另外一個大佬的多執行緒優化 transform 的思路,大佬的專案地址Leaking / Hunter

https://github.com/Leaking/Hunter

  1. 所有的輸入檔案先進行第一次檔案拷貝操作

  2. forecah   遍歷將每一個檔案操作壓入執行緒池中執行

  3. 獲取檔名以及 byte 陣列 呼叫我們定義的抽象介面

  4. 根據 interface   返回的 byte 生成 temp 檔案,然後進行檔案覆蓋操作

  5. 執行緒池等待所有任務執行完成之後結束 transform

DoubleTap的編譯速度優化

原來的 DoubleTap plugin 是整個專案的程式碼進行掃描的,雖然完成了增量編譯功能,同時我也過濾了很多無效掃描的邏輯,但是其實還是會拖慢整個編譯速度的。一直到我前一陣子學習了另外一個大佬的一個StringFog 專案的時候,發現大佬的常量加密的 Transform ,可以直接對 Module 生效。

https://github.com/MegatronKing/StringFog

程式碼地址  https://github.com/Leifzhang/AndroidAutoTrack/tree/master/Plugin/double_tap_plugin/src/main/java/com/kronos/doubletap

public class DoubleTapPlugin implements Plugin<Project> {


private static final String EXT_NAME = "doubleTab";

@Override
public void apply(Project project) {
boolean isApp = project.getPlugins().hasPlugin(AppPlugin.class);
project.getExtensions().create(EXT_NAME, DoubleTabConfig.class);
project.afterEvaluate(project1 -> {
DoubleTabConfig config = (DoubleTabConfig) project1.getExtensions().findByName(EXT_NAME);
if (config == null) {
config = new DoubleTabConfig();
}
config.transform();
});
if (isApp) {
AppExtension appExtension = project.getExtensions().getByType(AppExtension.class);
appExtension.registerTransform(new DoubleTapAppTransform());

return;
}
if (project.getPlugins().hasPlugin("com.android.library")) {
LibraryExtension libraryExtension = project.getExtensions().getByType(LibraryExtension.class);
libraryExtension.registerTransform(new DoubleTapLibraryTransform());
}
}
}

以前我在寫的時候一般只會給 AppExtension 註冊一個 Transform ,而在 LibraryExtension 同樣也可以註冊一個 Transform 。在 LibraryExtension 上註冊的會讓這部分位元組碼操作被使用在使用了這個 Plugin Module 上。

小貼士: 這個Transform同樣會對Aar生效哦,不僅僅是本地產物。

而這個 Transform 的程式碼上最大的差別就是,其中的輸入產物和型別有差別以外,其實別的程式碼全是一樣的。

class DoubleTapLibraryTransform : DoubleTapTransform() {

override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
return ImmutableSet.of(
QualifiedContent.Scope.PROJECT
)
}

override fun getInputTypes(): Set<QualifiedContent.ContentType>? {
return TransformManager.CONTENT_CLASS
}
}

這邊有個Scope作用域,InputTypes這幾個引數大家可以參考下別人的文章 深入理解Transform https://juejin.cn/post/6844903829671002126

我個人的一個小看法哦,如果只是一個需要針對模組內修改的話,那麼你完全不需要寫一個全域性操作的 Transform ,只需要對每個 Module 進行操作就好了。這樣有幾個好處,掃描速度會變得更快,因為我們不需要操作無關的 Jar 。另外如果 Module 沒有變更的情況下就不會參與編譯,可以變得更快。

自動化埋點的引數傳遞

我在寫自動化埋點 Demo 的時候,一直沒有特別好的解決關於引數的問題。以前留了個小坑,只能使用匿名內部類內定義的屬性,而如果是外部類的話,因為 asm 中的 ClassVisitor 寫起來,其實我感覺很不舒服,其原理都是基於事件的。當一個方法被觸發之後你要記錄下相關值,然後在另外一個函式內進行插入操作。

之前在做 ThreadPoolHook 的時候瞭解到滴滴的 Booster 內的 asm 用的都是 ClassNode ,這裡我先簡單的說下 ClassNode 好了。

ClassNode簡介

如果你仔細讀了關於位元組碼的文章後,你應該會知道Java中當一個方法被呼叫時會產生一個棧幀(Stack Frame),可以理解為那個方法被包含在了這個棧幀裡,棧幀包括3個部分,區域性變數區,運算元棧區和幀資料區.接下來我們主要要用到的是區域性變數區和運算元棧區.

一般一句簡單的 java 程式碼,被翻譯成位元組碼的情況下複雜度都會翻好幾倍,其中特別是 Java 位元組碼的棧幀。給一個方法傳遞引數,就是壓棧的操作,所以當用 ClassVisitor 直接操作的時候,我想要修改一行程式碼,其實難度都非常大。

ClassNode ClassVisito r的一個實現類,相比較於 ClassVisitor,ClassNode 已經儲存記錄了所有的 ClassVisitor 資訊,構建好了語法樹,包括方法內的程式碼以及行號,還有當前的類屬性,類資訊等等。其核心就是犧牲了記憶體,但是由於記錄了所有類資訊,所以對於複雜的多類聯動的操作,會更加方便實用。

不過TreeAPI比CoreAPI慢30%左右,記憶體佔用也高。

修改 Class ,我們只需使用 ClassTransformer ,然後在 transform 方法中修改對應的 ClassNode 即可。使用 TreeAPI CoreAPI 更耗時,記憶體佔用也多,但是對於某些複雜的修改也相對簡單。 treeAPI 被設計用於那些使用 coreAPI 一遍解析無法完成,需要解析多次的場景。

這部分如果大家有興趣詳細瞭解的話可以看下這篇文章啊,Java位元組碼(Bytecode)與ASM簡單說明。

http://blog.hakugyokurou.net/?p=409

ClassNode傳入引數

好了 show me the code 吧

class AutoTrackHelper : AsmHelper {

private val classNodeMap = hashMapOf<String, ClassNode>()

@Throws(IOException::class)
override fun modifyClass(srcClass: ByteArray): ByteArray {
val classNode = ClassNode(ASM5)
val classReader = ClassReader(srcClass)
//1 將讀入的位元組轉為classNode
classReader.accept(classNode, 0)
classNodeMap[classNode.name] = classNode
// 判斷當前類是否實現了OnClickListener介面
classNode.interfaces?.forEach {
if (it == "android/view/View\$OnClickListener") {
val field = classNode.getField()
classNode.methods?.forEach { method ->
// 找到onClick 方法
insertTrack(classNode, method, field)
}
}
}
//呼叫Fragment的onHiddenChange方法
visitFragment(classNode)
val classWriter = ClassWriter(0)
//3 將classNode轉為位元組陣列
classNode.accept(classWriter)
return classWriter.toByteArray()
}


private fun insertTrack(node: ClassNode, method: MethodNode, field: FieldNode?) {
// 判斷方法名和方法描述
if (method.name == "onClick" && method.desc == "(Landroid/view/View;)V") {
val className = node.outerClass
val parentNode = classNodeMap[className]
// 根據outClassName 獲取到外部類的Node
val parentField = field ?: parentNode?.getField()
val instructions = method.instructions
instructions?.iterator()?.forEach {
// 判斷是不是程式碼的截止點
if ((it.opcode >= Opcodes.IRETURN && it.opcode <= Opcodes.RETURN) || it.opcode == Opcodes.ATHROW) {
instructions.insertBefore(it, VarInsnNode(Opcodes.ALOAD, 1))
instructions.insertBefore(it, VarInsnNode(Opcodes.ALOAD, 1))
// 獲取到資料引數
if (parentField != null) {
parentField.apply {
instructions.insertBefore(it, VarInsnNode(Opcodes.ALOAD, 0))
instructions.insertBefore(
it, FieldInsnNode(Opcodes.GETFIELD, node.name, parentField.name, parentField.desc)
)
}
} else {
instructions.insertBefore(it, LdcInsnNode("1234"))
}
instructions.insertBefore(
it, MethodInsnNode(
Opcodes.INVOKESTATIC,
"com/wallstreetcn/sample/ToastHelper",
"toast",
"(Ljava/lang/Object;Landroid/view/View;Ljava/lang/Object;)V",
false
)
)
}
}
}
}
// 判斷Field是否包含註解
private fun ClassNode.getField(): FieldNode? {
return fields?.firstOrNull { field ->
var hasAnnotation = false
field?.visibleAnnotations?.forEach { annotation ->
if (annotation.desc == "Lcom/wallstreetcn/sample/adapter/Test;") {
hasAnnotation = true
}
}
hasAnnotation
}
}
}

這次我順便給大家畫了一個這部分邏輯的流程圖,方便大家可以搞懂這部分程式碼。這裡順便給大家展開下我之前用 ClassVisitor 的痛苦吧,這個地方可能是我的操作方式有問題哦。 asm 操作的是 .class 檔案,每一個內部類其實都是 .class 檔案,這部分掃描都是單獨的,如果你要用內部類去訪問一些外部類的 Field ,我是完全沒辦法的。因為兩個類的例項都不同,然後我整個人都感覺有點裂開了。

這次使用 ClassNode, 我用 HashMap 儲存了大部分類的 ClassNode ,然後通過 outClassName ,去獲取到 ClassNode 例項,然後就可以對其進行修改了。

上面基本上就是我所有的插樁的程式碼了,其實基本上都是字串匹配之類的,只是因為 bytecode 上的和 java 的不一樣,而且 bytecode 的可讀性比較差了點,之前也安利過大家 asm bytecode viewer 。還是很香的。

我的這個github專案,其實斷斷續續也寫了大概三年了。從第一個版本的只能照著別人程式碼抄,然後每次除錯只能發一個本地 aar ,然後從新編譯除錯。到後面雙擊的時候完成了增量編譯以及 buildSrc 編譯的能力。然後年初的時候我完成了 ThreadPool Hook Transform , 完成了位元組碼替換呼叫類,主要是拿來解決線上穩定性問題。年底的時候我更換了下編譯模式,最近我把原來最早的 AutoTrack 完成了重構以及把引數傳遞。

我覺得這個專案其實也算是見證了我的技術成長了吧,我不認為我是一個天賦異稟的人,我覺得人有時候努努力,逼一逼自己還是能學會一些你之前完全不瞭解的東西的。

文章主要要演示的程式碼都在github上 

https://github.com/Leifzhang/AndroidAutoTrack

關注我獲取更多知識或者投稿