天天看點

Android熱修複更新探索——Dalvik下冷啟動修複的新探索

對于android下的冷啟動類加載修複,最早的實作方案是qq空間提出的dex插入方案。該方案的主要思想,就是把插入新dex插入到classloader索引路徑的最前面。這樣在load一個class時,就會優先找到更新檔中的。後來微信的tinker和手q的qfix都基于該方案做了改進,而這類插入dex的方案,都會遇到一個主要的問題,就是如何解決dalvik虛拟機下類的pre-verify問題。

如果一個方法中直接引用到的類和該方法所屬類都在同一個dex中的話,那麼這個方法的所屬類就會被打上<code>class_ispreverified</code>,具體判定代碼可見虛拟機中的<code>verifyandoptimizeclass</code>函數。

我們先來看看騰訊的三大熱修複方案是如何解決這個問題的:

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,思路就是把原來的dex和patch裡的dex重新合并成一個。

然而我們的思路是反過來的。

我們可以這樣考慮,既然更新檔中已經有變動的類了,那隻要在原先基線包裡的dex裡面,去掉更新檔中也有的class。這樣,更新檔+去除了更新檔類的基線包,不就等于了新app中的所有類了嗎?

參照android原生multi-dex的實作再來看這個方案,會很好了解。multi-dex是把一個apk裡用到的所有類拆分到<code>classes.dex</code>、<code>classes2.dex</code>、<code>classes3.dex</code>、...之中,而每個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的結構:

由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 <code>string_id</code>

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 <code>type_id</code> index) major order, and then

by argument list (lexicographic ordering, individual arguments

ordered by <code>type_id</code> 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 <code>type_id</code>

index) is the major order, field name (by <code>string_id</code> index)

is the intermediate order, and type (by <code>type_id</code> 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

index) is the major order, method name (by <code>string_id</code>

index) is the intermediate order, and method prototype (by

<code>proto_id</code> index) is the minor order. the list must not

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 <code>call_site_off</code>. 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

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的所有類定義的。

正是<code>dexgetclassdef</code>函數傳回了類的定義。

而這裡pclassdefs是怎麼來的呢?

由此可以看出,一個類的所有dexclassdef,也就是類定義,是從<code>pheader-&gt;classdefsoff</code>偏移處開始,一個接一個地線性排列着的,一個dex裡面一共有<code>pheader-&gt;classdefssiz</code>個類定義。

由此,我們就可以直接找到<code>pheader-&gt;classdefsoff</code>偏移處,一個個地周遊所有的<code>dexclassdef</code>,如果發現這個<code>dexclassdef</code>的類名包含在我們的更新檔中,就把它移除,實作效果如下:

Android熱修複更新探索——Dalvik下冷啟動修複的新探索

接着,隻要修改<code>pheader-&gt;classdefssiz</code>,把dex中類的數目改為去除更新檔中類之後的數目即可。

我們隻是去除了類的定義,而對于類的方法實體以及其他dex資訊不做移除,雖然這樣會把這個被移除類的無用資訊殘留在dex檔案中,但這些資訊占不了太多空間,并且對dex的處理速度是提升很大的,這種移除類操作的方式就變得十分輕快。

由此,我們實作了完整的dex合成。但仍然有個問題,這個問題所有完整dex替換方案都會遇到,那就是對于application的處理。

衆所周知,application是整個app的入口,是以,在進入到替換的完整dex之前,一定會通過application的代碼,是以,application必然是加載在原來的老dex裡面的。隻有在更新檔加載後使用的類,會在新的完整dex裡面找到。

是以,在加載更新檔後,如果application類使用其他在新dex裡的類,由于不在同一個dex裡,如果application被打上了pre-verified标志,這時就會抛出異常:

對此,我們的解法很簡單,既然被設上了pre-verified标志,那麼,清除掉它就是了。

類的标志,位于<code>classobject</code>的<code>accessflags</code>成員。

是以,我們隻需要在jni層清除掉它即可

這樣,在<code>dvmresolveclass</code>找到了新dex裡的類後,由于class_ispreverified标志被清空,就不會判斷所在dex是否相同,進而成功避免抛出異常。

接下來,我們來對比一下目前市面上其他完整dex方案是怎麼做的。

tinker的方案,是在<code>androidmanifest.xml</code>聲明中就要求開發者将自己的application直接換成<code>tinkerapplication</code>。而對于真正app的application,要在初始化<code>tinkerapplication</code>時作為參數傳入。這樣<code>tinkerapplication</code>會接管這個傳入的application,在生命周期回調時通過反射的方式調用實際application的相關回調邏輯。這麼做确實很好地将入口application和使用者代碼隔離開了,不過需要改造原先存在的application,如果對application有更多擴充,接入成本也是比較高的。

amigo的方案,是在編譯過程中,用amigo自定義的gradle插件将app的application替換成了amigo自己的另一個application,并且将原來的application的name儲存起來,該修複的都修複完了的時候再調用之前儲存的的application 的<code>attach(context)</code>,然後将它設回到loadedapk中,最後調用它的<code>oncreate()</code>,執行原有application中的邏輯。這種方式隻是開發者的代碼層面無感覺,但其實是在編譯期間偷偷幫使用者做了替換,有點掩耳盜鈴的意味,并且這種對系統做反射替換本身也是有一定風險的。

相比之下,我們的application處理方案既沒有侵入編譯過程,也不需要進行反射替換,所有的相容操作都在運作期間都自動做好。接入過程極其順滑。

然而我們這種清除标志的方案并非一帆風順,開發過程中我們發現,如果這個入口application是沒有pre-verified的,反而有更大的問題。

這個問題是,dalvik虛拟機如果發現某個類沒有pre-verified,就會在初始化這個類時做verify操作,這将掃描這個類的所有代碼,在掃描過程中對這個類代碼裡使用到的類都要進行<code>dvmoptresolveclass</code>操作。

而這個<code>dvmoptresolveclass</code>正是罪魁禍首,它會在resolve的時候對使用到的類進行初始化,而這個邏輯是發生在application類初始化的時候。此時更新檔還沒進行加載,是以就會提前加載到原始dex中的類。接下來當更新檔加載完畢後,這些已經加載的類如果用到了新dex中的類,并且又是pre-verified時就會報錯。

這裡最大的問題在于,我們無法把更新檔加載提前到<code>dvmoptresolveclass</code>之前,因為在一個app的生命周期裡,沒有可能到達比入口application初始化更早的時期了。

而這個問題常見于多dex情形,當存在多dex時,無法保證application的用到的類和它處于同個dex中。如果隻有一個dex,一般就不會有這個問題。

多dex情況下要想解決這個問題,有兩種辦法:

第一種辦法,讓application用到的所有非系統類都和application位于同一個dex裡,這就可以保證pre-verified标志被打上,避免進入<code>dvmoptresolveclass</code>,而在更新檔加載完之後,我們再清除pre-verified标志,使得接下來使用其他類也不會報錯。

第二種辦法,把application裡面除了熱修複架構代碼以外的其他代碼都剝離開,單獨提出放到一個其他類裡面,這樣使得application不會直接用到過多非系統類,這樣,保證這個單獨拿出來的類和application處于同一個dex的幾率還是比較大的。如果想要更保險,application可以采用反射方式通路這個單獨類,這樣就徹底把application和其他類隔絕開了。

第一種方法實作較為簡單,因為android官方multi-dex機制會自動将application用到的類都打包到主dex中,是以隻要把熱修複初始化放在attachbasecontext的最前面,大多都沒問題。而第二種方法稍加繁瑣,是在代碼架構層面進行重新設計,不過可以一勞永逸地解決問題。

繼續閱讀