Gradle Transform + ASM 探索

前言

使用 Gradle Transform + ASM 實現代碼插樁的使用已經很是廣泛。本文試圖探索如何更加快速簡潔的利用 Transform 實現代碼插樁,並嘗試實現java

  • 經過註解對任意類當中全部的方法實現計算方法耗時的插樁
  • 經過配置實現對任意類(主要是針對第三方庫)當中指定方法的實現計算方法耗時的插樁
  • 對工程中全部的點擊事件進行插樁,方便埋點或肯定代碼位置
  • ......

Transform + ASM 能作什麼

簡單來講就是利用 AGP 提供的 Transform 接口,在應用打包的流程中,對 java/kotlin 編譯生成的 class 文件進行二次寫操做,插入一些自定義的邏輯。這些邏輯通常是重複且有規律的,而且大機率和業務邏輯無關的。android

一些統計應用數據的 SDK,會在頁面展示和退出的生命週期函數裏,在應用編譯期插入統計相關的邏輯,統計頁面展示數據;這種對開發者很是透明的實現,一方面接入成本很是低,另外一方面也減小了三方庫對現有工程的顯示侵入,儘量的減小了耦合。git

也有常見的代碼耗時統計的實現,在方法體開始的時候,利用 System.currentTimeMillis() 方法記錄開始時間,在方法返回以前,進行統計。固然,這樣的功能早在 2013 年已經由JakeWharton 大神用 aspectj的方案 實現過了github

Transform 基本流程

關於如何建立一個基於的 Gradle 插件項目,以及如何在 Plugin 中註冊的具體實現就不展開了,網上能夠找到好多這種教程,這裏從 Transform 的實現提及。shell

能夠看到實現一個自定義的 Transform 須要作的事情仍是很是有規律的。繼承 Transform 這個抽象類,覆寫這幾個方法通常來講就夠用了。每一個方法具體的功能從方法名就能夠了解了。緩存

  • getName 這個transform 的名稱,一個應用內能夠由多個 Transform,所以須要一個名稱標記,方便後面調試。
  • getInputTypes 輸入類型,ContentType 是一個枚舉,這個輸入類型是什麼意思呢?其實看一下這個枚舉的定義你就明白了。
ContentType 點擊展開
enum DefaultContentType implements ContentType {
        /** * The content is compiled Java code. This can be in a Jar file or in a folder. If * in a folder, it is expected to in sub-folders matching package names. */
        CLASSES(0x01),

        /** The content is standard Java resources. */
        RESOURCES(0x02);

        private final int value;

        DefaultContentType(int value) {
            this.value = value;
        }

        @Override
        public int getValue() {
            return value;
        }
    }
複製代碼

這裏能夠注意一下,使用 Transform 咱們還能夠對 resources 文件作處理,你應該據說過或者用過 AndResGuard 來混淆資源文件吧,看到這裏你是否是以爲本身也有點思路了呢。性能優化

  • isIncremental 是否支持增量編譯。對於一個稍微龐大點兒的項目,Gradle 現有的構建流程其實已經很耗時了,對於耗時這件事歸根結底惟一的解決方法就是並行和緩存,可是 Gradle 的不少任務是有依賴關係的,因此並行在很大程度上受到了限制。所以,緩存就成爲了惟一能夠去突破的方向。一個自定義的 Transform 在可能的狀況,支持增量編譯,能夠節省報一些編譯時間和資源,固然,因爲 Transform 要實現功能的限制,必須每一次全量編譯,那麼必定要記得刪除上一次編譯編譯的產物,以避免產生 bug。關於如何實現這些細節,後面會有介紹。服務器

  • getScopes 定義這個 Transform 要處理那些輸入文件。ScopeType 一樣是一個枚舉,看一下他的定義。markdown

ScopeType 點擊展開
enum Scope implements ScopeType {
        /** Only the project (module) content */
        PROJECT(0x01),
        /** Only the sub-projects (other modules) */
        SUB_PROJECTS(0x04),
        /** Only the external libraries */
        EXTERNAL_LIBRARIES(0x10),
        /** Code that is being tested by the current variant, including dependencies */
        TESTED_CODE(0x20),
        /** Local or remote dependencies that are provided-only */
        PROVIDED_ONLY(0x40),

        /** * Only the project's local dependencies (local jars) * * @deprecated local dependencies are now processed as {@link #EXTERNAL_LIBRARIES} */
        @Deprecated
        PROJECT_LOCAL_DEPS(0x02),
        /** * Only the sub-projects's local dependencies (local jars). * * @deprecated local dependencies are now processed as {@link #EXTERNAL_LIBRARIES} */
        @Deprecated
        SUB_PROJECTS_LOCAL_DEPS(0x08);

        private final int value;

        Scope(int value) {
            this.value = value;
        }

        @Override
        public int getValue() {
            return value;
        }
    }
複製代碼

能夠預知,這個範圍定義的越小,咱們的 Transform 須要處理的輸入就越少,執行也就越快。閉包

  • transform(transformInvocation: TransformInvocation?) 進行輸入內容的處理。

這裏須要再次強調一點:一個工程內會有多個 Transform,你定義的 Transform 在處理的是上一個 Transform 通過處理的輸出,而通過你處理的輸出,會由下一個 Transform 進行處理。全部的 transform 任務通常都在 app/build/intermediates/transform/ 這個目錄下能夠看到。

transform() 深刻

transform()方法的參數 TransformInvocation 是一個接口,提供了一些關於輸入的一些基本信息。利用這些信息咱們就能夠得到編譯流程中的 class 文件進行操做。

從上圖能夠看到,transform 處理輸入的思路仍是很簡單的,就是從TransformInvocation 獲取到總的輸入後,分別按照 class目錄 和 jar文件 集合的方式進行遍歷處理。(這裏簡單討論廣泛狀況,固然 TransformInvocation 接口還提供了 getReferencedInputs,getSecondaryInputs 這些接口,讓使用者處理一些特殊的輸入,上圖並無體現,暫時不展開討論)

transform 的核心難點有如下幾個點:

  • 正確、高效的進行文件目錄、jar 文件的解壓、class 文件 IO 流的處理,保證在這個過程當中不丟失文件和錯誤的寫入
  • 高效的找到要插樁的結點,過濾掉無效的 class
  • 支持增量編譯

實踐

上面說了一些流程和概念,下面就經過一個實例 (參考自 Koala) 來具體看一下一個基於註解,在 transform 任務執行的過程當中經過 ASM 插入統計方法耗時、參數、輸出的實現。很是感謝 Koala,感謝 lijiankun24 的開源。

效果

爲了方便後期敘述,這裏首先看一下使用方式和最終效果。

添加註解

咱們在 MainActivity 中的部分方法添加註解

點擊展開詳細
class MainActivity : AppCompatActivity() {

    @Cat
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        test()
        test2("a",100)
        test3()
        test4()
        val result = Util.dp2Px(10)
    }

    @Cat
    private fun test() {
        println("just test")
    }

    @Cat
    private fun test2(para1: String, para2: Int): Int {
        return 0
    }

    @Cat
    private fun test3(): View {
        return TextView(this)
    }

    private fun test4(){
        println("nothing")
    }
}
複製代碼

MainActivity 中除了 test4()以外的全部方法,都打上了 @Cat 註解,而且全部方法都會被調用。

輸出日誌

點擊展開詳細
2020-01-04 11:32:13.784 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.784 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.784 E: │ method's name:      test
2020-01-04 11:32:13.785 E: │ method's arguments: []
2020-01-04 11:32:13.785 E: │ method's result:    null
2020-01-04 11:32:13.791 E: │ method cost time:   1ms
2020-01-04 11:32:13.791 E: └───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.791 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.791 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.792 E: │ method's name:      test2
2020-01-04 11:32:13.792 E: │ method's arguments: [a, 100]
2020-01-04 11:32:13.792 E: │ method's result:    0
2020-01-04 11:32:13.793 E: │ method cost time:   0ms
2020-01-04 11:32:13.793 E: └───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.794 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.795 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.795 E: │ method's name:      test3
2020-01-04 11:32:13.796 E: │ method's arguments: []
2020-01-04 11:32:13.796 E: │ method's result:    android.widget.TextView{8a9397d V.ED..... ......ID 0,0-0,0}
2020-01-04 11:32:13.796 E: │ method cost time:   1ms
2020-01-04 11:32:13.796 E: └───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.797 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.797 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.797 E: │ method's name:      onCreate
2020-01-04 11:32:13.798 E: │ method's arguments: [null]
2020-01-04 11:32:13.798 E: │ method's result:    null
2020-01-04 11:32:13.798 E: │ method cost time:   156ms
2020-01-04 11:32:13.798 E: └───────────────────────────────────------───────────────────────────────────------
複製代碼

能夠看到,日誌輸出了除 test4() 方法以外全部方法的方法耗時、方法參數、方法名稱、方法返回值等信息,下面就來看看實現細節。

實現細節

主動調用

首先,對於一個上述的功能,若是用咱們直接手寫代碼的方式,應該是很簡單的。

打印的日誌有一些方法的信息,所以須要一個類來承載這些信息。

  • MethodInfo
data class MethodInfo(
    var className: String = "",
    var methodName: String = "",
    var result: Any? = "",
    var time: Long = 0,
    var params: ArrayList<Any?> = ArrayList()
)
複製代碼

按照常規思路,咱們須要在方法開始的時候,記錄一下開始時間,方法 return 以前再次記錄一下時間,而後計算出耗時。

  • MethodManager
object MethodManager {

    private val methodWareHouse = ArrayList<MethodInfo>(1024)

    @JvmStatic
    fun start(): Int {
        methodWareHouse.add(MethodInfo())
        return methodWareHouse.size - 1
    }

    @JvmStatic
    fun end(result: Any?, className: String, methodName: String, startTime: Long, id: Int) {
        val method = methodWareHouse[id]
        method.className = className
        method.methodName = methodName
        method.result = result
        method.time = System.currentTimeMillis() - startTime
        BeautyLog.printMethodInfo(method)
    }

}
複製代碼

這裏定義了兩個方法 start 和 end ,顧名思義就是在方法開始和結束的時候調用,並經過參數傳遞一些關鍵信息,最後打印這些信息。

這樣咱們能夠在任何一個方法中調用這些方法

fun foo(){
        val index =MethodManager.start()
        val start = System.currentTimeMillis()
        
        // some thing foo do
        
        MethodManager.end("",this.localClassName,"foo",start,index)
    }
複製代碼

誠然這樣的代碼寫起來很簡單,可是一方面這些代碼和 foo 方法原本要作的事情是沒有關係的,若是爲了單次測試方法耗時加上去,有點醜陋;再有就是若是有多個方法須要檢測耗時,須要把這樣的代碼寫不少次。所以,便有了經過 Transform + ASM 實現代碼插樁的需求。

插樁實現

在一個方法內,咱們本身寫上述代碼很簡單,代開 IDE 找到對應的類文件,定位到要計算耗時的方法,在方法體開始和結束以前插入代碼。可是,對於編譯器來講,這些沒有規律的事情是很是麻煩的。所以,爲了方便,咱們經過定義註解的方式,方便編譯器在代碼編譯階段能夠快速定位要插樁的位置。

這裏定義了一個註解 Cat, 爲啥取名 Cat,由於貓很萌啊。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface Cat {
}
複製代碼

按照上圖 transform(transformInvocation: TransformInvocation?) 處理輸入流程的流程,咱們能夠對全部的 class 文件進行處理。

這裏以處理 directoryInputs 爲例

input.directoryInputs.forEach { directoryInput ->
                if (directoryInput.file.isDirectory) {
                    FileUtils.getAllFiles(directoryInput.file).forEach {
                        val file = it
                        val name = file.name
                        println("directory")
                        println("name ==$name")
                        if (name.endsWith(".class") && name != ("R.class")
                            && !name.startsWith("R\$") && name != ("BuildConfig.class")
                        ) {

                            val reader = ClassReader(file.readBytes())
                            val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
                            val visitor = CatClassVisitor(writer)
                            reader.accept(visitor, ClassReader.EXPAND_FRAMES)

                            val code = writer.toByteArray()
                            val classPath = file.parentFile.absolutePath + File.separator + name
                            val fos = FileOutputStream(classPath)
                            fos.write(code)
                            fos.close()
                        }
                    }
                }

                val dest = transformInvocation.outputProvider?.getContentLocation(
                    directoryInput.name,
                    directoryInput.contentTypes,
                    directoryInput.scopes,
                    Format.DIRECTORY
                )


                FileUtils.copyDirectoryToDirectory(directoryInput.file, dest)
            }
複製代碼

這裏的操做很簡單,就是遍歷全部的 class 文件,對全部符合條件的 Class 經過 ASM 提供的接口進行處理,經過訪問者模式,提供一個自定義的 ClassVisitor 便可。這裏咱們的自定義 ClassVisitor 就是 CatClassVisitor,在 CatClassVisitor 內部的 visitMethod 實現中再次使用訪問者的模式,返回一個自定義的 CatMethodVisitor,在其內部咱們會根據方法註解,肯定當前方法是否須要進行插樁。

override fun visitAnnotation(desc: String, visible: Boolean): AnnotationVisitor {
        // 當前方法的註解,是不是咱們定義的註解。
        if (Constants.method_annotation == desc) {
            isInjected = true
        }
        return super.visitAnnotation(desc, visible)
    }

  override fun onMethodEnter() {
        if (isInjected) {
            
            methodId = newLocal(Type.INT_TYPE)
            mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                Constants.method_manager,
                "start",
                "()I",
                false
            )
            mv.visitIntInsn(Opcodes.ISTORE, methodId)

            ... more details ...
        }
    }

  override fun onMethodExit(opcode: Int) {
        if (isInjected) {

            ... other details ...

            mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                Constants.method_manager,
                "end",
                "(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;JI)V",
                false
            )
        }
    }
複製代碼

能夠看到這樣咱們就肯定了要進行代碼插樁的位置,關於 ASM 代碼插樁的具體細節已在當 Java 字節碼遇到 ASM 有過介紹,這裏再也不展開。此處具體實現能夠查看源碼

固然,咱們還須要處理輸入爲 jarInputs 的場景。在組件化開發的時候,不少時候,咱們是經過依賴 aar 包的方式,依賴其餘小夥伴提供的業務組件或基礎組件。或者是當咱們依賴第三方庫的時候,其實也是在依賴 aar。這時候,若是缺乏了對 jarInputs 的處理,會致使插樁功能的缺失。可是從上面的流程圖能夠看到,對 jarInputs 的處理只是多瞭解壓縮的過程,後續仍是對 class 文件的遍歷寫操做。

增量編譯

說到 Transform 必需要談的一個點就是增量編譯,其實關於增量編譯的實現,經過查看 AGP 自帶的幾個 Transform 能夠看到其實很簡單。

if (transformInvocation.isIncremental) {
                    when (jarInput.status ?: Status.NOTCHANGED) {
                        Status.NOTCHANGED -> {
                        }
                        Status.ADDED, Status.CHANGED -> transformJar(
                            function,
                            inputJar,
                            outputJar
                        )
                        Status.REMOVED -> FileUtils.delete(outputJar)
                    }
                } else {
                    transformJar(function, inputJar, outputJar)
                }
複製代碼

全部的輸入都是帶狀態的,根據這些狀態作不一樣的處理就行了。固然,也能夠根據前面提到的 getSecondaryInputs 提供的輸入進行處理支持增量編譯。

簡化 Transform 流程

回顧上面提到的 transform 處理流程及三個關鍵點,參考官方提供的 CustomClassTransform 咱們能夠抽象出一個更加通用的 Transform 基類。

默認支持 增量編譯,處理文件 IO 的操做

abstract class BaseTransform : Transform() {

    // 將對 class 文件的 asm 操做,處理完以後的再次複製,抽象爲一個 BiConsumer
    abstract fun provideFunction(): BiConsumer<InputStream, OutputStream>?

    // 默認的 class 過濾器,處理 .class 結尾的全部內容 (Maybe 能夠擴展)
    open fun classFilter(className: String): Boolean {
        return className.endsWith(SdkConstants.DOT_CLASS)
    }

    // Transform 使能開關
    open fun isEnabled() = true

    ... else function ...

    // 默認支持增量編譯
    override fun isIncremental(): Boolean {
        return true
    }
   

    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)

        val function = provideFunction()

        ......

        if (transformInvocation.isIncremental.not()) {
            outputProvider.deleteAll()
        }

        for (ti in transformInvocation.inputs) {
            for (jarInput in ti.jarInputs) {
                 ......
                if (transformInvocation.isIncremental) {
                    when (jarInput.status ?: Status.NOTCHANGED) {
                        Status.NOTCHANGED -> {
                        }
                        Status.ADDED, Status.CHANGED -> transformJar(
                            function,
                            inputJar,
                            outputJar
                        )
                        Status.REMOVED -> FileUtils.delete(outputJar)
                    }
                } else {
                    transformJar(function, inputJar, outputJar)
                }
            }
            for (di in ti.directoryInputs) {

                ......
                
                if (transformInvocation.isIncremental) {
                    for ((inputFile, value) in di.changedFiles) {

                        ......

                        transformFile(function, inputFile, out)

                        ......
                    }
                } else {
                    for (`in` in FileUtils.getAllFiles(inputDir)) {
                        if (classFilter(`in`.name)) {
                            val out =
                                toOutputFile(outputDir, inputDir, `in`)
                            transformFile(function, `in`, out)
                        }
                    }
                }
            }
        }
    }


    @Throws(IOException::class)
    open fun transformJar( function: BiConsumer<InputStream, OutputStream>?, inputJar: File, outputJar: File ) {
        Files.createParentDirs(outputJar)
        FileInputStream(inputJar).use { fis ->
            ZipInputStream(fis).use { zis ->
                FileOutputStream(outputJar).use { fos ->
                    ZipOutputStream(fos).use { zos ->
                        var entry = zis.nextEntry
                        while (entry != null && isValidZipEntryName(entry)) {
                            if (!entry.isDirectory && classFilter(entry.name)) {
                                zos.putNextEntry(ZipEntry(entry.name))
                                apply(function, zis, zos)
                            } else { // Do not copy resources
                            }
                            entry = zis.nextEntry
                        }
                    }
                }
            }
        }
    }

    @Throws(IOException::class)
    open fun transformFile( function: BiConsumer<InputStream, OutputStream>?, inputFile: File, outputFile: File ) {
        Files.createParentDirs(outputFile)
        FileInputStream(inputFile).use { fis ->
            FileOutputStream(outputFile).use { fos -> apply(function, fis, fos) }
        }
    }


    @Throws(IOException::class)
    open fun apply( function: BiConsumer<InputStream, OutputStream>?, `in`: InputStream, out: OutputStream ) {
        try {
            function?.accept(`in`, out)
        } catch (e: UncheckedIOException) {
            throw e.cause!!
        }
    }
}

複製代碼

以上對 transform 處理流程中,文件 IO,增量編譯的細節進行了封裝處理。把對 class 的寫操做和二次複製,統一爲 InputStream 和 OutoutStream 對象的處理。

使用註解實現類中全部方法的插樁

前面咱們經過定義註解 Cat 的方式,詳細實現了一次方法耗時的插樁。可是這個註解的使用範圍被限定在了方法上,若是咱們想要對一個類裏多個方法的耗時同時進行檢測的時候,就比較繁瑣了。所以,咱們能夠就這個註解簡單升級一下,實現一個支持 Class 內全部方法耗時檢測的插樁實現。

註解定義 Tiger

Tiger 顧名思義,這裏的實現就是在照貓畫虎。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface Tiger {
}
複製代碼

Transform 實現

class TigerTransform : BaseTransform() {

    override fun provideFunction(): BiConsumer<InputStream, OutputStream>? {
        return BiConsumer { t, u ->
            val reader = ClassReader(t)
            val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
            val visitor = TigerClassVisitor(writer)
            reader.accept(visitor, ClassReader.EXPAND_FRAMES)
            val code = writer.toByteArray()
            u.write(code)
        }
    }

    override fun getName(): String {
        return "tiger"
    }
}
複製代碼

經過直接繼承剛纔定義的 Transform 抽象類,咱們能夠把精力集中在如何處理 Class 文件的寫入和輸入上,也就是這裏的 InputStream 和 OutputStream 的處理,直接和 ASM 的 ClassReader 以及 ClassWriter 接口交互。沒必要再關心增量編譯,TransformInvocation 的輸出和輸入的 IO 這些內部細節了。

咱們看一下 TigerClassVisitor

class TigerClassVisitor(classVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM6, classVisitor) {

    private var needHook = false
    private lateinit var mClassName: String

    override fun visit( version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array<String>? ) {
        super.visit(version, access, name, signature, superName, interfaces)
        println("hand class $name")
        mClassName = name
    }

    override fun visitAnnotation(desc: String?, visible: Boolean): AnnotationVisitor {
        if (desc.equals(Constants.class_annotation)) {
            println("find $desc ,start hook ")
            needHook = true
        }
        return super.visitAnnotation(desc, visible)
    }

    override fun visitMethod( access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>? ): MethodVisitor {


        var methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)

        if (needHook) {
            .... hook visitor ...
        }

        return methodVisitor
    }
}
複製代碼

這裏的關鍵就是 visitAnnotation 方法,在這個回調方法裏,咱們能夠獲取到當前 Class 的註解,而當這個 Class 的註解和咱們定義的 Tiger 註解相等時,咱們就能夠對這個類當中的全部方法進行耗時檢測代碼的插樁了,在 visitMethod 方法內耗時代碼的插樁,上面已經實現過了。

咱們能夠到應用的 build 目錄下查看插樁代碼是否生效,好比 app/build/intermediates/transforms/tiger/{flavor}/{packageName}/xxx/ 目錄下找到編譯產物。

點擊展開
@Tiger
public class Util {
    private static final float DENSITY;

    public Util() {
        int var1 = MethodManager.start();
        long var2 = System.nanoTime();
        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "<init>", var2, var1);
    }

    public static int dp2Px(int dp) {
        int var1 = MethodManager.start();
        MethodManager.addParams(new Integer(dp), var1);
        long var2 = System.nanoTime();
        int var10000 = Math.round((float)dp * DENSITY);
        MethodManager.end(new Integer(var10000), "com/engineer/android/myapplication/Util", "dp2Px", var2, var1);
        return var10000;
    }

    public static void sleep(long seconds) {
        int var2 = MethodManager.start();
        MethodManager.addParams(new Long(seconds), var2);
        long var3 = System.nanoTime();

        try {
            Thread.sleep(seconds);
        } catch (InterruptedException var6) {
            var6.printStackTrace();
        }

        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "sleep", var3, var2);
    }

    public static void nothing() {
        int var0 = MethodManager.start();
        long var1 = System.nanoTime();
        System.out.println("do nothing,just test");
        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "nothing", var1, var0);
    }

    static {
        int var0 = MethodManager.start();
        long var1 = System.nanoTime();
        DENSITY = Resources.getSystem().getDisplayMetrics().density;
        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "<clinit>", var1, var0);
    }
}
複製代碼

能夠看到這個打了 Tiger 註解的Util類,其全部方法內部都已經有插樁代碼了。以後這些方法被調用的時候,就能夠看到方法耗時了。若是有其餘類的方法也須要一樣的功能,要作的事情很簡但,只須要用 Tiger 註解就能夠了。

配置任意類中方法的插樁

上面的實現都是基於咱們已有的代碼作文章,可是有時候咱們在作性能優化的時候,會須要統計一些咱們使用的開源庫的方法耗時,對於 public 方法也許還好,可是對於 private 方法或者是其餘一些場景,就會比較麻煩了,須要藉助代理模式(動態代理或靜態代理)來實現咱們須要的功能,或者是其餘手段,可是這樣的手段沒有通用性,此次換個庫要用,可能又要寫一遍相似的功能,或者你也能夠把三方庫源碼拉下來直接改也是能夠的。

這裏其實能夠藉助 ASM 稍微作一些輔助,簡化這些工做。這裏以 Glide 爲例。

Glide.with(this).load(url).into(imageView);
複製代碼

上面的代碼相信你們都不陌生,假設(只是假設)如今須要對統計 load 方法和 into 方法的耗時,那麼怎麼作呢?

思考一下上面的兩個實現,咱們是基於註解肯定了類和方法名,從而實如今特定的類或特定的方法中插入統計方法耗時的邏輯。那麼如今這些方法的源碼都沒法訪問了,註解也無法加了,怎麼辦呢?那就從問題的根本出發,直接由使用者告訴 transform 到底要在哪一個類的哪一個方法進行方法耗時的統計。

咱們能夠像 build.gradle 的 android 閉包同樣,本身定義一個這樣的結點。

open class TransformExtension {
    // class 爲鍵,方法名爲值得一個 map
    var tigerClassList = HashMap<String, ArrayList<String?>>()

}
複製代碼

在 build.gradle 文件中配置信息

transform {
        tigerClassList = ["com/bumptech/glide/RequestManager": ["load"],
                          "com/bumptech/glide/RequestBuilder": ["into"]]
    }
複製代碼

而後分別在 ClassVisitor 和 MethodVisitor 中根據類名和方法名肯定要進行插樁的結點。

init {
        ....
        classList = transform?.tigerClassList
    }

    override fun visit( version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array<String>? ) {
        super.visit(version, access, name, signature, superName, interfaces)
        mClassName = name
        if (classList?.contains(name) == true) {
            methodList = classList?.get(name) ?: ArrayList()
            needHook = true
        }
    }
複製代碼

能夠簡單看一下結果

點擊展開
19407-19407 E/0Cat: ┌───────────────────────────────────------───────────────────────────────────------
19407-19407 E/1Cat: │ class's name:       com/bumptech/glide/RequestManager
19407-19407 E/2Cat: │ method's name:      load
19407-19407 E/3Cat: │ method's arguments: [http://t8.baidu.com/it/u=1484500186,1503043093&fm=79&app=86&f=JPEG?w=1280&h=853]
19407-19407 E/4Cat: │ method's result:    com.bumptech.glide.RequestBuilder@9a29abdf
19407-19407 E/5Cat: │ method cost time:   1.52 ms
19407-19407 E/6Cat: └───────────────────────────────────------───────────────────────────────────------
19407-19407 E/0Cat: ┌───────────────────────────────────------───────────────────────────────────------
19407-19407 E/1Cat: │ class's name:       com/bumptech/glide/RequestBuilder
19407-19407 E/2Cat: │ method's name:      into
19407-19407 E/3Cat: │ method's arguments: [Target for: androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}, null, com.bumptech.glide.RequestBuilder@31a00c76, com.bumptech.glide.util.Executors$1@1098060]
19407-19407 E/4Cat: │ method's result:    Target for: androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}
19407-19407 E/5Cat: │ method cost time:   5.78 ms
19407-19407 E/6Cat: └───────────────────────────────────------───────────────────────────────────------
19407-19407 E/0Cat: ┌───────────────────────────────────------───────────────────────────────────------
19407-19407 E/1Cat: │ class's name:       com/bumptech/glide/RequestBuilder
19407-19407 E/2Cat: │ method's name:      into
19407-19407 E/3Cat: │ method's arguments: [androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}]
19407-19407 E/4Cat: │ method's result:    Target for: androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}
19407-19407 E/5Cat: │ method cost time:   10.88 ms
19407-19407 E/6Cat: └───────────────────────────────────------───────────────────────────────────------

複製代碼

能夠看到,插樁已經成功了,RequestManager 的 load 方法打印了完整的方法信息,延伸一下,是否是能夠在這裏統計一下,到底用 Glide 加載過哪些 url 呢?固然,如上日誌也看到,RequestBuilder 當中打印了兩個 into 方法,經過方法名插樁是有點粗暴,目標類當中若是有多個同名的方法(方法重載),那麼這些方法都會被插樁。這個問題其實也能夠經過提供方法 desc (也就是方法參數,返回值等信息) 來作更精確的匹配。可是,這裏若是隻是作測試,這樣粗粒度的也是能夠的,畢竟這樣對三方庫的插樁仍是比較hack的,線上環境最好仍是不要使用。

Android 中點擊事件的統計

這裏的點擊事件泛指實現了 View.OnClickListener 接口的點擊事件

在一個成熟的 App 中確定會包含埋點,埋點其實就是在統計用戶的行爲。好比打開了哪一個頁面,點擊了哪一個按鈕,使用了哪一個功能?經過對這些行爲的統計,經過數據就能夠獲知用戶最經常使用的功能,方便產品作決策。

關於點擊行爲這件事,首先想一想平時咱們都是怎麼實現的?無非就是兩種狀況,要麼就是實現 View.OnClickListener 這個接口,而後在 onClick 方法中展開;要麼就是匿名內部類,一樣是在 onClick 方法展開。所以,如何肯定 onClick 方法就成了咱們須要關注的問題。咱們不能按照以前方法名 equals 的簡單規則進行定位。由於這樣沒法避免方法重名或者是參數重名的問題,假設某個小夥伴寫了一個和 onClick(View view) 同名的普通方法,咱們實際定位的 hook 結點可能就不是點擊事件發生時的結點。所以,咱們須要確保 ASM 訪問的類實現了 android.view.View.OnClickListener 這個接口。

override fun visit( version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>? ) {
        super.visit(version, access, name, signature, superName, interfaces)
        className = name

              interfaces?.forEach {
                if (it == "android/view/View\$OnClickListener") {
                    println("bingo , find click in class : $className")
                    hack = true
                }
            }
        
    }
複製代碼

這個實現其實也很簡單,ClassVisitor 的 visit 方法提供了當前類實現了的全部接口,所以這裏簡單判斷一下會比較準確。固然,這裏也能夠同時判斷其餘接口,好比咱們要對應用內全部 TabBar 的選中事件進行插樁,就可判斷當前類是否實現了 com.google.android.material.bottomnavigation.BottomNavigationView.OnNavigationItemSelectedListener 這個接口。

因爲 Transform 會訪問 javac 編譯生成的全部 class,包括匿名內部類,所以這裏對於普通類和匿名內部類能夠統一處理。(關於匿名內部類和普通類的使用 ASM 的差別能夠參考這篇)。

當前類是否實現了接口肯定以後,下一步就能夠按照方法名及方法的參數和返回值進行更加精確的匹配了。

override fun visitMethod( access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>? ): MethodVisitor {
        var methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
        if (hack) {
            if (name.equals("onClick") && desc.equals("(Landroid/view/View;)V")) {
                methodVisitor =
                    TrackMethodVisitor(className, Opcodes.ASM6, methodVisitor, access, name, desc)
            }
        }

        return methodVisitor
    }
複製代碼

插樁的具體代碼,本質上和以前的幾個實現都是相似的,這裏就再也不貼代碼了。這裏簡單看一下效果。

E/0Track: ┌───────────────────────────────────------───────────────────────────────────------
E/1Track: │ class's name:             com.engineer.android.myapplication.SecondActivity
E/2Track: │           view's id:      com.engineer.android.myapplication:id/button
E/3Track: │ view's package name:      com.engineer.android.myapplication
E/4Track: └───────────────────────────────────------───────────────────────────────────------
複製代碼

當一個點擊事件發生時,咱們能夠獲取實現這個點擊事件的類,這個點擊事件的 id 和包名。經過這些信息,咱們就能夠大概得知這個點擊事件是在哪一個頁面(哪一個業務)。所以,這個實現也能夠幫助咱們定位代碼,有時候面對一份徹底陌生的代碼,很難定位到你所使用的功能到底在代碼的哪一個角落裏,經過這個 Transform 實現,能夠簡單定位一下範圍。

本身測試的時候發現,在 Java 中若是使用了 lambda 表達式來實現匿名內部類,那麼是不會按照常規的匿名內部類那樣處理,並不會生成額外的匿名類。所以,對於使用 lambda 表達式實現的點擊事件,這樣是沒法處理的。(暫時也沒想到其餘比較好的替代方案)

看到這裏,你是否是有一些想法了呢? 是否是能夠考慮實現基於 Activity/Fragment 生命週期的代碼插樁,來統計頁面的展示時間和次數呢?是否是能夠考慮將代碼裏的 Log.d 這樣的代碼統一刪除掉?是否是能夠將代碼中沒有引用和調用的代碼刪除掉呢?(這個可能有點難)

總結

首先明確一下,以上全部實現都是基於 Transform + ASM 技術棧的探索,只是簡單的學習和了解一下 Transform + ASM 可以作什麼以及怎麼作。所以,部分實現也許有瑕疵甚至是 bug。源碼 已同步到 Github 若是有想法,能夠提 issue。

經過對 Gradle Transform + ASM 的簡單探索,能夠看到在工程構建的過程當中,從源碼(包括java/kotlin/資源文件/其餘) 到中間的 class 再到 dex 文件直至最終的 apk 文件生成,在整個過程當中有不少的 task 被執行。而在 class 到 dex 這之間,利用 ASM 仍是能夠作不少文章的。

這裏咱們只是簡單的打印了 log,其實對一些關鍵信息,咱們徹底能夠進行插樁式的收集,好比在 Glide 內部插入統計加載圖片 URL 的代碼,好比關鍵方法(例如 Application 的 onCreate 方法)的耗時統計,有時候咱們關心的並不必定是具體的數據,而是數據所呈現出來的趨勢。能夠經過代碼插樁將這些信息批量保存在本地甚至是上傳到服務器,在後續流程中進一步的分析和拆解一些關鍵數據。

參考文檔

詳解Android Gradle生成字節碼流程

從 Java 字節碼到 ASM 實踐

App流暢度優化:利用字節碼插樁實現一個快速排查高耗時方法的工具

ByteX

相關文章
相關標籤/搜索