貨拉拉 Android 動態資源管理系統原理與實踐

語言: CN / TW / HK

jary,貨拉拉高階客戶端工程師,目前負責貨拉拉App Android端穩定性提升,包體積優化相關工作。

  1. 前言

  2. 隨著公司業務的擴充套件,貨拉拉使用者端apk包的體積也不斷變大,過去一年,使用者端android組進行了大量的瘦身工作,取得了較為顯著的成果。再使用常規方法,已經很難優化包體積了。

  3. 我們可以把一些使用頻率相對較低的資源不打包進apk,並在需要時下載到本地(例如動畫檔案,字型,zip壓縮包,so庫等)
  4. 我們注意到,貨拉拉使用者端apk中,使用了35個以上的so庫,並且都支援arm64-v8a和armeabi-v7a這2種abi,結果就是so體積成倍上漲。使用者端生產環境下的apk,解壓縮後,存放so包的lib目錄,佔據了整個應用41%的大小。
  5. 因此動態資源管理系統是下一個優化的重點,動畫,字型和zip包只是普通檔案,完全可以支援動態下載並使用。而so檔案本質上就是一種可動態載入並執行的檔案,將 so檔案動態下發是切實可行的,但是要將它從 apk中剔除並保證穩定性並不是一件易事。

  6. 行業方案

  7. 未找到現成的github專案或者三方sdk方案,來實現動態資源管理。

  8. 部分部落格提供了動態管理so檔案的思路,但是缺少完整流程。
  9. 行業目前並未提供完整的成熟方案供我們使用,需要我們自己造輪子。

  10. 功能和方案

  11. 實現功能

  12. 資源分類,預定義了字型,幀動畫,so這3種內建資源,以及單個檔案,多個檔案這2種可自定義資源。
  13. 提供通用的載入動態資源方法,所有資源均可由此載入。
  14. 內建資源,提供預設的應用方法,外部可以直接應用。自定義資源,使用者自行決定如何應用。
  15. 對於所有資源,提供可配置的方便快捷打包方式,減少手動操作。

  • 幾個概念

  • 資源載入:將動態資源通過下載,校驗,解壓等方式,對映到本地檔案的過程。

該過程對所有資源通用,sdk使用方無需修改資源載入方式。

  • 資源應用:動態資源對應的本地檔案應用到具體業務中。例如動態字型資源的應用,就是為TextView設定一個新的字型。

該過程每個資源不同,sdk使用方無需修改內建資源的應用方式,對於自定義資源,需要使用方自行決定應用方式。

  • 資源打包:包括生成一個待上傳的資原始檔,以及生成資源的Java描述(DynamicPkgInfo類)。so資源還包含了一些方法的hook操作。

該過程對所有資源都適用,統一使用可配置的dynamic_plugin外掛完成。sdk使用方無需修改資源打包方式,但是可通過配置dyanmic_plugin.gradle檔案,配置打包過程。

  • 通用資源載入

  • 如何確定資源已經下載過了,避免重複下載?

Java程式碼中,使用DynamicPkgInfo類來描述資源,該類中包含了資源的版本號。我們比較該類和本地資料庫中的資源版本號,如果不同,才會下載資源。

  • 下載資源是否提供多執行緒下載,斷點續傳等功能?

本sdk只提供了下載介面,未提供實際下載功能,因此如需這些功能,需要呼叫者自己實現。

  • 如何校驗資源,防止被篡改?

DynamicPkgInfo類中包含了資源校驗資訊,我們利用該類,對下載好的檔案進行md5碼,檔案長度,檔名稱的校驗。

  • 如何判斷資源是否壓縮包,以及如何解壓縮?

目前簡單的採用字尾名是否為.zip判斷,使用使用Java內建java.util.zip包下工具解壓。

  • 如何校驗解壓後的資源子檔案,防止被篡改?

DynamicPkgInfo同樣包含了zip包中所有子檔案的校驗資訊,我們利用它,來校驗所有解壓後的檔案。

資源應用

  • 字型資源應用,從載入好的本地檔案中,建立系統Typeface字型物件,並設定到TextView上。
  • 幀動畫資源應用,從載入好的本地檔案中,建立系統AnimationDrawable幀動畫物件,並設定到ImageView上。
  • 字型和幀動畫資源的應用流程,見第5章,內建資源應用流程。
  • so資源應用流程,見第7章,so資源載入和應用解決方案。
  • 自定義資源的應用,需要sdk使用者自己定義。
  • 資源打包

我們使用dynamic_plugin gradle外掛來完成所有資源的打包。

  • 字型資源打包

  • 掃描輸入目錄字型檔案,將他們拷貝到輸出目錄。
  • 為每個字型生成一個DynamicPkgInfo類的常量,代表該動態資源。
  • 幀動畫資源打包

  • 掃描輸入目錄幀動畫資料夾,將它們逐個壓縮,並將壓縮包輸出到指定目錄。
  • 為每一組幀動畫生成一個DynamicPkgInfo類的常量,代表該動態資源。
  • so資源打包

  • Hook系統System.loadLibrary方法的呼叫。
  • 系統打包流程中,刪除配置檔案指定so檔案,並將他們拷貝到指定目錄。
  • 掃描上面的so檔案目錄,將他們逐個壓縮,並將壓縮包輸出到指定目錄。
  • 為每一個so壓縮包生產一個DynamicPkgInfo類的常量,代表該動態資源。
  • 自定義資源打包

  • 單個檔案的資源打包同字型資源
  • 多個檔案的資源打包同幀動畫資源
  • 執行產物

  • 下圖為該打包外掛執行一次之後的產物。
  • input目錄,所有待打包資源的存放目錄,我們需要手動把要打包的資源拷貝這裡,例如字型檔案拷貝到input/typeface目錄下。注意so資源會在打包過程中,自動生成,無需手動處理。
  • output目錄,則是打包出來的產物,包括字型資源,so資源,幀動畫資源等,我們可以手動將此目錄下的打包後資源上傳到伺服器。
  • DynamicResConst.java檔案,該檔案中生成了所有資源的資訊。

  • DynamicResConst.java檔案的內容,我們在這裡也稍微看一下,圖中為字型資源和幀動畫資源的java描述。可以看到所有動態資源,都用DynamicPkgInfo類來描述。
  • 單個檔案資源,包含了資源的id,檔名稱,資源型別,下載地址,版本號,檔案長度以及md5碼。
  • 多個檔案資源,除了包含上述資訊外。還包含了該壓縮包解壓後,裡面每個檔案的名稱,檔案長度以及md5碼

  1. 整體架構

由於整個系統功能較複雜,我們將其分為3個module。

  • lib_dynamic_base:只包含md5,壓縮解壓等通用操作以及代表資源的實體類DynamicPkgInfo,該module為後面2個module的基礎。
  • lib_dynamic_res:提供了資源的載入和應用功能,目前包含字型資源,幀動畫資源,so資源以及自定義資源。
  • dynamic_plugin:為一個gradle plugin工程,提供了資源打包功能。

  • lib_dynamic_res模組架構

該庫包括了動態資源載入和應用全過程,我們分為5層實現

  • 外部介面層,主要為載入管理器和載入監聽器,提供了所有外部的介面。
  • 資源應用層,封裝了幾種內建動態資源的應用,字型資源,幀動畫資源,so資源。
  • 載入流程層,具體完成了資源的載入過程,主要採用狀態模式實現,包括一個狀態管理器,以及各種狀態,例如檢查本地版本狀態,下載狀態,校驗檔案狀態等。
  • 介面隔離層,主要是一些功能介面,例如下載功能,解壓縮功能,上報功能等,隔離了底層實現。
  • 具體實現層,各個具體功能的實現,例如資料庫操作,java zip庫等。

  • dynamic_plugin外掛架構

  • 系統外掛層,主要為系統gradle plugin的實現,以及對dynamic_plugin.gradle配置檔案的讀取和解析
  • 任務模組層,包含了各個任務,例如刪除並拷貝so檔案任務,壓縮zip包任務等。
  • 底層實現層,包含了具體功能的實現,例如asm框架和transform api,zip壓縮,javepoet程式碼生成等。

  1. 通用資源載入,內建資源應用流程

  2. 通用資源載入主流程

載入普通資源的主流程如下,首先判斷資源包指定版本號和本地資料庫版本號是否相同,如果想同,進入本地資源校驗流程,否則進入下載流程。

  • 下載校驗解壓流程

    • 我們首先呼叫下載介面下載資源。
    • 如果下載成功,我們校驗下載檔案,下載失敗,則嘗試刪除檔案,並直接跳到失敗結果。
    • 校驗下載檔案成功,我們在判斷是否為zip檔案,對於zip檔案,我們執行解壓縮操作,非zip檔案,直接成功。
    • 解壓縮完成後,我們在對解壓後的所有檔案執行校驗操作。

  • 本地資源校驗流程

    • 對於下載並解壓的壓縮包資源,以及本地資料庫版本和資源實體類版本號相同的資源,我們需要進行本地資源校驗流程。
    • 遍歷資源包指定的字檔案列表,對他們進行逐個檔案檢驗就可以了。

  • 單個檔案校驗流程

資源實體類中指定的檔名稱,檔案長度,檔案md5碼和本地檔案相同時,我們認為該檔案校驗成功了

  • 載入恢復流程

動態資源載入過程中,可能因為各種原因,導致載入未能得到成功或者失敗的結果,而在中間狀態被中斷,如應用程序被殺死,手機關機等等。為了避免載入意外中斷的情況下,完全從頭開始進行載入,我們設計了一個動態資源載入的恢復流程,如果異常中斷,我們下次載入資源時,可以恢復到當前狀態,繼續進行載入。

  • 下載過程的恢復和斷點續傳,需要下載介面的實現者負責。
  • 其他狀態,我們在狀態改變時,將資源id,當前狀態和待處理檔案路徑,儲存到資料庫。
  • 每次載入動態開始時,根據資源id查詢資料庫中是否有待恢復資料。
  • 有待恢復資料,轉到待恢復的狀態,否則,直接去檢查版本號狀態。
  • 資源載入成功或者失敗時,從資料庫中刪除當前資源id對應的恢復狀態。

  • 內建資源應用流程

前面我們總結了動態資源的載入流程,資源載入完成後,我們還需要將該資源進行應用,而這裡我們要說的就是將動態資源應用到對應View上的流程。

  • 根據資源id,從快取中獲取動態資源對應的本地檔案。
  • 檔案獲取成功,直接設定到view上,獲取失敗,進入下一步。
  • 引數列表中的佔位資源不為空,則將佔位資源設定到View上。
  • 將資源id設定到View的tag上,嘗試清除上次動態資源載入失敗狀態。
  • 使用管理器Manager類的load方法,執行之前的載入流程。
  • 非同步等待載入完成回撥,判斷資源id是否和View的tag相同,防止view被複用,導致的資源錯亂情況。
  • 如果Activity沒有被銷燬,則將資源設定到View上。

  1. lib_dynamic_res模組類設計

可與第4章,整體架構分層圖對照著看

  • 外部介面層

DynamicResManager類負責和外部互動,提供了初始化(init),載入資源(load),isResReady(判斷資源是否就緒),clearFailState(清除錯誤狀態等方法)等方法。

Config類,則可以向管理器提供執行緒池,下載器介面,本地資源資訊介面,本地資源狀態介面等配置資訊。

AbsResInfo抽象類,代表動態資源。

DynamicPkgInfo類,AbsResInfo的子類,提供給外部使用,代表了一個動態資源實體。

DynamicPkgInfo.FileInfo,AbsResInfo的子類,資源實體內部類,代表了資源中的一個子檔案。

DynamicPkgInfo.FolderInfo,AbsResInfo的子類,資源實體內部類,代表了資源中的一個子資料夾。

ILoadResListener介面,提供了載入資源時的回撥功能,會回撥載入成功,失敗,狀態變化,下載中進度

  • 資源應用層

AbsResApply抽象類,實現了動態資源在ui元素上的應用。

TypefaceResApply類,AbsResApply的子類,代表了字型資源的應用。

FrameAnimApply類,AbsResApply的子類,代表了幀動畫資源的應用。

AbsSoLoad抽象類,實現了so動態資源的應用。

RelinkerSoLoad類,AbsSoLoad的子類,使用Relinker第三方庫最終load so庫。

SystemSoLoad類,AbsSoLoad的子類,使用系統System.loadLibrary方法最終load so庫

  • 載入流程層

我們使用狀態模式來控制整個動態資源的載入流程。

IState,狀態介面,代表了載入流程中的一個狀態。

InitState類,初始化狀態。

CheckVersionState類,檢查資源實體類版本號與資料庫版本號是否相同狀態。

DownloadState類,下載資源狀態。

VerrifyFileState,校驗下載資源狀態。

UnZipState,解壓縮下載資源狀態。

VerifyZipState,校驗解壓後的所有檔案狀態。

IStateMechine,狀態管理機介面,負責管理前面所有的IState物件。

DefaultStateMachine類,狀態管理機的預設實現。

ResCtx類,狀態管理機執行過程中的全域性context物件,儲存了路徑資訊,載入成功資訊,載入失敗異常等全域性資訊。

  • 介面隔離和具體實現層

這2層的類,較為雜亂,限於篇幅,我們就不一一列舉了。

  • 類uml圖

  1. so資源動態化方案

  2. so資源打包問題

在打包so資源的過程中,我們遇到了如下問題。

  • 如何移除apk中的so檔案,並將他們收集起來?
  • 如何將多個so檔案壓縮打包,並生成對應的資訊?
  • 如何保證第三方sdk缺少so檔案時,不崩潰?
  • so資源打包解決方案

  • 移除並收集apk中的so檔案

看到移除 so檔案可能有些同學會問,這不是隻要在as中刪除libs目錄就搞定了麼?這樣會有幾個問題

  • 對於多個module的工程,我們需要逐個刪除每個module下的libs目錄,麻煩而且容易出錯。
  • 對於三方aar包中的so檔案,我們就沒法刪除了。
  • so檔案變化需要人工維護,容易出錯。

出於以上考慮,我們認為,在編譯時期,自動刪除並收集so檔案是最優解,那麼在編譯時期進行以上操作呢?我們注意到as在進行build時,會有大量的系統提供的task在執行,那麼這些系統task是否就完成了編譯並收集各個地方的so檔案,並把他們打包進apk的任務呢?

看一眼這幅超級複雜的apk構建流程圖,嗯,可以看到,系統確實會在apkBuilder構建前,將本地的c/c++檔案編譯成so庫,並將第三方的so庫一起打包到apk中,我們需要尋找的就是收集所有so庫的系統Task

通過查詢資料,我們發現,確實有2個系統task會用來處理合並so庫並且刪除debug符號(注意,task名稱可能與此處不完全相同)。

| Task名稱 | 實現類 | 作用 | 結果儲存目錄 | | ---------------------- | --------------------- | ---------------------- | ---------------------------------- | | mergeDebugNativeLibs | MergeNativeLibsTask | 合併所有依賴的 native 庫 | intermediates/merged_native_libs | | stripDebugDebugSymbols | StripDebugSymbolsTask | 從 Native 庫中移除 Debug 符號 | intermediates/stripped_native_libs |

  • 一般來說,應該在stripSymbols結束後去剔除 stripped_native_libs 目錄下的檔案。
  • 但是剔除debug符號操作,可能導致不同as版本得到的so檔案md5碼不相同。
  • 因此,我們採用了可配置方案,可以由使用者配置決定,在MergeNativeLibsTask或者stripDebugDebugSymbols後,執行刪除輸出資料夾中so檔案操作。
  • 第三方 so 一般都是 Release 編譯出來的,不進行strip影響也不大。而我們自己的so檔案,則strip操作可能會對so體積造成較大影響。
  • 下面我們以在MergeNativeLibsTask之後,執行刪除輸出資料夾中so檔案的方式,進行講解。

由於我們有多個gradle task需要執行,因此我們建立了一個名為dynamic_plugin的android plugin工程,內部包含了多個gradle task。關於as中新建外掛的方法,請自行搜尋其他部落格,本文因為篇幅問題,不進行講解。

在我們的dynamic_plugin外掛內部,我們新建一個名為DeleteAndCopySo的gradle task並將它插入到系統的merge和strip之間,利用該Task完成刪除merged_native_libs目錄下對應so檔案,並將其拷貝到我們指定的新目錄下。這樣apk打包時,就不會包含動態化的so檔案了

``` //獲取系統的mergeTask

Task mergeNativeTask = TaskUtil.getMergeNativeTask(project);

//獲取系統的skipTask

Task stripTask = TaskUtil.getStripSymbol(project);

//建立我們的DeleteAndCopySo task

Task deleteTask = project.getTasks().create(PluginConst.Task.DELETE_SO);

deleteTask.doLast(new Action() {

@Override

public void execute(Task task) {

    deleteAndCopySo(project, param);

}

});

//將我們的Task插入到merge和strip之間

stripTask.dependsOn(deleteTask);

deleteTask.dependsOn(mergeNativeTask); ```

  • 如何將多個so檔案壓縮打包,並生成對應的資訊?

上一步中,我們已經將so檔案從系統apk構建流程中刪除,並且拷貝到了指定目錄下。那麼現在我們應該做什麼呢?

  • 將so檔案打包成.zip壓縮包。
  • 生成該資源對應的實體類DynamicPkgInfo。包括檔案id,檔名稱,檔案型別,版本號,下載地址等基本資訊,以及檔案md5,檔案長度等校驗資訊。以及壓縮包下的所有子檔案及資料夾相關資訊。
  • 將該zip檔案上傳到伺服器,以方便下載和使用。

對於上述這些步驟,在我們的貨拉拉動態管理系統初始版本中,我們採用了自己打zip包,自己寫java程式碼來生成資源資訊的方式。

但是在後來的使用過程中,我們發現,手動進行這些步驟,很繁瑣且容易出錯,我們需要有一種自動化的方式進行上述過程。

我們在dynamic_plugin外掛內部,再新增一個ZipSoTask來進行壓縮so資料夾,以及生成資源資訊常量的操作。該task在DeleteAndCopySo之後,stripe系統task之前執行。

``` //執行將so資料夾壓縮成.zip操作

List zips = DynamicUtil.zipFolder(new File(param.getmInputSo()),outDir);

//根據so檔案和zip壓縮包資訊,生成md5,length等校驗資訊並存儲

DynamicUtil.createPkgDatas(mPkgList,zips,PluginConst.Type.SO);

//根據資源資訊類生產java檔案

param.getmFileCreate().createFile(mPkgList,param); ```

  • 前2步,壓縮so檔案,和根據so檔案,zip檔案生成校驗資訊並存儲比較簡單,就不詳細說了。
  • 第3步,根據前面的資訊,直接生產java檔案,我們使用了第三方的開源庫javapoet。
  • JavaPoet 是 Square 公司推出的開源 Java程式碼生成框架,提供介面生成 Java 原始檔。這個框架功能非常有用,我們可以很方便的使用它根據註解、資料庫模式、協議格式等來對應生成程式碼。通過這種自動化生成程式碼的方式,可以讓我們用更加簡潔優雅的方式要替代繁瑣冗雜的重複工作。
  • 大致的生產程式碼如下,首先生成一個DynamicResConst類,之後遍歷zip壓縮資源列表,為列表中的每一個資源,生成一個static final的常量,表示每個資源,最後生成java檔案。

``` //建立DynamicResConst類,用來儲存資源實體常量

TypeSpec.Builder typeBuilder = TypeSpec.classBuilder( "DynamicResConst" )

    .addModifiers(Modifier.PUBLIC, Modifier.FINAL);

//遍歷資源列表,生成對應實體類DynamicPkgInfo

for (DynamicPkgInfo pkg : pkgs) {

FieldSpec fsc = createField(pkg);

typeBuilder.addField(fsc);

}

//外掛java檔案,並寫入

JavaFile javaFile = JavaFile.builder(param.getmCreateJavaPkgName(), typeBuilder.build()).build();

try {

javaFile.writeTo(new File(param.getmOutputPath()));

} catch (Exception e) {

} ```

至於最後一步,將so壓縮包上傳到伺服器,我們在配置檔案中提供了一個上傳方法,不過預設實現為空,使用者可以手動上傳也可以修改預設方法實現自動上傳。自動生成的資原始檔中,版本號需要手動修改控制,下載地址手動上傳的話,也需要手動修改。

  • 保證第三方sdk在缺少so檔案時,不崩潰

很多三方sdk都要求在應用啟動時,進行初始化,一個使用so庫的類的典型類程式碼如下:

``` public class ThirdLib{

//靜態方法載入so庫

static{

System.loadLibrary("third");

}

//native方法示例

public native void testNative();

//java方法示例

public void test();

//......其他內容省略

} ```

  • 如果此時so庫沒有被載入好,直接使用ThirdLib類,則會執行static程式碼段中的System.loadLibrary方法,導致UnsatisfiedLinkError的錯誤,造成App崩潰。由於我們無法直接修改第三方sdk的原始碼,因此我們只能採用動態位元組碼技術,替換掉System.loadLibrary方法了。
  • 我們採用android的transform加asm技術,動態的將System.loadLibrary替換成我們自己的SoLoadUtil中的loadLibrary方法。
  • Gradle Transform 是 Android 官方提供給開發者在專案構建階段,即由 .class 到 .dex 轉換期間修改 .class 檔案的一套 API, 無論是class還是jar都可以控制。
  • ASM是一種通用Java位元組碼操作和分析框架。它可以用於修改現有的class檔案或動態生成class檔案。

具體執行替換的程式碼如下,在Asm框架中的MethodVisitor類中,重寫visitMethodInsn方法,判斷該方法的擁有者,名稱和引數列表和System.loadLibrary對應,則我們將他替換為我們的SoLoadUtil.loadLibrary方法

``` @Override

public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {

if(TextUtil.equals(owner, PluginConst.SYSTEM_CLASS) &&

        TextUtil.equals(name, PluginConst.LOAD_LIBRARY_METHOD) &&

        TextUtil.equals(descriptor, PluginConst.LOAD_LIBRARY_DESC)){

    owner = "com/xxx/xxx/dynamicres/util/SoLoadUtil" ;

    mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, name, descriptor, false);

    return;

}

mv.visitMethodInsn(opcode, owner, name, descriptor, isInterface);

} ```

替換後的方法主要邏輯為,使用第三方庫Relinker替代System.loadLibrary方法進行so檔案載入,並且catch住載入異常,來防止應用直接奔潰,並且在載入so庫異常時,將該庫的名稱儲存下來,在我們的so包被正常下發載入後,再次呼叫本方法,將so庫load到系統中。

``` protected void realSoLoad(Context c, String libName) {

try {

ReLinker. recursively ().loadLibrary(c, libName);

removeFormWaitList(libName);

} catch (Throwable t) {

addToWaitList(libName);

}

} ```

  • 對於so庫沒有載入完成,直接使用ThirdLib類導致System.loadLibrary方法被呼叫,導致的應用崩潰問題,我們已經解決了。
  • 而對於直接呼叫ThirdLib類的testNative方法,導致的應用崩潰問題,則無法解決。因此需要看情況決定是否能夠接受該種崩潰,以及是否將引發該問題的so庫進行動態化。

我們只需要在工程的主Application中,直接呼叫loadSo方法,對so動態資源進行載入。載入完成後,so庫就能正常使用了。

```

public void loadSo(DynamicSoInfo soInfo, ILoadSoListener listener) {

if (soInfo == null) {

    return;

}

//根據本機abi,獲取適合的動態資源實體類DynamicPkgInfo

DynamicPkgInfo pkg = soInfo.getPkgInfo(Build.SUPPORTED_ABIS);

if (pkg == null) {

    return;

}

//如果該so資源,已經被載入快取過了,直接listener的成功回撥,並返回

if (isLoadAndDispatchSo(pkg, listener)) {

    return;

}

//開啟資源載入,和普通資源流程一致

DynamicResManager manager = DynamicResManager.getInstance();

manager.load(pkg, new DefaultLoadResListener() {

    @Override

    public void onSucceed(LoadResInfo info) {

        super.onSucceed(info);

        //so成功下載校驗後,執行載入邏輯

        handleLoadSoSucceed(pkg, info, listener);

    }

});

} ```

  • so資源載入和應用問題

在so資源的載入和應用過程中,我們發現瞭如下問題

  • 如何判斷系統需要哪些so檔案,並按需正確載入?
  • 如何下載so檔案,並保證它的正確性?
  • 如何將下載的動態so檔案,正確應用到系統中?
  • so資源載入和應用解決方案

  • 如何判斷系統需要哪些so檔案,並正確下載安裝?

我們把arm64-v8a,armeabi-v7a等abi分開打包,上傳到伺服器。使用時,本地判斷abi支援,下載對應的abi包。這樣做的優點是節省流量和下載後佔據的空間。

至於判斷系統需要哪些abi的so包,並按需正確應用,則比較簡單,讀取系統的SUPPORTED_ABIS常量,這裡包含了系統支援的abi列表,而排在前面的表示優先順序更高。我們只要遍歷它,然後查詢我們的動態資源包是否有匹配,就達到了正確載入的目標。

```

private Map mSoInfos;

public DynamicPkgInfo getPkgInfo(){

//獲取本地系統支援的abi列表

String[] supportAbis = Build.SUPPORTED_ABIS;

if(supportAbis==null || supportAbis.length== 0 ){

    return null;

}

//遍歷abi支援列表

for(String abi : supportAbis){

    //從so動態資源中,查詢對應的abi資訊

    DynamicPkgInfo pkg = mSoInfos.get(abi);

    //找到則直接返回該資訊

    if(pkg != null){

        return pkg;

    }

}

return null;

} ```

  • 如何下載so檔案,並保證它的正確性?

複用通過資源載入流程即可。

  • 如何將下載的動態so檔案,正確應用到系統中?

這裡需要首先了解一下,系統載入so庫的工作流程,當我們呼叫 System#loadLibrary("xxx" ) 後,Android Framework 都幹了些了啥?Android 的 so 載入機制,大致可以分為以下四個環節。

  • 安裝 APK 包的時候,PMS 根據當前裝置的 abi 資訊,從 APK 包裡拷貝相應的 so 檔案。
  • 啟動 APP 的時候, Android Framework 建立應用的 ClassLoader 例項,並將當前應用相關的所有 so 檔案所在目錄注入到當前 ClassLoader 相關欄位。
  • 呼叫 System.loadLibrary("xxx"), framework 從當前上下文 ClassLoader 例項(或者使用者指定)的目錄數組裡查詢並載入名為 libxxx.so 的檔案。
  • 呼叫 so 相關 JNI 方法。

而我們這裡,由於so檔案不存在於apk當中,而是需要動態下載,所以我們顯然不能直接使用系統的System.loadLibrary方法載入so檔案。

而動態載入so的方法,在熱修復和外掛化框架中,已經比較成熟了,我們參考了市面上的開源框架後,選擇了騰訊的Tinker框架的載入方案,即使用反射classloader 將 so 包的路徑寫入 nativeLibraryPathElements 陣列最前面,其流程圖和解釋如下圖所示 。注意,此方法不同的android版本將有不同的實現。下面示例程式碼基於android9.0版本。

```

    private static void install(ClassLoader classLoader, File soFolder) throws Throwable {

Field pathListField = findField(classLoader, "pathList" );

        Object dexPathList = pathListField.get(classLoader);

        Field nativeLibraryDirectories = findField(dexPathList, "nativeLibraryDirectories" );

        List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);

        libDirs.add(0, soFolder);

        Field systemNativeLibraryDirectories =

                findField(dexPathList, "systemNativeLibraryDirectories" );

        List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);

        Method makePathElements =

                findMethod(dexPathList, "makePathElements" , List.class);

        libDirs.addAll(systemLibDirs);

        Object[] elements = (Object[]) makePathElements.

                invoke(dexPathList, libDirs);

        Field nativeLibraryPathElements = findField(dexPathList, "nativeLibraryPathElements" );

        nativeLibraryPathElements.setAccessible(true);

        nativeLibraryPathElements.set(dexPathList, elements);

    }

```

  • pathList變數:DexPathList類的例項。
  • nativeLibraryDirectories列表:包含了本App自帶so檔案的查詢路徑(如data/app/包名/lib/arm64)
  • systemNativeLibraryDirectories列表:包含系統so檔案查詢路徑(如system/lib64)
  • makePathElements:系統使用此方法,為所有so檔案,生成對應的 NativeLibraryElement物件
  • nativeLibraryPathElements陣列:系統用來儲存所有的so檔案路徑

當外界呼叫System.loadLibrary方法時,系統最終會呼叫到DexPathList類的findLibrary方法,該方法會在nativeLibraryPathElements陣列中查詢對應的路徑,我們將自己的so加入到nativeLibraryPathElements最前面,由此達到動態加入so的目標。

  1. so資源動態化的tips

  2. 為何要使用Relinker載入So檔案

    • 假如我們有2個so檔案,libA.so 和 libB.so,libA依賴libB,則當我們呼叫System.loadLibrary("libA") 的時候,android framework 會通過上面提到的呼叫鏈最終通過 dlopen 載入 libA.so 檔案,並接著通過其依賴資訊,自動使用 dlopen 載入 libB.so。
    • 在 Android N 以前,只要將 libA.so 和 libB.so 所在的檔案目錄路徑都注入到當前 ClassLoader 的 nativeLibraryPathElements 裡,則在載入 so 外掛的時候,這兩個檔案都能正常被找到。
    • 從 N 開始,libA.so 能正常載入,而 libB.so 會出現載入失敗錯誤。
    • 因為Android Native 用來連結 so 庫的 Linker.cpp dlopen 函式 的具體實現變化比較大(主要是引入了 Namespace 機制):以往的實現裡,Linker 會在 ClassLoder 例項的 nativeLibraryPathElements 裡的所有路徑查詢相應的 so 檔案。
    • 更新之後,Linker 裡檢索的路徑在建立 ClassLoader 例項後就被系統通過 Namespace 機制綁定了,當我們注入新的路徑之後,雖然 ClassLoader 裡的路徑增加了,但是 Linker 裡 Namespace 已經繫結的路徑集合並沒有同步更新,所以出現了 libA.so 檔案能找到,而 libB.so 找不到的情況。
    • 至於 Namespace 機制的工作原理了,可以簡單認為是一個以 ClassLoader 例項 HashCode 為 Key 的 Map,Native 層通過 ClassLoader 例項獲取 Map 裡存放的 Value(也就是 so 檔案路徑集合)。

解決該問題有如下幾種思路:

  • 自定義 System.loadLibrary,載入 SO 前,先解析 SO 的依賴資訊,再遞迴載入其依賴的 SO 檔案,這是開源庫soLoader的解決方案。
  • 自定義 Linker,完全自己控制 SO 檔案的檢索邏輯 ,這是開源庫Relinder的解決方案。
  • 替換 ClassLoader 。

本著不重複造輪子的原則,專案中使用了Relinker開源庫,用來載入so檔案。

  • so庫依賴分析工具

想要把 so 動態化技術應用到 APK 的瘦身專案中來,除了分析哪些 so 檔案體積佔比比較大之外,最好的做法是將其依賴的所有 so 檔案一定挪到外掛包裡。怎麼了解 APK 裡所有 so 檔案具體的依賴資訊呢?這裡推薦一款 Google 開源的 APK 解析工具android-classyshark,除了提供分析 APK dex/so 依賴資訊之外,它還提供了 GUI 視覺化介面,非常適合快速上手。

  1. so動態化流程

  2. so資源應用流程

  3. 獲取系統支援abi列表,根據該列表,找到合適的so動態資源實體類。
  4. 如果該資源已經被載入快取,則直接回調載入成功。
  5. 否則,開始資源通用載入流程,並非同步等待資源載入成功(流程見第5章)。
  6. 再次判斷下載校驗後的資源,是否支援本機abi。
  7. 將so包路徑加入DexPathList的陣列頭部。
  8. 遍歷等待載入so列表,嘗試載入所有so檔案,並將成功載入的so檔案,移除該列表。
  9. 將資源id和本地路徑加入快取,防止so被重複載入。
  10. 回撥載入完成監聽器。

  • SoLoadUtil.loadLibrary方法流程

從上一章我們知道,我們會使用transform api加asm框架,將系統的System.loadLibrary方法替換成我們的SoloadUtil.loadLibrary方法。我們替換系統方法的目的。一個是為了保證so庫不存在時,程式不崩潰,另外一個就是so庫下載校驗完成後,能自動完成之前失敗的載入,為此,我們設計瞭如下流程。

  • 其他方法呼叫到我們的SoloadUtil時,我們判斷我們的載入系統是否初始化完成
  • 已完成,則呼叫Relinkder庫嘗試載入so檔案,未完成則將該so庫加入待載入佇列中。
  • 如果Relinker載入so檔案成功,我們從待載入佇列中移除so,並且完成本次載入。
  • 否則我們依然將so檔案加入待載入佇列中。
  • 根據上面的so載入流程,當so動態資源真正下載校驗完成後,我們會遍歷待載入佇列,並完成所有之前未成功的so庫載入。

  1. dynamic_plugin外掛流程

  2. 整體流程

前面我們已經分析了通用資源載入,內建資源應用,完成了動態資源管理系統的主要部分。只剩下資源打包部分了,而所有資源的打包操作,都由dyanmic_plugin外掛來完成。為了完成打包功能,我們決定在這個dynamic_plugin外掛內部,新建3個Task。

  • Hook System.loadLibrary方法的TransformTask。
  • 系統打包流程中,刪除並拷貝so檔案的DeleteAndCopySoTask。
  • 壓縮so資源和其他多個檔案資源(例如幀動畫)的ZipResTask。
  • 為每個動態資源生成其對應的DynamicPkgInfo常量的功能,僅實現為一個普通方法。

所以主流程也就出來了

  • 讀取並解析dynamic_plugin.gradle配置檔案。
  • 根據配置資訊,決定是否將3個task加入任務佇列。
  • 啟動任務佇列。

  • TransformTask流程

該task流程,主要就是通過tranform api和asm框架的使用,我們在其中加入了掃描class範圍的可配置項。

  • 等待asm框架掃描class。
  • 判斷該class名稱是否在我們配置的替換列表中,如果不在,就直接返回。
  • 建立ClassVisitor和MethodVisitor,等待asm框架掃描每個方法。
  • 如果該方法的名稱,引數列表和呼叫者,都和System.loadLibrary方法相符合。
  • 我們替換為自己的SoloadUtil.loadLibrary方法。

  • DeleteAndCopySoTask流程

  • 根據配置檔案,找到系統的merge和strip task。
  • 將我們的task插入到2個系統task之間,並等待系統回撥我們的doLast方法。
  • 遍歷系統的mergeTask的輸出目錄,判斷該so檔案是否在我們配置的待掃描列表中。
  • 如果配置了需要拷貝so檔案,則我們將它拷貝到指定位置。
  • 如果配置了需要刪除so檔案,則我們將該so檔案刪除。

  • ZipResTask流程

  • 拷貝字型檔案,將檔案資訊加入資源列表。
  • 壓縮幀動畫檔案,將壓縮後的檔案資訊加入資源列表。
  • 壓縮so檔案,將壓縮後的檔案資訊加入資源列表。
  • 壓縮zip資料夾下檔案,將壓縮後的檔案資訊加入資源列表。
  • 遍歷資原始檔,為其生成相應的資源實體類DynamicPkgInfo。

  1. dynamic_plugin外掛類設計

可以與第4章,整體架構圖結合起來看。

  • 系統外掛層

DynamicPlugin類,實現了系統gradle外掛的plugin介面,為我們整個外掛的入口,主要解析配置檔案,並按照配置檔案建立task資訊。

DynamicParam類,提供了儲存並解析dyanmic_plugin配置檔案的方法。

  • 任務模組層

ITask介面,代表了一個我們定義的任務。

DeleteAndCopySoTask,刪除並拷貝so檔案任務。

TransfomrTask,替換系統System.loadLibrary方法任務。

ZipResTask,壓縮so和其他檔案,並生成對應的java資源實體類方法。

  • 底層實現層

SystemLoadClassVisitor類,Asm框架的class訪問類。

SystemLoadMethodVisitor類,Asm框架的method訪問類,用於替換System.loadLibrary方法。

JavaFileCreate類,使用javapoet框架產生java檔案。

其他輔助類,在此省略

  • 類uml圖

  1. dynamic_config.gradle配置檔案

該配置檔案主要包含了配置dynamic_plugin外掛執行步驟,外掛輸入輸出路徑,so檔案掃描路徑等資訊。

```

dynamic_config = [

    //是否執行替換System.loadlibrary操作

is_replace_load_library: false,

    //是否執行替換System.load操作

is_replace_load : false,

    //是否執行刪除so檔案操作

is_delete_so : false,

    //是否執行將so檔案拷貝到其他目錄操作

is_copy_so : false,

    //是否執行將動態資源打包,並生成java檔案操作

is_zip_res : false,

    //是否執行將so檔案打包,並生成java檔案操作

is_zip_so : false,

    //是否自動上傳所有資源,上傳方法為dynamic_upload

is_upload_res : false,

    //外掛是否工作在Release模式下

is_release_type : isReleaseBuildType(),

    //是否列印debug日誌

is_debug_log : true,

    //自動建立java檔案時的包名

create_java_pkg_name : 'com.test' ,

]

/**

  • 配置要刪除和拷貝的so檔案

  • map的key為壓縮包名稱,值為壓縮包包含的so檔案列表

  • key為debug_all_test時,會壓縮所有so包

*/

dynamic_scan_so_map = [

    guang_dong : [ 'libpajf.so' , 'libpajf_av.so' , 'libsqlite.so' ],

]

dynamic_so_config = [

    //so檔案忽略列表,該表中的檔案,不會被掃描。不在該列表中的檔案都會被掃描

// (dynamic_scan_so_map為空時,本列表才生效)

ignore_so_files: [],

    //so檔案掃描abi目錄,不在該目錄下的so將不被掃描

scan_so_abis : [ "arm64-v8a" , "armeabi-v7a" ],

    //拷貝出來的so資料夾字首,ignore_so_files生效時使用

so_input_prefix: 'test' ,

]

dynamic_lib_list = [

    //只有該列表中的包名,才會執行替換System.loadlibrary操作

//輸入debug_all_test,則會替換所有System.loadLibrary方法,用於測試

scan_load_library_pkgs : [],

    //在該列表中的包名或者類名,不會執行替換System.loadlibrary操作,和上面的配置可以同時生效

ignore_load_library_pkgs: [],

]

//該配置不要改動內容,需要改變路徑的,直接改變對應的方法內容即可

dynamic_dir = [

    //產生檔案的輸出目錄

output : createOrGetOutputPath(),

    //字型資源輸入目錄

typeface_input : createOrGetInputTypafacePath(),

    //幀動畫資源輸入目錄

frame_anim_input: createOrGetInputFrameAnimPath(),

    //so檔案資源輸入目錄

so_input : createOrGetInputSoPath(),

    //zip包輸入目錄

zip_input : createOrGetInputZipPath()

]

//該配置項,配置了android 2個gradle task的名稱

//主工程的mergeNativeLibs合併所有依賴的 native 庫

//主工程的stripDebugSymbols從 Native 庫中移除 Debug 符號。

dynamic_task = [

    //自定義的task執行哪裡

//true為mergeNativeLibs之後,stripDebugSymbols之前

//false為stripDebugSymbols之後,package之前

isTaskRunAfterMerge : true,

    //debug狀態下,mergeNativeLibs的task名稱

debugMergeNativeLibs : "mergeDebugNativeLibs" ,

    //release狀態下,mergeNativeLibs的task名稱

releaseMergeNativeLibs : "mergeReleaseNativeLibs" ,

    //debug狀態下,stripDebugSymbols的task名稱

debugStripDebugSymbols : "stripDebugDebugSymbols" ,

    //release狀態下,stripDebugSymbols的task名稱

releaseStripDebugSymbols: "stripReleaseDebugSymbols" ,

    //debug狀態下,系統打包task名稱

debugPackage : "packageDebug" ,

    //release狀態下,系統打包task名稱

releasePackage : "packageRelease" ,

    //debug狀態下,mergeNativeLibs的輸出目錄

debugNativeOutputPath : " $ { projectDir }/app/build/intermediates/merged_native_libs/debug/out/lib" ,

    //release狀態下,mergeNativeLibs的輸出目錄

releaseNativeOutputPath : " $ { projectDir }/app/build/intermediates/merged_native_libs/release/out/lib" ,

    //debug狀態下,,stripDebugSymbols的輸出目錄

debugStripOutputPath : " $ { projectDir }/app/build/intermediates/stripped_native_libs/debug/out/lib" ,

    //release狀態下,,stripDebugSymbols的輸出目錄

releaseStripOutputPath : " $ { projectDir }/app/build/intermediates/stripped_native_libs/release/out/lib" ,

]

//該閉包可以自動將檔案上傳到伺服器,引數列表為資源id,資原始檔路徑

//我們可以再次執行上傳伺服器操作,並返回對應的url。

//當然也可以不實現上傳操作,並自己手動上傳資源。

dynamic_upload = {

id, path ->

println( "dynamic_upload id $ { id } ,path $ { path }" )

    return 'http://url'

} ```

  1. 優化效果

通過引入動態資源管理系統,並將一鍵報警sdk相關的so檔案和其他普通資源動態化後,貨拉拉使用者端的包體積減少了8M,從54M變為了46M。後繼將會繼續嘗試進行其他so檔案的動態化。

  1. 參考文獻

https://www.jianshu.com/p/260137fdf7c5

https://mp.weixin.qq.com/s/X58fK02imnNkvUMFt23OAg