前言
上文Android熱修復主流方案盤點 中,提到了4種比較出名的熱修復方案,
- 騰訊Qzone超級補丁的
multidex
方案, - 騰訊Tinker的
dexdiff
方案, - 阿里
andFix
的純native
方法指針重定向方案(已廢棄,因為有了新的替代方案sophix
), - 美團的
robust
的instantRun
方案。
然而,在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.pngCLASS_ISPREVERIFIED
標記之后又執行了補丁修復,造成沖突。
解決方案
既然問題的根源在于 引用Util的A類被打上了
CLASS_ISPREVIRIFIED
標記,那么有沒有辦法讓這些類不被打上標記呢?
思考:
問:如何防止我們源代碼中所有的類被打上
CLASS_ISPPREVERIFIED
標記?
答:
理論上,一個android工程中所有的java類(除了Application之外)都有可能需要熱修復。如果讓這些類都去引用一個另一個dex文件之下的class,就能防止在dex解析的時候被打CLASS_ISPPREVERIFIED
標記。
但是這樣有一個弊端,就是CLASS_ISPPREVERIFIED
帶來的性能提升將會消失。但是既然出現bug,要解決,總要付出一點代價。代價且容后再說。
行動:
- 創建一個hack module,其中創建一個空白java類 AntilazyLoad。編譯它,得到 AntilazyLoad.class 然后用dx命令,將它打包成hack.dex
具體的命令為:dx --dex --output=hack.dex ./com/zhou/hack/Antilazyload.class
dx命令的位置為下圖所示,注意加入到系統環境變量path:
-
使用gradle插樁的方式,干涉gradle打包流程,在生成
javac命令之后
,在dx命令之前
,在所有我們編寫的所有class里面的構造函數內部,加上 AntilazyLoad 的直接使用(反射引用是不行的
)。
這一句話的信息量有點大,分步解釋:
gradle
插樁
類比為 用gson,fastjson
這類第三方框架來修改json
文件。我們也可以利用 特定的手段來自由修改class
文件。這類技術框架有ASM,AspectJ, Javassist
等。 由于我們androidStudio
用gradle
來構建項目,所以,還需要我們自定義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 task
:compileReleaseJavaWithJavac
或者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 又出了一個混合編譯的問題。下一篇詳解.