由于公司的業(yè)務(wù)不斷拓展,生產(chǎn)環(huán)境的 APK 大小也從我最初進(jìn)入公司時(shí)的 70M 變?yōu)榱?60MB ,在分析了 APK 結(jié)構(gòu)目錄之后,常規(guī)的壓縮方案已經(jīng)收效甚微了,動(dòng)態(tài)加載第三方的 SO 文件是下一個(gè)優(yōu)化的重點(diǎn)。SO 文件本質(zhì)上就是一種可動(dòng)態(tài)加載并執(zhí)行的文件,所以將 SO 動(dòng)態(tài)下發(fā)沒有技術(shù)風(fēng)險(xiǎn),但是要將它從 APK 中剔除并保證穩(wěn)定性并不是一件易事。
??從0到1需要解決那些問題?
對于從 0 到 1 開發(fā)一套方案我們先把相關(guān)技術(shù)點(diǎn)先提出來,再帶著問題去看看這些方案的解決思路這樣開發(fā)起來時(shí)最高效的。對于動(dòng)態(tài)下載 SO 庫我們對它的基本期望是 APK 中不包含 SO 文件,這樣就引申出了問題點(diǎn):
- 如何移除 APK 中的 SO 文件?
同時(shí),我們希望能夠兼容第三方 SDK 這樣就出現(xiàn)了
- 如何保證第三方 SDK 的 SO 不存在時(shí)的正常運(yùn)行?
另外我們還希望 SO 版本發(fā)生變化了也能夠不需要人工維護(hù)
- 如何維護(hù) SO 文件的正確性
只要解決了以上的幾個(gè)問題,大致的動(dòng)態(tài)下發(fā)框架就搭建完成了。

?如何移除 APK 中的 SO 文件?
看到移除 SO 文件可能有些同學(xué)會說“啊這不是很簡單么,只要把 libs 目錄刪掉就好了呀“,但是如果這樣做的話我們就木有辦法剔除 AAR 當(dāng)中的 SO 文件,還有 SO 文件變化需要人工維護(hù)等問題,所以出于各種考慮編譯時(shí)期動(dòng)態(tài)剔除 SO 文件都是最優(yōu)解。

在最新的編譯流程圖中,我們可以看到 Android Gradle Plugin 對資源文件進(jìn)行了 Compiled Resouces 操作,同時(shí)在平常在編譯的過程中在 Android Studio 的 Build 面板會輸出很多 Task 的日志其中不乏有資源相關(guān)的字眼:

由此,我們可以大膽假設(shè)一下,Android Gradle Plugin在打包的工程中是否有專門的 Task 是處理資源相關(guān)的邏輯?如果有了這個(gè) Task 我們是不是就能過在這之前 or 之后進(jìn)行 SO 文件剔除呢?

在查閱官方的文檔資料后,終于找到了倆處專門用于處理 SO 文件的 Task,確實(shí)正如我們所想,在編譯的過程中Android Gradle Plugin會整合不同目錄的 SO 文件最終匯總至一起:
| Task | 對應(yīng)實(shí)現(xiàn)類 | 作用 | 結(jié)果保存目錄 |
|---|---|---|---|
| mergeDebugNativeLibs | MergeNativeLibsTask | 合并所有依賴的 native 庫 | intermediates/merged_native_libs |
| stripDebugDebugSymbols | StripDebugSymbolsTask | 從 Native 庫中移除 Debug 符號。 | intermediates/stripped_native_libs |
對于我們來說只要?jiǎng)h除對應(yīng)目錄中的 SO 文件,最終打出來的 APK 中就不會包含該文件。
按最優(yōu)解來說,應(yīng)該在stripSymbols結(jié)束后去剔除 stripped_native_libs 目錄下的文件,但是擔(dān)心不同版本的Android Gradle Plugin會對這一步做不同的操作,所以決定選用mergeNativeLibs結(jié)束后去剔除原始的 so 來保證文件的 MD5 不發(fā)生變化,另外因?yàn)榈谌?SO 一般都是 Release 編譯出來的,就算進(jìn)行了stripDebugDebugSymbols也不會有太大效果。
同時(shí),為了能夠讓 APK 運(yùn)行之后能夠獲取到 SO 文件,我們需要將被剔除的 SO 文件上傳至遠(yuǎn)端,以供后面獲取使用。
?保證 SO 不存在時(shí)的穩(wěn)定性
常常用第三方 SDK 的同學(xué)肯定知道,很多第三方的 SDK 要求應(yīng)用啟動(dòng)時(shí)就完成初始化,它們內(nèi)部往往一初始化就調(diào)用了System.loadLibrary() 方法進(jìn)行 SO 的加載,如果這時(shí)候 SO 被我們剔除了那么系統(tǒng)就會出現(xiàn)UnsatisfiedLinkError的閃退,雖然有少部分 SDK 例如 MMKV 有提供初始化的回調(diào)供我們修改加載方法,但是這畢竟是少數(shù)情況,還是要想辦法修改第三方 SDK 的加載方式。

由于我們無法直接修改第三方 SDK 的源碼,這時(shí)候我們就只能依靠動(dòng)態(tài)字節(jié)碼也就是所謂的 AOP 在編譯時(shí)期對第三方 SDK 進(jìn)行修改了。常見的方式有很多例如AspectJ、Javassist、ASM等等,但是它們在使用上或多或少都有點(diǎn)麻煩,本著不(圖)重(省)復(fù)(事)造輪子的原則,直接上 GitHub 上找了個(gè)基于Javassist封裝的工具DroidAssist,利用它我們可以很輕易的就替換掉第三方 SDK 中的加載代碼,配置如下:
<Replace>
<!-- 替換系統(tǒng)的 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 加載不閃退還是不夠的,平常的開發(fā)過程中都是通過系統(tǒng)的方法System.loadLibrary()加載 SO ,這時(shí)候如果我們自己加載外部目錄的 SO 文件就可能出現(xiàn)系統(tǒng)找不到文件、SO 間的互相依賴無法成功等問題。由于動(dòng)態(tài)加載 SO 文件相關(guān)的技術(shù)在插件化、熱修復(fù)框架中以及相當(dāng)成熟了,在參考了市面上主流框架的實(shí)現(xiàn)之后總結(jié)了以下倆種方式:
- 重新構(gòu)建一個(gè) ClassLoader 將 SO 的地址傳入 LibrarySearchPath 當(dāng)中,并替換掉原先的 ClassLoader。
- 使用反射 ClassLoader 將 SO 包的地址寫入 LibrarySearchPath 當(dāng)中 。
這倆種方案都不可避免的修改到 ClassLoader ,由于擔(dān)心替換 ClassLoader 的風(fēng)險(xiǎn)這里選擇了反射修改 LibrarySearchPath 地址 TinkerLoadLibrary#installNativeLibraryPath(ClassLoader, File),正當(dāng)我以為已經(jīng)完美解決的時(shí)候,在 Android N 版本以上的手機(jī)出現(xià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:)
在查閱相關(guān)資料后發(fā)現(xiàn)由于 Android N 更改了 SO 文件路徑的尋找方式,恰巧我們的libpldroid_beauty.so依賴了另外一個(gè)日志輸出的文件liblog.so導(dǎo)致了libpldroid_beauty.so尋找不到。
Android Native 用來鏈接 so 庫的 Linker.cpp dlopen 函數(shù) 的具體實(shí)現(xiàn)變化比較大(主要是引入了 Namespace 機(jī)制):以往的實(shí)現(xiàn)里,Linker 會在 ClassLoder 實(shí)例的 nativeLibraryDirectories 里的所有路徑查找相應(yīng)的 so 文件;更新之后,Linker 里檢索的路徑在創(chuàng)建 ClassLoader 實(shí)例后就被系統(tǒng)通過 Namespace 機(jī)制綁定了,當(dāng)我們注入新的路徑之后,雖然 ClassLoader 里的路徑增加了,但是 Linker 里 Namespace 已經(jīng)綁定的路徑集合并沒有同步更新,所以出現(xiàn)了 libxxx.so 文件能找到,而 liblog.so 找不到的情況。
至于 Namespace 機(jī)制的工作原理了,可以簡單認(rèn)為是一個(gè)以 ClassLoader 實(shí)例 HashCode 為 Key 的 Map,Native 層通過 ClassLoader 實(shí)例獲取 Map 里存放的 Value(也就是 so 文件路徑集合)。
如果想解決這個(gè)問題,思路有這么幾種:
- 自定義 System.loadLibrary,加載 SO 前,先解析 SO 的依賴信息,再遞歸加載其依賴的 SO 文件 SoLoader。
- 自定義 Linker,完全自己控制 SO 文件的檢索邏輯 ReLinker。
- 替換 ClassLoader 。
由于項(xiàng)目中使用到的 SoLoader、Linker都可以解決這個(gè)問題,在權(quán)衡了倆種的調(diào)用方式之后這里使用SoLoader作為 SO 的加載工具。
??如何維護(hù) SO 文件的正確性
由于 SO 文件經(jīng)常會發(fā)生變更,我們希望保證每個(gè)版本的 APK 都能加載到對應(yīng)版本的 SO 文件,為此需要在 APK 中包含一份“基準(zhǔn)文件”,用于確認(rèn) SO 信息與校驗(yàn)文件安全。
基準(zhǔn)文件格式
{
"uploadTime": xx,
"soFile": [
{
"soMD5": "xxx",
"soName": "MTlabKit",
"version": 1,
"url": "https://xxx",
"soSize": xx
}
]
}
基準(zhǔn)文件包含了文件的 md5 信息、版本號、下載地址、文件大小等信息,在我們加載 SO 文件前先讀取“基準(zhǔn)文件”,確認(rèn) SO 信息正確之后再將它加載至內(nèi)存當(dāng)中。
鑒于目前 SO 文件剔除流程是在編譯時(shí)期做的,我們也順理成章的將基準(zhǔn)文件生成放到編譯時(shí)期,利用 Android Gradle Plugin 會 mergedAssets 資源的邏輯,我們將基準(zhǔn)文件保存至 merged_assets 下,自然會打包至 APK 當(dāng)中。
| Task | 作用 | 結(jié)果輸出目錄 |
|---|---|---|
| mergeDebugAssets | 合并所有 assets 文件 | intermediates/merged_assets/ |
每次打包時(shí),將先讀取上次的“基準(zhǔn)文件信息”后和本次剔除的 SO 文件的 md5 進(jìn)行比對后判斷文件是否發(fā)生了變化,如果發(fā)生變化了,重寫“基準(zhǔn)文件”。
APK 運(yùn)行時(shí)將讀取“基準(zhǔn)文件”的信息,這是加載 SO 文件的唯一信息。

小結(jié)
至此,我們已經(jīng)將 SO 文件的移除、安全加載、版本跟蹤的思路整理了出來,下面我們來一起回顧下。
如何移除 APK 中的 SO 文件?
在mergeNativeLibsTask 之后移除merged_native_libs目錄當(dāng)中需要剔除的 SO 文件。如何保證第三方 SDK 的 SO 不存在時(shí)的正常運(yùn)行?
利用動(dòng)態(tài)字節(jié)碼技術(shù)Javassist替換系統(tǒng)的 SO 方法保證文件不存在也不會發(fā)生閃退。如何維護(hù) SO 文件的正確性?
編譯時(shí)期根據(jù) SO 文件信息生成基準(zhǔn)文件,APK 運(yùn)行時(shí)依靠讀取基準(zhǔn)文件保證正確性。
至此上篇的內(nèi)容就結(jié)束了,下篇的內(nèi)容將著重的介紹代碼的實(shí)現(xiàn)以及實(shí)現(xiàn)過程中遇到的問題與細(xì)節(jié)的完善。由于時(shí)間關(guān)系,難免有些問題或 BUG 出現(xiàn),歡迎大家指出缺點(diǎn)與問題。咱們下期見~
