位元組碼引用檢測原理與實戰

vivo網際網路技術發表於2021-12-07

一、位元組碼與引用檢測

1.1 Java位元組碼

本章中的位元組碼重點研究Java 位元組碼,Java位元組碼(Java bytecode)是Java虛擬機器執行的一種指令格式。可以通過javap -c -v xxx.class(Class檔案路徑) 命令來檢視一個Class對應的位元組碼檔案,如下圖所示:

1.2 位元組碼檢測

位元組碼檢測本質就是對.java或.kt檔案編譯後生成的Class檔案進行相關的分析和檢測。在正式介紹位元組碼分析在引用檢測上的原理與實戰前,先介紹下位元組碼引用檢測的技術預研背景。

二、位元組碼檢測技術的預研背景

整個預研背景需要先從筆者負責的APP--內銷官網APP的軟體架構講起。

2.1 內銷官網APP軟體架構

內銷官網APP目前共12個子倉,子倉分別獨立編譯成AAR檔案供APP工程使用,軟體架構圖如下圖所示:

APP以下,上層淺藍色為業務層,中間綠色為元件層,最下層深藍色為基礎框架層:

  • 業務層:位於架構最上層,根據業務線劃分的業務模組(比如商城、社群、服務),與產品業務相對應。

  • 元件層:是APP的一些基礎功能(比如登入、自升級)和業務公用的元件(比如分享、地址管理、視訊播放),提供一定的複用能力。

  • 基礎框架層:通過跟業務完全無關的基礎元件(比如三方框架、自行封裝的通用能力),提供完全的複用能力。

2.2 內銷官網APP客戶端開發模式

  • 官網APP目前主要分3條業務線,多業務版本並行開發是常態,所以模組化非常必要。

  • 官網APP模組化的子倉均已AAR形式供APP使用,且存在上層AAR依賴下層AAR的情況。

  • 官網APP模組化分倉優化工作穿插在各業務版本中,各業務版本並行開發,底層倉庫難免有修改。

  • 官網APP各業務版本並行開發時,一般只會新拉取當前版本需要修改程式碼的倉庫,其他倉庫均繼續依賴老版本的AAR。

2.3 類、方法、屬性引用錯誤導致的執行時崩潰

假設以下場景:

官網APP5.0版本開發過程中,由於HardWare倉沒有業務修改,所以繼續使用上個版本4.9.0.0的HardWare(版本開發過程中一般只會重新拉取需要修改的倉庫,無需修改的倉庫會繼續使用老版本),但Core倉有程式碼修改,所以拉取了新的5.0分支,並修改了相關程式碼,刪除了CoreUtils類中的某個fun1方法,如下圖所示:

注:硬體檢測模組v4.9.0.0版本AAR中用到了核心倉 CoreUtils.class中的fun1方法,其他倉包括主APP工程均未使用到該fun1方法。

請大家思考下,以上場景專案編譯是否會有問題?

答:編譯無問題

APP主倉依賴的是4.9.0.0版本的HardWare倉編譯後的AAR檔案,這個AAR檔案早在4.9版本就編好沒動,所以HardWare倉沒有編譯問題;

APP主倉依賴的是5.0.0.0版本的Core倉,HardWare依賴的是4.9.0.0版本的Core倉,最終編譯會取Core倉的高版本5.0.0.0版本參與APP工程編譯,App倉沒有使用被刪除的fun1方法,也不存在編譯問題。

以上場景專案編譯完成後執行過程中是否會有問題?

答:有問題。

在APP執行到HardWare倉呼叫了CoreUtils類中fun1方法的情況下就會出現執行時崩潰:Method Not Found。

因為最終參與APP工程編譯的是5.0.0.0版本的Core倉,該版本已經刪除了fun1方法,所以會出現執行時錯誤。

真實案例:

1)找不到方法

2)找不到類

所幸以上問題均在開發、測試階段發現並及時修復掉了,如果流到線上,就是執行到某功能時的必崩場景,將會非常嚴重。

如果你負責的APP的所有module均是原始碼依賴,一般情況下如果存在引用問題,編譯器會進行提示,所以一般情況下無需擔心(除非依賴的底層sdk存在引用問題),但如果是類似官網這樣的軟體架構,則需要重點注意。

2.4 現狀分析、思考

本地測試過程中已出現過引用問題導致的執行時異常,這種執行時異常的檢測只靠人工是不夠的,必須要有自動化的檢測工具來進行檢查。傳統的findBugs、Lint等是程式碼靜態檢測工具,是無法檢測出這種潛在的引用問題導致的執行時異常的,靜態程式碼檢測無法解決此問題。所以自研自動化的檢測工具迫在眉睫!

三、位元組碼檢測的解決方案

如果能在APK編譯期間,通過自動化工具對所有JAR、AAR包中每個類做一遍檢測,檢測其中呼叫的方法、屬性的使用是否存在引用問題,將檢測出疑似問題的地方在編譯時進行提示,有必要的情況下直接報錯終止編譯,並輸出錯誤日誌來提醒開發人員檢查,防止問題流入線上出現執行時異常。

原理:各子倉的Java類(或Kotlin類)在編譯成AAR或JAR後,AAR、JAR中會有所有類的Class檔案,我們實際上就是需要對編譯後生成的Class檔案進行分析。

如何對Class檔案進行位元組碼分析?

這裡推薦使用 JavaAssist 或 ASM,我們知道Android編譯過程主要通過Gradle來控制的,要想分析Class檔案位元組碼,我們需要實現自己的Gradle Transform,在Transform裡對Class位元組碼進行分析,這裡我們直接做成Gradle外掛。

在編譯期間自動分析Class位元組碼是否存在方法引用、屬性引用、類引用找不到或者當前類無權訪問的問題,發現問題停止編譯,並輸出相關日誌,提醒開發人員分析,並支援對外掛的配置。

到這裡,整個方案的主體框架就比較清晰了,如下圖所示:

3.1 方法和屬性引用檢測原理

方法和屬性引用問題的識別:

如何識別一個方法引用存在問題?

  • 該方法被刪除,找不到相關方法名;

  • 找不到方法簽名相同的方法,主要是指方法的入引數量、入參型別無法匹配;

  • 方法是非public方法,當前類無許可權訪問該方法。

如何識別一個屬性(欄位)引用存在問題?

  • 該屬性被刪除,找不到相關屬性、欄位;

  • 屬性是非public屬性,當前類無許可權訪問該屬性。

許可權修飾符說明:

方法和屬性引用的位元組碼檢測:我們可以利用JavaAssist、ASM等支援位元組碼操作的庫來實現對所有類中方法、屬性的掃描,並分析方法呼叫、屬性引用是否存在引用問題。

3.2 方法和屬性引用檢測實戰

以下程式碼均已Kotlin編寫,實現Gradle Plugin、Transform具體過程省略,直接上檢測功能的程式碼。方法、欄位引用檢測:

// Gradle Plugin、自定義Transform的部分這裡不做贅述
// 方法引用檢測
// 遍歷每個類中的 每個方法 (包括構造方法 addBy Qihaoxin)
classObj.declaredBehaviors.forEach { ctMethod ->
    //遍歷當前類中所有方法
    ctMethod.instrument(object : ExprEditor() {
        override fun edit(m: MethodCall?) {
            super.edit(m)
            //每個方法呼叫都會回撥此方法,在此方法中進行檢測
            //引用檢查功能
            try {
                //這裡不是每個方法都需要校驗的,過濾掉 我們不需要處理的 系統方法,第三方sdk方法 等等 只校驗我們自己的業務邏輯程式碼
                if (ctMethod.declaringClass.name.isNeedCheck()) {
                    return
                }
                if (m == null) {
                    throw Exception("MethodCall is null")
                }
                //不需要檢查的包名
                if (m.className.isNotWarn() || classObj.name.isNotWarn()) {
                    return
                }
                //method找不到,底層會直接拋異常的,包括方法刪除、方法簽名不匹配的情況
                m.method.instrument(ExprEditor())
                //訪問許可權檢測,該方法非public,且對當前呼叫這個方法的類是不可見的
                if (!m.method.visibleFrom(classObj)) {
                    throw Exception("${m.method.name} 對 ${classObj.name} 這個類是不可見的")
                }
            } catch (e: Exception) {
                e.message?.let {
                    errorInfo += "--方法分析 Exception Message: ${e.message} \n"
                }
                errorInfo += "--方法分析異常發生在 ${ctMethod.declaringClass.name} 這個類的${m?.lineNumber}行, ${ctMethod.name} 這個方法  \n"
                errorInfo += "------------------------------------------------\n"
                isError = true;
            }
        }
 
        /**
         * 成員變數呼叫的分析主要有:
         * 變數直接被刪掉後找不到的問題
         * private變數的只能定義該變數的類試用
         * protected變數的可被類自己\子類\同包名的訪問
         * */
        override fun edit(f: FieldAccess?) {
            super.edit(f)
            try {
                if (f == null) {
                    throw Exception("FieldAccess is null")
                }
                //不需要檢查的包名
                if (f.className.isNotWarn() || classObj.name.isNotWarn()) {
                    return
                }
                //這裡不用判空,如果field找不到(這個屬性被刪掉了),底層會直接拋異常NotFoundException
                val modifiers = f.field.modifiers
                if (ctMethod.declaringClass.name == classObj.name) {
                    //只處理定義在本類中的方法,不然基類裡的方法也會被處理到--會出現本類實際沒訪問基類裡的private變數但報錯的問題
                    if (ctMethod.declaringClass.name == classObj.name) {
                        if (!f.field.visibleFrom(classObj)) {
                            throw Exception("${f.field.name} 對 ${classObj.name} 這個類是不可見的")
                        }
                    }
                }
            } catch (e: Exception) {
                e.message?.let {
                    errorInfo += "--欄位分析 Exception Message: ${e.message} \n"
                }
                errorInfo += "--欄位分析異常發生在 ${classObj.name} 該類在 ${f?.lineNumber}行,使用 ${f?.fieldName} 這個屬性時\n"
                errorInfo += "------------------------------------------------\n"
                isError = true
            }
        }
    })
}

在以上程式碼實現中,是遍歷了所有的方法,對方法內的方法呼叫、欄位訪問進行了檢測。那麼全域性變數如何檢查呢?

class BillActivity {
    ...
    private String mTest1 = CreateNewAddressActivity.TAG;
    private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c");
    ...
}

例如以上程式碼中,mTest1屬性的值以及mTest2屬性的值應該如何做檢測?這個問題困擾筆者良久。在JavaAssist、ASM中均未能找到獲取屬性當前值的相關的Api、也未能找到Class位元組碼直接分析屬性值的相關思路以及資料。

在研究了Class位元組碼相關知識,並做了大量的實驗,打了大量的Log後,解決思路才慢慢浮出水面。

我們先來看下BillActivity的一段位元組碼:

在這裡我們找到了定義的mTest1這個全域性變數,然後大家可以注意到,右邊Method中出現了一個init方法,實際上Java 在編譯之後會在位元組碼檔案中生成 init 方法,稱之為例項構造器,該例項構造器會將語句塊,變數初始化,呼叫父類的構造器等操作收斂到 init 方法中。那我們的mTest2這個全域性變數呢?

搜尋後發現mTest2實際上是在static程式碼塊中,這裡似乎mTest2賦值並沒有被方法包裹,如下圖所示:

實際上通過查閱大量資料後得知,Java 在編譯之後會在位元組碼檔案中生成 clinit 方法,稱之為類構造器,類構造器會將靜態語句塊,靜態變數初始化,收斂到 clinit 方法中。上圖通過javap檢視Class位元組碼中未顯示clinit方法是因為javap未對此進行相關的適配展示而已。

通過實驗Log發現mTest2的初始化確實出現在clinit方法中,且在ASMPlugin的ByteCode中檢視跟上圖相同的位元組碼,展示為帶有clinit方法標識的位元組碼,如下圖所示:

研究到這裡,我們實際也就知道了mTest1和mTest2的賦值實際都發生在init和clinit方法中。所以我們前面遍歷類中所有方法來檢測方法和屬性的引用檢查是可以覆蓋到全域性變數的。

問題到這裡似乎已經全部完美解決了,但我在全域性變數的程式碼這裡看了幾眼後,又發現了新的問題:

class BillActivity {
    ...
    private String mTest1 = CreateNewAddressActivity.TAG;
    private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c");
    ...
}

我們前面只關心了TAG這個屬性和getFormatProvinceInfo這個方法的引用是否存在問題,但我們沒有對CreateNewAddressActivity這個類本身做引用檢查,假設這個類是private的,這裡依然會有問題。所以我們引用檢查不能忘記對類引用的檢查。

3.3 類引用檢查原理

如何識別一個類引用存在問題?

  • 該類被刪除,找不到相關類;

  • 類是非public的,當前類無許可權訪問該類。

3.4 類引用檢測實戰

類引用檢查

//類的引用檢查
if (classObj.packageName.isNeedCheck()) {
    classObj.refClasses?.toList()?.forEach { refClassName ->
        try {
            if (refClassName.toString().isNotWarn() || classObj.name.isNotWarn()) {
                return@forEach
            }
            //該類被刪除,找不到相關類
            val refClass = classPool.getCtClass(refClassName.toString())
                ?: throw NotFoundException("無法找到該類:$refClassName")
            //許可權檢測
            //.....省略.....跟方法和屬性的許可權檢測一樣,這裡不再贅述
        } catch (e: Exception) {
            e.message?.let {
                errorInfo += "--類引用分析 Exception Message: ${e.message} \n"
            }
            errorInfo += "--類引用分析異常 在類:${classObj.name} 中引用了 $refClassName \n"
            errorInfo += "------------------------------------------------\n"
            isError = true
        }
    }
}

到這裡本次位元組碼引用檢測的原理以及實戰就介紹完了。

3.5 解決方案的反思

在內銷官網的buildSrc中實現了引用檢測功能後,得知其他APP很多都已做了模組化,聯想到其他APP可能也採用類似官網的模組化架構,也會存在類似痛點,反思當前技術實現並不具備通用的接入能力,深感這件事其實並沒有做完,在解決自身APP痛點後需要橫向賦能其他APP,解決大團隊所面臨的痛點,所有才有了後面的獨立Gradle外掛。

四、獨立Gradle外掛

如果需要在編譯期間進行引用檢測的APP模組,歡迎大家接入我開發的這款位元組碼引用檢測的Gradle外掛。

4.1 獨立Gradle外掛目標

1)獨立Gradle外掛,方便所有APP接入;

2)支援常用的開發配置項,支援外掛功能開關、異常跳過等配置;

3)對Java、Kotlin編譯後的位元組碼進行引用檢查,能在CI、Jenkins上編譯APK包發現引用問題時,編譯報錯並輸出引用問題的具體資訊供開發分析、解決。

4.2 外掛功能

1)方法引用檢測;

2)屬性(欄位)引用檢測;

3)類引用檢測;

4)外掛支援常用配置,可開可關。

比如能檢測出Class Not Found \Method Not Found或者Field Not Found 的問題。整個外掛在編譯期間執行時間很短,以內銷官網APP為例,該外掛在APP編譯期間執行時間在 2.3秒左右,速度很快,不必擔心會增加編譯耗時。

4.3 外掛接入

在主工程根目錄build.gradle中新增依賴:

dependencies {
        ...
        classpath "com.byteace.refercheck:byteace-refercheck:35-SNAPSHOT" //目前是試執行版本,版本還需迭代;歡迎大家體驗並提建議和問題,幫助不斷完善外掛功能
}

在APP工程的build.gradle中使用外掛並設定配置資訊:

//官網自研的位元組碼引用檢查外掛
apply plugin: 'com.byteace.refercheck'
//官網自研的位元組碼引用檢查外掛-配置項
referCheckConfig {
        enable true //是否開啟引用檢查功能
        strictMode true // 控制是否發現問題時停止構建,
        check "com.abc.def" //需要檢查的類的包名,因為工程中會使用很多sdk或者第三方庫我們一般不做檢查,只檢查我們需要關注的類的包名
        notWarn "org.apache.http,com.core.videocompressor.VideoController" //人工檢查確認後不需要報錯的包名
}

4.4 外掛配置項說明

Enable:是否開啟引用檢查功能,如果為false,則不進行引用檢查

StrictMode:嚴苛模式開啟時,發現引用異常直接中斷編譯(嚴苛模式關閉時,只會將異常資訊打在編譯過程的日誌中,發現引用問題不會終止編譯)。

建議:Jekins或CI上打Release包時build.gradle中配置的enable和strictMode都設定為true。

Check:需要檢測的包名,一般只配置檢查當前APP包名即可,如需對依賴的第三方sdk等做檢查,可根據需要進行配置。

NotWarn:發現引用問題不報錯的白名單,在開發人員檢查外掛報錯的問題並認定實際不會導致崩潰後,可將當前引用不到的類名配置在這裡,可跳過檢查。如A類引用不到B類中的某個方法,可將B類的類名配置在這裡,將不會報錯。

4.5 內銷官網APP中NotWarn配置項說明

內銷官網APP將org.apache.http以及com.core.videocompressor.VideoController加入到了不報錯白名單中。org.apache.http 實際用的是Android系統中的包,該包並沒有參與APK編譯,如果不加該配置項,則會報錯,但實際執行不會出錯。

com.core.videocompressor.VideoController 該項不加的話會報錯:FileProcessFactory中引用不到CompressProgressListener類。排查下FileProcessFactory程式碼,FileProcessFactory類的138行 呼叫了convertVideo方法,最後一個listner引數傳的null。

該類的位元組碼Class檔案如下,會自動對converVideo最後一個入參null進行強制型別轉換:

而這個CompressProgressListener並不是public的,是預設的package。而且FileProcessFactory類與CompressProgressListener不在同一個package下,所以會報錯。但實際執行時並不會崩潰,所以需要將其類名加入到不報錯的白名單中。

如果在外掛使用過程中遇到不應報錯的案例,可以通過白名單控制進行跳過,同時希望將案例反饋給我,我這邊對案例進行分析並對外掛進行迭代更新。

五、總結

預研過程中由於位元組碼知識較深,且網路上類似位元組碼插樁、進行程式碼生成的的教程較多,但做位元組碼分析的資料太少,所以需要熟悉位元組碼知識並在實踐中慢慢實驗和摸索,細節也需慢慢打磨。

在預研過程中積極思考解決方案的通用性和可配置性,最終開發出通用的Gradle外掛,積極推動其他模組接入,藉此次寶貴的機會進行橫向技術賦能,爭取大團隊的成功。

目前已有兩個APP接入外掛,外掛會持續維護並迭代,等外掛穩定後規劃整合到CI、Jenkins上。歡迎有需求的APP接入引用檢測的Gradle外掛,希望能幫助到存在引用檢測痛點的APP和團隊。

作者:vivo官網商城客戶端團隊-Qi Haoxin

相關文章