Android中Gradle插件和Transform

目錄
1、Gradle插件
2、Transform
3、ASM
4、應用-防止快速點擊的插件

1、Gradle插件

1.1、Gradle插件是什麼?

Gradle插件打包了可重用的構建邏輯,可以適用不同的項目和構建。

1.2、自定義Gradle插件的流程

(1)、新建一個 Android Library項目,然後除了主目錄刪除主目錄中的所有文件;
(2)、main目錄下建立groovy目錄和 resources目錄,groovy目錄用於寫插件邏輯, resources目錄下用於聲明自定義的插件;
(3)、書寫插件的方法就是,寫一個類實現Plugin類,並實現其apply方法,在apply方法中完成插件邏輯;
(4)、在resources目錄下(建立/META-INF/gradle-plugins目錄,並)建立一個(plugin.)properties的文件,在裏面聲明自定義的插件。這個properties文件的名稱是我們應用插件時使用的名稱。

1.3、Gradle插件應用流程

(5)、使用uploadArchives將插件上傳的maven庫。
(6)、依賴路徑,使用apply plugin應用插件。

2、Transform API

2.1、Transform API是什麼

Transform用於在編譯打包的.class文件到.dex文件流程中,去轉換.class文件。
目前 jarMerge、proguard、multi-dex、Instant-Run都已經換成 Transform 實現。

2.2、如何註冊一個自定的Transform

public class SingleClickHunterPlugin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        AppExtension appExtension = project.getExtensions().getByType(AppExtension);
        appExtension.registerTransform(new SingleClickHunterTransform(project), Collections.EMPTY_LIST);
    }
}

在自定義插件的apply方法中,獲取module對應的project的AppExtension,然後通過其registerTransform方法註冊一個自定義的Transform。

註冊之後,在編譯流程中會通過TaskManager#createPostCompilationTasks爲這個自定義的Transform生成一個對應的Task,(transformClassesWithSingleClickHunterTransformForDebug),在.class文件轉換成.dex文件的流程中會執行這個Task,對所有的.class文件(可包括第三方庫的.class)進行轉換,轉換的邏輯定義在Transform的transform方法中。

2.3、自定義一個Transform

public class CustomTransform extends Transform {
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
        //當前是否是增量編譯(由isIncremental() 方法的返回和當前編譯是否有增量基礎)
        boolean isIncremental = transformInvocation.isIncremental();
        //消費型輸入,可以從中獲取jar包和class文件夾路徑。需要輸出給下一個任務
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        //OutputProvider管理輸出路徑,如果消費型輸入爲空,你會發現OutputProvider == null
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

        for(TransformInput input : inputs) {
            for(JarInput jarInput : input.getJarInputs()) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getFile().getAbsolutePath(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR);
                //將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的了        
                FileUtils.copyFile(jarInput.getFile(), dest);
            }

            for(DirectoryInput directoryInput : input.getDirectoryInputs()) {
                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY);
                //將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的了        
                FileUtils.copyDirectory(directoryInput.getFile(), dest);
            }
        }
    }
    @Override
    public String getName() {
        return "CustomTransform";
    }
    @Override 
    public boolean isIncremental() {
        return true; //是否開啓增量編譯
    }
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }
}

在transform方法中,我們需要將每個jar包和class文件複製到dest路徑,這個dest路徑就是下一個Transform的輸入數據。而在複製時,就可以將jar包和class文件的字節碼做一些修改,再進行復制。

2.4、Transform兩個過濾緯度

ContentType,數據類型,有CLASSES和RESOURCES兩種。
其中的CLASSES包含了源項目中的.class文件和第三方庫中的.class文件。
RESOURCES僅包含源項目中的.class文件。
對應getInputTypes() 方法。

Scope,表示要處理的.class文件的範圍,主要有
PROJECT, SUB_PROJECTS,EXTERNAL_LIBRARIES等。
對應getScopes() 方法。

2.5、支持增量編譯

Transform支持增量編譯分爲兩步:

(1)重寫Transform的接口方法:isIncremental(),返回true。

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

(2)判斷當前編譯對於Transform是否是增量編譯:
如果不是增量編譯,就按照前面的方式,依次處理所有的class文件;
(比如說clean之後的第一次編譯沒有增量基礎,即使Transform的isIncremental放回true,當前編譯對Transform仍然不是增量編譯,所有需要依次處理所有的class文件)
如果是增量編譯,根據每個文件的Status,處理文件:
如果文件有改變,就按照前面的方式,去處理這個問題。
如果文件沒有改變,就不需要進行處理,因爲在輸出目錄已經有一個上次處理過的class文件了
(NOTCHANGED: 當前文件不需處理,甚至複製操作都不用;
ADDED、CHANGED: 正常處理,輸出給下一個任務;
REMOVED: 移除outputProvider獲取路徑對應的文件。)

注意:當前編譯對於Transform是否是增量編譯受兩個方面的影響:
(1)isIncremental() 方法的返回值;
(2)當前編譯是否有增量基礎;(clean之後的第一次編譯沒有增量基礎,之後的編譯有增量基礎)

增量的時間縮短爲全量的速度提升了3倍多,而且這個速度優化會隨着工程的變大而更加顯著。

2.6、支持併發編譯

private WaitableExecutor waitableExecutor = WaitableExecutor.useGlobalSharedThreadPool();
//異步併發處理jar/class
waitableExecutor.execute(() -> {
    bytecodeWeaver.weaveJar(srcJar, destJar);
    return null;
});
waitableExecutor.execute(() -> {
    bytecodeWeaver.weaveSingleClassToFile(file, outputFile, inputDirPath);
    return null;
});  
//等待所有任務結束
waitableExecutor.waitForTasksWithQuickFail(true);

爲什麼要等待所有任務結束?
如果不等待,主線程就會進入下一個任務的處理,可能當前的任務的處理工作還沒完成。

併發Transform和非併發Transform下,編譯速度提高了80%。

3、ASM

ASM ,速度快、代碼量小、功能強大,要寫字節碼、學習曲線高。
Javassist,學習簡單,不用寫字節碼,比ASM慢,功能少。

3.1、ASM訪問字節碼流程

private void copy(String inputPath, String outputPath) {
        FileInputStream is = new FileInputStream(inputPath);
        ClassReader cr = new ClassReader(is);
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        cr.accept(cw, 0);
        FileOutputStream fos = new FileOutputStream(outputPath);
        fos.write(cw.toByteArray());
}

(1)、ClassReader負責讀取.class字節碼;
(2)、ClassReader將所有字節碼傳遞ClassWriter(是一個ClassVisitor)中的(多個)visitxxx接口方法依次進行處理;
(3)、ClassWriter訪問某個方法時會將這個方法的所有字節碼傳遞給MethodWriter(是一個MethodVisitor)處理。

默認ClassWriter會保存傳遞到它的所有字節碼,可使用ClassWriter.toByteArray()方法獲取經過ClassWriter的字節碼。

3.2、以上流程代碼證明:ClassReader.accept()。

public void accept(ClassVisitor classVisitor, Attribute[] attributePrototypes, int parsingOptions) {
    
    // 讀取當前class的字節碼信息
    int accessFlags = this.readUnsignedShort(currentOffset);
    String thisClass = this.readClass(currentOffset + 2, charBuffer);
    String superClass = this.readClass(currentOffset + 4, charBuffer);
    String[] interfaces = new String[this.readUnsignedShort(currentOffset + 6)];
    
    //classVisitor就是剛纔accept方法傳進來的ClassWriter,每次visitXXX都負責將字節碼的信息存儲起來
    classVisitor.visit(this.readInt(this.cpInfoOffsets[1] - 7), accessFlags, thisClass, signature, superClass, interfaces);
    
    /**
        略去很多visit邏輯
    */
    //visit Attribute
    while(attributes != null) {
        Attribute nextAttribute = attributes.nextAttribute;
        attributes.nextAttribute = null;
        classVisitor.visitAttribute(attributes);
        attributes = nextAttribute;
    }
    /**
        略去很多visit邏輯
    */
    classVisitor.visitEnd();
}

Gradle中的ClassWriter默認對傳遞給它的字節碼不做任何處理,只做保存工作。
通過默認ClassWriter處理字節碼的流程如下:


3.3、修改字節碼

要修改字節碼,需要自定義ClassWriter,在其訪問類的相應方法時對其做相應操作(使用自定義的MetiodWriter),達到字節碼插樁的目的。

3.4、什麼事增量編譯

我理解的增量編譯:
1、基於Task的上次輸出快照和這次輸入快照對比,如果相同,則跳過相應任務;
2、基於Task本身是否支持增量更新。

3.4、增量編譯實驗

3.4.1、Transform 的isIncremental()返回true。
@Override
public boolean isIncremental() {
    return true;
}

(1)、clean之後,第一次編譯,即使Transform裏面isIncremental()返回true,Transform開啓了增量編譯,此時對Transform來說仍然不是增量編譯, transform方法中isIncremental = false;

(2)、不做任何改變直接進行第二次編譯,Transform別標記爲up-to-date,被跳過執行;

(3)、修改一個文件中代碼,進行第三次編譯,此時對Transform來說是增量編譯,transform方法中isIncremental = true。

3.4.2、Transform 的isIncremental()返回false。
@Override
public boolean isIncremental() {
    return false;
}

(1)、clean之後,第一次編譯,此時對Transform來說不是增量編譯, transform方法中isIncremental = false;

(2)、不做任何改變直接進行第二次編譯,Transform別標記爲up-to-date,被跳過執行;

(3)、修改一個文件中代碼,進行第三次編譯,此時對Transform來說不是增量編譯,transform方法中isIncremental = false。

結論:1、一次編譯對Transform來說是否是增量編譯取決於兩個方面:
(1)、當前編譯是否有增量基礎;
(2)、當前Transform是否開啓增量編譯。

結論:2、不管Transform是否開啓增量編譯,若TransformTask的當前輸入快照和上次輸出快照相同,則跳過當前TransformTask。

4、Gradle插件和Transform實戰應用

https://github.com/Leaking/Hunter/pulls

按鈕快速點擊的問題在於:可能重複打開多個頁面。

原理:

4.1、防止快速點擊的原理

記錄兩次點擊的時間差,如果這個時間差小於我們定義的一個時間間隔,那麼就直接返回,不進行點擊的邏輯處理。

4.2、如何全局解決項目中所有按鈕的快速點擊問題

第一種方法是手動全局添加,它的問題在於:
(1)、按鈕太多,工作量大,容易遺漏;
(2)、無法給第三方sdk中的按鈕添加此邏輯。
第二種方法是採用AOP的方式去添加,具體的過程是:
在打包過程中有一個階段是class文件轉換dex的階段,所有class文件會經歷多個Transform進行處理,我們可以自定義一個Transform得到所有的class文件,然後掃描判斷這個類是否實現OnClickListener,如果實現就在其onclick方法中是用asm操作字節碼插入上述防止快速點擊的邏輯。最後將所有文件複製到輸出目錄就可以了。
這樣做可以實現功能,但是發現處理速度較慢,修改5個類,這個Transform大概需要10s處理。
於是我做了兩點優化:
(1)、支持併發編譯
(2)、支持增量編譯
做了這兩點優化後,修改5個類,這個Transform大概在1.5s左右處理。提升了6倍多。

另外我發現我們app中有少數按鈕是需要快速點擊的,所有我又自定義了一個註解,在onclick方法上面加上這個註解就不會插入防止快速點擊的邏輯。
實現原理也很簡單,就是在字節碼插樁的時候去判斷onclick方法上是否有這樣一個註解,如果有就不插入。

注意:此插件尚未解決點擊事件委派問題。如:

View.OnClickListener DelegateClickListener;
button.setOnClickListener(new OnClickListener() {
        @Override
        public void onClick(View v) {
            DelegateClickListener.onclick(v);
        }
    });

稍後會發布最新版本解決這個問題。

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