熱修復Class流派和Dex流派實現原理

語言: CN / TW / HK

theme: condensed-night-purple

Class流派原理

基本原理:載入類的時候是找element,每個element對於一個dex。我要把我修復的那個類單獨放到dex插入dexlist前面,在你做類載入從前往後找優先從你的dex載入載入的就是你修復後的class.這就是

實現程式碼

  1. 通過context拿到pathClassLoader,根據你下發的dex生成一個dexclassloader。

  2. 拿到兩個的pathlist,在拿到兩個pathlist的element,然後把生成的dexclassloader的element放到pathclassloader的element前面。然後把合併後的element賦值給pathclassloader的element

Davlik虛擬機器上遇到的問題

unexpectDex崩潰

davlik虛擬機器上會丟擲unexpectDex崩潰()

業務情況:A引用了待修復的B類(下發的類)

丟擲unexpectDex崩潰同時滿足的三個條件

丟擲這個崩潰需要同時滿足三個條件:

image.png

  1. 補丁類不是通過靜態類或者instance of的方式被引用
  2. 引用下發補丁的類在dexopt階段verify成功,引用類被打上了CLASS_ISPERVRIFYIED的標誌
  3. 這兩個類不在一個dex上

在你app載入被引用類的時候(A引用B,也就是載入B類的時候)會做這樣一個校驗,如果你同時滿足這三個條件就會崩潰

由於補丁類是單獨的放在一個dex中所以第三個條件沒法變。只能從1和2入手

應用安裝的時候需要一個dexopt階段,會對你的dex進行優化成odex後續執行載入的odex才能執行

dexopt階段的過程

檢查靜態方法,私有方法,建構函式,虛方法所呼叫的類是否根當前類在同一個dex中(A在呼叫上面方法時呼叫的BCDE類是否和A類在同一個dex上)

在同一個dex上,虛擬機器就會對A類做一些優化並打上CLASS_ISPREVERIFIED標誌

比如A引用B。並且A和B在一個dex裡的時候A類會打上CLASS_ISPERVRIFYIED標誌

何時丟擲異常

在之後載入A類(dexopt階段標記的類)的時候虛擬機器會檢查Verfiy標記的結果進行反向做verfiy的校驗

當校驗的時候同時滿足上面三個條件的話就不通過丟擲unexceptDex異常,只有校驗通過才會吧類載入上來

QZone插樁組織preverify方案

這個方案肯定不滿足第三個條件,所以只能從第一個或第二個條件下手

QZone從第二個條件入手通過插妝阻止preverify

解決思路:當上面那些特殊方法(建構函式,靜態函式...)呼叫的是同一個dex上的類會被標誌,那麼我跨dex訪問就不會打上標誌。最簡單的就是在建構函式裡面進行訪問跨dex即可,這樣不在同一個dex就不會打標誌

實現:

建立一個空的類放到一個獨立的dex上

在所有類的建構函式裡面都去訪問那個獨立dex裡面空的類,所有的類都存在一個跨dex的訪問,所以整個app裡面的所有類都不會被打傷標誌

但是獨立的dex需要先被載入進來,因為APP的PathClassLoader找不到這個類。利用雙親委派模型機制(載入類的時候先從緩衝中找)先把這個空類載入進來後續就可以訪問到這個類了。


缺點:

影響了odex的校驗和優化過程存在一個性能的問題

降低APP啟動效能,執行記憶體增加

Qfix提前constclass引用方案

從丟擲的第一個條件入手

針對靜態類呼叫和instanceof這兩種方式以外的方式會拋異常

如果我以靜態類來呼叫補丁類的話即使存在跨dex呼叫被打傷標誌也不會丟擲異常,同時classloader載入類的時候只要載入過會優先從快取裡面讀利用這個機制。

davlik虛擬機器載入類的過程:

先會從dex的快取裡面找如果有就直接返回不會有後續的校驗和載入過程,後面載入和校驗完成後也會放到dex的快取裡面

實現思路

APP啟動的時候把補丁類放進來以後,提前以靜態方式引用補丁類,這個引用不會拋異常(靜態類引用方式)同時會讓這個補丁類提前載入到虛擬機器的快取中,後面的訪問即使是非靜態的即使有標誌衝突的也不需要進行校驗了。可以直接返回後續從緩衝中讀到這個類

實現程式碼:

  1. 在application最開始的地方用靜態類方式載入補丁類,但是我們並不知道要修復哪個類,所以不可能在application裡面把所有的類都載入一遍(哈哈哈,不科學)
  2. QFix通過nativehook直接呼叫虛擬機器載入類的native方法,APP打包的時候儲存了各個類的dexId和classId。在執行的時候找到補丁類所在的dexid和classid在jni側主動呼叫虛擬機器解析class的方法(設定formUnverifedConstant引數為true代表這次的呼叫是以constantof或者是以instanceof的方式呼叫進來,這個為true就不會做preverify的校驗),這一次呼叫你的補丁就在快取裡面了,後續使用就直接從快取裡面找就可以,也不需要進行校驗了

因為呼叫的是虛擬機器的native方法載入類,所以在不同虛擬機器上有較多的適配,同時會有穩定性的問題。分享文件裡面說出來在在X86上有問題

Art虛擬機器上遇到的問題

不僅僅是下面級聯優化的問題,還有其他問題在dex流派上在標註

Art虛擬機器上由於方法內聯會帶來更大的問題,不管是哪個虛擬機器在安裝階都有個dex優化的過程

不同安卓版本有不同的odex編譯器,早期編譯器用的是QuickCompile後面用的比較多的是OptimizingCompare

不同的編譯器進行方法內聯時有不同的方法條件,並且Optiminzing有級聯優化操作(method1呼叫method2裡面呼叫method3裡面呼叫method4)如果這些呼叫的方法都滿足虛擬機器的內聯條件。

最終編譯後的method1裡面直接包含了method2method3method4的程式碼(方法

2包含3和4的程式碼,3包含4的程式碼),內聯的意思是把程式碼直接寫進來而不是通過方法id進行呼叫

問題

假如ClassA正好要引用你的補丁類,而補丁類之前在虛擬機器優化的時候滿足內聯條件,那麼老的方法已經被寫到引用類裡面了。這時候在下發新的class修復的時候可以正常載入class,但是方法的呼叫並沒有呼叫到你的新類class上來,因為你的實現已經被寫到引用類裡面了。就會存在問題

由於內聯,執行流程並未跳轉到新的方法裡面,引用類裡面的方法是用的老的方法。對於引用類來說用的還是老方法中區域性變量表存放的內容 所以查詢成員字串都是用的舊方法的索引。但是新的補丁類索引是可能發生變化的引用類訪問的時候就會出現crash出錯的問題。

解決方法

由於級聯優化的存在因此把你要修復的類,你的子類,呼叫你的類都必須整個放到patch裡面,下發整個patch,所以整個patch會很大

Dex流派熱修復原理

class是干擾的系統api較為底層所以存在適配和相容性問題。

後來tinker走上了dex存量熱修復的路徑

原理:進行全量dex的替換,但是不可能吧整個dex下發,所以下發的是dex的diff。

新老dex的diff在服務端生成,通過diff演算法:

Sigma用的是比較常見的BsDiff

tinker做的比較深入依據dex結構發明了一個dexdiff演算法,讓你diff差異包更小,合成效率更高

步驟

  1. 服務端生成了新老dex的diff之後就會生成差異包。差異包會被你的patch程序請求到和本地依據安裝的dex進行merge成新的dex也就是通過patch還原成新的dex
  2. 通過新dex創建出一個新的dexclassloader,把這個新dexclassloader設定成App的pathclassloader的parent。根據雙親委派模型你載入的就是新的dexclassloader,也就是修復後的類

修復為什麼要在獨立程序做?

  1. 即使業務程序無線崩潰,patch程序也能修復你的問題
  2. 業務程序可能在做迭代,做合併可能會出現crash
  3. 獨立程序中做的話不依賴於主程序啟動,其他業務程序的啟動也可以吧patch程序拉起來進行統一的修復

注意點

parch程序中PathCore合併核心程式碼中的一些操作是和Application一起由PathClassLoader載入的,如果你的pathcore呼叫了你的業務邏輯沒有做解耦的話, 那麼這個時候path會載入你的舊業務的類(由pathclassloader載入),由於雙親委派模型後續這些舊業務的類是從pathClassLoader快取拿的而不是從你patch程序做完合併後的dexclassloader拿的就會出現問題導致呼叫類和載入類不一致,所以需要進行和業務解耦。

就是如果在生成新的dex替換pathclassloader的parent之前訪問了之前的類,那麼是由pathClassLoader載入的,就會導致載入的類是舊的dex。而因為有快取,一直是拿的pathClassLoader載入的類而不是合併後修復完成的dexClassLoader的類

基本的共性問題

dex的熱修復有一些基本典型的問題需要解決:

  1. patch的入口和patch的核心業務需要和業務進行隔離
  2. patch合併需要放到獨立程序做
  3. 每次打包的mapping會變化:如果不對混淆進行干預,每次打包的混淆規則是會變化的,所以會導致哪怕是很小的改動也會導致兩個包的dex差異非常大所以需要對混淆的mapping進行儲存,在打新包的時候apply這個mapping就會保持混淆一致不會導致差異
  4. 每次打包的分包結果會變化:如果APP大的話,會存在跨dex訪問(針對這種多dex情況哪怕你沒有修改也會導致他的分包結果不一樣)。所以在打基準包的時候也要把他的分包結果儲存下來(打新包時按照這個結果進行分包)
  5. patch程序做完patch合併之後,主程序利用patch的時候會立馬黑屏或者anr。虛擬機器是不會直接訪問dex的有個dexopt階段(應用安裝時候做的,動態載入dex時也會做這個階段)。dexopt是由系統觸發的。所以會黑屏就是因為你的主程序直接用得動態載入的dex觸發了dexopt導致黑屏。所以在patch程序合併完新的dex之後應該立刻去觸發dexopt.

如何觸發dexopt

直接手動new一個dexclassloader,然後虛擬機器就會做全量的dexopt在獨立程序中(雖然dexopt過程放到了獨立的patch程序做,但是還是會存在部分anr,後面問題在列出)

Art dex2oat對熱修復影響

dex2oat是對dex進行編譯的一個程序。在art虛擬機器上你的dex是需要編譯成機器碼以後才能被虛擬機器載入和執行的

dex2oat編譯模式

編譯過程有十幾種模式,比較關心的只有三種:

  1. interpret only:該模式在first boot或者install的時候(第一次啟動或者安裝)進行。只會做verify,程式碼還是解釋執行,不做機器碼的編譯操作。效能是和davlik虛擬機器保持一致
  2. speed:該模式在new DexClassLoader的時候觸發。會做全量的機器碼編譯
  3. speed profile:該模式在系統做oat升級的時候或者混合編譯(有一個background的dexopt在系統idle的時候會喚醒做dexopt)的時候,他只編譯你app對應的profile裡儲存的熱程式碼,只編譯這部分熱程式碼。

全量編譯機器碼:art虛擬機器為了提高效能,會對程式碼做全量機器碼編譯。這個過程會在ClassLoader載入類的時候發現傳入進來的opt路徑上不存在odex檔案的時候就會自動觸發。因為是第一次newclassloader之前沒有做過編譯也就沒有odex檔案所以就會做全量編譯

解決方案演進

  • 所以如果主程序啟動直接做全量編譯直接掛
  • 如果在patch進行全量編譯,由於dex2oat過程非常長在部分機型達到幾分鐘好的機型上也得等二三十秒而且非常佔資源就有可能你的整個apt過程沒做完來不及。比如使用者總是點開新聞看幾秒就殺掉導致你一直做不完優化,修復就一直用不上可能會拖慢主程序導致ARN
  • tinker的方案:所以patch程序先進行輕量編譯,如果做完了就用,做不完的話應用老的先讓使用者能用,並且避免全量編譯(你都用的是老的了,就沒必要做過多的全量編譯了可能會導致佔用資源過多業務程序也卡)。如果patch做輕量量編譯可以用就用,不能用避免全量編譯先讓使用者跑起來(如何避免全量編譯稍後介紹)

輕量編譯也有一定耗時,導致首次啟動慢。而且你輕量編譯之後你的獨立程序也是無限制的在做全量編譯可能搶資源導致主程序拖滿然後ANR(概率較小tinker準備忽略,因為APP效能足夠好)

  • App在前臺執行也有可能會導致patch程序搶佔資源導致anr。所以在這個基礎上面進一步優化:patch程序拉倒patch後先進行輕量編譯主程序優先用輕量編譯後的patch。找合適的時機做全量編譯(合適時機:我的APP退倒後臺,其他APP在前臺的時候/鎖屏)系統做background dexopt也是系統不用的時候去做
避免全量編譯

有三種方案:

  1. Atlas方案:在Native側修改Art虛擬機器的執行模式,直接用DexFile底層介面載入Dex檔案(影響同進程下的dex載入並且DexFile在O版本以上被廢棄)存在可用性和相容問題
  2. Tbs方案:發現如果在new DexClassLoader的時候optDir傳入為null的時候會置空oat_location就不會對你做全量編譯(8.0上系統會忽略你傳入的這個路徑)
  3. Tinker方案:dexopt就是執行虛擬機器的一個命令列,所以在你係統觸發全量編譯之前手動去呼叫dex2oat命令執行編譯方式intercept-only只做清涼的編譯。先用你輕量編譯達到的結果首次啟動或者首次安裝完以後的執行效果和虛擬機器一樣的效果讓他先跑起來也是出現問題之後的優化方案

Android N混合編譯對熱修復影響

混合編譯:AOT,解釋,JIT三種模式並存。

使用者真正使用到的類可能只有很少部分,我們為什麼要為了百分之二三十的程式碼去做全量編譯呢?沒有必要

N之前的Art虛擬機器上安裝是做的全量編譯,所以安裝的時候會等很久,做Jit及時編譯又會很慢

在N上解決了這個問題通過混合編譯縮短安裝時間,系統OAT升級更快: 安裝和首次啟動用intervept-only的方式沒有編譯(和davlik虛擬機器一樣的效果),對哪些程式碼做編譯,什麼時候做編譯呢:來看N上的增量編譯過程:

Android N虛擬機器增量編譯過程

虛擬機器會在APP程式碼執行過程中收集執行到的程式碼放到profile檔案上,系統會通過jobSchedule啟動BackgroundDexOptService。這個Service會在滅屏/充電的狀態下啟動。晚上睡覺或者其他手機空閒的情況時就會啟動任務把收集到的程式碼給編譯好(這些熱程式碼是經常跑的所以會快)。後面啟動的時候就會很塊,通過這種方式給APP做增量的編譯。編譯完之後會生成base.odex和base。art(稱之為App的image)

虛擬機器認為這是熱程式碼所以在你APP啟動的時候就提前幫你吧這部分程式碼載入起來。在ClassLoader建立ClassLinker的時候一次性載入到dexcache上

所以就是你剛啟動Application裡面什麼都還沒做就已經載入了一些類(以前編譯好的熱程式碼)

Art混合編譯對熱修復的三種情況影響分析

  • 要修復的類不在appimage中: Dex流派採用的是雙親委派預期的是通過parent去載入如果你要修復的類正好不在appimage裡面也就是沒有被提前載入那麼這個機制就沒錯補丁可以生效
  • 要修復的類有一部分在appimage中: 如果你有一部分在appimage裡面。就導致一部分用的新的,一部分用的老的。這樣訪問就會出現地址錯亂出現crash
  • 要修復的類已經在appimage中:如果你全部都在appimage裡面,你修復的這些正好之前都被收集了,那麼你這個patch是不會生效的

解決方案

在N以上的裝置拋棄設定parent的模式,做全量的直接替換吊我們的pathclassloader而不是設定他的parent

實現步驟

  1. 建立補丁dex的DexClassLoader
  2. 通過contextimpl拿到loadkedApk在拿到持有的PathClassLoader物件。就是系統幫我們建立的pathClassloader
  3. 通過反射替換這個屬性為補丁的classloader

原理:因為系統的appimage提前載入是載入到系統的pathClassloader快取上的。而我們後續執行的是用我們替換的classloader,所以這個新的classloader上沒有了appimage的存在了

影響:由於沒有了appimage的存在所以效能上會有犧牲但是是能達到修復的目的,統計下來影響是非常小的

本文正在參加「金石計劃 . 瓜分6萬現金大獎」