我的 Android 重構(gòu)之旅:動(dòng)態(tài)下發(fā) SO 庫(上)

由于公司的業(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 編譯流程

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

編譯時(shí)機(jī)

由此,我們可以大膽假設(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)目中使用到的 SoLoaderLinker都可以解決這個(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 文件的唯一信息。

基準(zhǔn)文件生成流程

小結(jié)

至此,我們已經(jīng)將 SO 文件的移除、安全加載、版本跟蹤的思路整理了出來,下面我們來一起回顧下。

  1. 如何移除 APK 中的 SO 文件?
    mergeNativeLibs Task 之后移除 merged_native_libs 目錄當(dāng)中需要剔除的 SO 文件。

  2. 如何保證第三方 SDK 的 SO 不存在時(shí)的正常運(yùn)行?
    利用動(dòng)態(tài)字節(jié)碼技術(shù)Javassist替換系統(tǒng)的 SO 方法保證文件不存在也不會發(fā)生閃退。

  3. 如何維護(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)與問題。咱們下期見~


參考資料

  1. 動(dòng)態(tài)下發(fā) so 庫在 Android APK 安裝包瘦身方面的應(yīng)用
  2. Gradle構(gòu)建過程
  3. Apk 打包流程
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 本篇文章已授權(quán)微信公眾號 guolin_blog (郭霖)獨(dú)家發(fā)布 最近碰到一些 so 文件問題,順便將相關(guān)知識點(diǎn)...
    請叫我大蘇閱讀 7,388評論 3 40
  • 我是黑夜里大雨紛飛的人啊 1 “又到一年六月,有人笑有人哭,有人歡樂有人憂愁,有人驚喜有人失落,有的覺得收獲滿滿有...
    陌忘宇閱讀 8,887評論 28 54
  • 信任包括信任自己和信任他人 很多時(shí)候,很多事情,失敗、遺憾、錯(cuò)過,源于不自信,不信任他人 覺得自己做不成,別人做不...
    吳氵晃閱讀 6,391評論 4 8
  • 怎么對待生活,它也會怎么對你 人都是哭著來到這個(gè)美麗的人間。每個(gè)人從來到塵寰到升入天堂,整個(gè)生命的歷程都是一本書,...
    靜靜在等你閱讀 5,332評論 1 6

友情鏈接更多精彩內(nèi)容