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兩個過濾緯度

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處理字節碼的流程如下:


通過默認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、防止快速點擊的原理

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

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方法上是否有這樣一個注解,如果有就不插入。

問題:
1,點擊事件委派問題。導致按鈕點擊沒有響應。如:

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

在一個view的點擊事件中有兩個OnClickListener處理這個事件,具體來說就是在第一個OnClickListener的onClick中調用了第二個OnClickListener的onclick方法,講這個事件傳遞個了二個OnClickListener

出現這個問題的原因是,我們開始將點擊時間和view綁定,那么必然存在第一個onClick方法可以響應點擊邏輯,第二個onclick方法就直接返回了,因為代碼的執行時間必然比我們定義的時間間隔短。

問題的根源是點擊時間和view綁定,那么我們將點擊時間和view解綁,然后將點擊時間和OnclickListener關聯起來,就是每個OnclickListener中有一個點擊時間,這樣,就不會相互影響了。

2,lambda表達式問題
最新的編譯流程中,lambda代碼轉換成class后,不會脫糖,所以是找不到onclick方法的。

public class OtherTestViewHolder extends ViewHolder {
    private int i = 100;

    public OtherTestViewHolder(@NonNull View itemView) {
        super(itemView);
        itemView.setOnClickListener((v) -> {
            Log.i("", "");
            ToastHelper.toast(v, v, "1234");
        });
    }
}
//刪除無效的不想閱讀的代碼
// access flags 0x1
 public <init>(Landroid/view/View;)V
  L2
   LINENUMBER 19 L2
   ALOAD 1

   // 通過INVOKEDYNAMIC 將當前的的setOnClickListener 鏈接到lambda$new$0靜態方法上去。
   INVOKEDYNAMIC onClick()Landroid/view/View$OnClickListener; [
     // arguments:
     (Landroid/view/View;)V, 
     // handle kind 0x6 : INVOKESTATIC
     com/wallstreetcn/sample/adapter/OtherTestViewHolder.lambda$new$0(Landroid/view/View;)V, 
     (Landroid/view/View;)V
   ]

// access flags 0x100A
private static synthetic lambda$new$0(Landroid/view/View;)V
 L0
  LINENUMBER 20 L0
  LDC ""
  LDC ""
  INVOKESTATIC android/util/Log.i (Ljava/lang/String;Ljava/lang/String;)I
  POP
 L1
  LINENUMBER 21 L1
  ALOAD 0
  ALOAD 0
  LDC "1234"
  INVOKESTATIC com/wallstreetcn/sample/ToastHelper.toast (Ljava/lang/Object;Landroid/view/View;Ljava/lang/Object;)V
  RETURN
 L2
  LOCALVARIABLE v Landroid/view/View; L0 L2 0
  MAXSTACK = 2
  MAXLOCALS = 1

最新的編譯流程中,Lambda表達式會被翻譯成INVOKEDYNAMIC指令,(它的名稱是onclick,描述符是OnClickListener),然后對應的onclick里面的邏輯會被包裝成一個靜態方法,在這個INVOKEDYNAMIC指令的參數中,會記錄將這個onclick對應的靜態方法的名字和描述,我們根據這個名稱和描述就可以找對 onclick對應的方法。進而就可以對這個靜態方法進行后續的字節碼操作就可以了。

fun ClassNode.lambdaHelper(): MutableList<MethodNode> {
    val lambdaMethodNodes = mutableListOf<MethodNode>()
    methods?.forEach { method ->
        method.instructions.iterator()?.forEach {
            // 先從  method.instructions中找到`InvokeDynamicInsnNode`
            if (it is InvokeDynamicInsnNode) {
              // 判斷是不是我想要修改的類 舉例View\$OnClickListener
                if (it.name == "onClick" && it.desc.contains(")Landroid/view/View\$OnClickListener;")) {
                    Log.info("dynamicName:${it.name} dynamicDesc:${it.desc}")
                    //獲取指令中的參數,name和desc
                    val args = it.bsmArgs
                    args.forEach { arg ->
                      // 根據其中的name和desc等找到其所對應的靜態方法,之后加入list中
                        if (arg is Handle) {
                            val methodNode = findMethodByNameAndDesc(arg.name, arg.desc, arg.tag)
                            Log.info("findMethodByNameAndDesc argName:${arg.name}  argDesc:${arg.desc} " +
                                    "method:${method?.name} ")
                            if (methodNode != null) {
                                lambdaMethodNodes.add(methodNode)
                            }
                        }
                    }
                }
            }
        }
    }
    //然后返回當前類所有要修改的lambda
    lambdaMethodNodes.forEach {
        Log.info("lambdaName:${it.name} lambdaDesc:${it.desc} lambdaAccess:${it.access}")
    }
    return lambdaMethodNodes

}
// 根據名字和描述以及操作類型找到對應的方法
fun ClassNode.findMethodByNameAndDesc(name: String, desc: String, access: Int): MethodNode? {
    return methods?.firstOrNull {
        it.name == name && it.desc == desc
    }
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容