Gradle-初探代碼注入Transform

簡介

本文主要介紹gradle打包過程中transform階段,這裏大概說下AOP(Aspect Oriented Programming),這是一種面向切面的思想,預支對應的是OOP(Object Oriented Programming)面向對象編程,這裏不展開說明。可以看下對AOP總結的思維導圖

劃重點

本篇文章主要介紹下面的幾點:

  • Transform可以做什麼
  • 簡單瞭解App打包過程,以及介紹Transform
  • 生成自己的MyConfig類文件,有助我們更好理解
  • 介紹ASM

Transform可以做什麼

最主要的目的就是:解耦,開發人員專注於需求,其他的邊角料,交給Transform來處理。

  1. 權限判斷,避免代碼中相關的地方都是權限申請和處理的代碼
  2. 無痕埋點,簡單的場景可以使用,但是場景比較複雜時,就不好處理了,目前網上沒有很好的解決方案
  3. 性能監控,trace+字節碼插樁,完美監控
  4. 事件防抖,避免短期內多次點擊按鈕
  5. 熱修復,在所有方法前插入一個預留的函數,可以將有bug的方法替換成下發的方法。
  6. 優化代碼,例如刪除項目中體積很大的R文件中的字段、優化內聯函數等等

  7. 還有很多功能,都值得我們去嘗試。

App打包過程 & Transform

首先我們回顧一下App的打包流程,App打包都需要經理哪些流程,每一個步驟都幹了什麼?

apk打包過程

谷歌官網的一幅圖

從上面的圖中大概理一下流程

  1. 編譯器將app的源碼編譯成DEX(Dalvik Executable)文件(其中包括運行在Android設備上的字節碼),將所有其他的內容轉換爲已經編譯的資源。
  2. APK打包器將DEX文件和已編譯資源合併成單個APK。不過,必須先簽署APK,才能將應用安裝並部署到Android設備上
  3. APK打包器使用調試或者發佈密鑰庫簽署你的APK:
    • 如果你構建的是debug版本應用,打包器會使用debug密鑰庫簽署你的應用,Android Studio自動使用debug密鑰庫配置新項目
    • 如果你構建的是release版本,打包器會使用release密鑰庫簽署你的應用
  4. 在生成最終APK之前,打包器會使用zipalign工具對應用進行優化,減少其在設備上運行時的內存佔用

然後再看一張谷歌之前的打包流程圖

這張圖相比較第一張圖而言就更加詳細了,從這張圖中,可以看到打包流程可以分爲以下七步:

  1. aapt-打包res資源文件,生成R.java、resources.arsc和res文件(二進制&非二進制如res/raw和pic保持原樣)
  2. AIDL-Android藉口定義語言,Android提供的IPC(Inter Process Communication,進程間通信)的一種獨特實現。這個階段處理.aidl文件,生成對應的Java接口文件。
  3. Java Compiler-通過Java Compiler編譯R.java、Java接口文件、Java源文件,生成.class文件。
  4. dex-通過dex命令,將.class文件和第三方庫中的.class文件處理生成class.dex。
  5. apkbuilder-將class.dex、resources.arsc、res文件夾(res/raw資源被原封不動的打包進APK之外,其他資源都會被編譯或者處理)、OtherResouces(assets文件夾)、AndroidManifest.xml打包進apk文件。
  6. Jarsigner-對上面的apk進行debug或release簽名
  7. aipalign-將簽名後的pak進行對其處理

最後看一張更加詳細的圖片

Transform

Transform階段就是在apk打包圖中紅圈的位置,第二張圖更加詳細的表示了Transform的過程,是在.class->.dex的過程。

Gradle Transform是Android官方提供給開發者在項目構建階段由class到dex轉換期間修改class文件的一套api。目前經典的應用就是字節碼插樁和代碼注入技術。有了這個API,我們就可以根據自己的業務需求做一些定製。

先看下transform主要有哪些方法

  1. getName():Transform的名稱,但是這裏並不是真正的名稱,真正的名稱還需要進行拼接
  2. getInputTypes():Transform處理文件的類型
    • CLASSES 表示要處理編譯後的字節碼,可能是jar包也可能是目錄
    • RESOURCES表示處理標準的java資源
  3. getScopes():Transform的作用域
    type Des
    PROJECT 只處理當前的文件
    SUB_PROJECTS 只處理子項目
    EXTERNAL_LIBRARIES 只處理外部的依賴庫
    TESTED_CODE 測試代碼
    PROVIDED_ONLY 只處理本地或遠程以provided形式引入的依賴庫
    PROJECT_LOCAL_DEPS (Deprecated,使用EXTERNAL_LIBRARIES) 只處理當前項目的本地依賴,例如jar、aar
    SUB_PROJECTS_LOCAL_DEPS (Deprecated,使用EXTERNAL_LIBRARIES) 只處理子項目的本地依賴。
  4. isIncremental():是否支持增量編譯,增量編譯就是如果第二次編譯相應的task沒有改變,那麼就直接跳過,節省時間,更詳細的解釋可以看這裏
  5. transform():這是最主要的方法,這裏對文件或jar進行處理,進行代碼的插入。
    • TransformInput:對輸入的class文件轉變成目標字節碼文件,TransformInput就是這些輸入文件的抽象。目前它包含DirectoryInput集合與JarInput集合。
    • DirectoryInput:源碼方式參與項目編譯的所有目錄結構及其目錄下的源文件。
    • JarInput:Jar包方式參與項目編譯的所有本地jar或遠程jar包
    • TransformOutProvider:通過這個類來獲取輸出路徑。

通過自定義Plugin創建一個類

我們都知道通過Gradle編譯後,會生成一個BuildConfig的類,其中有一些項目的信息,例如APPLICATION_ID、DEBUG等信息,我們依照BuildConfig生成規則,也生成一個自己的MyConfig類,通過這個例子我們可以瞭解一些gradle的語法和api。

在自定義Plugin類的apply()函數中添加下面的代碼

class ConfigPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        //只在'applicatoin'中使用,否則拋出異常
        if (!project.plugins.hasPlugin(AppPlugin::class.java)) {
            throw GradleException("this plugin is not application")
        }
        //獲取build.gradle中的"android"閉包
        val android = project.extensions.getByType(AppExtension::class.java)
        //創建自己的閉包
        val config = project.extensions.create("config", ConfigExtension::class.java)
        //遍歷"android"閉包中的"buildTypes"閉包,一般有release和debug兩種
        android.applicationVariants.all {
            it as ApplicationVariantImpl
            println("variant name: ${it.name}")

            //創建自己的config task
            val buildConfigTask = project.tasks.create("DemoBuildConfig${it.name.capitalize()}")
            //在task最後去創建java文件
            buildConfigTask.doLast { task ->
                createJavaConfig(it, config)
            }
            // 找到系統的buildConfig Task
            val generateBuildConfigTask = project.tasks.getByName(it.variantData.scope.taskContainer.generateBuildConfigTask?.name)
            //自己的Config Task 依賴於系統的Config Task
            generateBuildConfigTask.let {
                buildConfigTask.dependsOn(it)
                it.finalizedBy(buildConfigTask)
            }
        }
    }

    fun createJavaConfig(variant: ApplicationVariantImpl, config: ConfigExtension) {
        val FileName = "MyConfig"
        val constantStr = StringBuilder()
        constantStr.append("\n").append("package ")
                .append(config.packageName).append("; \n\n")
                .append("public class $FileName {").append("\n")
        config.constantMap.forEach {
            constantStr.append("public static final String ${it.key} = \"${it.value}\";\n")
        }
        constantStr.append("} \n")
        println("content: ${constantStr}")

        val outputDir = variant.variantData.scope.buildConfigSourceOutputDir
        val javaFile = File(outputDir, config.packageName.replace(".", "/") + "/$FileName.java")
        println("javaFilePath: ${javaFile.absolutePath}")
        javaFile.writeText(constantStr.toString(), Charsets.UTF_8)
    }

}

就可以生成MyConfig類,這個類簡單定義了一些我們可以在build.gradle中定義的變量,可以分爲下面的幾個步驟

  1. 判斷是否爲application的module(僅在application中進行操作)
  2. 遍歷buildTypes也就是release和debug
  3. 在對應的buildTypes中創建task
  4. 設置自定義的task依賴於BUildConfig的Task
  5. 新建自定義的MyConfig.java文件

Transform的優化:增量與併發

增量

我們想一個問題,遍歷一遍項目中所有源文件和jar,時間都是很長的,如果我們每次改一行編譯都需要經過這個過程,是很浪費時間。這個時候需要實現增量編譯,什麼意思呢?增量,顧名思義,就是在已有的基礎上,對增加的進行編譯,這樣在編譯過一次的基礎上,以後就會大大的縮短時間。

想要開啓增量編譯,我們需要重寫Transform的這個接口,返回true,上面代碼中的註釋也說明了。

 @Override
    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:
                        break;
                    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);
            }
        }
    }
}

這樣做真的有用嗎?讓我們用數據說話,首先準備好測試數據,一個demo,對所有的源文件和第三方依賴庫進行掃描,使用增量和非增量的模式進行三次編譯,然後取平均值。
兩種方式計算自定義transform:

  1. ./gradlew assembleDebug --profile命令,在根目錄的build/reports目錄下會生成一個文件,可以查找每一個transform所花費的時間。
  2. 在自定義的transformTask之前記錄時間,在執行完後記錄所花費的時間,貼一下簡單的代碼
 Task doubleCheckTask = project.tasks["transformClassesWithDoubleCheckTransformFor${variant.name.capitalize()}"]
        doubleCheckTask.configure {
            def startTime
            doFirst {
                startTime = System.nanoTime()
            }
            doLast {
                println()
                println " --> COST: ${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime)} ms"
                println()
            }
        }

這裏我們使用的是第二種方式,我們在每次只改動一行代碼的情況下,然後讓我們看結論

試驗次數 非增量 增量
1 1309ms 151ms
2 1093ms 183ms
3 1153ms 130ms

可以發現,增量的速度比全量的速度快了將近10倍多,這是增量的文件比較少的情況,但是總的來說增量編譯還是會大幅度增加編譯的速度。

併發編譯

併發編譯並不複雜,只需要將上面處理單個jar/class的邏輯,併發處理,最後阻塞等待所有任務結束即可。看下僞代碼:

 WaitableExecutor waitableExecutor = WaitableExecutor.useGlobalSharedThreadPool();
//異步併發處理jar/class
waitableExecutor.execute(() -> {
   //jar織入字節碼
    return null;
});
waitableExecutor.execute(() -> {
   //file織入字節碼
    return null;
});  
//等待所有任務結束
waitableExecutor.waitForTasksWithQuickFail(true);

與增量編譯一樣,做一些實驗對比,用數據說話。

試驗次數 正常編譯 併發編譯
1 1309ms 856ms
2 1093ms 702ms
3 1153ms 790ms

使用ASM織入代碼

從前面幾個章節中,瞭解了自定義Plugin、Transform、Transform的優化,最後一步,就是對目標類進行改造,也就是對class文件進行代碼織入

ASM簡介

ASM官網中這樣介紹ASM

ASM is an all purpose Java bytecode manipulation and analysis framework. It can be used to modify existing classes or to dynamically generate classes, directly in binary form. ASM provides some common bytecode transformations and analysis algorithms from which custom complex transformations and code analysis tools can be built. ASM offers similar functionality as other Java bytecode frameworks, but is focused on performance. Because it was designed and implemented to be as small and as fast as possible, it is well suited for use in dynamic systems (but can of course be used in a static way too, e.g. in compilers).

ASM是用來對Java字節碼進行修改和分析的框架。ASM可以用來修改已經存在的類或者動態生成類,它是直接對二進制文件進行操作的。ASM提供了一些常見的字節碼轉換和分析算法,從這些轉換和分析算法中構建定製複雜的轉換和代碼分析工具。因爲它被設計的和實現的非常的小和儘可能得快,所以它非常和用來動態系統(但是也可以用在靜態的方式,例如在編譯時)

常用字節碼框架

常用的字節碼框架就三個Aspectj、Javassist、ASM,三個都有什麼區別呢?

織入代碼的時期

一圖勝千言,直接看圖

效率和學習成本

  • AspectJ:是一個代碼生成工具,使用它定義的語法生成規則來編寫,基本上要掃描所有的文件,當然AspectJx已經實現的非常的好。最主要的是有個坑,在抖音目前的多module的工程上,是有很多坑的,例如,和後面的Transform過程有一些衝突,導致代碼一直織入失敗,而且編譯的時長也大大增加。
  • Javasist:直接操作修改編譯後的字節碼,而且可以自定義Transform,編譯時長可以做很大空間的優化,就是織入代碼的效率不如ASM。
    有關javassits的使用可以看這篇文章

根據網上的信息,大神得出的數據結果,這裏盜用一下,基本上有3倍的差別,文件越多,ASM和Javasist的效率相差就越大。

ASM的用法

ASM框架中的核心類有以下幾個:

  • ClassReader:用來解析編譯過的class字節碼文件
  • ClassWriter:用來重新構建編譯後的類,比如修改類名、屬性以及方法,甚至可以生成新的類的字節碼文件
  • ClassVisitor:主要負責“拜訪”類成員信息。其中包括標記在類上的註解、類的構造方法、類的字段、類的方法、靜態代碼塊。
  • AdviceAdapter:實現了MethodVisitor接口,主要負責“拜訪”方法的信息,用來具體的方法字節碼操作。

ClassVisitor的全部方法如下,按一定的次序來遍歷類中的成員

讓我們簡單寫個demo,這段代碼很簡單,通過Visitor API讀取一個class的內容,保存到另一個文件中去

static void copy(File inputFile, File outputFile) {
        def weavedBytes = inputFile.bytes
        ClassReader classReader = new ClassReader(bytes)
        ClassWriter classWriter = new ClassWriter(classReader,
                ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS)

        DoubleClickCheckModifyClassAdapter classAdapter = new DoubleClickCheckModifyClassAdapter(classWriter)
        try {
            classReader.accept(classAdapter, ClassReader.EXPAND_FRAMES)
            weavedBytes = classWriter.toByteArray()
        } catch (Exception e) {
            println "Exception occurred when visit code \n " + e.printStackTrace()
        }

        outputFile.withOutputStream{
            it.write(weavedBytes)
        }
    }

首先,我們通過ClassReader讀取某個class文件,然後定義一個ClassWriter,這個ClassWriter其實就是一個ClassVisitor的實現,負責將ClassReader傳遞過來的數據寫到一個字節流中,而真正觸發這個邏輯就是通過ClassWriter的accept方式。

上面代碼DoubleClickCheckModifyClassAdapter類,也是一個Visitor,也就是我們自定義需要實現的功能。

最後,我們通過ClassWriter的toByteArray(),將從ClassReader傳遞到ClassWriter的字節碼導出,寫入新的文件即可。這樣我們就完成了字節碼的操作,是不是感覺也不難。

ASM code

從上面的例子中,可以看出來,只有DoubleClickCheckModifyClassAdapter需要自己定義,其他的都由上面的模板來寫就行,來看下這個類是怎麼實現的。


public class DoubleClickCheckModifyClassAdapter extends ClassVisitor implements Opcodes {

    public DoubleClickCheckModifyClassAdapter(ClassVisitor cv) {
        super(Opcodes.ASM5, cv);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
        if ((ASMUtils.isPublic(access) && !ASMUtils.isStatic(access)) && 
                name.equals("onClick") && 
                desc.equals("(Landroid/view/View;)V")) {
            methodVisitor = new View$OnClickMethodVisitor(methodVisitor);
        }

        return methodVisitor;
    }
}

可以從上面的visitMethod方法中看到,我們只對View的onClick函數進行代碼織入,然後再看下View$OnClickMethodVisitor的實現

public class View$OnClickMethodVisitor extends MethodVisitor {
    private boolean weaved;

    public View$OnClickMethodVisitor(MethodVisitor mv) {
        super(Opcodes.ASM5, mv);
    }

    @Override
    public void visitCode() {
        super.visitCode();
        if (weaved) return;

        AnnotationVisitor annotationVisitor =
                mv.visitAnnotation("L" + DoubleCheckConfig.checkClassAnnotation + ";", false);
        annotationVisitor.visitEnd();

        mv.visitMethodInsn(Opcodes.INVOKESTATIC, DoubleCheckConfig.checkClassPath, "isClickable", "()Z", false);
        Label l1 = new Label();
        mv.visitJumpInsn(Opcodes.IFNE, l1);
        mv.visitInsn(Opcodes.RETURN);
        mv.visitLabel(l1);
    }

    @Override
    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
        /*Lcom/smartdengg/clickdebounce/Debounced;*/
        weaved = desc.equals("L" + DoubleCheckConfig.checkClassAnnotation + ";");
        return super.visitAnnotation(desc, visible);
    }

}

最主要的實現就是visitCode函數,這裏面,我們實現了代碼的織入。

我們設想一下,如果要對某個class進行修改,那需要對字節碼具體做什麼修改呢?最直觀的方法就是,先編譯生成目標class,然後看它的字節碼和原來class的字節碼有什麼區別,但是這樣還不夠,其實我們最終並不是讀取字節碼,而是使用ASM來修改。

問題來了,我不懂ASM,對字節碼更是很陌生,怎麼辦?難道我要從新學習一下字節碼,才能進行開發嗎?答案當然不是,如果我們只是對字節碼做一些簡單的操作,完全可以使用工具來幫我們完成

這裏安利一個非常好用的工具,Intellij IDEA有個插件Asm Bytecode Outline,可以查看一個class文件的bytecode和ASM code,同樣,Android Studio同樣也有一個類似的插件ASM Bytecode Viewer實現了同樣的功能。

如何編寫ASM代碼

這是我們源代碼

public class A {
    static void toast(Context context) {
        Toast.makeText(context,"test",Toast.LENGTH_LONG).show();
    }
}

我們想通過字節碼插裝後變爲

public class A {
    static void toast(Context context) {
        Log.i("tag","test");
        Toast.makeText(context,"test",Toast.LENGTH_LONG).show();
    }
}

也就是在toast函數的第一行插入Log代碼。

  1. 我們將要插入的代碼先寫入源代碼中,在文件中右擊鼠標.
  2. 點擊ASM Bytecode Viewer
  3. 打開右側的ASM預覽界面,就能看到對應的ASM代碼


到此爲止,貌似使用對比ASM code的方式,來實現字節碼修改也不難,但是,這種方式只是可以實現一些修改字節碼的基礎場景,還有很多場景是需要對字節碼有一些基礎只是才能做到,而且,要閱讀懂ASM code,也是需要一定字節碼的知識。所以,如果要開發字節碼工程,還是需要學習一番字節碼的。

實際應用

Theory without practice is empty,practice without theory is blind

我們既然已經瞭解了ASM的原理,那麼我們應用於實踐,我們不能爲了學技術而學技術,技術最終是要服務於業務的,我們更加應該從業務的角度出發,來思考問題和提升自己(題外話)。

有一個場景,在可點擊的地方,經常出現連擊,但是結果並不是我們想要的,例如,跳轉到個人頁面,我們快速點擊兩次,就會發現出現了兩個個人頁面,需要back兩次才能回到之前的頁面,這個肯定是不符合預期的,幾乎在所有的點擊地方,都應該做防抖動的操作(抖動,就是快速或者不小心在短時間內多次點擊,我也不知道爲什麼叫抖動,網上都這樣寫),現在有幾個思路:

  • Kotlin中實現view的擴展函數,這樣就可以統一的地方做防抖動操作,但是如果大家不是理解這個點,有可能還會調用之前的點擊事件,那麼還有可能出現這個問題。
  • Java中封裝一個工具類,要求每個開發人員在在OnClick中添加這個函數,但是這個很容易被遺忘,可操作性不大。
  • 使用AOP(AspectJ和ASM),在編譯期間,將所有的Onclick函數中做判斷,而且ASM兼容Java和Kotlin。這裏選擇ASM,上一章節已經說明原因

從以上幾點中,可以得出使用ASM是目前最好的方案。

參考文章

ASM 操作字節碼初探
一起玩轉Android項目中的字節碼
一文讀懂 AOP | 你想要的最全面 AOP 方法探討

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章