Android熱修復方案的兼容策略CLASS_ISPREVERIFIED問題

前言

上文Android熱修復主流方案盤點 中,提到了4種比較出名的熱修復方案,

  • 騰訊Qzone超級補丁的multidex方案,
  • 騰訊Tinker的dexdiff方案,
  • 阿里andFix純native方法指針重定向方案(已廢棄,因為有了新的替代方案 sophix ),
  • 美團的robustinstantRun方案。

然而,在android多版本的兼容上,這些熱修復方案多多少少存在一些問題。
本文思路來源為兩篇官方技術博文:
安卓App熱補丁動態修復技術介紹
Android N混合編譯與對熱補丁影響解析
可惜大佬發文一般人看不懂,所以我重新解讀一下,更通俗易懂地展示這兩個坑的解決方案.

正文大綱

  • CLASS_ISPREVERIFIED兼容問題
  • Android N混合編譯兼容問題

正文

CLASS_ISPREVERIFIED 兼容問題

Demo地址:https://github.com/18598925736/HotUpdateDemo/tree/4.4_crash_solution

問題描述

一句話描述問題:

在apk安裝的時候,Dalvik虛擬機如果發現 一個類A它所引用的其他類,和它自己都處于同一個dex文件內部,那么類A就會被打上一個 CLASS_ISPREVERIFIED 標記,從而提高性能。那么按照這個思路,如果類A引用了一個有bug的類Util,然后我們用multidex熱修復方案給他推了一個patch.dex,然后重啟修復,這個類已經被打上了標記,但是重啟app之后,它所引用的類Util 此時和它又不處于一個dex內(新的Util類在patch.dex內)。此時,起了一個沖突,既打上了標記,又發現不處于一個dex內的引用類,程序就會報錯。

CLASS_ISPREVERIFIED 分4個單詞 class , is , pre verified , 是否 被預先 校驗

此問題只會出現在Dalvik虛擬機之下(4.4 sdk19 以下默認使用dalvik,5.0 sdk 21 以后便默認使用art虛擬機),art不會有類似問題。所以可以認為此問題只出現在5.0以下(不含5.0)的機器上.

問題演示

我使用的是上一篇文章的 Android Muitldex熱更新修復方案原理demo
下載之后,直接運行在SDK 19 android4.4的模擬器上。
這是一個已經加入了補丁包fix.dex的demo工程。
當你直接運行,會發現程序崩潰,報錯如下:

image.png
大概意思就是 有一個類的引用預先校驗了,但是沒有找到預想中的實現。這就是由于 被打上了CLASS_ISPREVERIFIED標記之后又執行了補丁修復,造成沖突。

解決方案

既然問題的根源在于 引用Util的A類被打上了 CLASS_ISPREVIRIFIED標記,那么有沒有辦法讓這些類不被打上標記呢?

思考:

:如何防止我們源代碼中所有的類被打上CLASS_ISPPREVERIFIED標記?
答:
理論上,一個android工程中所有的java類(除了Application之外)都有可能需要熱修復。如果讓這些類都去引用一個另一個dex文件之下的class,就能防止在dex解析的時候被打CLASS_ISPPREVERIFIED標記。
但是這樣有一個弊端,就是 CLASS_ISPPREVERIFIED帶來的性能提升將會消失。但是既然出現bug,要解決,總要付出一點代價。代價且容后再說。

行動

  1. 創建一個hack module,其中創建一個空白java類 AntilazyLoad。編譯它,得到 AntilazyLoad.class 然后用dx命令,將它打包成hack.dex


具體的命令為:dx --dex --output=hack.dex ./com/zhou/hack/Antilazyload.class

dx命令的位置為下圖所示,注意加入到系統環境變量path

  1. 使用gradle插樁的方式,干涉gradle打包流程,在生成javac命令之后,在dx命令之前,在所有我們編寫的所有class里面的構造函數內部,加上 AntilazyLoad 的直接使用(反射引用是不行的)。
    這一句話的信息量有點大,分步解釋:
  • gradle插樁
    類比為 用 gson,fastjson這類第三方框架來修改json文件。我們也可以利用 特定的手段來自由修改class文件。這類技術框架有ASM,AspectJ, Javassist等。 由于我們androidStudiogradle來構建項目,所以,還需要我們自定義gradle插件,來在合適的時機 使用ASM 這種技術框架來在class文件中修改字節碼內容。
  • javac命令之后dx命令之前
    gradle執行項目構建,是通過一個一個的task來進行。比如 將java文件用javac命令編譯為 class,任務名字叫做::app:compileDebugJavaWithJavac
    我們進行插樁的時機,便是上圖中javac之后,dx之前。
    另外,任何一個Task,都有input元素和output元素,以及可以設置doFirst閉包,表示執行任務之前先執行一段邏輯,設置doLast,表示執行任務執行之后再執行一段邏輯。

Demo完全解讀

上面的解決方案,只是大略提及方案思路,真實去執行方案的時候會涉及到非常多的小細節,我認為有必要將細節中比較重要的部分逐一分步詳解。

項目結構

hack module

image.png

這個Module的作用,僅僅是生成一個普通的java類的class文件,然后用class 通過dx命令生成hack.dex(名字隨意,只不過約定俗稱用的hack)文件而已。沒有別的。得到 hack.dex之后,它的使命就完成了。生成dex的方法上文已詳述。

buildSrc module

image.png

這個Module只是一個普通的javaModule,但是,它是androidStudio中比較特殊的一個名字,當你在空白項目中創建一個buildSrc目錄之后,執行同步,as就會為你自動生成如圖所示的module結構。因為,這個名字是gradle插件特有的。
image.png

HotfixPlugin.java 作為gradle插件的核心類,其關鍵代碼如下:

project.afterEvaluate(new Action<Project>() {
            @Override
            public void execute(Project project) {
                //找到額外屬性
                final HotfixExt hotfixExt = project.getExtensions().findByType(HotfixExt.class);
                // 找到系統屬性
                AppExtension appExtension = project.getExtensions().findByType(AppExtension.class);
                DomainObjectSet<ApplicationVariant> applicationVariants = appExtension.getApplicationVariants();
                for (ApplicationVariant var : applicationVariants) {
                    final String variantName = var.getName();//debug  release 因為任務的名字是release/debug有關,我們要找到確切的切入點,就必須拿到這個值
                    final String myTaskName = "transformClassesWithDexBuilderFor" + firstCharUpperCase(variantName);
                    final Task task = project.getTasks().findByName(myTaskName);
                    task.doFirst(new Action<Task>() {
                        @Override
                        public void execute(Task task) {
                            System.out.println("\n\n\n=================task.doFirst=================\n\n\n");
                            Set<File> files = task.getInputs().getFiles().getFiles();
                            for (File file : files) {
                                String filePath = file.getAbsolutePath();
                                if (filePath.endsWith(".jar")) {
                                    processJar(file);
                                } else if (filePath.endsWith(".class")) {
                                    processClass(variantName, file); //對于class的處理完畢
                                }
                            }
                            System.out.println("\n\n\n=================task.doFirst   end=================\n\n\n");
                        }
                    });
                }
                System.out.println("=================end=================");
            }
        });
    }
  • AppExtension appExtension = project.getExtensions().findByType(AppExtension.class);

AppExtension 是gradle在編譯項目的時候讀取來自android app的配置.

兩張圖看明白:
appModule的build.gradle

appModule的build.gradle 中我們寫的這些配置,在AppExtension中可以一一中找到get方法。

  • DomainObjectSet<ApplicationVariant> applicationVariants = appExtension.getApplicationVariants();

這個所謂的 ApplicationVariant(翻譯:app變體) 是android打包的中一個很常見的概念,它就是 debug/release . 由于我們去進行debug 或者 release打包的時候,幾乎所有的gradle命令會附帶上 debug/release .


所以,下一步我們對指定的task進行修改,要拿到這個值。

  • 我們的思路是 在java變成class之后在class變成 dex之前,將class進行ASM插樁。所以,我們要找的 gradle task 是 : transformClassesWithDexBuilderForRelease 或者 transformClassesWithDexBuilderForDebug 給它重寫doFirst。
    也可以 找到 gradle taskcompileReleaseJavaWithJavac 或者 compileDebugJavaWithJavac. 給它重寫 doLast。效果相同。

所以:
image.png
  • 開始重寫doFirst,所有task都有input輸入和output輸出。我們這里獲取它的輸入getInputs(). 然后進行文件遍歷。發現,既有jar文件也有class文件。jar文件是class的壓縮包。要進行插裝,必須分別處理。class文件直接插樁。jar文件解壓縮之后插樁。
image.png
  • class文件的插樁。

注:有些class,不需要熱修復,也就不需要插樁,比如android support包,或者androidx兼容包。比如MyApplication類。

private void processClass(String variantName, File file) {
        String path = file.getAbsolutePath();//拿到完整路徑,如下:
        // D:\studydemo\hotfix\HotUpdateDemo\app\build\intermediates\classes\debug\com\example\administrator\myapplication\MainActivity.class
        // 這么一大串,包括三個部分,以debug為分界。
        // D:\studydemo\hotfix\HotUpdateDemo\app\build\intermediates\classes\ 是目錄
        // debug\ 是編譯變體名
        // com\example\administrator\myapplication\MainActivity.class 類完整路徑
        //將他進行分割
        String className = path.split(variantName)[1].substring(1);
//        System.out.println("className:" + className);//拿到完整類名 com\example\administrator\myapplication\MainActivity.class
        // 由于有些class我們不用執行插樁,包括Application,也包括 androidx和support包
        if (isAndroidClz(className) || isApplicationClz(className)) {
            return;
        }
        // 能走到這里的,都是需要插樁的,那么,在這個任務執行時,我需要:
        // 使用文件流
        try {
            FileInputStream fis = new FileInputStream(path);
            byte[] byteCode = referHackWhenInit(fis);
            fis.close();

            FileOutputStream fos = new FileOutputStream(path);
            fos.write(byteCode);
            fos.close();

            //成功給class加了一行代碼
            System.out.println("className:" + className + "植入hack成功");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
  • jar文件的插樁:
private void processJar(File file) {
        try {
            // 先預備一個備份文件
            File bakJar = new File(file.getParent(), file.getName() + ".bak");
            JarOutputStream jos = new JarOutputStream(new FileOutputStream(bakJar));
            JarFile jarFile = new JarFile(file);
            Enumeration<JarEntry> entries = jarFile.entries(); // 準備遍歷
            while (entries.hasMoreElements()) {
                JarEntry jarEntry = entries.nextElement(); // 迭代器遍歷

                jos.putNextEntry(new JarEntry(jarEntry.getName()));
                InputStream is = jarFile.getInputStream(jarEntry);

                String className = jarEntry.getName();
                if (className.endsWith(".class") && !isApplicationClz(className)
                        && !isAndroidClz(className)) {
                    byte[] byteCode = referHackWhenInit(is);
                    jos.write(byteCode);
                } else {
                    //輸出到臨時文件
                    jos.write(IOUtils.toByteArray(is));
                }
                jos.closeEntry();
            }
            jos.close();
            jarFile.close();
            file.delete();
            bakJar.renameTo(file);
            //成功給class加了一行代碼
            System.out.println("jarName:" + file.getAbsolutePath() + "植入hack成功");
        } catch (Exception e) {

        }
    }
  • ASM插樁寫法:

以下代碼,將這樣一句代碼插入到了構造函數中Class var10000 = Antilazyload.class;

插樁之后,java類的構造函數如圖所示:
image.png
.

app module

相比于之前的app module,差別并不大,如圖:

差別1: 多出來的hack.dex是用dx命令前面生成的。必須放到assets中。
差別2:之前沒考慮要插入多個dex的情況,所以,hook參數是File,現在要插入fix.dex和hack.dex,參數改為 List<File> .

經本人多次驗證。
Demo地址:https://github.com/18598925736/HotUpdateDemo/tree/4.4_crash_solution
可在4.4版本模擬器上 正常進行multidex熱修復.

結語

4.4的multidex CLASS_ISPREVERIFIED熱修復的問題已經解決. 然而到了7.0 又出了一個混合編譯的問題。下一篇詳解.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370