安卓TV插件化9.0內聯崩潰原因及解決方案
插件化技術本質上是深度挖掘安卓系統的私有能力,需要對各個版本的安卓系統進行適配。不但要保證低版本的低性能設備運行順暢,也要保證高版本的系統是可以使用插件化升級的。隨著 TV 端高版本系統的比例逐漸增大,對高版本系統的適配成為了插件化技術的主要挑戰。為支持低性能設備的首次安裝運行順暢,我們的插件化框架將插件加載功能與 TV 業務邏輯放在了同一個 dex 中,但這樣的架構在高版本系統上又會產生內聯崩潰的問題,在既要保證低端設備性能的同時,又要覆蓋到高版本設備的前提下,內聯崩潰是一個無法簡單繞過的一個問題。
本文從內聯崩潰的背景開始介紹,再結合 TV 端的插件化特點深入分析產生的原因,最后給出 TV 端的解決方案。
01背景
什么是內聯
內聯(inline)是一種編譯優化方法。編譯器自動地將函數體的代碼插入到這個函數的調用處,將函數調用展開為函數體的代碼。這種優化能消除函數調用的開銷,但會使指令數量膨脹。
下面用 Kotlin 語言中的內聯舉例:
Kotlin 的內聯使用 inline 關鍵字定義內聯,將內聯的選擇權完全交給了使用者,只要寫了 inline 關鍵字就一定會內聯。另外,Kotlin 的內聯觸發時機是編譯,在 Kotlin 編譯為字節碼的時候就會將 inline 函數調用展開,生成的字節碼不會調用內聯方法,而是直接將指令插入到調用處。
Kotlin 語言的內聯可以說是最簡單的一種內聯,而本文要分析的內聯崩潰的內聯是 ART 虛擬機在將字節碼編譯為機器指令時進行的內聯。這種內聯是完全自動的,由 ART 虛擬機自己決定,沒有語言層面的機制去觸發它,而且根據不同版本的 ART 虛擬機,內聯的規則和細節都可能不同。它的觸發時機是運行時,也就是發生在安卓設備運行應用時,而不是在開發期間。具體來說,是發生在 JIT 和 AOT 的過程中的。
內聯崩潰的場景
1、特點和條件內聯崩潰是一種 native 崩潰,有明確的 abort message,容易辨認,發生在以下場景:
- 崩潰發生時一定在運行 epg 插件(或者叫宿主插件),one 版本時不可能發生。
- 只有 epg 插件有這個問題。投屏、設置、體育等所有功能插件沒有這個問題。
- 只發生在 Android 9.0(P) 系統上。
觸發的概率較低,但在大量用戶的基礎上有一定量級,奇異果的各個版本都能在 APM 中搜到這種崩潰。在 APM 上一直存在,但沒有針對性地統計過具體在 9.0 系統的崩潰率。
這個內聯崩潰問題本地不容易復現,需要長時間運行 one 版本才有可能觸發內聯,進而在運行插件版時發生崩潰。
這個內聯崩潰是安卓插件化領域已知的問題,tinker 有公開分享的解決方案,但對于我們并不適用,后文會詳細說明。
2、崩潰現場Native 崩潰 abort message:
entrypoint_utils-inl.h:94] Inlined method resolution crossed dex file boundary: from void com.gala.video.plugincenter.download.downloader.DownloadManager$TaskHolder.progress(com.gala.video.module.plugincenter.bean.download.DownloadItem, long, long, long, b...
崩潰棧:
JIT、AOT、混合編譯
1、JIT
JIT(Just-In-Time),也稱為即時編譯。是一種運行時優化技術,虛擬機在運行時將字節碼編譯為機器碼,從而提高執行效率。在普通的 JVM 上就有 JIT 的存在,安卓的 Dalvik 與 ART 也都實現了 JIT。JIT 是運行在 app 的進程中的,有一個獨立的線程負責運行 JIT。
2、AOT
AOT(Ahead-Of-Time),也稱為預編譯。注意這個預編譯是運行在安卓設備中的,不是我們開發過程中的編譯。所謂預編譯,是指在安裝的時候進行一次全量編譯,將字節碼轉換為機器碼,從而提高運行速度。但是 AOT 由于進行了全量編譯,會生成很大的二進制文件,占用更多的空間,并且安裝過程比較慢,尤其是ROM升級后,所有 APP 都要重來一遍AOT,會陷入漫長的等待。
3、混合編譯
由于 AOT 有占用空間大和安裝時間長的缺點,從 Android N(7.0) 開始,ART 中引入了混合編譯的模式。混合編譯即將“解釋執行”、“JIT”與“AOT”這三種代碼執行模式按照一定的規則來切換,平衡運行效率、內存使用與CPU耗電。在應用安裝時不做 AOT 編譯,直接以解釋方式執行,達到快速啟動的效果。在應用運行時分析運行過的代碼以及“熱代碼”,進行 JIT 編譯。同時將哪些代碼屬于“熱代碼”的記錄存儲下來,等設備空閑與充電時,使用 AOT 編譯這份配置中的“熱代碼”。
02原因分析?
abort message
根據 abort message:
entrypoint_utils-inl.h:94] Inlined method resolution crossed dex file boundary…
?我們找到 entrypoint_utils-inl.h 文件的 94 行:
這段代碼和注釋的大概意思是,如果被內聯的函數與調用它的函數不在同一個 dex 中,在執行這個內聯函數的時候,就會主動觸發崩潰。
如果一個應用沒有使用任何插件技術,那么是不可能產生這個問題的。因為一個函數被內聯的前提條件之一就是調用它的函數與它在同一個 dex 中。這看似是一個矛盾,但因為有了插件化技術,就讓這個場景有可能出現了。
插件化
先從奇異果的插件化架構說起:
圖中 one 版本就是沒使用宿主插件的版本,用戶剛安裝好我們的 apk 或者剛升級 apk 后啟動就是這個版本。插件版本是下載并安裝了宿主插件,并且重啟后加載了宿主插件的版本。
我們只需考慮宿主插件,而不考慮其他的子插件,因為這些子插件始終是獨立的 dex,不可能與其他模塊的產生內聯的相互調用。
由于這個問題與 dex 文件有關,所以下面從 dex 的角度說明插件相關架構。
可以簡單地理解 one 版本只有一個dex 文件,而每一個插件都是一個額外的 dex 文件。宿主插件(也就是epg插件)也是額外的一個 dex 文件,這個 dex 文件就在我們每次部署插件時上傳的 apk 內。
從功能的角度理解,將插件升級后不會更新的部分稱為 host 部分,epg 插件的所有內容稱為 epg 部分。
在 one 版本運行時,epg 部分與 host 部分屬于同一個 dex,所以從 host 調用到 epg 的函數調用,是可能被內聯的。經過插件升級,epg 部分是新的 dex,host 還是原來的 dex,從 host 調用到 epg 的函數調用就跨越了兩個 dex。因此,在插件升級后可能會產生跨越 dex 的內聯調用,也就觸發了崩潰。
host 不會也不能直接調用 epg。host 會提供一些接口,接口的方法中有一些回調類型接口,epg 實現了回調接口,然后這些接口被 host 內部調用,就發生了 host 到 epg 的間接調用。
從模塊設計角度看,epg 屬于業務模塊,依賴一些屬于 host 部分的底層庫,可直接調用。Host 部分不依賴業務,也就是不依賴 epg,沒有直接調用,但有回調。出現內聯崩潰的調用都是從 host 回調到 epg 的方法。
回調示例
為什么主動崩潰
1、原因
因為跨 dex 內聯的調用一旦出現,就意味著被內聯的機器碼是“歷史”代碼,可能與“新”的另一個 dex 中的字節碼不匹配。相當于生成了錯誤的機器碼,與實際的字節碼對應不上。這就可能產生各種無法預測的錯誤。
當然,如果插件 dex 與 one 版本的 dex 內容一模一樣,理論上是不會有問題的。插件 dex 修改的內容越多,就越可能出現這個問題。從長期的代碼演變角度來說,出現問題是必然的,出現了多少問題是概率性的。
在 9.0 上系統主動崩潰是安卓系統的一種主動防御,用“自殺”來防止發生跨 dex 內聯可能導致的“無法預測”的程序行為。在早于 9.0 的版本上谷歌可能還沒有發現這種跨 dex 內聯的問題,然后在 10.0 的版本又放寬了限制,可能認識到了跨 dex 內聯是低概率事件,還是讓玩弄 dex 的玩家自己負責吧。
所以,9.0 inline 問題不只是在 9.0 上的顯性崩潰問題,在 7.0 以上除了 9.0 以外的其他版本上,仍然可能會有隱性的“無法預測”的程序行為,可能是 crash,也可能是邏輯上的異常。如果通過阻止內聯的方式解決了 9.0 inline 崩潰問題,也可以順帶解決跨 dex 內聯在所有系統版本上的潛在問題。
2、“無法預測”的錯誤
在內聯生成的機器碼中,并不一定將整個調用鏈上的所有字節碼都生成了機器碼。比如A.a() 調用 B.b(),B.b() 調用 C.c() 。一種可能的情況是:A.a() 內聯了 B.b(),但對 C.c() 的調用沒有內聯。
這種情況下,在 A.a() 中執行C.c() 的時候會從機器碼跳回虛擬機執行 ,然后就得走類的加載和方法的解析(resolve)的流程:找到 解析緩存(DexCache[]),并根據緩存中的偏移指針,找到這個類,進而找到方法去執行。注意需要兩個值才能找到類,一個是 DexCache 數組,另一個是這個數組中的類 C 的偏移指針(下標)。
這個 DexCache 數組是與dex 文件相關聯的,不同的 dex 文件 DexCache 數組也不一樣。在編譯生成的機器碼中,使用的是一個函數指針直接跳到了 native 版本的 FindClass,得到的結果是保證正確的。也就是說在運行插件版時能正確獲取到插件 dex 的 DexCache[]。
然而 DexCache 數組中的偏移量卻是“寫死”在機器碼中的立即數,也就是dex2oat 編譯后直接寫在指令中的。用舊 DexCache 數組中的偏移量,在新 DexCache 數組中查找 Class,最后得到的不一定是正確的 Class。在這個 native 的層次上,不會有 ClassCastException 拋出來阻止進一步發生錯誤,只會默默執行直到出現問題。比如調用方法對應不上導致崩潰、static 變量找不到返回 null 等等。
03解決方案
業界方案——Tinker
Tinker 最終采用的應對方案是去掉 ART 環境下的合成增量 dex 的邏輯,直接合成全量的 NewDex,這樣除了 loader 類,所有方法統一都用了 NewDex 里的,也就不怕有方法被內聯了。——摘自《ART 下的方法內聯策略及其對 Android 熱修復方案的影響分析 》
Tinker 的方案相當于找到了一個切面,將不可能互相調用的 loader 與其他 Class 切開,將所有類分成兩個集合。熱修復后,通過合成 dex 的方式,將“其他Class”部分完全替換掉,“其他Class” 的內部就沒有了內聯問題,并且 loader 部分與“其他 Class”部分是沒有相互調用的,也就不可能內聯。因此 Tinker 是通過整體架構的方式規劃 Class 避免內聯。
我們的方案
在不修改插件架構的前提下,無法使用 Tinker 的方案。沒有一個切面可以保證宿主和插件不互相調用,例如我們的下載模塊是放在插件中心內的。
既然無法通過 dex 分割的方式預防內聯,那我們只能選擇其他方式預防或阻止內聯。阻止內聯應該在 one 版本上阻止,因為是 one 版本上發生的內聯引發的問題,也就是說要處理的是 apk 升級的 apk,而不是插件升級包 apk。
內聯的目的是對程序的優化,而且也確實能提升應用的執行效率,我們沒必要,也不應該,也很難在整個應用范圍內全面地禁止內聯。上文說過我們的內聯方向是從 host 調用 epg,epg 實現了 host 提供的接口被 host 調用。在 APM 上,目前捕獲到了兩個調用的內聯引發的崩潰,這種調用的數量不會很大,我們是否能夠精確找到這些調用并加以處理呢?
1、精確定位內聯調用
我們可以簡單地手動檢查 host 調用epg 的代碼,但這種方式肯定是不完備的,因為我們能看到的代碼只是一小部分,還有隱藏在 jar 包等無法直接看到的部分。如果未來代碼發生了改變。即使只是重構,也有可能使原來不內聯的調用變成內聯的調用,因此手動查找和分析并不靠譜,必須有一種自動搜索方式來精確查找。
舉個例子,觸發內聯的條件之一就是被內聯的函數的字節碼的數量不能超過一個最大值,如果重構簡化了代碼可能字節碼數量就會減少,從不會被內聯變成可能被內聯。
在編譯過程中直接讀取字節碼,在字節碼中搜索可能發生內聯的調用語句,收集這些調用的信息,再根據這些信息通過某種方式修改字節碼,達到阻止內聯的方式。
我們的 xpluin 插件中已經實現了一些 ASM 腳手架來修改字節碼,可以直接選擇這些工具來讀取和改寫字節碼。
收集好所有的可能內聯的調用語句后,就可以考慮使用哪種方式來阻止內聯。
2、阻止內聯的方案以下條件必須都滿足才能觸發內聯:
- App 不是 Debug 版本的;
- 被調用的方法所在的類與調用者所在的類位于同一個 Dex;(注意,符合 Class N 命名規則的多個 Dex 要看成同一個 Dex)
- 被調用的方法的字節碼條數不超過 dex2oat 通過--inline-max-code-units指定的值,6.x 默認為 100,7.x 默認為 32;
- 被調用的方法不含 try 塊;
- 被調用的方法不含非法字節碼;
- 對于7.x版本,被調用方法還不能包含對接口方法的調用。(invoke-interface指令)
——摘自《ART 下的方法內聯策略及其對 Android 熱修復方案的影響分析》
雖然這些條件只要打破一個就可以阻止內聯,但實際上可以利用的點并不多。比如,我們不可能將線上包改為 debug 版本;我們也不可能插入一些非法字節碼;我們更不可能增加字節碼數量。對我們可能有用的只有 2 和 4 了,針對 "2. dex 拆分" 和 "4. try塊",有兩種方案如下:
ClassLoader 拆分方案
這個方案是利用了這樣一個隱藏規則,即使是同一個 dex,由不同的 ClassLoader 加載的類,也不算同一個 dex 內。
由于 ClassLoader 的雙親委派模型規則,一個 Class 直接引用的其他 Class,直接走 native 層的查找路徑,不走 Java ClassLoader。因此不受約束的 Class 引用結構,使用多個 ClassLoader 會產生雙向引用結構,徒增 ClassLoader 的復雜度。
考慮到自定義 ClassLoader 在 host 范圍內,如果出問題,host 代碼無法通過插件升級修復,風險較大,因此最終并沒有采用這個方案。
插入 try-catch 方案
ClassLoader 方案是以類為最小單位處理函數內聯,而插入 try 塊的是針對函數的。在收集好調用信息之后,就非常簡單了,在 epg 中找到需要插入 try 塊的函數,在函數中插入一個 try 塊即可。
搜索"可能內聯的調用"編譯時算法
使用 AOP 的方式,在編譯期 gradle Transform 階段找到所有的需要修改的方法,通過 ASM 修改字節碼,在這些要修改的方法中插入一條 try 語句。這個問題的特點:輸入規模比較大,總共有 2W+ 個類。但解的數量很少,約 100 以內(12.4 版本處理了 85 個)。
算法簡介:
插入的字節碼
為什么要插入這么復雜的一條語句呢?直接插入空的 try-catch 語句不行么?
不行,在由 java 字節碼轉換為dex 字節碼的階段,d8 會進行一些優化操作。如果 try {} 中是空的,那么不會生成任何代碼;如果是無意義的代碼,比如只定義了一個局部變量 int a = 0; 也會被優化刪除掉。因此只能插入一些簡單的有副作用的代碼。
04結論?
本文介紹了一種適合 TV 端的解決插件化內聯崩潰的方法:通過查找可能的內聯調用點,在編譯期自動插入字節碼阻止內聯的觸發的方式來預防內聯崩潰問題,適用于TV 端插件化框架不分割業務 dex 的特點。目前該方法通過若干版本的驗證已解決內聯崩潰問題。?