應用於Android無埋點的Gradle外掛解析

weixin_33912445發表於2018-02-12

自定義外掛涉及到幾個知識點,比如Gradle構建工具、Groovy語法、Gradle外掛開發流程等等。這些知識我就預設大家都知道了。想學習或溫習的可以參考:

這個外掛用來做什麼?

試想一下我們程式碼埋點的過程:首先定位到事件響應函式,例如Button的onClick函式,然後在該事件響應函式中呼叫SDK資料蒐集介面。

Button btnLogin = (Button) findViewById(R.id.btn_login);

btnLogin.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        ...
        NPTracker.getInstance().trackEvent(Constant.EVENT_LOGIN_METHOD);
    }
});

下面,我們介紹使用gradle外掛自動在目標響應函式中插入SDK資料蒐集程式碼,達到自動埋點的目的。本文對此的實戰即通過位元組碼插樁,在class檔案編譯成dex之前(同時也是proguard操作之前),遍歷所有要編譯的class檔案並對其中符合條件的方法進行修改,注入我們要呼叫的SDK資料蒐集程式碼,從而實現自動埋點的目的。

外掛是如何與應用關聯起來的?

為了方便開發,新建一個Android專案,然後開發只針對當前專案的Gradle外掛。針對當前專案開發Gradle外掛相對較簡單,但必須注意的是:

新建的Module名稱必須為BuildSrc

目錄結構示意如下:

2965551-8adb681b8c6cdfd4
外掛工程目錄

然後自定義Gradle外掛類:

package com.codeless.plugin

import com.android.build.gradle.BaseExtension
import com.codeless.plugin.utils.DataHelper
import com.codeless.plugin.utils.Log
import org.gradle.api.Plugin
import org.gradle.api.Project

class InjectPluginImpl implements Plugin<Project> {
   @Override
   void apply(Project project) {
       project.extensions.create('codelessConfig', InjectPluginParams)
       registerTransform(project)
       ...
   }
   
   ...
}

然後在app module的build.gradle中引入自定義外掛:

apply plugin: com.codeless.plugin.InjectPluginImpl

codelessConfig {
    //this will determine the name of this plugin transform, no practical use.
    pluginName = 'myPluginTest'
    //turn this on to make it print help content, default value is true
    showHelp = true
    //this flag will decide whether the log of the modifying process be printed or not, default value is false
    keepQuiet = false
    //this is a kit feature of the plugin, set it true to see the time consume of this build
    watchTimeConsume = false

    //this is the most important part, 3rd party JAR packages that want our plugin to inject;
    //our plugin will inject package defined in 'AndroidManifest.xml' and 'butterknife.internal.butterknife.internal.DebouncingOnClickListener' by default.
    //structure is like ['butterknife.internal','com.a.c'], type is HashSet<String>.
    //You can also specify the name of the class;
    //example: ['com.xxx.xxx.BaseFragment']
    targetPackages = ['okhttp3']
}

可能有人對apply plugin: com.codeless.plugin.InjectPluginImpl不是很理解,沒關係,那下面這個呢:

apply plugin: 'com.android.library' <==如果是編譯 Library,則載入此外掛
apply plugin: 'com.android.application' <==如果是編譯 Android APP,則載入此外掛

Project 的 API 請戳 Project。apply 其實是 Project 實現的 PluginAware 介面定義的:

2965551-806e07ffe347686b
apply函式

apply函式接收多種引數,上述用法呼叫的其實是void apply(Map<String,?> options)

2965551-e8db808cd5787cff
apply plugin

plugin 作為Map Key,表示載入指定id的外掛。

回到apply plugin: com.codeless.plugin.InjectPluginImpl,它表示載入id為com.codeless.plugin.InjectPluginImpl的外掛,也就是我們的自定義外掛。

apply plugin: com.codeless.plugin.InjectPluginImpl這一行會導致直接執行com.codeless.plugin.InjectPluginImpl外掛的void apply(Project project)方法,傳入app module的build.gradle對應的 Project 物件。

知識擴充:

每一個build.gradle檔案都會轉換成一個 Project 物件。通過 Project 物件可以訪問到build.gradle中的各種配置。

拿到了app module的build.gradle對應的 Project 物件,自然就能通過 project.codelessConfig訪問傳入的一些配置項嘍。例如:

project.afterEvaluate {
    Log.setQuiet(project.codelessConfig.keepQuiet);
    Log.setShowHelp(project.codelessConfig.showHelp);
    Log.logHelp();
    if (project.codelessConfig.watchTimeConsume) {
        Log.info "watchTimeConsume enabled"
        project.gradle.addListener(new TimeListener())
    } else {
        Log.info "watchTimeConsume disabled"
    }
}

講到這裡,相信大家已經清楚,我們的自定義外掛是如何應用到app module的了吧。

Transform程式碼注入原理

1.5.0-beta1開始,android的gradle外掛引入了com.android.build.api.transform.Transformtransform-api),可以用於在android 打包、class轉換成dex過程中,加入開發者自定義的處理邏輯。

初識Transform

Transform的工作流程

2965551-f289ae9bb2767f59
Transform的工作流程

Transform每次都是將一個輸入進行處理,然後將處理結果輸出,而輸出的結果將會作為另一個Transform的輸入。

理解Transform需要先了解一些概念:

概念 描述
TransformInput 輸入檔案:DirectoryInput集合與JarInput集合
DirectoryInput 以原始碼方式參與專案編譯的所有目錄結構及其目錄下的原始碼檔案
JarInput 以jar包方式參與專案編譯的所有本地jar包或遠端jar包
TransformOutputProvider Transform的輸出
Scope 作用域:PROJECT、SUB_PROJECTS、EXTERNAL_LIBRARIES等
ContentType 檔案的型別:CLASSES、RESOURCES、DEX、NATIVE_LIBS等

知識擴充:

通過 Scope 和 ContentType 可以組成一個資源流。例如,PROJECT 和 CLASSES,表示了主專案中java 編譯成的 class 組成的一個資源流。再如,SUB_PROJECTS 和 CLASSES ,表示的是本地子專案中的 java 編譯成的 class 組成的一個資源流。Transform 用來處理和轉換這些流。

Transform是一個抽象類,我們需要自定義一個Transform,並實現必要的幾個方法:

public class InjectTransform extends Transform {
    @Override
    public String getName() {
        return "xxxxxx";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        // 配置 Transform 的輸入型別為 Class
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<QualifiedContent.Scope> getScopes() {
        // 配置 Transform 的作用域為全工程
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return false;
    }

    @Override
    public void transform(
            @NonNull Context context,
            @NonNull Collection<TransformInput> inputs,
            @NonNull Collection<TransformInput> referencedInputs,
            @Nullable TransformOutputProvider outputProvider,
            boolean isIncremental) throws IOException, TransformException, InterruptedException {
        // ...
    }
}

下面一一解釋這幾個方法:

  • getName

    指明本Transform的名字,隨意

  • getInputTypes

    指明Transform的輸入型別,例如,返回 TransformManager.CONTENT_CLASS 表示配置 Transform 的輸入型別為 Class。

  • getScopes

    指明Transform的作用域,例如,返回 TransformManager.SCOPE_FULL_PROJECT 表示配置 Transform 的作用域為全工程。

  • isIncremental

    指明是否是增量構建

  • transform

    用於處理具體的輸入輸出,核心操作都在這裡。上例中,配置 Transform 的輸入型別為 Class, 作用域為全工程,因此在transform方法中,inputs 會傳入工程內所有的 class 檔案。

定義好 Transform 後,接下來要在自定義的Plugin中註冊該Transform,從而新增到android編譯流程中。

class InjectPluginImpl implements Plugin<Project> {
    @Override
    void apply(Project project) {
        ...
        registerTransform(project)
    }

    def static registerTransform(Project project) {
        BaseExtension android = project.extensions.getByType(BaseExtension)
        InjectTransform transform = new InjectTransform(project)
        android.registerTransform(transform)
    }
}

Transform的工作原理

Gradle 包中有一個 TransformManager 的類,用來管理所有的 Transform,其中,TransformManager 包含addTransform方法:

public <T extends Transform> AndroidTask<TransformTask> addTransform(
        @NonNull TaskFactory taskFactory,
        @NonNull TransformVariantScope scope,
        @NonNull T transform,
        @Nullable TransformTask.ConfigActionCallback<T> callback) {
           ...
           transforms.add(transform);
           
           // create the task...
           AndroidTask<TransformTask> task = taskRegistry.create(
                    taskFactory,
                    new TransformTask.ConfigAction<>(
                            scope.getFullVariantName(),
                            taskName,
                            transform,
                            inputStreams,
                            referencedStreams,
                            outputStream,
                            callback));
           ...
           return task;
       }
   }
}

顯然,addTransform方法在執行的過程中,會將 T (T extends Transform) 包裝成一個 TransformTask 物件,並進一步包裝成AndroidTask物件。所以可以理解為一個 Transform 就是一個 Task。該 Task 執行時,gradle引擎會去呼叫含有@TaskAction註解的方法,TransformTask類擁有Transfrom型別欄位,其transform方法被標記為@TaskAction,且TransformTask的transform方法最終呼叫了Transfrom的transform方法。

/**
 * A task running a transform.
 */
@ParallelizableTask
public class TransformTask extends StreamBasedTask implements Context {

    private Transform transform;
    Collection<SecondaryFile> secondaryFiles = null;

    public Transform getTransform() {
        return transform;
    }

    ...

    @TaskAction
    void transform(final IncrementalTaskInputs incrementalTaskInputs)
            throws IOException, TransformException, InterruptedException {
        ...
        ThreadRecorder.get().record(ExecutionType.TASK_TRANSFORM, executionInfo,
                getProject().getPath(), getVariantName(), new Recorder.Block<Void>() {
            @Override
            public Void call() throws Exception {

                transform.transform(new TransformInvocationBuilder(TransformTask.this)
                        .addInputs(consumedInputs.getValue())
                        .addReferencedInputs(referencedInputs.getValue())
                        .addSecondaryInputs(changedSecondaryInputs.getValue())
                        .addOutputProvider(outputStream != null
                        ? outputStream.asOutput()
                        : null)
                        .setIncrementalMode(isIncremental.getValue())
                        .build());
                return null;
            }
        });
    }
}

Gradle 的包中有一個 TaskManager 類,管理所有的 Task 執行。 其中有一個createPostCompilationTasks方法:

    public void createPostCompilationTasks(
            @NonNull TaskFactory tasks,
            @NonNull final VariantScope variantScope) {
        ...
        // ----- External Transforms -----
        // 新增自定義的 Transform
        List<Transform> customTransforms = extension.getTransforms();
        List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();

        for (int i = 0, count = customTransforms.size() ; i < count ; i++) {
            Transform transform = customTransforms.get(i);
            AndroidTask<TransformTask> task = transformManager
                    .addTransform(tasks, variantScope, transform);
            ...
        }
        ...
        // ----- Minify next -----
        // minifyEnabled 為 true 表示開啟混淆
        // 新增 Proguard Transform
        if (isMinifyEnabled) {
            boolean outputToJarFile = isMultiDexEnabled && isLegacyMultiDexMode;
            createMinifyTransform(tasks, variantScope, outputToJarFile);
        }
        ...
        
        // non Library test are running as native multi-dex
        if (isMultiDexEnabled && isLegacyMultiDexMode) {
            ...
            // 新增 JarMergeTransform
            // create a transform to jar the inputs into a single jar.
            if (!isMinifyEnabled) {
                // merge the classes only, no need to package the resources since they are
                // not used during the computation.
                JarMergingTransform jarMergingTransform = new JarMergingTransform(
                        TransformManager.SCOPE_FULL_PROJECT);
                variantScope.addColdSwapBuildTask(
                        transformManager.addTransform(tasks, variantScope, jarMergingTransform));
            }

            // 新增 MultiDex Transform
            // create the transform that's going to take the code and the proguard keep list
            // from above and compute the main class list.
            MultiDexTransform multiDexTransform = new MultiDexTransform(
                    variantScope,
                    extension.getDexOptions(),
                    null);
            multiDexClassListTask = transformManager.addTransform(
                    tasks, variantScope, multiDexTransform);
            multiDexClassListTask.optionalDependsOn(tasks, manifestKeepListTask);
            variantScope.addColdSwapBuildTask(multiDexClassListTask);
        }
        ...
        // 新增 Dex Transform
        // create dex transform
        DefaultDexOptions dexOptions = DefaultDexOptions.copyOf(extension.getDexOptions());
        ...
    }

該方法在 javaCompile 之後呼叫, 會遍歷所有的 Transform,然後一一新增進 TransformManager。 新增完自定義的 Transform 之後,再新增 Proguard, JarMergeTransform, MultiDex, Dex 等 Transform。所以,現在應該清楚為什麼Transform可以在編譯之後、class轉換成dex之前,加入開發者自定義的處理邏輯了吧。

原理篇就講這麼多了,欲知更多,可以參考:

注入程式碼的時機

我們可以先看一個transform的例子,它將不做任何處理,只是將輸入原樣輸出:

@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
    // 配置 Transform 的輸入型別為 Class
    return TransformManager.CONTENT_CLASS;
}

@Override
public Set<QualifiedContent.Scope> getScopes() {
    // 配置 Transform 的作用域為全工程
    return TransformManager.SCOPE_FULL_PROJECT;
}
    
@Override
void transform(Context context, Collection<TransformInput> inputs,
               Collection<TransformInput> referencedInputs,
               TransformOutputProvider outputProvider, boolean isIncremental)
        throws IOException, TransformException, InterruptedException {
    // Transform的inputs有兩種型別,一種是目錄,一種是jar包,要分開遍歷
    inputs.each { TransformInput input ->
        //對型別為“資料夾”的input進行遍歷
        input.directoryInputs.each { DirectoryInput directoryInput ->
            //資料夾裡面包含的是我們手寫的類以及R.class、BuildConfig.class以及R$XXX.class等

            // 獲取output目錄
            def dest = outputProvider.getContentLocation(directoryInput.name,
                    directoryInput.contentTypes, directoryInput.scopes,
                    Format.DIRECTORY)

            // 將input的目錄複製到output指定目錄
            FileUtils.copyDirectory(directoryInput.file, dest)
        }
        //對型別為jar檔案的input進行遍歷
        input.jarInputs.each { JarInput jarInput ->

            //jar檔案一般是第三方依賴庫jar檔案

            // 重新命名輸出檔案(同目錄copyFile會衝突)
            def jarName = jarInput.name
            def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
            if (jarName.endsWith(".jar")) {
                jarName = jarName.substring(0, jarName.length() - 4)
            }
            //生成輸出路徑
            def dest = outputProvider.getContentLocation(jarName + md5Name,
                    jarInput.contentTypes, jarInput.scopes, Format.JAR)
            //將輸入內容複製到輸出
            FileUtils.copyFile(jarInput.file, dest)
        }
    }
}

在這個例子中,由於配置了 Transform 的輸入型別為 Class, 作用域為全工程,因此在transform方法中,inputs 會傳入工程內所有的 class 檔案。inputs 為Collection<TransformInput> 集合物件,集合元素 TransformInput 定義如下:

public interface TransformInput {

    /**
     * Returns a collection of {@link JarInput}.
     */
    @NonNull
    Collection<JarInput> getJarInputs();

    /**
     * Returns a collection of {@link DirectoryInput}.
     */
    @NonNull
    Collection<DirectoryInput> getDirectoryInputs();
}

看介面方法可知,inputs 包含了 jar 包和目錄。也就是說,transform方法可以遍歷到原始碼目錄中java類對應的class檔案,也可以遍歷到第三方jar包內的class檔案。辛辛苦苦摸索到這裡,想修改的class檔案就在眼前了,此時不注入程式碼更待何時呢?如下圖所示,我們可以分別遍歷原始碼目錄&jar包,對滿足修改條件的class檔案或jar包進行修改,將修改後的檔案輸出:

2965551-8fbc721014b870f2
遍歷目錄
2965551-b681b1d784a325f7
遍歷jar

ASM實現無埋點

通過前面的分析,我們已經知道在哪裡修改class檔案了,然後發現又走不下去了,class檔案怎麼修改呢?難道我們要自己手動編寫位元組碼指令修改二進位制檔案?好在有ASM這個庫,至於ASM語法等知識並非本文的重點,所以,本文只討論無埋點外掛中涉及的部分。需要學習或溫習的請猛戳系列博文:

ASM關鍵知識點

ASM被稱為是類的掃描器,它可以掃描到組成一個類的各個結構:

  • 描述類訪問控制許可權,類名,父類,介面和註解
  • 每個被宣告的成員變數,同樣包括訪問控制許可權、名稱、型別、註解
  • 方法及建構函式,包括訪問控制許可權,名稱、名稱、返回值型別、引數型別、註解等;同時還包含方法體

ASM對class的生成和轉換是基於ClassVisitor抽象類的,該類的每個方法都對應class的一個結構,它的完整介面如下:

2965551-946bc093032438df
ClassVisitor

下面重點介紹我們需要用到的幾個方法:

  • void visit(int version, int access, String name,
    String signature, String superName, String[] interfaces)

    該方法是當掃描類時第一個訪問的方法。各引數代表的含義是:類版本、修飾符、類名、泛型資訊、繼承的父類、實現的介面。我們只需關心繼承的父類和實現的介面,當執行到 visit 方法時,可以通過全域性變數儲存繼承的父類和實現的介面資訊。程式碼示例如下:

    static class MethodFilterClassVisitor extends ClassVisitor {
            private String superName
            private String[] interfaces
            private ClassVisitor classVisitor
    
            public MethodFilterClassVisitor(
                    final ClassVisitor cv) {
                super(Opcodes.ASM5, cv);
                this.classVisitor = cv
            }
    
            @Override
            public void visit(int version, int access, String name,
                              String signature, String superName, String[] interfaces) {
                Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces);
                this.superName = superName
                // 記錄該類實現了哪些介面
                this.interfaces = interfaces
                super.visit(version, access, name, signature, superName, interfaces);
            }
    }
    
  • MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)

    當掃描器掃描到類的方法時呼叫該方法。各引數代表的含義是:修飾符、方法名、方法簽名、泛型資訊、丟擲的異常。其中,方法簽名的格式如下:(引數列表)返回值型別;例如void onClick(View v)的方法簽名為(Landroid/view/View;)V

  • visitEnd()

    當掃描器完成類掃描時才會呼叫該方法。

當ASM的ClassReader讀取到Method時就轉入MethodVisitor介面處理。方法的定義,以及方法中指令的定義都會通過MethodVisitor介面通知給程式。MethodVisitor的具體實現由ClassVisitor的visitMethod返回值指定。下面是MethodVisitor介面的所有方法定義:

2965551-1fa5eb20958e0481
MethodVisitor

由於我們的無埋點目前只支援在方法體頭部或尾部插入埋點程式碼,因此我們只需關心下面兩個方法:

  • visitCode()

    表示ASM開始掃描這個方法。

  • visitEnd()

    表示方法輸出完畢。

ASM-Bytecode工具

由於 JVM 對位元組碼十分敏感,修改過程中稍微有一絲錯誤都會導致虛擬機器錯誤,而想要排查錯誤卻是一件比較困難的事情。因此不建議大家手動編寫ASM 程式碼,而是藉助 ASM-Bytecode 工具。

ASM-Bytecode 是一個Eclipse外掛,外掛的地址請戳ASM-Bytecode

也可通過Eclipse Marketplace安裝(推薦)

2965551-7ec1811ddece1d17
Eclipse Marketplace安裝外掛

外掛效果如下:

2965551-d0016c4344e27291
ASM-Bytecode

ASM無埋點例項

比如我們希望向View的onClick方法裡插入自定義的程式碼:

com.codeless.tracker.PluginAgent.onClick(v);

假設插入前是這樣子的:

public class MainActivity extends AppCompatActivity implements
        View.OnClickListener{
    @Override
    public void onClick(View v) {
    
    }
}

插入後的期望程式碼:

package com.codeless.demo;

public class MainActivity extends AppCompatActivity implements
        View.OnClickListener{
    @Override
    public void onClick(View v) {
        PluginAgent.onClick(var1);
    }
}

最便捷的方法是,使用 ASM-Bytecode 工具將插入後的期望程式碼翻譯成 ASM 程式碼:

mv.visitVarInsn(ALOAD, 1);
mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 2);
mv.visitEnd();

從而得到

com.codeless.tracker.PluginAgent.onClick(v);

對應的 ASM 程式碼語句如下

mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);

然後,使用 ClassVisitor 掃描原始碼class檔案(當然也包含MainActivity.class啦),將該語句插到void onClick(View v)方法體頭部。具體操作請留意程式碼註釋:

public class MethodFilterClassVisitor extends ClassVisitor {
    private String superName
    private String[] interfaces
    private ClassVisitor classVisitor

    public MethodFilterClassVisitor(
            final ClassVisitor cv) {
        super(Opcodes.ASM5, cv);
        this.classVisitor = cv
    }

    @Override
    public void visit(int version, int access, String name,
                      String signature, String superName, String[] interfaces) {
        Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces);
        this.superName = superName
        // 記錄該類實現了哪些介面
        this.interfaces = interfaces
        super.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name,
                                     String desc, String signature, String[] exceptions) {
        MethodVisitor myMv = null;
        if (interfaces != null && interfaces.length > 0) {
            // 根據方法名+方法描述判斷是否需要修改方法體;
            // 例如,當前遍歷到View的onClick方法時,name是onClick,desc是(Landroid/view/View;)V;
            // 則滿足修改條件onClick(Landroid/view/View;)V
            // 當第一個條件滿足後,還需進一步判斷當前類是否實現了View$OnClickListener介面
            if ('onClick(Landroid/view/View;)V' == (name + desc) && interfaces.contains('android/view/View$OnClickListener')) {
                try {
                    MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
                    myMv = new MethodLogVisitor(methodVisitor) {
                        @Override
                        void visitCode() {
                            super.visitCode();
                            // 在方法體開頭插入自定義埋點程式碼
                            methodVisitor.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
                        }
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                    myMv = null
                }
            }

        }

        if (myMv != null) {
            return myMv;
        } else {
            return cv.visitMethod(access, name, desc, signature, exceptions);
        }
    }
}

總結一下上面的例子,就是當一個ActivityFragment實現了View$OnClickListener介面,使用外掛遍歷到該ActivityFragment位元組碼中的onClick(View v)時,向該方法中插入com.codeless.tracker.PluginAgent.onClick(v)com.codeless.tracker.PluginAgent中的onClick(View v)方法即是您想要注入到點選事件響應onClick中的程式碼。

上面只是簡化版的例子,完整專案程式碼要比這個複雜和豐富一些,有興趣歡迎forkLazierTracker檢視完整程式碼。

分析到這裡,不知道大家清楚了沒,如果還有疑問或者有好的建議,可以在LazierTracker下面提issue,就醬。

相關文章