1.Dalvik下完整dex方案的新探索
1.1 冷啓動類加載修復
對於Android下的冷啓動類加載修復,最早的實現方案是QQ空間提出的dex插入方案。該方案的主要思想,就是把dex插入到ClassLoader索引路徑的最前面。這樣在加載一個類的時候,就會優先查找補丁中的類。後來微信的Tinker和手Q的QFix方案都基於該方案做了改進,而這類插入dex的方案,都會遇到一個嚴重的問題,那就是如果解決DVM下類的pre-verify問題。
如果一個類中直接引用到所有非系統類和該類在同一個dex中的話,這個類就會被打上 CLASS_ISPREVERIFIED
標識,具體判定代碼可見虛擬機中的 verifyAndOptiomizeClass()
函數。
我們先來看看騰訊的三大修複方案是如何解決這個問題的:
- QQ空間
在每個類中插入一個來自其它dex的hack.class,此讓所有類都無法滿足pre-verified條件 - Tinker
合成全量dex文件,這樣所有類在全量dex中解決,從而消除類重複帶來的衝突 - QFix
獲取虛擬機的某些底層函數,提前解析所有補丁類。以此繞過 pre-verify檢查
以上三種方法裏,QQ空間方案會侵入到打包流程,並且爲hack添加了一些臃腫的代碼。
QFix需要獲取底層虛擬機的函數,不夠穩定可靠。並且和空間方案一樣,有個大問題是無法新增public函數,後文會詳細講解。
現在看來比較好的方式,就是像Tinker那樣全量合成完整的新dex。Tinker的合成方案是從dex的方法和指令維度進行全量合成,雖然可以大大節省空間,但是由於對dex內容的比較粒度過細 ,實現較爲複雜,因此性能消耗比較嚴重。
實際上,dex的大小佔整個APK的比例是比較低的,而佔空間大的主要還是APK的資源文件。因此,Tinker方案的時空代價轉換的性價比不高。
其實,dex比較的最佳粒度,應該是在類的維度,它既不像方法和指令維度那樣細微,也不想bsbiff比較那般粗糙。在類的維度,可以達到時間和控件平衡的最佳效果。
1.2 一種新的全量Dex方案
一般來說,合成完整dex,思路就是把原有的dex和補丁包裏的dex重新合併成一個。
然而Sophix的思路是反過來的。
可以這樣考慮,既然補丁中已經有變動的類了,那麼只要在原先基線包的dex中,去掉補丁中也有的類。這樣,補丁+去除補丁類的基線包,不就等於新App中的所有類了嗎。
參照Android原生multi-dex
的實現再來看這個方案,會有很好的理解。multi-dex是把一個APK裏用到的所有類拆分到 classes.dex
、classes2.dex
、classes3.dex
等之中,而每個dex都只包含了部分的類定義,但單個dex也是可以加載的,因爲只要把所有的dex都加載進去,本dex中不存在的類就可以在運行期間在其他的dex中找到。
同理,基線包在dex在去掉了補丁中的類後,原有需要發生變更的類就被消除了,基線包dex裏就包含不變的類了。而這些不變的類在用到補丁中的新類時會自動地找到補丁dex,補丁包中的新類在需要用到不變的類時也會只找到基線包dex的類。
這樣基線包裏面不使用補丁類的類仍舊可以按照原來的邏輯做odex,最大程度地保證了dexopt的效果。
這麼一來,我們就不需要像傳統合成思路那樣去判斷類的增加和修改情況,也不需要處理合成時方法數超了的情況,對於dex的結構也不用進行破壞性重構。
現在,合成完整dex的問題就簡化爲如果在基線包dex裏面去掉補丁包中包含的所有類,接下來我們看一下dex中去除指定類的具體實現。
首先,來看下dex文件中header的結構:
struct DexHeader {
u1 magic[8]; /* 版本標識 */
u4 checksum; /* adler32 檢驗 */
u1 signature[kSHA1DigestLen]; /* SHA-1 哈希值 */
u4 fileSize; /* 整個文件大小 */
u4 headerSize; /* DexHeader 大小 */
u4 endianTag; /* 字節序標記 */
u4 linkSize; /* 鏈接段大小 */
u4 linkOff; /* 鏈接段偏移 */
u4 mapOff; /* DexMapList 的文件偏移 */
u4 stringIdsSize; /* DexStringId 的個數 */
u4 stringIdsOff; /* DexStringId 的文件偏移 */
u4 typeIdsSize; /* DexTypeId 的個數 */
u4 typeIdsOff; /* DexTypeId 的文件偏移 */
u4 protoIdsSize; /* DexProtoId 的個數 */
u4 protoIdsOff; /* DexProtoId 的文件偏移 */
u4 fieldIdsSize; /* DexFieldId 的個數 */
u4 fieldIdsOff; /* DexFieldId 的文件偏移 */
u4 methodIdsSize; /* DexMethodId 的個數 */
u4 methodIdsOff; /* DexMethodId 的文件偏移 */
u4 classDefsSize; /* DexClassDef 的個數 */
u4 classDefsOff; /* DexClassDef 的文件偏移 */
u4 dataSize; /* 數據段的大小 */
u4 dataOff; /* 數據段的文件偏移 */
};
DexHeader 是dex文件的頭部,用來描述整個dex文件中每個屬性在dex結構中的具體位置,從這些描述信息,我們能看出dex具備很多個屬性。如下所示:
數據名稱 | 解釋 |
---|---|
header | dex文件頭部,記錄整個dex文件的相關屬性 |
string_ids | 字符串數據索引,記錄了每個字符串在數據區的偏移量 |
type_ids | 類似數據索引,記錄了每個類型的字符串索引 |
proto_ids | 原型數據索引,記錄了方法聲明的字符串,返回類型字符串,參數列表 |
field_ids | 字段數據索引,記錄了所屬類,類型以及方法名 |
method_ids | 類方法索引,記錄方法所屬類名,方法聲明以及方法名等信息 |
class_defs | 類定義數據索引,記錄指定類各類信息,包括接口,超類,類數據偏移量 |
data | 數據區,保存了各個類的真實數據 |
link_data | 靜態鏈接文件中使用的數據。 |
這裏我們打算去除dex中的類,因此我們最關心的自然是這裏的 class_defs屬性。
需要注意的是,並不是要把某個類的所有信息都從dex移除,因爲如果這麼做,可能會導致dex的各個部分都發生變化,從而需要大量調整offset,這樣就會變得費時費力了,我們要做的,僅僅是使得在解析這個dex的時候找不到這個類的定義就可以了。
因此,只需要移除定義的入口,對於類的具體內容不進行刪除,這樣可以最大限度減少offset
的修改。
我們來看虛擬機在dexopt的時候是如果找到某個dex中所有的類定義的,它是在 verifyAndOptimizeClasses()
中,注意,和之前的 verifyAndOptimizeClass()
是兩個方法,它也是在安裝Apk時調用的:
// dalvik/vm/analysis/DexPrepare.cpp
static void verifyAndOptimizeClasses(DexFile* pDexFile, bool doVerify,
bool doOpt)
{
u4 count = pDexFile->pHeader->classDefsSize;
u4 idx;
for (idx = 0; idx < count; idx++) {
const DexClassDef* pClassDef;
const char* classDescriptor;
ClassObject* clazz;
pClassDef = dexGetClassDef(pDexFile, idx); // 1
classDescriptor = dexStringByTypeIdx(pDexFile, pClassDef->classIdx);
clazz = dvmLookupClass(classDescriptor, NULL, false);
if (clazz != NULL) {
verifyAndOptimizeClass(pDexFile, clazz, pClassDef, doVerify, doOpt); // 2
} else {
ALOGV("DexOpt: not optimizing unavailable class '%s'",
classDescriptor);
}
}
}
註釋1:返回了 pDexFile下第idx個類的定義。
註釋2:調用我們熟知的 verifyAndOptimizeClass
來對這個類進行類校驗和類優化。
我們來看下注釋1的 dexGetClassDef()
方法:
// dalvik/libdex/DexFile.h
DEX_INLINE const DexClassDef* dexGetClassDef(const DexFile* pDexFile, u4 idx) {
assert(idx < pDexFile->pHeader->classDefsSize);
return &pDexFile->pClassDefs[idx]; //返回 pClassDefs的第idx元素
}
而這裏的 pClassDefs是這麼來的呢?下面是dex的賦值:
// dalvik/libdex/DexFile.cpp
void dexFileSetupBasicPointers(DexFile* pDexFile, const u1* data) {
DexHeader *pHeader = (DexHeader*) data;
...
pDexFile->pClassDefs = (const DexClassDef*) (data + pHeader->classDefsOff);
...
}
由此可以看出,一個類的所DexClassDef,也就是類定義,是從 pHeader->classDefsOff
偏移處開始的,依次呈線性排列的,一個dex裏面一共有 pHeader->classDefsSize
個類定義。
因此,我們就可以直接找到pHead->classDefsOff
偏移處,遍歷所有的DexClassDef
,如果發現這個 DexClassDef的類名包含在補丁中,就把它移除,實現下圖所示的效果:
接下來,只要修改 pHeader->classDefsSize
,把dex中類的數目改爲去除補丁中的類之後的數目即可。
我們只是去除了類的定義,而對於類的方法實體以及其他dex信息不做移除,雖然這樣會把這個被移除類的無用信息殘留在dex文件中,但這些信息並不佔用太多空間。移除類操作的方法對dex的處理速度提升幫助是很大的。
1.3 對於Application的處理
由此,我們實現了完整的dex合成。但仍然有個問題,這個問題所有完整dex替換方案都會遇到,那就是對Application的處理。
總所周知,Application是整個App的入口,因此,在進入到替換的完整dex之前,一定會通過Application的代碼,然而Application必然是加載在原來的dex裏面的。只有在補丁加載後使用的類,會在新的完整dex裏面找到。
因此,在加載補丁後,如果Application類使用其他新dex裏的類,由於在不同的一個dex裏,如果Application被打上了pre-verified標識,這時就會拋出異常。
對此,我們解決辦法很簡單,既然被打上了pre-verified標識,那麼,清除它就是了。
類的標識位於 ClassObject
的 accessFlags
成員中,而 pre-verifiyed標識的定義是 CLASS_ISPREVERIFIED = (1 << 16)
,因此,我們只需要在JNI層清除掉它即可:
classObj->accessFlags &= ~CLASS_ISPREVERIFIED;
這樣,在 dvmResolveClass()
中找到新的dex裏的類後,由於 CLASS_ISPREVERIFIED
標識被清空,就不會判斷所在dex是否相同,從而成功避免拋出異常。
接下來,我們來比對目前市場上其他完整dex方案是怎麼做的。
(1)Tinker
Tinker的方案是在 AndroidManifest.xml
聲明中就要求開發者將自己的Application直接替換成 TinkerApplication
。而對於真正App的Application,要在初始化TinkerApplication時作爲參數傳入。這樣 TinkerApplication
會接管這個傳入的Application,在生命週期回調時通過反射的方式調用實際 Application的相關回調邏輯。這麼做確實很好地將入口Application和用戶代碼隔離開,不過需要改造原有的Application,如果對Application有更多擴展,接入成本也是比較高的。
(2)Amigo
Amigo的方案是在編譯過程中,用Amigo自定義的gradle插件將App的Application替換成了 Amigo自己的另一個Application,並且將原來的Application的name保存了起來,該修復的問題都修復完後再調用之前保存的Application的 attach(context)
。將它回調到loadedApk()
中,最後調用它的onCreate()
,執行原有Application的邏輯,這種方式只是在代碼層面開發者無感知,但其實在編譯期間偷偷幫用戶做了替換,有點掩耳盜鈴的意思,並且這種對系統做反射替換本身也是由一定的風險的。
相比之下,Sophix的Application處理方案既沒有侵入編譯過程,也不需要進行反射替換,所有的兼容操作都在運行期自動做好。接入過程及其順滑。
1.4 dvmOptReslveClass問題與對策
然而Sophix這種清除標識的方案並非一帆風順,在開發過程中發現,如果這個入口Application是沒有pre-verified,反而有更大的問題。
這個問題是,DVM如果發現某個類沒有 pre-verified,就會在初始化這個類的時候做verify操作,將會掃描這個類的所有代碼,在掃描過程中對這個類代碼使用到的類都要進行 dvmOptResolveClass()
操作。
這個 dvmOptResolveClass()
正是罪魁禍首,它會在解析的時候對使用到的類進行初始化,而這個邏輯是發生在Application類初始化的時候。此時補丁還沒有進行加載,所以就會提前加載到原始dex中的類。接下來當補丁類加載完畢後,當這些已經加載的類用到新dex中的類,並且又是 pre-verified時就會報錯。
這裏最大的問題是在於我們無法把補丁加載提前到 dvmOptResolveClass之前,因爲在一個App的生命週期裏,沒有可能到達比入口Application初始化更早的時期了。
而這個問題常見於多dex情形,當存在多dex時,無法保證Application用到的類和它處於同個dex中。如果只有一個dex,一般就不會有這個問題。
多dex情況下要想解決這個問題,有兩種辦法:
- 讓Application用到的所有非系統的類和Application位於同一個dex中,這就可以保證pre-verified標識被打上,避免進入
dvmOptResolveClass
,而在補丁加載完之後,我們再清楚pre-verified標識,使得接下來使用其他類也不會報錯 - 把Applicaiton裏面除了熱修復框架代碼以外的其他代碼都剝離開,單獨提出放到一個其他類裏面,這樣使得Application不會直接用到過多的非系統類,這樣,保證這個單獨拿出來的類和Application處於同一個dex的概率還是比較大的。如果想要更保險,Application可以採用反射方式方式訪問這個單獨得類,這樣就徹底把Application和其他類隔絕開了。
第一種方法實現較爲簡單,因爲Android官方multi-dex機制會自動將Application用到的類都打包到主dex中,所以只要把熱修復初始化放在 attachBaseContext()
的最前面,一般都沒有問題。
而第二種方法稍加繁瑣,是在代碼架構層面進行重新設計,不過可以一勞永逸的解決問題。
2. 入口類與初始化時機選擇
2.1 初始化時機
冷啓動完整修複方案,本質就是替換掉整個原有的dex文件。然而“完整替換”只是一種理想化的設想,實際上無法做到“完整的”。原因是熱修復的初始化本身也是一段代碼。必須調用到這段代碼,熱修復才能執行完成,因此調用到熱修復的類,肯定是使用者自己的類,這個類是無法被熱修復影響到的,並且它只存在於原始安裝包的 classes.dex
中。如果要使熱修復類之前使用的其他類最少,只能放在Application類入口中。
那麼,放在Activity類裏面是不是也可以呢?當然,如果你的App裏面沒有Application,放到Activity裏面似乎也沒有太大的問題,並且簡單測試好像也能正常工作。
但是,如果你的AndroidManifest中註冊了ContentProvider,事情就沒有那麼順利了。ContentProvider的onCreate方法優先調用於Activity的onCreate方法。這就使得我們可能還沒有完成熱修復替換,就先執行到了 ContentProvider中的業務邏輯代碼,導致某些類被提前引入。提前引入其他類的危害我們在之前的章節已經說明,這不僅會導致這些類無法修復,更可能引起 pre-verify異常,因此,只有把初始化放在Application類中,才能保證不會錯誤地提早引入類。
如果放在Application中,又有兩種選擇:放在onCreate()
中或者放在 attachBaseContext()
中。
放在 attachBaseContext()
中自然是沒有問題的,因爲他是Application中最早被執行的代碼,但需要注意的是,在attachBaseContext裏面有很多限制,此時App申請的權限還沒有授予完成,所以會遇到無法訪問網絡之類的問題,因此在attachBaseContext裏面可以執行初始化,但是不可以進行網絡請求下載新補丁。
那放在Application的onCreate中可以嗎?簡單測試似乎沒有什麼問題。然而,它和之前的Activity的onCreate方法一樣,執行時間會晚於ContentProvider的onCreate方法。
當然,如果你的AndroidManifest裏面沒有註冊過ContentProvider,並且能夠保證引入的第三方庫的AndoridManifest裏面也沒有註冊,放在onCreate裏面就沒有什麼問題。不過保險起見,爲了避免以後某天項目在無意中引入,還是放在attachBaseContext裏面最好。
2.2 防不勝防的細節錯誤
在進行初始化的時候,經常容易錯誤地提早引入其他類。
下面這段代碼是Sophix的熱修復初始化代碼,SophixManager
需要在設置各種屬性後調用 initialize()
方法進行初始化,就以這段代碼爲例:
public class SampleApplication extends Application {
LocalStorageUtil localStorageUtil = new LocalStorageUtil();
@Override
protected void attachBaseContext(Context base) {
CrashReport.initCrashReport(this);
SophixWrApper.init(this);
MultiDex.install(this);
localStorageUtil.init(this);
}
@Override
public void onCreate() {
super.onCreate();
SophixWrApper.query();
}
public LocalStorageUtil getLocalStorageUtil() {
return localStorageUtil;
}
static private class SophixWrApper {
static void init(Application context) {
final SophixManager instance = SophixManager.getInstance();
instance.setContext(context)
.setAppVersion(BuildConfig.VERSION_NAME)
.setPatchLoadStatusStub(new PatchLoadStatusListener() {
@Override
public void onLoad(final int mode,
final int code,
final String info,
final int handlePatchVersion) {
if (code == PatchStatus.CODE_LOAD_SUCCESS) {
MyLogger.d("", "Sophix load patch success");
} else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {
MyLogger.d("", "Sophix preload patch success");
}
}
});
instance.initialze();
}
static void query() {
SophixManager.getInstance().queryAndLoadNewPatch();
}
}
}
這段簡單的代碼裏面,包含了許多開發者都會出現的錯誤,這裏一一指出每個問題:
CreashReport.initCrashReport(this)
在Sophix熱修復初始化之前提早引入了,必然是不行的- 雖然初始化確實是在attachBaseContext裏面,但是包裝了一個SophixWrApper類,這會導致初始化之前提前引入類,因此Sophix的初始化不可以包裝在其他類中。
- 在
setAppVersion
的時候使用了BuildConfig類,這個BuildConfig類是Android編譯期間動態生成的,也屬於非系統類,如果在這裏使用就會有提前引入的問題,這裏建議用PackageManager來獲取版本號。 - LocalStorageUtil直接在聲明處賦值了它的示例,這個賦值其實是隱式發生在對象構造函數中的,這個時候甚至是更早與attachBaseContext的,因此也是不行的,需要在初始化之後才能進行賦值
- 在回調用中使用了MyLogger,在回調狀態的時候引入很可能熱修復還未初始化完畢,因此這裏需要換位系統類android.utils.log
- MultiDex.install(this)調用放在了熱修復初始化後,這樣做雖然沒有引入類的問題,但是可能會導致後面熱修復框架初始化的時候找不到其他不在主dex中的熱修復框架內部類,因此需要把它提前到熱修復初始化之前。而提早引入MultiDex類不會帶來問題,因爲在熱修復初始化之後,再也沒有調用到這個MultiDex類的地方。
- 最後,經常會有人一樓了 syper.attachBaseContext(base),如果缺少它,後面都無法正常運行。
現在來看一下修改後的代碼:
public class SampleApplication extends Application {
LocalStorageUtil localStorageUtil;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
initSophix(this);
CrashReport.initCrashReport(this);
initlocalStorageUtil();
}
@Override
public void onCreate() {
super.onCreate();
SophixManager.getInstance().queryAndLoadNewPatch();
}
private void initlocalStorageUtil(){
localStorageUtil = new LocalStorageUtil();
localStorageUtil.init(this);
}
public LocalStorageUtil getLocalStorageUtil() {
return localStorageUtil;
}
private void initSophix(Application context) {
String AppVersion = "0";
try {
AppVersion = this.getPackageManager()
.getPackageInfo(this.getPackageName(),0)
.versionName;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
final SophixManager instance = SophixManager.getInstance();
instance.setContext(context)
.setAppVersion(AppVersion)
.setAppKey(null)
.setEnableDebug(false)
.setEnableFullLog()
.setPatchLoadStatusStub(new PatchLoadStatusListener() {
@Override
public void onLoad(final int mode,
final int code,
final String info,
final int handlePatchVersion) {
if (code == PatchStatus.CODE_LOAD_SUCCESS) {
Log.d("", "Sophix load patch success");
} else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {
Log.d("", "Sophix preload patch success");
}
}
});
instance.initialze();
}
}
這樣就萬無一失了。這裏初始化放到獨立的initSophix是沒有關係的,因爲是入口Application自己的方法,所以不會新引入任何其他類。