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

    咱們產線的主流程頁面中有幾個比較複雜的頁面在版本迭代中流暢度頻繁出現反覆,常常因爲開發的不注意致使變卡,主要是對流暢度缺乏必要的監控和可持續的優化手段,這個系列是對上半年實踐App流暢度監控、優化過程當中的一點總結,但願能夠給須要的同窗一點小參考。java

固然App內存上的優化,儘可能減小內存抖動也能顯著提高流暢度,內存優化方面能夠參考以前的文章:實踐App內存優化:如何有序地作內存分析與優化android

整個系列將主要包括如下幾部分:git

  1. 卡頓與View的繪製過程解析github

    這部份內容比較多,主要是從源碼層面解析一下整個過程,也是咱們後面作流暢度監控與優化的基礎api

  2. Debug階段如何對實時幀率進行監控和顯示瀏覽器

    根據上面的原理,設計一個顯示實時幀率的工具,能夠有效的在開發階段發現問題服務器

  3. 如何實現流暢度自動化測試app

    實現一個流暢度UI自動化測試,在上線前跑一下UI自動化並生成流暢度報表郵件給相關人員框架

  4. 線上的用戶流暢度的監控方案socket

    實時反映真實用戶的流暢度體驗,線上龐大的數據能夠敏感的反應出版本迭代間流暢度的變化

  5. 實現一個方便排查高耗時方法的工具

    利用自定義gradle plugin+ASM插樁實現快速而準確的找出耗時的方法,進行鍼對性的優化

  6. 分享提高app流暢度的一些經驗

    分享一些成本小收益高的提高流暢度的方案


   

工欲善其事必先利其器,今天首先分享一下在優化頁面流暢度過程當中本身實現的一個方便快速排查高耗時方法的工具:MethodTraceMan,畢竟保持主流程流暢,避免在主流程執行高耗時方法永遠是優化卡頓最直接的手段,只要咱們能快速方便的排查到高耗時的方法,就能夠作針對性優化。

實現一個方便排查高耗時方法的工具

    日常咱們用來排查Android卡頓的比較熟悉的工具備TraceViewsystrace等,通常分爲兩種模式:instrumentsample。可是這些工具不論是哪一種模式都有各自不足的地方,好比instruement模式,能夠得到全部函數的調用過程,信息比較豐富,可是會帶來極大的性能開銷,致使統計的耗時與實際不符;而sample模式是經過採樣的方式進行分析的,因此信息豐富度上就大打折扣,像systrace就屬於sample型的,它只能監控一些系統調用的耗時狀況。

    除了上面說的工具,著名的JackWharton也實現了一個能夠打印出出方法耗時的工具hugo,它是基於註解觸發的,在一個方法上加上特定註解便可打印出該方法的耗時等信息,可是若是咱們想排查高耗時方法,顯然在全部方法上一個一個加註解太費勁了。  

那麼咱們在作卡頓優化的過程當中須要一個什麼樣的工具呢?

  • 能夠方便地統計全部方法的耗時
  • 對性能影響微小,能準確統計出方法的精確耗時
  • 支持耗時篩選、線程篩選、方法名搜索等功能,能快速發現主線程高耗時方法

    要實現這樣一個工具,首先想到的就是經過插樁技術來實現,在編譯過程當中對全部的方法進行插樁,在方法進入和方法結束的地方進行打點,就能夠在對性能影響很小的方式下統計到每一個方法的耗時。統計到每一個方法的耗時數據後,咱們再實現一個UI界面來展現這些數據,並實現耗時篩選、線程篩選、方法名搜索等功能,這樣咱們就能夠快速的找到主線程高耗時的方法進行鍼對性的優化了。

1. 效果預覽

咱們先來看下最終實現的效果預覽:

輸出全部的方法耗時,高耗時方法以紅色預警,同時支持對耗時篩選,線程篩選,方法名搜索等,好比想篩出主線程耗時大於50ms的方法,就能夠很方便的找出。  

詳細的集成以及使用文檔詳見:MethodTraceMan

效果預覽

2. 技術選型

    插樁技術其實充斥在咱們日常開發中的方方面面,能夠幫助咱們實現不少繁瑣複雜的功能,還能夠幫助咱們提升功能的穩定性,好比ButterKnife、Protocol Buffers等都會在編譯時期生成代碼,固然插樁技術也分不少種,好比ButterKnife是利用APT在編譯的開始階段對java文件進行操做,而像AscpectJ、ASM等則是在java文件編譯爲字節碼文件後,對字節碼進行操做,固然還有一些能夠在字節碼文件被編譯爲dex文件後對dex進行操做的框架。 因爲咱們的需求是在編譯期對全部的方法的進入和結束的地方插樁進行耗時統計,因此最終的技術選型鎖定在對字節碼文件的操做。那麼咱們來對比一下AspectJ和ASM兩種字節碼插樁的框架:

一. AspectJ

    AspectJ是老牌的字節碼處理框架了,其優勢就是使用簡單上手容易,不須要了解字節碼相關知識也能夠在項目中集成使用,只要指定簡單的規則就能夠完成對代碼的插樁,好比咱們如今要實現對全部方法的進入和退出時進行插樁,十分簡單,以下:

@Before("execution(* **(..))")
public void beforeMethod(JoinPoint joinPoint) {
    //TODO 耗時統計
}

@After("execution(* **(..))")
public void afterMethod() {
    //TODO 耗時統計
}

複製代碼

固然相對於優勢來講,AspectJ的缺點是,因爲其基於規則,因此其切入點相對固定,對於字節碼文件的操做自由度以及開發的掌控度就大打折扣。還有就是咱們要實現的是對全部方法進行插樁,因此代碼注入後的性能也是咱們須要關注的一個重要的點,咱們但願只插入咱們想插入的代碼,而AspectJ會額外生成一些包裝代碼,對性能以及包大小有必定影響。

二. ASM

    ASM是一個十分強大的字節碼處理框架,基本上能夠實現任何對字節碼的操做,也就是自由度和開發的掌控度很高,可是其相對來講比AspectJ上手難度要高,須要對Java字節碼有必定了解,不過ASM爲咱們提供了訪問者模式來訪問字節碼文件,這種模式下能夠比較簡單的作一些字節碼操做,實現一些功能。同時ASM能夠精確的只注入咱們想要注入的代碼,不會額外生成一些包裝代碼,因此性能上影響比較微小。

上面說了不少,對於java字節碼,這裏作一些簡單的介紹:

java字節碼

咱們都知道在java文件的經過javac編譯後會生成十六進制的class文件,好比咱們先編寫一個簡單的Test.java文件:

public class Test {
    private int m = 1;

    public int add() {
        int j = 2;
        int k = m + j;
        return k;
    }
}
複製代碼

而後咱們經過 javac Test.java -g來編譯爲Test.class,用文本編輯器打開以下:

test.class

能夠看到是一堆十六進制數,可是其實這一堆十六進制數是按嚴格的結構拼接在一塊兒的,按順序分別是:魔數(cafe babe)、java版本號、常量池、訪問權限標誌、當前類索引、父類索引、接口索引、字段表、方法表、附加屬性等十個部分,這些部分以十六進制的形式表達出來並緊湊的拼接在一塊兒,就是上面看到的class字節碼文件。

固然上面的十六進制文件顯然不具有可閱讀性,因此咱們能夠經過 javap -verbose Test來反編譯,有興趣的能夠本身試一試,就能夠看到上面說的十個部分,因爲咱們作字節碼插樁通常和方法表關聯比較大,因此咱們下面着重看一下方法表,下面是反編譯後的add()方法:

add方法

能夠看到包括三部分:

  1. Code: 這裏部分就是方法裏的JVM指令操做碼,也是最重要的一部分,由於咱們方法裏的邏輯實際上就是一條一條的指令操做碼來完成的。這裏能夠看到咱們的add方法是經過9條指令操做碼完成的。固然插樁重點操做的也是這一塊,只要能修改指令,也就能操控任何代碼了。
  2. LineNumberTable: 這個是表示行號表。是咱們的java源碼與指令行的行號對應。好比咱們上面的add方法java源碼裏總共有三行,也就是上圖中的line十、line十一、line12,這三行對應的JVM指令行數。有了這樣的對應關係後,就能夠實現好比Debug調試的功能,指令執行的時候,咱們就能夠定位到該指令對應的源碼所在的位置。
  3. LocalVariableTable:本地變量表,主要包括This和方法裏的局部變量。從上圖能夠看到add方法裏有this、j、k三個局部變量。

因爲JVM指令集是基於棧的,上面咱們已經瞭解到了add方法的邏輯編譯爲class文件後變成了9個指令操做碼,下面咱們簡單看看這些指令操做碼是如何配合操做數棧+本地變量表+常量池來執行add方法的邏輯的:

指令操做

按順序執行9條指令操做碼:

  • 0:把數字2入棧
  • 1:將2賦值給本地變量表中的j
  • 二、3:獲取常量池中的m入棧
  • 6:將本地變量表中的j入棧
  • 七、8:將m和j相加,而後賦值給本地變量表中的k
  • 九、10:將本地變量表中的k入棧,並return

好的,關於java字節碼的暫時就簡單介紹這些,主要是讓咱們基本瞭解字節碼文件的結構,以及編譯後代碼時如何運行的。而ASM能夠經過操做指令碼來生成字節碼或者插樁,當你能夠利用ASM來接觸到字節碼,而且能夠利用ASM的api來操控字節碼時,就有很大的自由度來進行各類字節碼的生成、修改、操做等等,也就能產生很強大的功能。

3、Gradle plugin + Transform

    上面對於插樁框架的選擇,咱們經過對比最終選擇了ASM,可是ASM只負責操做字節碼,咱們還須要經過自定義gradle plugin的形式來干預編譯過程,在編譯過程當中獲取到全部的class文件和jar包,而後遍歷他們,利用ASM來修改字節碼,達到插樁的目的。

    那麼幹預編譯的過程,咱們的第一個念頭可能就是,對class轉爲dex的任務進行hook,在class轉爲dex以前拿到全部的class文件,而後利用ASM對這些字節碼文件進行插樁,而後再把處理過的字節碼文件做爲transformClassesWithDex任務的輸入便可。這種方案的好處是易於控制,咱們明確的知道操做的字節碼文件是最終的字節碼,由於咱們是在transformClassesWithDex任務的前一刻拿到字節碼文件的。缺點就是,若是項目開啓了混淆,那麼在transformClassesWithDex任務的前一刻拿到的字節碼文件顯然是通過了混淆了的,因此利用ASM操做字節碼的時候還須要mapping文件進行配合才能找到正確的插樁點,這一點比較麻煩。

    幸好gradle還爲咱們提供了另外一種干預編譯轉換過程的方法:Transform.其實咱們稍微翻一下gradle編譯過程的源碼,就會發現一些咱們熟知的功能都是經過Transform來實現的。還有一點,就是關於混淆的問題,上面咱們說了若是經過hook transformClassesWithDex任務的方式來實現插樁,開啓混淆的狀況下會出現問題,那麼利用Transform的方式會不會有混淆的問題呢?下面咱們從gradle源碼上面找一下答案:

咱們從com.android.build.gradle.internal.TaskManager類裏的createCompileTask()方法看起,顯然這是一個建立編譯任務的方法:

protected void createCompileTask(@NonNull VariantScope variantScope) {
        //建立一個將java文件編譯爲class文件的任務
        JavaCompile javacTask = createJavacTask(variantScope);
        addJavacClassesStream(variantScope);
        setJavaCompilerTask(javacTask, variantScope);
        
        //建立一些在編譯爲class文件後執行的額外任務,好比一些Transform等
        createPostCompilationTasks(variantScope);
    }
複製代碼

接下來咱們看看createPostCompilationTasks()方法,這個方法比較長,下面只保留重要的幾個代碼:

public void createPostCompilationTasks(@NonNull final VariantScope variantScope) {
       、、、、、、
    TransformManager transformManager = variantScope.getTransformManager();
      、、、、、
     // ----- External Transforms 這個就是咱們自定義註冊進來的Transform-----
     // apply all the external transforms.
        List<Transform> customTransforms = extension.getTransforms();
        List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();
        、、、、、、
        、、、、、、
        // ----- Minify next 這個就是混淆代碼的Transform-----
        CodeShrinker shrinker = maybeCreateJavaCodeShrinkerTransform(variantScope);
        、、、、、、
        、、、、、、
    }
複製代碼

    其實這個方法裏有不少其餘Transform,這裏都省略了,咱們重點只看咱們自定義註冊的Transform和混淆代碼的Transform,從上面的代碼上咱們自定義的Transform是在混淆Transform以前添加進TransformManager,因此執行的時候咱們自定義的Transform也會在混淆以前執行的,也就是說咱們利用自定義Transform的方式對代碼進行插樁是不受混淆影響的

因此咱們最終肯定的方案就是 Gradle plugin + Transform +ASM的技術方案。下面咱們正式說說利用該技術方案進行具體實現。

3. 具體實現

這裏具體實現只挑重點實現步驟講,詳細的能夠看具體源碼,文章結尾提供了項目的github地址。

1、自定義gradle plugin

關於如何建立一個自定義gradle plugin的項目,這邊就不細說了,能夠網上搜索,或者直接看MethodTraceMan項目的源碼也行,自定義gradle plgin繼承自Plugin類,入口是apply方法,咱們的apply方法裏很簡單,就是建立一個自定義擴展配置,而後就是註冊一下咱們自定義的Transform:

@Override
    void apply(Project project) {

        println '*****************MethodTraceMan Plugin apply*********************'
        project.extensions.create("traceMan", TraceManConfig)

        def android = project.extensions.getByType(AppExtension)
        android.registerTransform(new TraceManTransform(project))
    }
複製代碼

2、自定義Transform實現

這裏咱們建立了一個名叫traceMan的擴展,這樣咱們能夠再使用這個plugin的時候進行一些配置,好比配置插樁的範圍,配置是否開啓插樁等,這樣咱們就能夠根據本身的須要來配置。

接下來咱們看一下TraceManTransform的實現:

public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        println '[MethodTraceMan]: transform()'
        def traceManConfig = project.traceMan
        String output = traceManConfig.output if (output == null || output.isEmpty()) {
            traceManConfig.output = project.getBuildDir().getAbsolutePath() + File.separator + "traceman_output"
        }

        if (traceManConfig.open) {
            //讀取配置
            Config traceConfig = initConfig()
            traceConfig.parseTraceConfigFile()


            Collection<TransformInput> inputs = transformInvocation.inputs
            TransformOutputProvider outputProvider = transformInvocation.outputProvider if (outputProvider != null) {
                outputProvider.deleteAll()
            }

            //遍歷,分爲class文件變量和jar包的遍歷
            inputs.each { TransformInput input ->
                input.directoryInputs.each { DirectoryInput directoryInput ->
                    traceSrcFiles(directoryInput, outputProvider, traceConfig)
                }

                input.jarInputs.each { JarInput jarInput ->
                    traceJarFiles(jarInput, outputProvider, traceConfig)
                }
            }
        }
    }
複製代碼

3、利用ASM進行插樁

接下來看看遍歷class文件後如何利用ASM的訪問者模式進行插樁:

static void traceSrcFiles(DirectoryInput directoryInput, TransformOutputProvider outputProvider, Config traceConfig) {
        if (directoryInput.file.isDirectory()) {
            directoryInput.file.eachFileRecurse { File file ->
                def name = file.name
                //根據配置的插樁範圍決定要對某個class文件進行處理
                if (traceConfig.isNeedTraceClass(name)) {
                    //利用ASM的api對class文件進行訪問
                    ClassReader classReader = new ClassReader(file.bytes)
                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                    ClassVisitor cv = new TraceClassVisitor(Opcodes.ASM5, classWriter, traceConfig)
                    classReader.accept(cv, EXPAND_FRAMES)
                    byte[] code = classWriter.toByteArray()
                    FileOutputStream fos = new FileOutputStream(
                            file.parentFile.absolutePath + File.separator + name)
                    fos.write(code)
                    fos.close()
                }
            }
        }

        //處理完輸出給下一任務做爲輸入
        def dest = outputProvider.getContentLocation(directoryInput.name,
                directoryInput.contentTypes, directoryInput.scopes,
                Format.DIRECTORY)
        FileUtils.copyDirectory(directoryInput.file, dest)
    }
複製代碼

能夠看到,最終是TraceClassVisitor類裏對class文件進行處理的,咱們看一下TraceClassVisitor

class TraceClassVisitor(api: Int, cv: ClassVisitor?, var traceConfig: Config) : ClassVisitor(api, cv) {

    private var className: String? = null
    private var isABSClass = false
    private var isBeatClass = false
    private var isConfigTraceClass = false

    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)

        this.className = name
        //抽象方法或者接口
        if (access and Opcodes.ACC_ABSTRACT > 0 || access and Opcodes.ACC_INTERFACE > 0) {
            this.isABSClass = true
        }

        //插樁代碼所屬類
        val resultClassName = name?.replace(".", "/")
        if (resultClassName == traceConfig.mBeatClass) {
            this.isBeatClass = true
        }

        //是不是配置的須要插樁的類
        name?.let { className ->
            isConfigTraceClass = traceConfig.isConfigTraceClass(className)
        }
    }

    override fun visitMethod(
        access: Int,
        name: String?,
        desc: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val isConstructor = MethodFilter.isConstructor(name)
        //抽象方法、構造方法、不是插樁範圍內的方法,則不進行插樁
        return if (isABSClass || isBeatClass || !isConfigTraceClass || isConstructor) {
            super.visitMethod(access, name, desc, signature, exceptions)
        } else {
            //TraceMethodVisitor中對方法進行插樁
            val mv = cv.visitMethod(access, name, desc, signature, exceptions)
            TraceMethodVisitor(api, mv, access, name, desc, className, traceConfig)
        }
    }
}
複製代碼

再來看看TraceMethodVisitor:

override fun onMethodEnter() {
        super.onMethodEnter()
        //利用ASM在方法進入的時候 經過插入指令調用耗時統計的方法:start()
        mv.visitLdcInsn(generatorMethodName())
        mv.visitMethodInsn(INVOKESTATIC, traceConfig.mBeatClass, "start", "(Ljava/lang/String;)V", false)

    }

    override fun onMethodExit(opcode: Int) {
        //利用ASM在方法進入的時候 經過插入指令調用耗時統計的方法:end()
        mv.visitLdcInsn(generatorMethodName())
        mv.visitMethodInsn(INVOKESTATIC, traceConfig.mBeatClass, "end", "(Ljava/lang/String;)V", false)
    }
複製代碼

這樣,咱們就能夠在全部配置的在插樁範圍內的方法都在方法進入的時候調用TraceMan.start()方法,在方法退出的時候調用TraceMan.end()方法進行耗時統計。而TraceMan這個類也是可配置的,也就是你能夠經過配置決定在方法進入和退出的時候調用哪一個類的哪一個方法。

至於TraceMan.start()TraceMan.end()是如何實現對一個方法的耗時統計,如何輸出全部方法的耗時,能夠具體看源碼裏TraceMan類的具體實現,這裏就不具體展開了。

4. UI界面展現

    經過上面的方法插樁,以及耗時數據的處理,咱們已經能夠獲取到全部方法的耗時統計,那麼爲了這個工具的易用性,咱們再來實現一個UI展現界面,可讓方法的耗時數據能夠實時的展現在瀏覽器上,而且支持耗時篩選、線程篩選、方法名搜索等功能。

    咱們使用React實現了一個UI展現界面,而後在手機上搭建了一個服務器,這樣在瀏覽器上就能夠經過地址訪問到這個UI展現界面,而且經過socket進行數據傳輸,咱們的插樁代碼產生方法耗時數據,而後React實現的UI界面接收數據、消費數據、展現數據。

    UI界面展現這部分的實現提及來比較瑣碎,這裏就不詳細展開了,感興趣的同窗能夠看看源碼。

該項目的源碼和詳細的集成以及使用方法,我在github上維護了詳細的文檔,歡迎提供意見MethodTraceMan

5. 總結

    以上就是咱們在優化流暢度的過程當中實現的一個協助咱們快速解決問題的工具,也簡單分享了相關的技術知識,但願對也爲頁面流暢度苦惱的同窗提供一點點想法。以後將分享其餘的幾個部分,主要包括:Android View繪製原理幀率流暢度監控幀率自動化測試流暢度優化實用技巧等等。固然對於卡頓以及流暢度的監控及優化還有不少須要作的工做,咱們的主要目標是但願從監控到排查問題工具再到卡頓解決造成一個閉環的方案,讓版本迭代間的流暢度問題作到可控、可發現、易解決,這是咱們努力的方向。

在集成和使用過程當中有遇到任何問題或者建議,歡迎與我交流,添加請註明來意:

chat
相關文章
相關標籤/搜索