由于公司的業務不斷拓展,生產環境的 APK 大小也從我最初進入公司時的 70M 變為了160MB ,在分析了 APK 結構目錄之后,常規的壓縮方案已經收效甚微了,動態加載第三方的 SO 文件是下一個優化的重點。SO 文件本質上就是一種可動態加載并執行的文件,所以將 SO 動態下發沒有技術風險,但是要將它從 APK 中剔除并保證穩定性并不是一件易事。
??從0到1需要解決那些問題?
對于從 0 到 1 開發一套方案我們先把相關技術點先提出來,再帶著問題去看看這些方案的解決思路這樣開發起來時最高效的。對于動態下載 SO 庫我們對它的基本期望是 APK 中不包含 SO 文件,這樣就引申出了問題點:
- 如何移除 APK 中的 SO 文件?
同時,我們希望能夠兼容第三方 SDK 這樣就出現了
- 如何保證第三方 SDK 的 SO 不存在時的正常運行?
另外我們還希望 SO 版本發生變化了也能夠不需要人工維護
- 如何維護 SO 文件的正確性
只要解決了以上的幾個問題,大致的動態下發框架就搭建完成了。
?如何移除 APK 中的 SO 文件?
看到移除 SO 文件可能有些同學會說“啊這不是很簡單么,只要把 libs 目錄刪掉就好了呀“,但是如果這樣做的話我們就木有辦法剔除 AAR 當中的 SO 文件,還有 SO 文件變化需要人工維護等問題,所以出于各種考慮編譯時期動態剔除 SO 文件都是最優解。
在最新的編譯流程圖中,我們可以看到 Android Gradle Plugin 對資源文件進行了 Compiled Resouces 操作,同時在平常在編譯的過程中在 Android Studio 的 Build 面板會輸出很多 Task 的日志其中不乏有資源相關的字眼:
由此,我們可以大膽假設一下,Android Gradle Plugin
在打包的工程中是否有專門的 Task 是處理資源相關的邏輯?如果有了這個 Task 我們是不是就能過在這之前 or 之后進行 SO 文件剔除呢?
在查閱官方的文檔資料后,終于找到了倆處專門用于處理 SO 文件的 Task,確實正如我們所想,在編譯的過程中Android Gradle Plugin
會整合不同目錄的 SO 文件最終匯總至一起:
Task | 對應實現類 | 作用 | 結果保存目錄 |
---|---|---|---|
mergeDebugNativeLibs | MergeNativeLibsTask | 合并所有依賴的 native 庫 | intermediates/merged_native_libs |
stripDebugDebugSymbols | StripDebugSymbolsTask | 從 Native 庫中移除 Debug 符號。 | intermediates/stripped_native_libs |
對于我們來說只要刪除對應目錄中的 SO 文件,最終打出來的 APK 中就不會包含該文件。
按最優解來說,應該在stripSymbols
結束后去剔除 stripped_native_libs 目錄下的文件,但是擔心不同版本的Android Gradle Plugin
會對這一步做不同的操作,所以決定選用mergeNativeLibs
結束后去剔除原始的 so 來保證文件的 MD5 不發生變化,另外因為第三方 SO 一般都是 Release 編譯出來的,就算進行了stripDebugDebugSymbols
也不會有太大效果。
同時,為了能夠讓 APK 運行之后能夠獲取到 SO 文件,我們需要將被剔除的 SO 文件上傳至遠端,以供后面獲取使用。
?保證 SO 不存在時的穩定性
常常用第三方 SDK 的同學肯定知道,很多第三方的 SDK 要求應用啟動時就完成初始化,它們內部往往一初始化就調用了System.loadLibrary()
方法進行 SO 的加載,如果這時候 SO 被我們剔除了那么系統就會出現UnsatisfiedLinkError
的閃退,雖然有少部分 SDK 例如 MMKV 有提供初始化的回調供我們修改加載方法,但是這畢竟是少數情況,還是要想辦法修改第三方 SDK 的加載方式。
由于我們無法直接修改第三方 SDK 的源碼,這時候我們就只能依靠動態字節碼
也就是所謂的 AOP 在編譯時期對第三方 SDK 進行修改了。常見的方式有很多例如AspectJ
、Javassist
、ASM
等等,但是它們在使用上或多或少都有點麻煩,本著不(圖)重(省)復(事)造輪子的原則,直接上 GitHub 上找了個基于Javassist
封裝的工具DroidAssist
,利用它我們可以很輕易的就替換掉第三方 SDK 中的加載代碼,配置如下:
<Replace>
<!-- 替換系統的 so 加載 -->
<MethodCall>
<Source>
void System.loadLibrary(java.lang.String)
</Source>
<Target>
<!-- 安全加載的方法,找不到 SO 文件不會閃退 -->
hb.dynamic.NativeLibraryStore.getInstance().securityLoadNativeLibrary($1);
</Target>
<Filter>
<!-- 針對的第三方 SDK -->
<Include>com.meitu.mtlab.*</Include>
<Include>org.webrtc.*</Include>
<Include>com.zego.*</Include>
<Include>com.faceunity.*</Include>
<Include>io.agora.*</Include>
</Filter>
</MethodCall>
</Replace>
??加載外部的 SO 文件
光解決了 SO 加載不閃退還是不夠的,平常的開發過程中都是通過系統的方法System.loadLibrary()
加載 SO ,這時候如果我們自己加載外部目錄的 SO 文件就可能出現系統找不到文件、SO 間的互相依賴無法成功等問題。由于動態加載 SO 文件相關的技術在插件化、熱修復框架中以及相當成熟了,在參考了市面上主流框架的實現之后總結了以下倆種方式:
- 重新構建一個 ClassLoader 將 SO 的地址傳入 LibrarySearchPath 當中,并替換掉原先的 ClassLoader。
- 使用反射 ClassLoader 將 SO 包的地址寫入 LibrarySearchPath 當中 。
這倆種方案都不可避免的修改到 ClassLoader ,由于擔心替換 ClassLoader 的風險這里選擇了反射修改 LibrarySearchPath 地址 TinkerLoadLibrary#installNativeLibraryPath(ClassLoader, File)
,正當我以為已經完美解決的時候,在 Android N 版本以上的手機出現了閃退:
E/ExceptionHandler: Uncaught Exception java.lang.UnsatisfiedLinkError: dlopen failed: library "libpldroid_beauty.so" not found
at java.lang.Runtime.loadLibrary0(Runtime.java:)
at java.lang.System.loadLibrary(System.java:)
在查閱相關資料后發現由于 Android N 更改了 SO 文件路徑的尋找方式,恰巧我們的libpldroid_beauty.so
依賴了另外一個日志輸出的文件liblog.so
導致了libpldroid_beauty.so
尋找不到。
Android Native 用來鏈接 so 庫的 Linker.cpp dlopen 函數 的具體實現變化比較大(主要是引入了 Namespace 機制):以往的實現里,Linker 會在 ClassLoder 實例的 nativeLibraryDirectories 里的所有路徑查找相應的 so 文件;更新之后,Linker 里檢索的路徑在創建 ClassLoader 實例后就被系統通過 Namespace 機制綁定了,當我們注入新的路徑之后,雖然 ClassLoader 里的路徑增加了,但是 Linker 里 Namespace 已經綁定的路徑集合并沒有同步更新,所以出現了 libxxx.so 文件能找到,而 liblog.so 找不到的情況。
至于 Namespace 機制的工作原理了,可以簡單認為是一個以 ClassLoader 實例 HashCode 為 Key 的 Map,Native 層通過 ClassLoader 實例獲取 Map 里存放的 Value(也就是 so 文件路徑集合)。
如果想解決這個問題,思路有這么幾種:
- 自定義 System.loadLibrary,加載 SO 前,先解析 SO 的依賴信息,再遞歸加載其依賴的 SO 文件 SoLoader。
- 自定義 Linker,完全自己控制 SO 文件的檢索邏輯 ReLinker。
- 替換 ClassLoader 。
由于項目中使用到的 SoLoader
、Linker
都可以解決這個問題,在權衡了倆種的調用方式之后這里使用SoLoader
作為 SO 的加載工具。
??如何維護 SO 文件的正確性
由于 SO 文件經常會發生變更,我們希望保證每個版本的 APK 都能加載到對應版本的 SO 文件,為此需要在 APK 中包含一份“基準文件”,用于確認 SO 信息與校驗文件安全。
基準文件格式
{
"uploadTime": xx,
"soFile": [
{
"soMD5": "xxx",
"soName": "MTlabKit",
"version": 1,
"url": "https://xxx",
"soSize": xx
}
]
}
基準文件包含了文件的 md5 信息、版本號、下載地址、文件大小等信息,在我們加載 SO 文件前先讀取“基準文件”,確認 SO 信息正確之后再將它加載至內存當中。
鑒于目前 SO 文件剔除流程是在編譯時期做的,我們也順理成章的將基準文件生成放到編譯時期,利用 Android Gradle Plugin 會 mergedAssets 資源的邏輯,我們將基準文件保存至 merged_assets
下,自然會打包至 APK 當中。
Task | 作用 | 結果輸出目錄 |
---|---|---|
mergeDebugAssets | 合并所有 assets 文件 | intermediates/merged_assets/ |
每次打包時,將先讀取上次的“基準文件信息”后和本次剔除的 SO 文件的 md5 進行比對后判斷文件是否發生了變化,如果發生變化了,重寫“基準文件”。
APK 運行時將讀取“基準文件”的信息,這是加載 SO 文件的唯一信息。
小結
至此,我們已經將 SO 文件的移除、安全加載、版本跟蹤的思路整理了出來,下面我們來一起回顧下。
如何移除 APK 中的 SO 文件?
在mergeNativeLibs
Task 之后移除merged_native_libs
目錄當中需要剔除的 SO 文件。如何保證第三方 SDK 的 SO 不存在時的正常運行?
利用動態字節碼技術Javassist
替換系統的 SO 方法保證文件不存在也不會發生閃退。如何維護 SO 文件的正確性?
編譯時期根據 SO 文件信息生成基準文件,APK 運行時依靠讀取基準文件保證正確性。
至此上篇的內容就結束了,下篇的內容將著重的介紹代碼的實現以及實現過程中遇到的問題與細節的完善。由于時間關系,難免有些問題或 BUG 出現,歡迎大家指出缺點與問題。咱們下期見~