Android位元組碼插樁採坑筆記

weixinjie發表於2019-03-04

1.寫在前面

俗話說“任何技術都是脫離了業務都將是空中樓閣”。最開始有研究位元組碼插樁技術衝動的是我們接入了一款統計類的SDK(這裡我就不具體說是哪款了)。他們的套路是第三方開發者需要接入他們的外掛(Gradle Plugin),然後便可以實現無埋點進行客戶端的全量資料統計(全量的意思是包括頁面開啟速度、方法耗時、各種點選事件等)。當時由於需求排期比較急,一直沒有時間研究他們的實現方式。春節假期,我實在難以控制體內的求知慾,通過查資料以及反編譯他們的程式碼終於找到了技術的本源——位元組碼插樁。正好公司這段時間要繼續搞一套統計系統,為了不侵入原有的專案架構,我也打算使用位元組碼插樁技術來實現。so寫這篇文章的目的是將預研期的坑share一下,避免更多小夥伴入坑~

先簡要描述一下接下來我們要幹什麼

簡單來講,我們要實現無埋點對客戶端的全量統計。這裡的統計概括的範圍比較廣泛,常見的場景有:

  • 頁面(Activity、Fragment)的開啟事件
  • 各種點選事件的統計,包括但不限於Click LongClick TouchEvent
  • Debug期需要統計各個方法的耗時。注意這裡的方法包括接入的第三方SDK的方法。
  • 待補充

要實現這些功能需要擁有哪些技術點呢?

  • 面向切面程式設計思想(AOP)
  • Android打包流程
  • 自定義Gradle外掛
  • 位元組碼編織
  • 結合自己的業務實現統計程式碼
  • 沒了。。。

2.開始惡補技術點

2.1 技術點——什麼是AOP

AOP(Aspect Oriented Program的首字母縮寫)是一種面向切面程式設計的思想。這種程式設計思想是相對於OOP(ObjectOriented Programming即物件導向程式設計)來說的。說破大天,我們們要實現的功能還是統計嘛,大規模的重複統計行為是典型的AOP使用場景。所以搞懂什麼是AOP以及為什麼要用AOP變得很重要

先來說一下大家熟悉的物件導向程式設計:物件導向的特點是繼承、多型和封裝。而封裝就要求將功能分散到不同的物件中去,這在軟體設計中往往稱為職責分配。實際上也就是說,讓不同的類設計不同的方法。這樣程式碼就分散到一個個的類中去了。這樣做的好處是降低了程式碼的複雜程度,使類可重用。

But物件導向的程式設計天生有個缺點就是分散程式碼的同時,也增加了程式碼的重複性。比如我希望在專案裡面所有的模組都增加日誌統計模組,按照OOP的思想,我們需要在各個模組裡面都新增統計程式碼,但是如果按照AOP的思想,可以將統計的地方抽象成切面,只需要在切面裡面新增統計程式碼就OK了。

切面圖

其實在服務端的領域AOP已經被各路大佬玩的風生水起,例如Spring這類跨時代的框架。我第一次接觸AOP就是在自學Spring框架的的時候。最常見實現AOP的方式就是代理。

2.2 技術點——Android打包流程

既然想用位元組碼插樁來實現無埋點,對Android的打包流程總是要了解一下的。不然我們們怎麼系統什麼時候會把Class檔案生成出來供我們插樁呢?官網的打包流程不是那麼的直觀。所以一起來看一下更直觀的構建流程吧。

Android打包流程

一圖頂千言,經過“Java Compiler步驟”,系統便生成了.class檔案。這些class檔案經過dex步驟再次轉化成Android識別的.dex檔案。既然我們要做位元組碼插樁,就必須hook打包流程,在dex步驟之前對class位元組碼進行掃描與重新編織,然後將編織好的class檔案交給dex過程。這樣就實現了所謂的無埋點。那麼問題來了,我們怎麼知道系統已經完成了“Java Compiler”步驟呢?這就引出下一個技術點——自定義Gradle外掛。

2.3 技術點——自定義Gradle外掛

接著2.2小節的問題,我們怎麼知道打包系統已經完成“Java Compiler”步驟?即使知道打包系統生成了class位元組碼檔案又怎麼Hook掉該流程在完成自定義位元組碼編織後再進行“dex”過程呢?原來,對於Android Gradle Plugin 版本在1.5.0及以上的情況,Google官方提供了transformapi用作位元組碼插樁的入口。說的直白一點通過自定義Gradle外掛,重寫裡面transform方法就可以在“Java Compiler”過程結束之後 “dex”過程開始之前獲得回撥。這正是位元組碼重新編織的絕佳時機。

關於怎樣定義Gradle外掛值得參考的資源

因為本文重點講位元組碼插樁的技術流程,強調從面上覆蓋這套技術所涉及到的技術點,所以關於自定義外掛的內容不展開講解了。按照上面推薦的資源自己基本可以跑通自定義Gradle外掛的流程。如果大家自定義外掛的詳細內容請聯絡我,如果有必要我可以出一篇自定義Gradle外掛的教程。文末會給出郵箱。

關於transform值得參考的資源:

  • 官方文件
  • 滴滴外掛化專案VirtualApk,該專案中的virtualapk-gradle-plugin就是利用這個插樁入口將外掛的資源與宿主的資源進行剝離,防止宿主apk與外掛apk資源衝突。詳見該專案裡面StripClassAndResTransform類。

2.4 技術點——位元組碼編織

位元組碼的相關知識是本文的核心技術點

2.4.1 什麼是位元組碼

Java 位元組碼(英語:Java bytecode)是Java虛擬機器執行的一種指令格式。通俗來講位元組碼就是經過javac命令編譯之後生成的Class檔案。Class檔案包含了Java虛擬機器指令集和符號表以及若干其他的輔助資訊。Class檔案是一組以8位位元組為基礎單位的二進位制流,哥哥資料專案嚴格按照順序緊湊的排列在Class檔案之中,中間沒有任何分隔符,這使得整個Class檔案中儲存的內容幾乎全是程式執行時的必要資料。

因為Java虛擬機器的提供商有很多,其具體的虛擬機器實現邏輯都不相同,但是這些虛擬機器的提供商都嚴格遵守《Java虛擬機器規範》的限制。所以一份正確的位元組碼檔案是可以被不同的虛擬機器提供商正確的執行的。借用《深入理解Java虛擬機器》一書的話就是“程式碼編譯的結果從本地機器碼轉變成位元組碼,是儲存格式發展的一小步,確實程式語言發展的一大步”。

2.4.2 位元組碼的內容

位元組碼內容

這張圖是一張java位元組碼的總覽圖。一共含有10部分,包含魔數,版本號,常量池,欄位表集合等等。同樣本篇文章不展開介紹具體內容請參考這篇博文,有條件的同學請閱讀《深入理解Java虛擬機器》一書。我現在讀了兩遍,每次讀都有新的感悟。推薦大家也讀一下,對自己的成長非常有好處。

關於位元組碼幾個重要的內容:

全限定名

Class檔案中使用全限定名來表示一個類的引用,全限定名很容易理解,即把類名所有“.”換成了“/”

例如

android.widget.TextView
複製程式碼

的全限定名為

android/widget/TextView
複製程式碼

描述符

描述符的作用是描述欄位的資料型別、方法的引數列表(包括數量、型別以及順序)和返回值。根據描述符的規則,基本資料型別(byte char double float int long short boolean)以及代表無返回值的void型別都用一個大寫字元來表示,物件型別則用字元“L”加物件的全限定名來表示,一般物件型別末尾都會加一個“;”來表示全限定名的結束。如下表

標誌字元 含義
B 基本型別byte
C 基本型別char
D 基本型別double
F 基本型別float
I 基本型別int
J 基本型別long
S 基本型別short
Z 基本型別boolean
V 特殊型別void
L 物件型別,例如Ljava/lang/Object

對於陣列型別,每一個維度將使用“[”字元來表示
例如我們需要定義一個String型別的二維陣列

java.lang.String[][]
將會被表示成
[[java/lang/String;

int[]
將會被表示成
[I;
複製程式碼

用描述符來描述方法時,按照先引數列表後返回值的順序進行描述。引數列表按照引數的順序放到一組小括號“()”之內。舉幾個栗子:

void init()
會被描述成
()V

void setText(String s)
會被描述成
(Ljava/lang/String)V;

java.lang.String toString()
會被描述成
()Ljava/lang/String;
複製程式碼

2.4.3 虛擬機器位元組碼執行引擎知識

執行引擎是虛擬機器最核心的組成部分之一。本篇仍然控制版面,避免長篇大論的討論具體內容而忽略需要解決的問題的本質。下面我們重點討論一下Java的執行時記憶體佈局:

虛擬機器的記憶體可以分為堆記憶體與棧記憶體。堆記憶體是所有執行緒共享的,棧記憶體則是執行緒私有的。下圖為虛擬機器執行時資料區

執行時資料區

這裡重點解釋一下棧記憶體。Java虛擬機器棧是執行緒私有的,它描述的是Java方法執行的記憶體模型:每個方法在執行的同時會建立一個棧幀用於存區域性變數表、運算元棧、動態連結、方法返回地址等資訊。每一個方法從呼叫到執行完畢的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。每一個棧幀都包含了區域性變數表、運算元棧、動態連結、方法返回地址和一些額外的附加資訊。在編譯成class檔案後,棧幀中需要多大的區域性變數表和多深的運算元棧已經儲存在位元組碼檔案(class檔案)的code屬性中,因此一個棧幀需要分配多少記憶體,不會受到程式執行的影響,只會根據虛擬機器的具體實現不同。一個執行緒中的方法呼叫鏈可能會很長,即有很多棧幀。對於一個當前活動的執行緒中,只有位於執行緒棧頂的棧幀才是有效的,稱為當前棧幀(current stack Frame),這個棧幀關聯的方法稱為當前方法(current method),棧幀的概念圖如下:

Android位元組碼插樁採坑筆記

解釋一下上圖相關概念:

  • 區域性變數表:區域性變數表是一組變數儲存空間,用於儲存方法引數(就是方法入參)和方法內部定義區域性變數。區域性變數表的容量以容量槽為最小單位(slot)。虛擬機器通過索引的定位方式使用區域性變數表,索引值的範圍為0到區域性變數的最大slot值,在static方法中,0代表的是“ this”,即當前呼叫該方法的引用(主調方),其餘引數從1開始分配,當引數列表中的引數分配完後,就開始給方法內的區域性變數分配。用Android的click方法舉個例子:
 public void onClick(View v) {
                
            }
複製程式碼

這個方法的區域性變數表的容量槽為:

Slot Number value
0 this
1 View v
  • 運算元棧:運算元棧又被稱為操作棧,它是一個後入先出的棧結構。當一個方法剛開始執行時,運算元棧裡是空的,在方法的執行過程中,會有各種位元組碼指令向運算元棧中寫入和提取內容,也就是出棧和入棧的過程。例如,在執行位元組碼指令iadd(兩個int型別整數相加)時要求運算元棧中最接近棧頂的兩個元素已經存入兩個int型別的值,然後執行相加時,會將這兩個int值相加,然後將相加的結果入棧。具體的位元組碼操作指令可以參考維基百科,也可以參考國內巴掌的文章

2.4.4 位元組碼編織之ASM簡介

惡補完前面的知識點,終於到了最後的一步。怎樣對位元組碼進行編織呢?這裡我選了一個強大的開源庫ASM。

什麼是ASM?

ASM 是一個 Java 位元組碼操控框架。它能被用來動態生成類或者增強既有類的功能。ASM 可以直接產生二進位制 class 檔案,也可以在類被載入入 Java 虛擬機器之前動態改變類行為。Java class 被儲存在嚴格格式定義的 .class 檔案裡,這些類檔案擁有足夠的後設資料來解析類中的所有元素:類名稱、方法、屬性以及 Java 位元組碼(指令)。ASM 從類檔案中讀入資訊後,能夠改變類行為,分析類資訊,甚至能夠根據使用者要求生成新類。

為什麼選擇ASM來進行位元組碼編織?

因為有了前人做的實驗,我沒有對位元組碼編織的庫進行效率測試。參考網易樂得團隊的實驗結果:

Framework First time Later times
Javassist 257 5.2
BCEL 473 5.5
ASM 62.4 1.1

通過上表可見,ASM的效率更高。不過效率高的前提是該庫的語法更接近位元組碼層面。所以上面的虛擬機器相關知識顯得更加重要。

這個庫也沒什麼可展開描述的,值得參考的資源:

為了快速上手ASM,安利一個外掛[ASM Bytecode Outline]。這裡需要感謝巴掌的文章。ASM的內容就介紹到這裡,具體怎麼使用大家參考專案程式碼或者自己研究一波文件就好了。

3.專案實戰

我們以Activity的開啟為切面,對客戶端內所有Activity的onCreate onDestroy進行插樁。建議先clone一份demo專案

3.1 新建Gradle外掛

按照2.3小節的內容,聰明的你一定能很快新建一個Gradle外掛並能跑通流程吧。如果你的流程沒跑通可以參考專案原始碼。

需要注意的點:

注意點1:

專案中需要將Compile的地址換成你的本機地址,否則編譯會失敗。需要改動的檔案有traceplugin/gradle.properties中的LOCAL_REPO_URL屬性。

Android位元組碼插樁採坑筆記

以及跟專案下的build.gradle檔案中的maven地址

Android位元組碼插樁採坑筆記

3.2 完善自定義外掛,新增掃描與修改邏輯

例如demo專案中的TracePlugin.groovy就是掃描的入口,通過重寫transform方法,我們可以獲得插樁入口,將對Class檔案的處理轉化成ASM處理。

public class TracePlugin extends Transform implements Plugin<Project> {
    void apply(Project project) {
        def android = project.extensions.getByType(AppExtension);
        //對外掛進行註冊,新增插樁入口
        android.registerTransform(this)
    }


    @Override
    public String getName() {
        return "TracePlugin";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return false;
    }
    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental) 
            throws IOException, TransformException, InterruptedException {
        println `//===============TracePlugin visit start===============//`
        //刪除之前的輸出
        if (outputProvider != null)
            outputProvider.deleteAll()
        //遍歷inputs裡的TransformInput
        inputs.each { TransformInput input ->
            //遍歷input裡邊的DirectoryInput
            input.directoryInputs.each {
                DirectoryInput directoryInput ->
                    //是否是目錄
                    if (directoryInput.file.isDirectory()) {
                        //遍歷目錄
                        directoryInput.file.eachFileRecurse {
                            File file ->
                                def filename = file.name;
                                def name = file.name
                                //這裡進行我們的處理 TODO
                                if (name.endsWith(".class") && !name.startsWith("R$") &&
                                        !"R.class".equals(name) && !"BuildConfig.class".equals(name)) {
                                    ClassReader classReader = new ClassReader(file.bytes)
                                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                                    def className = name.split(".class")[0]
                                    ClassVisitor cv = new TraceVisitor(className, classWriter)
                                    classReader.accept(cv, EXPAND_FRAMES)
                                    byte[] code = classWriter.toByteArray()
                                    FileOutputStream fos = new FileOutputStream(
                                            file.parentFile.absolutePath + File.separator + name)
                                    fos.write(code)
                                    fos.close()

                                }
                        }
                    }
                    //處理完輸入檔案之後,要把輸出給下一個任務
                    def dest = outputProvider.getContentLocation(directoryInput.name,
                            directoryInput.contentTypes, directoryInput.scopes,
                            Format.DIRECTORY)
                    FileUtils.copyDirectory(directoryInput.file, dest)
            }


            input.jarInputs.each { JarInput jarInput ->
                /**
                 * 重名名輸出檔案,因為可能同名,會覆蓋
                 */
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }

                File tmpFile = null;
                if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
                    JarFile jarFile = new JarFile(jarInput.file);
                    Enumeration enumeration = jarFile.entries();
                    tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_trace.jar");
                    //避免上次的快取被重複插入
                    if (tmpFile.exists()) {
                        tmpFile.delete();
                    }
                    JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile));
                    //用於儲存
                    ArrayList<String> processorList = new ArrayList<>();
                    while (enumeration.hasMoreElements()) {
                        JarEntry jarEntry = (JarEntry) enumeration.nextElement();
                        String entryName = jarEntry.getName();
                        ZipEntry zipEntry = new ZipEntry(entryName);
                        //println "MeetyouCost entryName :" + entryName
                        InputStream inputStream = jarFile.getInputStream(jarEntry);
                        //如果是inject檔案就跳過

                        //重點:插樁class
                        if (entryName.endsWith(".class") && !entryName.contains("R$") &&
                                !entryName.contains("R.class") && !entryName.contains("BuildConfig.class")) {
                            //class檔案處理
                            jarOutputStream.putNextEntry(zipEntry);
                            ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
                            ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                            def className = entryName.split(".class")[0]
                            ClassVisitor cv = new TraceVisitor(className, classWriter)
                            classReader.accept(cv, EXPAND_FRAMES)
                            byte[] code = classWriter.toByteArray()
                            jarOutputStream.write(code)

                        } else if (entryName.contains("META-INF/services/javax.annotation.processing.Processor")) {
                            if (!processorList.contains(entryName)) {
                                processorList.add(entryName)
                                jarOutputStream.putNextEntry(zipEntry);
                                jarOutputStream.write(IOUtils.toByteArray(inputStream));
                            } else {
                                println "duplicate entry:" + entryName
                            }
                        } else {

                            jarOutputStream.putNextEntry(zipEntry);
                            jarOutputStream.write(IOUtils.toByteArray(inputStream));
                        }

                        jarOutputStream.closeEntry();
                    }
                    //寫入inject註解

                    //結束
                    jarOutputStream.close();
                    jarFile.close();
                }

                //處理jar進行位元組碼注入處理 TODO

                def dest = outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)
                if (tmpFile == null) {
                    FileUtils.copyFile(jarInput.file, dest)
                } else {
                    FileUtils.copyFile(tmpFile, dest)
                    tmpFile.delete()
                }
            }
        }
        println `//===============TracePlugin visit end===============//`

    }
複製程式碼

上述TracePlugin.groovy檔案完成了位元組碼與ASM的結合,那具體怎麼修改位元組碼呢?新建繼承自ClassVisitor的Visitor類

  • 重寫裡面的visit方法以便篩選哪些類需要插樁,例如篩選所有繼承自Activity的類才插樁。
  • 重寫visitMethod方法以便篩選當前類哪些方法需要插樁。例如篩選所有onCreate方法才插樁。
    具體註釋見程式碼:
/**
 * 對繼承自AppCompatActivity的Activity進行插樁
 */

public class TraceVisitor extends ClassVisitor {

    /**
     * 類名
     */
    private String className;

    /**
     * 父類名
     */
    private String superName;

    /**
     * 該類實現的介面
     */
    private String[] interfaces;

    public TraceVisitor(String className, ClassVisitor classVisitor) {
        super(Opcodes.ASM5, classVisitor);
    }

    /**
     * ASM進入到類的方法時進行回撥
     *
     * @param access
     * @param name       方法名
     * @param desc
     * @param signature
     * @param exceptions
     * @return
     */
    @Override
    public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature,
                                     String[] exceptions) {
        MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
        methodVisitor = new AdviceAdapter(Opcodes.ASM5, methodVisitor, access, name, desc) {

            private boolean isInject() {
                //如果父類名是AppCompatActivity則攔截這個方法,實際應用中可以換成自己的父類例如BaseActivity
                if (superName.contains("AppCompatActivity")) {
                    return true;
                }
                return false;
            }

            @Override
            public void visitCode() {
                super.visitCode();

            }

            @Override
            public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
                return super.visitAnnotation(desc, visible);
            }

            @Override
            public void visitFieldInsn(int opcode, String owner, String name, String desc) {
                super.visitFieldInsn(opcode, owner, name, desc);
            }


            /**
             * 方法開始之前回撥
             */
            @Override
            protected void onMethodEnter() {
                if (isInject()) {
                    if ("onCreate".equals(name)) {
                        mv.visitVarInsn(ALOAD, 0);
                        mv.visitMethodInsn(INVOKESTATIC,
                                "will/github/com/androidaop/traceutils/TraceUtil",
                                "onActivityCreate", "(Landroid/app/Activity;)V",
                                false);
                    } else if ("onDestroy".equals(name)) {
                        mv.visitVarInsn(ALOAD, 0);
                        mv.visitMethodInsn(INVOKESTATIC, "will/github/com/androidaop/traceutils/TraceUtil"
                                , "onActivityDestroy", "(Landroid/app/Activity;)V", false);
                    }
                }
            }

            /**
             * 方法結束時回撥
             * @param i
             */
            @Override
            protected void onMethodExit(int i) {
                super.onMethodExit(i);
            }
        };
        return methodVisitor;

    }

    /**
     * 當ASM進入類時回撥
     *
     * @param version
     * @param access
     * @param name       類名
     * @param signature
     * @param superName  父類名
     * @param interfaces 實現的介面名
     */
    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        this.className = name;
        this.superName = superName;
        this.interfaces = interfaces;
    }
}
複製程式碼

注意:

如果你對ASM用的並不是那麼熟練,別忘了ASM Bytecode Outline外掛。上面TraceVisitor.java中的onMethodEnter方法內部程式碼便是從ASM Bytecode Outline生成直接拷貝過來的。至於這個外掛怎麼使用2.4.4小節已經介紹過了。

3.3 完善自定義統計工具,實現最終資料統計

demo專案中app/TraceUtil.java類是用來統計的程式碼,專案中我只是在onCreate與onDestroy時彈出了一個Toast,你完全可以把這兩個函式執行的時間記錄下來,實現統計使用者線上時長等邏輯。TraceUtils.java程式碼如下:

/**
 * Created by will on 2018/3/9.
 */

public class TraceUtil {
    private final String TAG = "TraceUtil";

    /**
     * 當Activity執行了onCreate時觸發
     *
     * @param activity
     */
    public static void onActivityCreate(Activity activity) {
        Toast.makeText(activity
                , activity.getClass().getName() + "call onCreate"
                , Toast.LENGTH_LONG).show();
    }


    /**
     * 當Activity執行了onDestroy時觸發
     *
     * @param activity
     */
    public static void onActivityDestroy(Activity activity) {
        Toast.makeText(activity
                , activity.getClass().getName() + "call onDestroy"
                , Toast.LENGTH_LONG).show();
    }
}
複製程式碼

看到這裡有人會有疑問,這個TraceUtil的onActivityCreate與onActivityDestroy是什麼時候被執行的?當然是通過TraceVisitor的visitMethod方法插樁插進去的呀。

3.4 自己執行一下Demo & Enjoy

專案程式碼

看下專案的效果,統計程式碼已經被成功注入。

專案效果

4. 其他的小Tips

  • 位元組碼插樁是面向整個應用的插樁,如果我們只想插某一個函式的樁應該怎麼辦呢?例如我只想插MainActivity的onCreate函式,而不想插其他Activity的onCreate。這時候可以使用自定義註解來解決。方案是自定義一個註解,在想統計的方法上打上這個註解,在ASM的ClassVisitor類中重寫visitAnnotation方法來確定要不要插樁。怎樣自定義註解可以看我的這篇博文
  • 如果想插不同的樁該怎麼辦呢?例如我既想統計Activity的生命週期函式又想統計View的Click事件。講道理這塊我的經驗不夠豐富,我的方案比較low,我是通過在ClassVisitor中判斷當前類的名字、當前類的父類名字、當前類實現了哪些介面、以及當前類方法的名字來判斷的,比較臃腫。小夥伴們有什麼好的想法可以留言或聯絡我

寫在最後

由於這篇博文所涉及到的知識點比較多,很多地方我可能沒有展開寫的比較糙。如果寫的有什麼問題希望大家及時提出來,一起學習,一起進步。

參考資源


About Me

contact way value
mail weixinjie1993@gmail.com
wechat W2006292
github https://github.com/weixinjie
blog https://juejin.im/user/57673c83207703006bb92bf6

相關文章