手把手教你,用Transform API和ASM實現一個防快速點擊案例

0. 前言

在Android Gradle Plugin中,有一個叫Transform API(從1.5.0版本才有的)的東西.利用這個Transform API咱可以在.class文件轉換成dex文件之前,對.class文件進行處理.比如監控,埋點之類的.

而對.class文件進行處理這個操作,咱們這里使用ASM.ASM是一個通用的Java字節碼操作和分析框架。它可以直接以二進制形式用于修改現有類或動態生成類.咱們在打包的時候,直接操作字節碼修改class,對運行時性能是沒有任何影響的,所以它的效率是相當高的.

本篇文章給大家簡單介紹一下Transform和ASM的使用,最后再結合一個小栗子練習一下.文中demo源碼地址

1. 使用Transform API

1.1 注冊一個自定義的Transform

首先寫一個Plugin,然后通過registerTransform方法進行注冊自定義的Transform.

class MethodTimeTransformPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        //注冊方式1
        AppExtension appExtension = project.extensions.getByType(AppExtension)
        appExtension.registerTransform(new MethodTimeTransform())

        //注冊方式2
        //project.android.registerTransform(new MethodTimeTransform())
    }
}
復制代碼

通過獲取module的Project的AppExtension,通過它的registerTransform方法注冊的Transform.

這里注冊之后,會在編譯過程中的TransformManager#addTransform中生成一個task,然后在執行這個task的時候會執行到我們自定義的Transform的transform方法.這個task的執行時機其實就是.class文件轉換成.dex文件的時候,轉換的邏輯是定義在transform方法中的.

1.2 自定義一個Transform

先讓大家看一下比較標準的Transform模板代碼:

class MethodTimeTransform extends Transform {

    @Override
    String getName() {
        return "MethodTimeTransform"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        //需要處理的數據類型,這里表示class文件
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        //作用范圍
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        //是否支持增量編譯
        return true
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)

        //TransformOutputProvider管理輸出路徑,如果消費型輸入為空,則outputProvider也為空
        TransformOutputProvider outputProvider = transformInvocation.outputProvider

        //transformInvocation.inputs的類型是Collection<TransformInput>,可以從中獲取jar包和class文件夾路徑。需要輸出給下一個任務
        transformInvocation.inputs.each { input -> //這里的input是TransformInput

            input.jarInputs.each { jarInput ->
                //處理jar
                processJarInput(jarInput, outputProvider)
            }

            input.directoryInputs.each { directoryInput ->
                //處理源碼文件
                processDirectoryInput(directoryInput, outputProvider)
            }
        }
    }

    void processJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {
        File dest = outputProvider.getContentLocation(jarInput.file.absolutePath, jarInput.contentTypes, jarInput.scopes, Format.JAR)
        //將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的
        println("拷貝文件 $dest -----")
        FileUtils.copyFile(jarInput.file, dest)
    }

    void processDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
        File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format
                .DIRECTORY)
        //將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的
        println("拷貝文件夾 $dest -----")
        FileUtils.copyDirectory(directoryInput.file, dest)
    }

}
復制代碼
  1. getName(): 表示當前Transform名稱,這個名稱會被用來創建目錄,它會出現在app/build/intermediates/transforms目錄下面.
  2. getInputTypes(): 需要處理的數據類型,用于確定我們需要對哪些類型的結果進行轉換,比如class,資源文件等:
    • CONTENT_CLASS:表示需要處理java的class文件
    • CONTENT_JARS:表示需要處理java的class與資源文件
    • CONTENT_RESOURCES:表示需要處理java的資源文件
    • CONTENT_NATIVE_LIBS:表示需要處理native庫的代碼
    • CONTENT_DEX:表示需要處理DEX文件
    • CONTENT_DEX_WITH_RESOURCES:表示需要處理DEX與java的資源文件
  3. getScopes(): 表示Transform要操作的內容范圍(上面demo里面使用的SCOPE_FULL_PROJECT是Scope的集合,包含了Scope.PROJECT,Scope.SUB_PROJECTS,Scope.EXTERNAL_LIBRARIES這幾個東西.當然,TransformManager里面還有一些其他集合,這里不做舉例).
    • PROJECT: 只有項目內容
    • SUB_PROJECTS: 只有子項目
    • EXTERNAL_LIBRARIES: 只有外部庫
    • TESTED_CODE: 測試代碼
    • PROVIDED_ONLY: 只提供本地或遠程依賴項
  4. isIncremental(): 是否支持增量更新
    • 如果返回true,則TransformInput會包含一份修改的文件列表
    • 如果是false,則進行全量編譯,刪除上一次輸出內容
  5. transform(): 進行具體轉換邏輯.
    • 消費型Transform: 在transform方法中,我們需要將每個jar包和class文件復制到dest路徑,這個dest路徑就是下一個Transform的輸入數據.在復制的時候,我們可以將jar和class文件的字節碼做一些修改,再進行復制. 可以看出,如果我們注冊了Transform,但是又不將內容復制到下一個Transform需要的輸入路徑的話,就會出問題,比如少了一些class之類的.上面的demo中僅僅是將所有的輸入文件拷貝到目標目錄下,并沒有對字節碼文件進行任何處理.
    • 引用型Transform: 當前Transform可以讀取這些輸入,而不需要輸出給下一個Transform.

可以看出,最關鍵的核心代碼就是transform()方法里面,我們需要做一些class文件字節碼的修改,才能讓Transform發揮其效果.

道理是這個道理,但是字節碼那玩意兒想改就能改么? 忘記字節碼是什么的小伙伴可以看我之前發的文章 Java字節碼解讀 復習一下. 字節碼比較復雜,連"讀懂"都非常非常困難,還讓我去改它,那更是難上加難.

不過,幸好咱們可以借助后面介紹的ASM工具進行方便的修改字節碼工作.

1.3 增量編譯

就是Transform中的isIncremental()方法返回值,如果是false的話,則表示不開啟增量編譯,每次都得處理每個文件,非常非常拖慢編譯時間. 我們可以借助該方法,返回值改成true,開啟增量編譯.當然,開啟了增量編譯之后需要檢查每個文件的Status,然后根據這個文件的Status進行不同的操作.

具體的Status如下:

  • NOTCHANGED: 當前文件不需要處理,連復制操作也不用
  • ADDED: 正常處理,輸出給下一個任務
  • CHANGED: 正常處理,輸出給下一個任務
  • REMOVED: 移除outputProvider獲取路徑對應的文件

來看一下代碼如何實現,咱將上面的dmeo代碼簡單改改:

@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
    super.transform(transformInvocation)
    printCopyRight()

    //TransformOutputProvider管理輸出路徑,如果消費型輸入為空,則outputProvider也為空
    TransformOutputProvider outputProvider = transformInvocation.outputProvider

    //當前是否是增量編譯,由isIncremental方法決定的
    // 當上面的isIncremental()寫的返回true,這里得到的值不一定是true,還得看當時環境.比如clean之后第一次運行肯定就不是增量編譯嘛.
    boolean isIncremental = transformInvocation.isIncremental()
    if (!isIncremental) {
        //不是增量編譯則刪除之前的所有文件
        outputProvider.deleteAll()
    }

    //transformInvocation.inputs的類型是Collection<TransformInput>,可以從中獲取jar包和class文件夾路徑。需要輸出給下一個任務
    transformInvocation.inputs.each { input -> //這里的input是TransformInput

        input.jarInputs.each { jarInput ->
            //處理jar
            processJarInput(jarInput, outputProvider, isIncremental)
        }

        input.directoryInputs.each { directoryInput ->
            //處理源碼文件
            processDirectoryInput(directoryInput, outputProvider, isIncremental)
        }
    }
}

/**
 * 處理jar
 * 將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的
 */
void processJarInput(JarInput jarInput, TransformOutputProvider outputProvider, boolean isIncremental) {
    def status = jarInput.status
    File dest = outputProvider.getContentLocation(jarInput.file.absolutePath, jarInput.contentTypes, jarInput.scopes, Format.JAR)
    if (isIncremental) {
        switch (status) {
            case Status.NOTCHANGED:
                break
            case Status.ADDED:
            case Status.CHANGED:
                transformJar(jarInput.file, dest)
                break
            case Status.REMOVED:
                if (dest.exists()) {
                    FileUtils.forceDelete(dest)
                }
                break
        }
    } else {
        transformJar(jarInput.file, dest)
    }

}

void transformJar(File jarInputFile, File dest) {
    //println("拷貝文件 $dest -----")
    FileUtils.copyFile(jarInputFile, dest)
}

/**
 * 處理源碼文件
 * 將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的
 */
void processDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider, boolean isIncremental) {
    File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format
            .DIRECTORY)
    FileUtils.forceMkdir(dest)

    println("isIncremental = $isIncremental")

    if (isIncremental) {
        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 Status.NOTCHANGED:
                    break
                case Status.ADDED:
                case Status.CHANGED:
                    FileUtils.touch(destFile)
                    transformSingleFile(inputFile, destFile)
                    break
                case Status.REMOVED:
                    if (destFile.exists()) {
                        FileUtils.forceDelete(destFile)
                    }
                    break
            }
        }
    } else {
        transformDirectory(directoryInput.file, dest)
    }
}

void transformSingleFile(File inputFile, File destFile) {
    println("拷貝單個文件")
    FileUtils.copyFile(inputFile, destFile)
}

void transformDirectory(File directoryInputFile, File dest) {
    println("拷貝文件夾 $dest -----")
    FileUtils.copyDirectory(directoryInputFile, dest)
}
復制代碼

根據是否為增量更新,如果不是,則刪除之前的所有文件.然后對每個文件進行狀態判斷,根據其狀態來決定到底是該刪除,或者復制.開啟增量編譯之后,速度會有特別大的提升.

1.4 并發編譯

畢竟是在電腦上進行編譯,盡管壓榨電腦性能,我們把并發編譯給搞起.說來也輕巧,就下面幾行代碼就行

private WaitableExecutor mWaitableExecutor = WaitableExecutor.useGlobalSharedThreadPool()
transformInvocation.inputs.each { input -> //這里的input是TransformInput

    input.jarInputs.each { jarInput ->
        //處理jar
        mWaitableExecutor.execute(new Callable<Object>() {
            @Override
            Object call() throws Exception {
                //多線程
                processJarInput(jarInput, outputProvider, isIncremental)
                return null
            }
        })
    }

    //處理源碼文件
    input.directoryInputs.each { directoryInput ->
        //多線程
        mWaitableExecutor.execute(new Callable<Object>() {
            @Override
            Object call() throws Exception {
                processDirectoryInput(directoryInput, outputProvider, isIncremental)
                return null
            }
        })
    }
}

//等待所有任務結束
mWaitableExecutor.waitForTasksWithQuickFail(true)
復制代碼

增加的代碼不多,其他都是之前的.就是讓處理邏輯的地方放線程里面去執行,然后得等這些線程都處理完成才結束任務.

到這里Transform基本的API也將介紹完了,原理(系統有一些列Transform用于在class轉dex的過程中的處理邏輯,我們也可以自定義Transform參與其中,這個Transform最終其實是在一個Task里面執行的.)的話也知曉了個大概,接下來我們看看如何利用ASM修改字節碼實現炫酷的功能吧.

2. ASM

2.1 介紹

ASM官網

官網上是這樣介紹ASM的: ASM是一個通用的Java字節碼操作和分析框架。它可以直接以二進制形式用于修改現有類或動態生成類。ASM提供了一些常見的字節碼轉換和分析算法,可從中構建定制的復雜轉換和代碼分析工具。ASM提供了與其他Java字節碼框架類似的功能,但是側重于 性能。因為它的設計和實現是盡可能的小和盡可能快,所以它非常適合在動態系統中使用(但當然也可以以靜態方式使用,例如在編譯器中)。(可能翻譯得不是很準確,英文好的同學可以去官網看原話)

2.2 引入ASM

下面是我的demo中的buildSrc里面build.gradle配置.它包含了Plugin+Transform+ASM的所有依賴,放心拿去用.

dependencies {
    implementation gradleApi()
    implementation localGroovy()
    //常用io操作
    implementation "commons-io:commons-io:2.6"

    // Android DSL  Android編譯的大部分gradle源碼
    implementation 'com.android.tools.build:gradle:3.6.2'
    implementation 'com.android.tools.build:gradle-api:3.6.2'
    //ASM
    implementation 'org.ow2.asm:asm:7.1'
    implementation 'org.ow2.asm:asm-util:7.1'
    implementation 'org.ow2.asm:asm-commons:7.1'
}
復制代碼

2.3 ASM基本使用

在使用之前我們先來看一些常用的對象

  • ClassReader : 按照Java虛擬機規范中定義的方式來解析class文件中的內容,在遇到合適的字段時調用ClassVisitor中相應的方法
  • ClassVisitor : Java中類的訪問者,提供一系列方法由ClassReader調用.它是一個抽象類,在使用時需要繼承此類.
  • ClassWriter : 它是一個繼承了ClassVisitor的類,主要負責將ClassReader傳遞過來的數據寫到一個字節流中.在傳遞數據完成之后,可以通過它的toByteArray方法獲得完整的字節流.
  • ModuleVisitor : Java中模塊的訪問者,作為ClassVisitor.visitModule方法的返回值,要是不關心模塊的使用情況,可以返回一個null.
  • AnnotationVisitor : Java中注解的訪問者,作為ClassVisitor.visitTypeAnnotation的返回值,不關心注解使用情況也是可以返回null.
  • FieldVisitor : Java中字段的訪問者,作為ClassVisitor.visitField的返回值,不關心字段使用情況也是可以返回null.
  • MethodVisitor:Java中方法的訪問者,作為ClassVisitor.visitMethod的返回值,不關心方法使用情況也是可以返回null.

上面這些對象先簡單過一下,眼熟就行,待會兒會使用到這些對象.

大體工作流程: 通過ClassReader讀取class字節碼文件,然后ClassReader將讀取到的數據通過一個ClassVisitor(上面的ClassWriter其實就是一個ClassVisitor)將數據表現出來.表現形式: 將字節碼的每個細節按順序通過接口的方式傳遞給ClassVisitor.就比如說,訪問到了class文件的xx方法,就會回調ClassVisitor的visitMethod方法;訪問到了class文件的屬性,就會回調ClassVisitor的visitField方法.

ClassWriter是一個繼承了ClassVisitor的類,它保存了這些由ClassReader讀取出來的字節流數據,最后通過它的toByteArray方法獲得完整的字節流.

上面的概念比較生硬,咱們先來寫一個簡單的復制class文件的方法:

private void copyFile(File inputFile, File outputFile) {
    FileInputStream inputStream = new FileInputStream(inputFile)
    FileOutputStream outputStream = new FileOutputStream(outputFile)

    //1\. 構建ClassReader對象
    ClassReader classReader = new ClassReader(inputStream)
    //2\. 構建ClassVisitor的實現類ClassWriter
    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
    //3\. 將ClassReader讀取到的內容回調給ClassVisitor接口
    classReader.accept(classWriter, ClassReader.EXPAND_FRAMES)
    //4\. 通過classWriter對象的toByteArray方法拿到完整的字節流
    outputStream.write(classWriter.toByteArray())

    inputStream.close()
    outputStream.close()
}
復制代碼

看到這里,可能有的同學已經有點感覺了.ClassReader對象就是專門負責讀取字節碼文件的,而ClassWriter就是一個繼承了ClassVisitor的類,當ClassReader讀取字節碼文件的時候,數據會通過ClassVisitor回調回來.咱們可以自定義一個ClassWriter用來接收讀取到的字節數據,接收數據的同時,咱們再插入一點東西到這些數據的前面或者后面,最后通過ClassWriter的toByteArray方法將這些字節碼數據導出,寫入新的文件,這就是我們所說的插樁了.

現在咱們舉個栗子,到底插樁能有啥用?就實現一個簡單的需求吧,在每個方法的最前面插入一句打印Hello World!的代碼.

修改前的代碼如下所示:

private void test() {
    System.out.println("test");
}
復制代碼

預期修改后的代碼:

private void test() {
    System.out.println("Hello World!");
    System.out.println("test");
}
復制代碼

將上面的復制文件的代碼簡單改改

void traceFile(File inputFile, File outputFile) {
    FileInputStream inputStream = new FileInputStream(inputFile)
    FileOutputStream outputStream = new FileOutputStream(outputFile)

    ClassReader classReader = new ClassReader(inputStream)
    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
    classReader.accept(new HelloClassVisitor(classWriter)), ClassReader.EXPAND_FRAMES)
    outputStream.write(classWriter.toByteArray())

    inputStream.close()
    outputStream.close()
}
復制代碼

唯一有變化的地方就是classReader的accept方法傳入的ClassVisitor對象變了,咱自定義了一個HelloClassVisitor.

class HelloClassVisitor extends ClassVisitor {

    HelloClassVisitor(ClassVisitor cv) {
        //這里需要指定一下版本Opcodes.ASM7
        super(Opcodes.ASM7, cv)
    }

    @Override
    MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        def methodVisitor = cv.visitMethod(access, name, descriptor, signature, exceptions)
        return new HelloMethodVisitor(api, methodVisitor, access, name, descriptor)
    }
}
復制代碼

我們自定義了一個ClassVisitor,它將ClassWriter傳入其中.在ClassVisitor的實現中,只要傳入了classVisitor對象,那么就會將功能委托給這個classVisitor對象.相當于我傳入的這個ClassWriter就讀取到了字節碼,最后toByteArray就是所有的字節碼.多說無益,看看代碼:

public abstract class ClassVisitor {
    /** The class visitor to which this visitor must delegate method calls. May be null. */
  protected ClassVisitor cv;

   public ClassVisitor(final int api, final ClassVisitor classVisitor) {
    if (api != Opcodes.ASM7 && api != Opcodes.ASM6 && api != Opcodes.ASM5 && api != Opcodes.ASM4) {
      throw new IllegalArgumentException("Unsupported api " + api);
    }
    this.api = api;
    this.cv = classVisitor;
  }

  public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) {
    if (cv != null) {
      return cv.visitAnnotation(descriptor, visible);
    }
    return null;
  }

  public MethodVisitor visitMethod(
      final int access,
      final String name,
      final String descriptor,
      final String signature,
      final String[] exceptions) {
    if (cv != null) {
      return cv.visitMethod(access, name, descriptor, signature, exceptions);
    }
    return null;
  }

  ...
}
復制代碼

有了我們傳入的ClassWriter,咱們在自定義ClassVisitor的時候,只需要關注需要修改的地方即可.咱們是想對方法進行插樁,自然就得關心visitMethod方法,該方法會在ClassReader閱讀class文件里面的方法時會回調.這里我們首先是在HelloClassVisitor的visitMethod中調用了ClassVisitor的visitMethod方法,拿到MethodVisitor對象.

而MethodVisitor是和ClassVisitor是類似的,在ClassReader閱讀方法的時候會回調這個類里面的visitParameter(訪問方法參數),visitAnnotationDefault(訪問注解的默認值),visitAnnotation(訪問注解)等等.

所以為了能夠對方法插樁,咱們需要再包一層,自己實現一下MethodVisitor,我們將ClassWriter.visitMethod返回的MethodVisitor傳入自定義的MethodVisitor,并在方法剛開始的地方進行插樁.AdviceAdapter是一個繼承自MethodVisitor的類,它能夠方便的回調方法進入(onMethodEnter)和方法退出(onMethodExit). 我們只需要在方法進入,也就是onMethodEnter方法里面進行插樁即可.

class HelloMethodVisitor extends AdviceAdapter {

        HelloMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
            super(api, methodVisitor, access, name, descriptor)
        }

        //方法進入
        @Override
        protected void onMethodEnter() {
            super.onMethodEnter()
            //這里的mv是MethodVisitor
            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitLdcInsn("Hello World!");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
}
復制代碼

插樁的核心代碼,需要一些字節碼的核心知識,這里不展開介紹,推薦大家閱讀《深入理解Java虛擬機》關于字節碼的章節.

當然,要想快速地寫出這些代碼也是有捷徑的,安裝一個ASM Bytecode Outline插件,然后隨便寫一個Test類,然后隨便寫一個方法

public class Test {
    public void hello() {
        System.out.println("Hello World!");
    }
}
復制代碼

然后選中該Test.java文件,右鍵菜單,點擊Show ByteCode outline

在右側窗口內選擇ASMified,即可得到如下代碼:

mv = cw.visitMethod(ACC_PUBLIC, "hello", "()V", null, null);
mv.visitCode();
Label l0 = new Label();
mv.visitLabel(l0);
mv.visitLineNumber(42, l0);
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello World!");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
Label l1 = new Label();
mv.visitLabel(l1);
mv.visitLineNumber(43, l1);
mv.visitInsn(RETURN);
Label l2 = new Label();
mv.visitLabel(l2);
mv.visitLocalVariable("this", "Lcom/xfhy/gradledemo/Test;", null, l0, l2, 0);
mv.visitMaxs(2, 1);
mv.visitEnd();
復制代碼

其中關于Label的咱不需要,所以只剩下核心代碼

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello World!");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
復制代碼

到這里,ASM的基本使用已經告一段落.ASM可操作性非常強,人有多大膽,地有多大產.只要你想實現的,基本都能實現.關鍵在于你的想法.但是有個小問題,上面的插件只能生成一些簡單的代碼,如果需要寫一些復雜的邏輯,就必須深入Java字節碼,才能自己寫出來或者是看懂ASM的插樁代碼.

3. ASM 實戰 防快速點擊(抖動)

上面那個小demo在每個方法里面打印一句"Hello World!"好像沒什么實際意義..咱決定做個有實際意義的東西,一般情況下,我們在做開發的會去防止用戶快速點擊某個View.這是為了追求更好的用戶體驗,如果不處理的話,在快速點擊Button的時候可能會連續打開2個相同的界面,在用戶看來確實有點奇怪,影響體驗.所以,一般情況下,我們會去做一下限制.

處理的時候,其實也很簡單,我們只需要取快速點擊事件中的其中一次點擊事件就行了.有哪些方案進行處理呢?下面是我想到的幾種

  1. 在BaseActivity的dispatchTouchEvent里判斷一下,如果ACTION_DOWN&&快速點擊則返回true就行.
  2. 寫一個工具類,記錄上一次點擊的時間,每次在onClick里面判斷一下,是否為快速點擊,如果是,則不響應事件.
  3. 可以在方案2的基礎上,記錄每個View上一次的點擊時間,控制更為精準.

下面是我簡單實現的一個工具類FastClickUtil.java

public class FastClickUtil {

    private static final int FAST_CLICK_TIME_DISTANCE = 300;
    private static long sLastClickTime = 0;

    public static boolean isFastDoubleClick() {
        long time = System.currentTimeMillis();
        long timeDistance = time - sLastClickTime;
        if (0 < timeDistance && timeDistance < FAST_CLICK_TIME_DISTANCE) {
            return true;
        }
        sLastClickTime = time;
        return false;
    }

}
復制代碼

有了這個工具類,那咱們就可以在每個onClick方法的最前面插入isFastDoubleClick()判斷語句,簡單判斷一下即可實現防抖.就像下面這樣:

public void onClick(View view) {
    if (!FastClickUtil.isFastDoubleClick()) {
        ......
    }
}
復制代碼

為了實現上面這個最終效果,我們其實只需要這樣做:

  1. 找到onClick方法
  2. 進行插樁

除了自定義ClassVisitor,其他代碼是和上面的demo差不多的,咱直接看自定義ClassVisitor.

class FastClickClassVisitor extends ClassVisitor {

    FastClickClassVisitor(ClassVisitor classVisitor) {
        super(Opcodes.ASM7, classVisitor)
    }

    @Override
    MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        def methodVisitor = cv.visitMethod(access, name, descriptor, signature, exceptions)
        if (name == "onClick" && descriptor == "(Landroid/view/View;)V") {
            return new FastMethodVisitor(api, methodVisitor, access, name, descriptor)
        } else {
            return methodVisitor
        }
    }
}
復制代碼

在ClassVisitor里面的visitMethod里面,只需要找到onClick方法,然后自定義自己的MethodVisitor.

class FastMethodVisitor extends AdviceAdapter {

    FastMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
        super(api, methodVisitor, access, name, descriptor)
    }

    //方法進入
    @Override
    protected void onMethodEnter() {
        super.onMethodEnter()
        mv.visitMethodInsn(INVOKESTATIC, "com/xfhy/gradledemo/FastClickUtil", "isFastDoubleClick", "()Z", false)
        Label label = new Label()
        mv.visitJumpInsn(IFEQ, label)
        mv.visitInsn(RETURN)
        mv.visitLabel(label)
    }
}
復制代碼

在方法進入(onMethodEnter())里面調用FastClickUtil的靜態方法isFastDoubleClick()判斷一下即可.到此,我們的小案例計算全部完成了.可以看到,利用ASM輕輕松松就能實現我們之前看起來比較麻煩的功能,而且低侵入性,不用改動之前的所有代碼.

插樁之后可以將編譯完成的apk直接拖入jadx里面看一下最終源碼驗證,也可以直接將apk安裝到手機上進行驗證.

當然了,上面的這種實現有些不太人性化的地方.比如某些View的點擊事件,不需要防抖.怎么辦?用上面這種方式不太合適,咱可以自定義一個注解,在不需要處理防抖的onClick方法上標注一下這個注解.然后在ASM這邊判斷一下,如果某onClick方法上有這個注解就不進行插樁.事情完美解決.這里就不帶著大家實現了,留給大家課后實踐.

image

作者:瀟風寒月
鏈接:https://juejin.im/post/6864349303843307534

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。