ASM+Gradle Transfrom API 實現(xiàn)編譯期間代碼的修改

ASM 是什么?

AOP(面向切面編程),是一種編程思想,但是它的實現(xiàn)方式有很多,比如:APT、AspectJ、JavaAssist、ASM 等。


常見的幾種AOP區(qū)別

ASM 和 Javassist類似,也是一個 Java 字節(jié)碼操控框架。它能被用來動態(tài)生成類或者增強既有類的功能。ASM 可以直接產(chǎn)生二進制 class 文件,也可以在類被加載入 Java 虛擬機之前動態(tài)改變類行為。Java class 被存儲在嚴格格式定義的 .class 文件里,這些類文件擁有足夠的元數(shù)據(jù)來解析類中的所有元素:類名稱、方法、屬性以及 Java 字節(jié)碼(指令)。ASM 從類文件中讀入信息后,能夠改變類行為,分析類信息,甚至能夠根據(jù)用戶要求生成新類。

簡單點說,通過 javac 將 .java 文件編譯成 .class 文件,.class 文件中的內(nèi)容雖然不同,但是它們都具有相同的格式,ASM 通過使用訪問者(visitor)模式,按照 .class 文件特有的格式從頭到尾掃描一遍 .class 文件中的內(nèi)容,在掃描的過程中,就可以對 .class 文件做一些操作了,有點黑科技的感覺

所以ASM 就是一個字節(jié)碼操作庫,可以大大降低我們操作字節(jié)碼的難度

Android 的打包過程

android 打包流程

如圖所示是Android打包流程,.java文件->.class文件->.dex文件,只要在紅圈處攔截住,拿到所有方法進行修改完再放行就可以了,而做到這一步也不難,Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API, 允許第三方 Plugin 在打包 dex 文件之前的編譯過程中操作 .class 文件,我們做的就是實現(xiàn)Transform進行.class文件遍歷拿到所有方法,修改完成對原文件進行替換。


對應(yīng)細節(jié)圖

原理概述

我們可以自定義一個Gradle Plugin,然后注冊一個Transform對象,在tranform方法里,可以分別遍歷目錄和jar包,然后我們就可以遍歷當前應(yīng)用程序的所有.class文件,然后在利用ASM框架的相關(guān)API,去加載響應(yīng)的.class 文件,并解析,就可以找到滿足特定條件的.class文件和相關(guān)方法,最后去修改相應(yīng)的方法以動態(tài)插入埋點字節(jié)碼,從而達到自動埋點的效果。

DEMO

本范例嘗試對點擊android中的普通點擊事件進行一個攔截,并在其中插入代碼。

1、創(chuàng)建android工程,只寫一個簡單點擊事件即可(

代碼..略

2、創(chuàng)建plugin lib module

1、修改plugin的gradle

apply plugin: 'groovy'
apply plugin: 'maven'
dependencies {
    compile gradleApi()
    compile localGroovy()

    compile 'org.ow2.asm:asm:6.0'
    compile 'org.ow2.asm:asm-commons:6.0'
    compile 'org.ow2.asm:asm-analysis:6.0'
    compile 'org.ow2.asm:asm-util:6.0'
    compile 'org.ow2.asm:asm-tree:6.0'
    compileOnly 'com.android.tools.build:gradle:3.2.1', {//這里注意需要保持版本一致,否則會報錯
        exclude group:'org.ow2.asm'
    }
}
repositories {
    jcenter()
}

//調(diào)試模式下在本地生成倉庫(也可推入自己已有的maven倉庫)
uploadArchives {
    repositories.mavenDeployer {
        //本地倉庫路徑,以放到項目根目錄下的 repo 的文件夾為例
        repository(url: uri('../repo'))

        //groupId ,自行定義
        pom.groupId = 'com.canzhang.android'

        //artifactId
        pom.artifactId = 'bury-point-com.canzhang.plugin'

        //插件版本號
        pom.version = '1.0.0-SNAPSHOT'
    }
}

2、在main目錄下新建groovy包

groovy 是一種語言,和java語法比較類似

image.png

3、創(chuàng)建transform類
這個類的作用就是在被編譯成dex之前能夠攔截到.class文件,然后找到匹配我們需求的,進行修改調(diào)整。

/**
 * Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API,
 * 允許第三方 Plugin 在打包 dex 文件之前的編譯過程中操作 .class 文件,
 * 我們做的就是實現(xiàn)Transform進行.class文件遍歷拿到所有方法,修改完成對原文件進行替換。
 */
class AnalyticsTransform extends Transform {
    private static Project project
    private AnalyticsExtension analyticsExtension

    AnalyticsTransform(Project project, AnalyticsExtension analyticsExtension) {
        this.project = project
        this.analyticsExtension = analyticsExtension
    }

    /**
     * /返回該transform對應(yīng)的task名稱(編譯后會出現(xiàn)在build/intermediates/transform下生成對應(yīng)的文件夾)
     * @return
     */
    @Override
    String getName() {
        return AnalyticsSetting.PLUGIN_NAME
    }

    /**
     * 需要處理的數(shù)據(jù)類型,有兩種枚舉類型
     * CLASSES 代表處理的 java 的 class 文件,RESOURCES 代表要處理 java 的資源
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指 Transform 要操作內(nèi)容的范圍,官方文檔 Scope 有 7 種類型:
     * 1. EXTERNAL_LIBRARIES        只有外部庫
     * 2. PROJECT                   只有項目內(nèi)容
     * 3. PROJECT_LOCAL_DEPS        只有項目的本地依賴(本地jar)
     * 4. PROVIDED_ONLY             只提供本地或遠程依賴項
     * 5. SUB_PROJECTS              只有子項目。
     * 6. SUB_PROJECTS_LOCAL_DEPS   只有子項目的本地依賴項(本地jar)。
     * 7. TESTED_CODE               由當前變量(包括依賴項)測試的代碼
     * @return
     */
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        //點進去可以看到這個包含(項目、項目依賴、外部庫)
        //Scope.PROJECT,
        //Scope.SUB_PROJECTS,
        //Scope.EXTERNAL_LIBRARIES
        return TransformManager.SCOPE_FULL_PROJECT
//        return Sets.immutableEnumSet(
//                QualifiedContent.Scope.PROJECT,
//                QualifiedContent.Scope.SUB_PROJECTS)
    }

    @Override
    boolean isIncremental() {//是否增量構(gòu)建
        return false
    }

    //這里需要注意,就算什么都不做,也需要把所有的輸入文件拷貝到目標目錄下,否則下一個Task就沒有TransformInput了,
    // 如果是此方法空實現(xiàn),最后會導(dǎo)致打包的APK缺少.class文件
    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        _transform(transformInvocation.context, transformInvocation.inputs, transformInvocation.outputProvider, transformInvocation.incremental)
    }

    void _transform(Context context, Collection<TransformInput> inputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        if (!incremental) {
            outputProvider.deleteAll()
        }

        /**Transform 的 inputs 有兩種類型,一種是目錄,一種是 jar 包,要分開遍歷 */
        inputs.each { TransformInput input ->
            /**遍歷目錄*/
            input.directoryInputs.each { DirectoryInput directoryInput ->
                /**當前這個 Transform 輸出目錄*/
                File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                File dir = directoryInput.file

                if (dir) {
                    HashMap<String, File> modifyMap = new HashMap<>()
                    /**遍歷以某一擴展名結(jié)尾的文件*/
                    dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
                        File classFile ->
                            if (AnalyticsClassModifier.isShouldModify(classFile.name, analyticsExtension)) {
                                File modified = AnalyticsClassModifier.modifyClassFile(dir, classFile, context.getTemporaryDir())
                                if (modified != null) {
                                    /**key 為包名 + 類名,如:/cn/data/autotrack/android/app/MainActivity.class*/
                                    String ke = classFile.absolutePath.replace(dir.absolutePath, "")
                                    modifyMap.put(ke, modified)//修改過后的放到一個map中然后在寫回源目錄,覆蓋原來的文件
                                }
                            }
                    }
                    FileUtils.copyDirectory(directoryInput.file, dest)
                    modifyMap.entrySet().each {
                        Map.Entry<String, File> en ->
                            File target = new File(dest.absolutePath + en.getKey())
                            if (target.exists()) {
                                target.delete()
                            }
                            FileUtils.copyFile(en.getValue(), target)
                            en.getValue().delete()
                    }
                }
            }

            /**遍歷 jar*/
            input.jarInputs.each { JarInput jarInput ->
                String destName = jarInput.file.name

                /**截取文件路徑的 md5 值重命名輸出文件,因為可能同名,會覆蓋*/
                def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath).substring(0, 8)
                /** 獲取 jar 名字*/
                if (destName.endsWith(".jar")) {
                    destName = destName.substring(0, destName.length() - 4)
                }

                /** 獲得輸出文件*/
                File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)

                def modifiedJar = AnalyticsClassModifier.modifyJar(jarInput.file, context.getTemporaryDir(), true, analyticsExtension)
                if (modifiedJar == null) {
                    modifiedJar = jarInput.file
                }
                FileUtils.copyFile(modifiedJar, dest)
            }
        }
    }

  
}

3、創(chuàng)建插件類

/**
 * 可以通過配置主工程目錄中的gradle.properties 中的
 * canPlugin.disablePlugin字段來控制是否開啟此插件
 */
class AnalyticsPlugin implements Plugin<Project> {
    void apply(Project project) {

        //這個AnalyticsExtension 以及canPlugin名稱,可以提供我們在外層配置一些參數(shù),從而支持外層擴展
        AnalyticsExtension extension = project.extensions.create("canPlugin", AnalyticsExtension)

        //這個可以讀取工程的gradle.properties 里面的can.disablePlugin 字段,控住是否注冊此插件
        boolean disableAnalyticsPlugin = false
        Properties properties = new Properties()
        if (project.rootProject.file('gradle.properties').exists()) {
            properties.load(project.rootProject.file('gradle.properties').newDataInputStream())
            disableAnalyticsPlugin = Boolean.parseBoolean(properties.getProperty("disablePlugin", "false"))
        }

        if (!disableAnalyticsPlugin) {
            println("------------您開啟了全埋點插樁插件--------------")
            AppExtension appExtension = project.extensions.findByType(AppExtension.class)
            //注冊我們的transform類
            appExtension.registerTransform(new com.canzhang.plugin.AnalyticsTransform(project, extension))
        } else {
            println("------------您已關(guān)閉了全埋點插樁插件--------------")
        }
    }
}

到這里插件和gradle的tranform類我們都創(chuàng)建好了,下面需要看該怎么修改我們想修改的類了。
4、ASM中的ClassVisitor
ClassVisitor:主要負責遍歷類的信息,包括類上的注解、構(gòu)造方法、字段等等。
所以我們可以在這個類中篩選出符合我們條件的類或者方法,然后去修改,實現(xiàn)我們的目的。
比如我們本例子就是為了找到實現(xiàn)了View$OnClickListener接口的類,然后遍歷這個類,并找到重寫后的onClick(View v)方法。

這里就細節(jié)貼代碼了,不懂得地方可以看注釋

/**
 * 使用ASM的ClassReader類讀取.class的字節(jié)數(shù)據(jù),并加載類,
 * 然后用自定義的ClassVisitor,進行修改符合特定條件的方法,
 * 最后返回修改后的字節(jié)數(shù)組
 */
class AnalyticsClassVisitor extends ClassVisitor implements Opcodes {

//插入的外部類具體路徑
    private String[] mInterfaces
    private ClassVisitor classVisitor
    private String mCurrentClassName

    AnalyticsClassVisitor(final ClassVisitor classVisitor) {
        super(Opcodes.ASM6, classVisitor)
        this.classVisitor = classVisitor
    }

    private
    static void visitMethodWithLoadedParams(MethodVisitor methodVisitor, int opcode, String owner, String methodName, String methodDesc, int start, int count, List<Integer> paramOpcodes) {
        for (int i = start; i < start + count; i++) {
            methodVisitor.visitVarInsn(paramOpcodes[i - start], i)
        }
        methodVisitor.visitMethodInsn(opcode, owner, methodName, methodDesc, false)
    }

    /**
     * 這里可以拿到關(guān)于.class的所有信息,比如當前類所實現(xiàn)的接口類表等
     * @param version 表示jdk的版本
     * @param access 當前類的修飾符 (這個和ASM 和 java有些差異,比如public 在這里就是ACC_PUBLIC)
     * @param name 當前類名
     * @param signature 泛型信息
     * @param superName 當前類的父類
     * @param interfaces 當前類實現(xiàn)的接口列表
     */
    @Override
    void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces)
        mInterfaces = interfaces
        mCurrentClassName = name

        AnalyticsUtils.logD("當前的類是:" + name)
        AnalyticsUtils.logD("當前類實現(xiàn)的接口有:" + mInterfaces)
    }

    /**
     * 這里可以拿到關(guān)于method的所有信息,比如方法名,方法的參數(shù)描述等
     * @param access 方法的修飾符
     * @param name 方法名
     * @param desc 方法簽名(就是(參數(shù)列表)返回值類型拼接)
     * @param signature 泛型相關(guān)信息
     * @param exceptions 方法拋出的異常信息
     * @return
     */
    @Override
    MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)

        String nameDesc = name + desc

        methodVisitor = new AnalyticsDefaultMethodVisitor(methodVisitor, access, name, desc) {

            @Override
            void visitEnd() {
                super.visitEnd()
            }

            @Override
            void visitInvokeDynamicInsn(String name1, String desc1, Handle bsm, Object... bsmArgs) {
                super.visitInvokeDynamicInsn(name1, desc1, bsm, bsmArgs)
            }

            @Override
            protected void onMethodExit(int opcode) {//方法退出節(jié)點
                super.onMethodExit(opcode)
            }

            @Override
            protected void onMethodEnter() {//方法進入節(jié)點
                super.onMethodEnter()

                if ((mInterfaces != null && mInterfaces.length > 0)) {
                    //如果當前類實現(xiàn)的接口有View$OnClickListener,并且當前進入的方法是onClick(Landroid/view/View;)V
                    //這里如果不知道怎么寫,可以寫個demo打印一下,就很快知道了,這里涉及一些ASM和Java中不同的寫法。
                    if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
                        AnalyticsUtils.logD("插樁:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)
                         //這里就是插代碼邏輯了
                        methodVisitor.visitVarInsn(ALOAD, 1)
                        methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
                    }
                }
            }

            @Override
            AnnotationVisitor visitAnnotation(String s, boolean b) {
                return super.visitAnnotation(s, b)
            }
        }
        return methodVisitor
    }
}

要插入的代碼

public class MySdk {
    /**
     * 常規(guī)view 被點擊,自動埋點
     *
     * @param view View
     */
    @Keep
    public static void onViewClick(View view) {
        Log.e("Test","成功插入 666666:"+view);
    }
}

核心代碼分析

            @Override
            protected void onMethodEnter() {//方法進入節(jié)點
                super.onMethodEnter()

                if ((mInterfaces != null && mInterfaces.length > 0)) {
                    //如果當前類實現(xiàn)的接口有View$OnClickListener,并且當前進入的方法是onClick(Landroid/view/View;)V
                    //這里如果不知道怎么寫,可以寫個demo打印一下,就很快知道了,這里涉及一些ASM和Java中不同的寫法。
                    if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
                        AnalyticsUtils.logD("插樁:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)

                        //這里就是插代碼邏輯了
                        methodVisitor.visitVarInsn(ALOAD, 1)
                        methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
                    }
                }
            }

當方法進入的時候,如果判斷符合我們的條件,則進行方法插入。

  • 問題1:nameDesc為啥這么寫。
    nameDesc == 'onClick(Landroid/view/View;)V'為什么是這樣寫的,后面的V是個什么東東。
    首先grovvy中是可以使用==號來判斷字符串是否相等的,其次方法名是和java有一些差異,這個我們可以深入去了解這些差異學(xué)習(xí),就可以理解為何這么寫。還有一種簡單的方法,可以直接打印日志的方式來快速知道我們需要的方法應(yīng)該怎么寫。
    入?yún)?yīng)關(guān)系表
    image.png

例子

image.png

  • 問題2: 這插入的是什么鬼,怎么有點看不懂,如何知道怎么插。
    ASM就是幫助我們操作字節(jié)碼的,封裝了一些api可供我們調(diào)用,這個轉(zhuǎn)換可以使用一個插件 ASM Bytecode outline ,android studio 可以下載此插件(參考教程
    )。

5、創(chuàng)建配置文件
按照如圖所示創(chuàng)建對應(yīng)路徑和配置文件com.canzhang.plugin.properties,這里需要注意

  • 配置文件的名字:com.canzhang.plugin就是插件的名稱,就是稍后我們生成插件后,引用此插件的module需要聲明的那個:apply plugin: 'com.canzhang.plugin'
  • 配置內(nèi)容就是我們插件的的包名和類名
# 此文件名為插件引用名,下面這行則是對應(yīng)的插件路徑
implementation-class=com.canzhang.plugin.AnalyticsPlugin
image.png

6、然后我們就可以運行構(gòu)建plugin了


image.png

構(gòu)建好之后我們就可以在本地看到這樣一個文件夾


image.png

這里如果想開放此插件給到其他工程使用,則可以提交repo到githup,然后按照下方配置流程進行配置(步驟7),另外需要額外配置倉庫地址

 maven { url "https://raw.githubusercontent.com/gudujiucheng/ASMDemo/master/repo" }

其中:https://raw.githubusercontent.com/為固定路徑,gudujiucheng為Github用戶名,ASMDemo為項目名,master/repo為倉庫相對路徑。

7、使用插件

  • 項目gradle配置(配置本地倉庫、并引入插件)
buildscript {
    
    repositories {
        google()
        jcenter()
        //本地調(diào)試倉庫
        maven {
            url uri('repo')
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.0'

        //引用插件
        classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}
  • 主module gradle 配置
apply plugin: 'com.canzhang.plugin'

然后運行編譯之后,就可以看到我們插樁的代碼了。
插樁前的代碼:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.tv_test).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(MainActivity.this, "普通點擊事件", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

如下圖所示,可以看到具體插樁后的字節(jié)碼,可以點擊查看(注意如果插樁的class是jar包內(nèi)的,則需要自行反編譯jar進行查看(推薦一個簡單易用的反編譯工具:https://github.com/linchaolong/ApkToolPlus),或者調(diào)整插件,使輸出一份class到指定文件夾查看)。

插樁后

更多細節(jié)待續(xù)....

注意事項:

  • 沒有生成插件之前,要把依賴去掉,不然跑不起來
    主module屏蔽
apply plugin: 'com.canzhang.plugin'

主工程的gradle屏蔽

classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'

屏蔽之后先build項目成功后,在觸發(fā)生成插件,然后在放開屏蔽的兩項,就可以了


生成插架

生成的插件
  • 關(guān)于混淆:關(guān)于混淆可以不用擔心?;煜鋵嵤莻€ProguardTransform,在自定義的Transform之后執(zhí)行。

  • 插件插入不存在的代碼也是不會報錯的,因為是在編譯后插入的,直到運行的時候才會報錯,所以要注意插入代碼的正確性。

  • 出現(xiàn)莫名其妙的錯誤,如RuntimeException
    這里asm不同版本的api,有時候會做api版本限制,要檢查下,自己的api版本是否錯誤:(發(fā)生這些錯誤的原因,主要是因為我們寫死的版本,和項目實際應(yīng)用的asm版本不相同導(dǎo)致的)

    我們的代碼

比如下面這些版本限制觸發(fā)的異常:(這里只是拋出了異常,并沒有很細致的提示,所以需要留意看錯誤日志)


asm api

asm api

其他細節(jié)

  • 上文是用groovy來寫的(groovy的編譯錯誤提示不是很好,建議用其他語言寫),也可以使用java或者kotlin來寫,可以選擇自己熟悉的語法,這幾種語言最后都會轉(zhuǎn)換成字節(jié)碼,通過jvm來執(zhí)行。
  • 如果用于項目,可以考慮參考其他框架進行一些增量編譯和多線程并發(fā)處理文件等方面的優(yōu)化,提高編譯速度,可參考:https://github.com/Leaking/Hunter

參考文章:

本文主要是用于記錄,參考自神策全埋點教程
http://www.itdecent.cn/p/9039a3e46dbc
http://www.itdecent.cn/p/c2c1d350d245
http://www.itdecent.cn/p/16ed4d233fd1

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容