AndroidMultidex熱修復CLASS_ISPREVERIFIED問題解決方案

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

  • 騰訊Qzone超級補丁的 multidex方案,

  • 騰訊Tinker的 dexdiff方案,

  • 阿里 andFix純native方法指針重定向方案(已廢棄,因爲有了新的替代方案 sophix ),

  • 美團的 robustinstantRun方案。

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

正文大綱

  • CLASS_ISPREVERIFIED兼容問題

  • AndroidN混合編譯兼容問題

正文

CLASS_ISPREVERIFIED 兼容問題

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

問題描述

一句話描述問題:

在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

大概意思就是 有一個類的引用預先校驗了,但是沒有找到預想中的實現。這就是由於 被打上了 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命令的位置爲下圖所示,注意加入到系統環境變量pathimage

2.使用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

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

buildSrc module

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

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=================");
        }
    });
  }


  • AppExtensionappExtension=project.getExtensions().findByType(AppExtension.class);

AppExtension 是gradle在編譯項目的時候讀取來自android app的配置. 兩張圖看明白:

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

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

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

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

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

image

  • 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插樁寫法

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

插樁之後, java類的構造函數如圖所示:

.app module

相比於之前的app module,差別並不大,如圖:

差別1: 多出來的hack.dex是用dx命令前面生成的。必須放到assets中。

差別2:之前沒考慮要插入多個dex的情況,所以,hook參數是File,現在要插入fix.dex和hack.dex,參數改爲 List .

經本人多次驗證。Demo地址:
https://github.com/18598925736/HotUpdateDemo/tree/4.4crashsolution

可在4.4版本模擬器上正常進行 multidex熱修復.

結語

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章