Android熱修複升級探索——Dalvik下冷啟動修複的新探索
冷啟動類加載修複
對於Android下的冷啟動類加載修複,最早的實現方案是QQ空間提出的dex插入方案。該方案的主要思想,就是把插入新dex插入到ClassLoader索引路徑的最前麵。這樣在load一個class時,就會優先找到補丁中的。後來微信的Tinker和手Q的QFix都基於該方案做了改進,而這類插入dex的方案,都會遇到一個主要的問題,就是如何解決Dalvik虛擬機下類的pre-verify問題。
如果一個方法中直接引用到的類和該方法所屬類都在同一個dex中的話,那麼這個方法的所屬類就會被打上CLASS_ISPREVERIFIED
,具體判定代碼可見虛擬機中的verifyAndOptimizeClass
函數。
我們先來看看騰訊的三大熱修複方案是如何解決這個問題的:
QQ空間的處理方式,是在每個類中插入一個來自其他dex的hack.class,由此讓所有類裏麵都無法滿足pre-verified條件。
Tinker的方式,是合成全量的dex文件,這樣所有class的都在全量dex中解決,從而消除class重複而帶來的衝突。
QFix的方式,是取得虛擬機中的某些底層函數,提前resolve所有補丁類。以此繞過Pre-verify檢查。
以上的三種方案裏麵,QQ空間方案會侵入打包流程,並且為了hack添加一些臃腫的代碼,實現起來很不優雅。而我們一開始采用的QFix的方案,需要獲取底層虛擬機的函數,不夠穩定可靠。並且,和空間方案一樣,有個比較大的問題是無法新增public函數,具體原因後續還將有文章進行詳解。
現在看來比較好的方式,就是像Tinker那樣全量合成完整新dex。他們的合成方案,是從dex的方法和指令維度進行全量合成,雖然可以很大地節省空間,但由於對dex內容的比較**粒度過細**,實現較為複雜,性能消耗比較嚴重。實際上,dex的大小占整個apk的比例是比較低的,而占空間大的主要還是apk中的資源文件。因此,Tinker方案的時空代價轉換的性價比不高。
其實,dex比較的最佳粒度,應該是在類的維度。它既不像方法和指令維度那樣的細微,也不像bsbiff比較那般的粗糙。在類的維度,可以達到時間和空間平衡的最佳效果。基於這個準則,我們實現了一種完全不同的全量dex替換方案。這套方案目前已經集成進阿裏非侵入式熱修複方案Sophix中。
一種新的全量Dex方案
一般來說,合成完整dex,思路就是把原來的dex和patch裏的dex重新合並成一個。
然而我們的思路是反過來的。
我們可以這樣考慮,既然補丁中已經有變動的類了,那隻要在原先基線包裏的dex裏麵,去掉補丁中也有的class。這樣,補丁+去除了補丁類的基線包,不就等於了新app中的所有類了嗎?
參照Android原生multi-dex的實現再來看這個方案,會很好理解。multi-dex是把一個apk裏用到的所有類拆分到classes.dex
、classes2.dex
、classes3.dex
、...之中,而每個dex都隻包含了部分的類的定義,但單個dex也是可以加載的,因為隻要把所有dex都load進去,本dex中不存在的類就可以在運行期間在其他的dex中找到。
因此同理,在基線包dex裏麵在去掉了補丁中class後,原先需要發生變更的舊的class就被消除了,基線包dex裏就隻包含不變的class。而這些不變的class要用到補丁中的新class時會自動地找到補丁dex,補丁包中的新class在需要用到不變的class時也會找到基線包dex的class。這樣的話,基線包裏麵不使用補丁類的class仍舊可以按原來的邏輯做odex,最大地保證了dexopt的效果。
這麼一來,我們不再需要像傳統合成的思路那樣判斷類的增加和修改情況,而且也不需要處理合成時方法數超過的情況,對於dex的結構也不用進行破壞性重構。
現在,合成完整dex的問題就簡化為了——如何在基線包dex裏麵去掉補丁包中包含的所有類。接下來我們看一下在dex中去除指定類的具體實現。
首先,來看dex文件中header的結構:
/*
* Direct-mapped "header_item" struct.
*/
struct DexHeader {
uint8_t magic[8]; /* includes version number */
uint32_t checksum; /* adler32 checksum */
uint8_t signature[kSHA1DigestLen]; /* SHA-1 hash */
uint32_t fileSize; /* length of entire file */
uint32_t headerSize; /* offset to start of next section */
uint32_t endianTag;
uint32_t linkSize;
uint32_t linkOff;
uint32_t mapOff;
uint32_t stringIdsSize;
uint32_t stringIdsOff;
uint32_t typeIdsSize;
uint32_t typeIdsOff;
uint32_t protoIdsSize;
uint32_t protoIdsOff;
uint32_t fieldIdsSize;
uint32_t fieldIdsOff;
uint32_t methodIdsSize;
uint32_t methodIdsOff;
uint32_t classDefsSize;
uint32_t classDefsOff;
uint32_t dataSize;
uint32_t dataOff;
};
由dex header就可以取得dex的各個重要屬性,這些屬性在文件中的分布如下所示:
Name | Format | Description |
---|---|---|
header | header_item | the header |
string_ids | string_id_item[] | string identifiers list. These are identifiers for all the strings used by this file, either for internal naming (e.g., type descriptors) or as constant objects referred to by code. This list must be sorted by string contents, using UTF-16 code point values (not in a locale-sensitive manner), and it must not contain any duplicate entries. |
type_ids | type_id_item[] | type identifiers list. These are identifiers for all types (classes,
arrays, or primitive types) referred to by this file, whether defined
in the file or not. This list must be sorted by string_id
index, and it must not contain any duplicate entries.
|
proto_ids | proto_id_item[] | method prototype identifiers list. These are identifiers for all
prototypes referred to by this file. This list must be sorted in
return-type (by type_id index) major order, and then
by argument list (lexicographic ordering, individual arguments
ordered by type_id index). The list must not
contain any duplicate entries.
|
field_ids | field_id_item[] | field identifiers list. These are identifiers for all fields
referred to by this file, whether defined in the file or not. This
list must be sorted, where the defining type (by type_id
index) is the major order, field name (by string_id index)
is the intermediate order, and type (by type_id index)
is the minor order. The list must not contain any duplicate entries.
|
method_ids | method_id_item[] | method identifiers list. These are identifiers for all methods
referred to by this file, whether defined in the file or not. This
list must be sorted, where the defining type (by type_id
index) is the major order, method name (by string_id
index) is the intermediate order, and method prototype (by
proto_id index) is the minor order. The list must not
contain any duplicate entries.
|
class_defs | class_def_item[] | class definitions list. The classes must be ordered such that a given class's superclass and implemented interfaces appear in the list earlier than the referring class. Furthermore, it is invalid for a definition for the same-named class to appear more than once in the list. |
call_site_ids | call_site_id_item[] | call site identifiers list. These are identifiers for all call sites
referred to by this file, whether defined in the file or not. This list
must be sorted in ascending order of call_site_off . This
list must not contain any duplicate entries.
|
method_handles | method_handle_item[] | method handles list. A list of all method handles referred to by this file, whether defined in the file or not. This list is not sorted and may contain duplicates which will logically correspond to different method handle instances. |
data | ubyte[] | data area, containing all the support data for the tables listed above. Different items have different alignment requirements, and padding bytes are inserted before each item if necessary to achieve proper alignment. |
link_data | ubyte[] | data used in statically linked files. The format of the data in this section is left unspecified by this document. This section is empty in unlinked files, and runtime implementations may use it as they see fit. |
這裏我們是打算去除dex裏的Class,因此我們最關心的自然是這裏麵的class_defs。
需要注意的是,我們並不是要把某個Class的所有信息都從dex移除,因為如果這麼做,可能會導致dex的各個部分都發生變化,從而需要大量調整offset,這樣就變得就費時費力了。我們要做的,僅僅是讓在解析這個dex的時候找不到這個Class的定義就行了。**因此,隻需要移除定義的入口,對於Class的具體內容不進行刪除,這樣可以最大可能地減少offset的修改。**
我們來看虛擬機在dexopt的時候是如何找到某個dex的所有類定義的。
@android-4.4.4_r2/dalvik/vm/analysis/DexPrepare.cpp
/*
* Verify and/or optimize all classes that were successfully loaded from
* this DEX file.
*/
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);
classDescriptor = dexStringByTypeIdx(pDexFile, pClassDef->classIdx);
/* all classes are loaded into the bootstrap class loader */
clazz = dvmLookupClass(classDescriptor, NULL, false);
if (clazz != NULL) {
verifyAndOptimizeClass(pDexFile, clazz, pClassDef, doVerify, doOpt);
} else {
// TODO: log when in verbose mode
ALOGV("DexOpt: not optimizing unavailable class '%s'",
classDescriptor);
}
}
}
正是dexGetClassDef
函數返回了類的定義。
@android-4.4.4_r2/dalvik/libdex/DexFile.h
/* return the ClassDef with the specified index */
DEX_INLINE const DexClassDef* dexGetClassDef(const DexFile* pDexFile, u4 idx) {
assert(idx < pDexFile->pHeader->classDefsSize);
return &pDexFile->pClassDefs[idx];
}
而這裏pClassDefs是怎麼來的呢?
/*
* Set up the basic raw data pointers of a DexFile. This function isn't
* meant for general use.
*/
void dexFileSetupBasicPointers(DexFile* pDexFile, const u1* data) {
DexHeader *pHeader = (DexHeader*) data;
... ...
pDexFile->pClassDefs = (const DexClassDef*) (data + pHeader->classDefsOff);
... ...
}
由此可以看出,一個類的所有DexClassDef,也就是類定義,是從pHeader->classDefsOff
偏移處開始,一個接一個地線性排列著的,一個dex裏麵一共有pHeader->classDefsSiz
個類定義。
由此,我們就可以直接找到pHeader->classDefsOff
偏移處,一個個地遍曆所有的DexClassDef
,如果發現這個DexClassDef
的類名包含在我們的補丁中,就把它移除,實現效果如下:
接著,隻要修改pHeader->classDefsSiz
,把dex中類的數目改為去除補丁中類之後的數目即可。
我們隻是去除了類的定義,而對於類的方法實體以及其他dex信息不做移除,雖然這樣會把這個被移除類的無用信息殘留在dex文件中,但這些信息占不了太多空間,並且對dex的處理速度是提升很大的,這種移除類操作的方式就變得十分輕快。
對於Application的處理
由此,我們實現了完整的dex合成。但仍然有個問題,這個問題所有完整dex替換方案都會遇到,那就是對於Application的處理。
眾所周知,Application是整個app的入口,因此,在進入到替換的完整dex之前,一定會通過Application的代碼,因此,Application必然是加載在原來的老dex裏麵的。隻有在補丁加載後使用的類,會在新的完整dex裏麵找到。
因此,在加載補丁後,如果Application類使用其他在新dex裏的類,由於不在同一個dex裏,如果Application被打上了pre-verified標誌,這時就會拋出異常:
FATAL EXCEPTION: main
Process: com.taobao.worktest, PID: 2481
java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
at com.taobao.test.MyApplication.test(MyApplication.java:59)
at com.taobao.test.MyApplication.onCreate(MyApplication.java:39)
at android.app.Instrumentation.callApplicationOnCreate(Instrumentation.java:1007)
at android.app.ActivityThread.handleBindApplication(ActivityThread.java:4328)
at android.app.ActivityThread.access$1500(ActivityThread.java:135)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1256)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:136)
at android.app.ActivityThread.main(ActivityThread.java:5001)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:515)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:785)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:601)
at dalvik.system.NativeStart.main(Native Method)
對此,我們的解法很簡單,既然被設上了pre-verified標誌,那麼,清除掉它就是了。
類的標誌,位於ClassObject
的accessFlags
成員。
struct ClassObject : Object {
/* leave space for instance data; we could access fields directly if we
freeze the definition of java/lang/Class */
u4 instanceData[CLASS_FIELD_SLOTS];
/* UTF-8 descriptor for the class; from constant pool, or on heap
if generated ("[C") */
const char* descriptor;
char* descriptorAlloc;
/* access flags; low 16 bits are defined by VM spec */
u4 accessFlags;
/* VM-unique class serial number, nonzero, set very early */
u4 serialNumber;
/* DexFile from which we came; needed to resolve constant pool entries */
/* (will be NULL for VM-generated, e.g. arrays and primitive classes) */
DvmDex* pDvmDex;
/* state of class initialization */
ClassStatus status;
... ...
}
因此,我們隻需要在jni層清除掉它即可
clazzObj->accessFlags &= ~CLASS_ISPREVERIFIED;
這樣,在dvmResolveClass
找到了新dex裏的類後,由於CLASS_ISPREVERIFIED標誌被清空,就不會判斷所在dex是否相同,從而成功避免拋出異常。
ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,
bool fromUnverifiedConstant) {
... ...
// 條件不滿足,不進行check
if (!fromUnverifiedConstant &&
IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED))
{
ClassObject* resClassCheck = resClass;
if (dvmIsArrayClass(resClassCheck))
resClassCheck = resClassCheck->elementClass;
// 判斷是否在同一dex中
if (referrer->pDvmDex != resClassCheck->pDvmDex &&
resClassCheck->classLoader != NULL)
{
ALOGW("Class resolved by unexpected DEX:"
" %s(%p):%p ref [%s] %s(%p):%p",
referrer->descriptor, referrer->classLoader,
referrer->pDvmDex,
resClass->descriptor, resClassCheck->descriptor,
resClassCheck->classLoader, resClassCheck->pDvmDex);
ALOGW("(%s had used a different %s during pre-verification)",
referrer->descriptor, resClass->descriptor);
dvmThrowIllegalAccessError(
"Class ref in pre-verified class resolved to unexpected "
"implementation");
return NULL;
}
}
... ...
接下來,我們來對比一下目前市麵上其他完整dex方案是怎麼做的。
Tinker的方案,是在AndroidManifest.xml
聲明中就要求開發者將自己的Application直接換成TinkerApplication
。而對於真正app的Application,要在初始化TinkerApplication
時作為參數傳入。這樣TinkerApplication
會接管這個傳入的Application,在生命周期回調時通過反射的方式調用實際Application的相關回調邏輯。這麼做確實很好地將入口Application和用戶代碼隔離開了,不過需要改造原先存在的Application,如果對Application有更多擴展,接入成本也是比較高的。
Amigo的方案,是在編譯過程中,用Amigo自定義的gradle插件將app的Application替換成了Amigo自己的另一個Application,並且將原來的Application的name保存起來,該修複的都修複完了的時候再調用之前保存的的Application 的attach(context)
,然後將它設回到loadedApk中,最後調用它的onCreate()
,執行原有Application中的邏輯。這種方式隻是開發者的代碼層麵無感知,但其實是在編譯期間偷偷幫用戶做了替換,有點掩耳盜鈴的意味,並且這種對係統做反射替換本身也是有一定風險的。
相比之下,我們的Application處理方案既沒有侵入編譯過程,也不需要進行反射替換,所有的兼容操作都在運行期間都自動做好。接入過程極其順滑。
dvmOptResolveClass問題與對策
然而我們這種清除標誌的方案並非一帆風順,開發過程中我們發現,如果這個入口Application是**沒有pre-verified**的,反而有更大的問題。
這個問題是,Dalvik虛擬機如果發現某個類沒有pre-verified,就會在初始化這個類時做Verify操作,這將掃描這個類的所有代碼,在掃描過程中**對這個類代碼裏使用到的類**都要進行dvmOptResolveClass
操作。
而這個dvmOptResolveClass
正是罪魁禍首,它會在Resolve的時候對使用到的類進行初始化,而這個邏輯是發生在Application類初始化的時候。此時補丁還沒進行加載,所以就會提前加載到原始dex中的類。接下來當補丁加載完畢後,這些已經加載的類如果用到了新dex中的類,並且又是pre-verified時就會報錯。
這裏最大的問題在於,我們無法把補丁加載提前到dvmOptResolveClass
之前,因為在一個app的生命周期裏,沒有可能到達比入口Application初始化更早的時期了。
而這個問題常見於多dex情形,當存在多dex時,無法保證Application的用到的類和它處於同個dex中。如果隻有一個dex,一般就不會有這個問題。
多dex情況下要想解決這個問題,有兩種辦法:
- 第一種辦法,讓Application用到的所有非係統類都和Application位於同一個dex裏,這就可以保證pre-verified標誌被打上,避免進入
dvmOptResolveClass
,而在補丁加載完之後,我們再清除pre-verified標誌,使得接下來使用其他類也不會報錯。 - 第二種辦法,把Application裏麵除了熱修複框架代碼以外的其他代碼都剝離開,單獨提出放到一個其他類裏麵,這樣使得Application不會直接用到過多非係統類,這樣,保證這個單獨拿出來的類和Application處於同一個dex的幾率還是比較大的。如果想要更保險,Application可以采用反射方式訪問這個單獨類,這樣就徹底把Application和其他類隔絕開了。
第一種方法實現較為簡單,因為Android官方multi-dex機製會自動將Application用到的類都打包到主dex中,因此隻要把熱修複初始化放在attachBaseContext的最前麵,大多都沒問題。而第二種方法稍加繁瑣,是在代碼架構層麵進行重新設計,不過可以一勞永逸地解決問題。
總結
總體而言,這套新實現方案更簡潔優雅地實現了Dalvik虛擬機下的完整dex的替換,大大降低了集成成本。現在就可以點擊閱讀原文進行體驗,如果你在4.4以下版本確實遇到了本文所說的崩潰,不要驚慌,仔細按照前麵提到的解決方法,調整一下你的Application就好啦。
原創文章,轉載請注明出處。手淘公眾號文章鏈接:https://mp.weixin.qq.com/s/-WC8EoyRXjXPBUrxozywVg
最後更新:2017-06-20 20:58:35