京東金融Android瘦身探索與實踐

語言: CN / TW / HK

作者:京東科技 馮建華

一、背景

隨着業務不斷迭代更新,App的大小也在快速增加,2019年~2022年期間一度超過了117M,期間我們也做了部分優化如圖1紅色部分所示,但在做優化的同時面臨着新的增量代碼,包體積一直持續上升包體積直接或間接地影響着下載轉化率、安裝時間、磁盤空間等重要指標,所以投入精力發掘更深層次的安裝包體積優化是十分必要的。根據谷歌商店的內部數據,APK體積每減少10M,平均可增加~1.5%的下載轉化率,如圖2所示:

 



圖1 京東金融Android版本2019-2022體積變化過程 (紅色部分是期間做的部分優化,但是很快就反彈回去了)

 



圖2 谷歌商店應用轉化率增加幅度 / 10M [1]

因此2022年9月開始我們針對金融APP進行了瘦身專項整治,在不考慮增量的情況,無刪減業務代碼的情況下實現從117M瘦身至74M,在本次安裝包瘦身過程中我們遇到了不少坑,同時也積累了些經驗,在此分享給大家。

二、APK分析

接下來我們會簡單分析下 Apk內各組成部分,以及 Apk 作為 ZIP,其標準結構是什麼樣的,為包瘦身的目標設定及任務拆解提供數據支撐。

2.1 APK內容分析

 

 



圖3 APK 結構

•classes.dex APK 中可能包含一個或多個 classes.dex 文件,應用程序內的 Java/Kotlin 源碼最終會以字節碼的方式存在於 classes.dex 文件中。

•resources.arsc aapt工具在編譯資源會將一些資源或者資源索引打包成resources.arsc。

•res/ 源碼工程中 res 目錄下除了 values 外的資源文件,這些文件路徑同時會記錄在 resources.arsc 中。

•lib/ nativeLibraries,即源碼工程 jni 目錄下的 so 文件,二級目錄為 NDK支持的 ABI。

•assets/ 與 res/ 資源目錄不同,assets/ 下的資源文件不會在 resources.arsc 中生成查詢條目,且 assets/ 下的資源目錄可完全自定義,在程序中通過 AssetManager 對象來獲取。

•META-INF/ 該文件夾下主要包含 CERT.SF 和 CERT.RSA 簽名文件, 以及 MANIFEST.MF 清單文件

•AndroidManifest.xml 應用清單文件,用於描述應用基本信息,主要包括應用包名、應用id、應用組件、所需權限、設備兼容性等。

2.2 SDK大小分析

通過我們自研的能效提升平台Pandora[7],可以直觀地看到SDK的大小,如圖4所示:

 



圖4 SDK大小排序(包含版本號)

 



圖5 SDK中包含的SO庫列表及大小

根據SDK分析後結合業務,來判斷哪些業務適合做插件化,進而直觀的降低包體積。

2.3 ZIP結構分析

可以用zipinfo命令輸出壓縮包中每個文件的詳細信息日誌,用法:zipinfo -l --t --h test.apk > test.txt

輸出的日誌文件打開如圖6所示,每個文件的壓縮信息一行,包括文件名、原始大小、壓縮後大小等指標:

 



圖6 APK內文件信息大小

對以上日誌信息進行逐行解析,根據解混淆後的文件名路徑、文件類型進行歸類統計,即可得出Apk的總覽信息,包括各類型文件的數量、總大小、單一文件大小等指標,並建立文件大小索引。

三、瘦身實踐

整體實施路徑如圖7所示,主要分為:

1.常規技術方案,通過Gradle插件(代碼無侵入、自動化)在編譯時期完成APP瘦身;

2.進階技術方案,將部分業務線差別性的通過插件化或者SO動態下載的方式就行改造,業務改造的越多,收益越高;

3.業務優化方案,針對業務線的數據埋點,生成訪問UV進行排名,將UV較低的業務線反饋架構委員會,評估是否可以進行下線或者通過進階技術方案(2)進行改造,進而減小包體積。

 



圖7 整體實施路徑

3-1 常規技術方案

3-1-1 圖片處理

經過上述的APP的剖析,得出佔用體積第一大的還是圖片,因此將APP所有含SDK內所有圖片在編譯打包過程中通過瘦身任務自動完成圖片優化處理,整體優化方案如圖8所示:

 



圖8 圖片優化方案

1.多 DPI 優化:

Android 為了適配各種不同分辨率或者模式的設備,為開發者設計了同一資源多個配置的資源路徑,app 通過 resource 獲取圖片資源時,自動根據設備配置加載適配的資源,但這些配置伴隨着的問題就是高分辨率的設備包含低分辨率的無用圖片或者低分辨率的設備包含高分辨率的無用圖片。

一般情況下,針對國內應用市場,App 為了減少包大小,會選用市場佔有率最高的一套 dpi(google 推薦 xxhdpi)兼容所有設備。而針對海外應用市場的 APP,大多會通過 AppBundle 打包上傳至 Google Play,能夠享受動態分發 dpi 這一功能,不同分辨率手機可以下載不同 dpi 的圖片資源,因此我們需要提供多套 dpi 來滿足所有設備。在項目中,我們的圖片有的只有一套 dpi,有的有多套 dpi,針對上述兩種場景,我們分別在打包時合併資源、複製資源,減少了包大小。

2.轉換為webp格式:

WebP是谷歌提供的一種支持有損壓縮和無損壓縮的圖片文件格式,而且可以提供比JPEG或PNG更好的壓縮。在Android 4.0(API level 14)中支持有損的WebP圖像,在Android 4.3(API level 18)和更高版本中支持無損和透明的WebP圖像

因此:我們採用插件在編譯時期僅保留針對圖片通過Google提供的shell程序進行格式轉換,轉換成功刪除舊的圖片,進而達到APK瘦身的效果

3.png壓縮

Pngquant是一個好用的png壓縮工具一個,可以進行有損圖片壓縮的命令行工具,因此在1和2處理結束後,可以使用Pngquant進行二次壓縮,達到更優的圖片瘦身。

3-1-2 R文件內聯優化

DEX裏是Java/Kotlin 源碼編譯後的字節碼文件,對DEX的優化其實就是怎麼優化字節碼文件,DEX中包含大量的資源索引R文件,這裏主要講下如何通過資源ID內聯後進行R文件刪除,達到APK瘦身的目的:

R文件瘦身的可行性分析

日常開發階段,在主工程中通過R.xx.xx的方式引用資源,經過編譯後R類引用對應的常量會被編譯進class中。

setContentView(2131427356);

這種變化叫做內聯,內聯是java的一種機制(如果一個常量被標記為static final,在java編譯的過程中會將常量內聯到代碼中,減少一次變量的內存尋址)。非主工程中,R類資源ID以引用的方式編譯進class中,不會產生內聯。

setContentView(R.layout.activity_main);

產生這種現象的原因是AGP打包工具導致的。具體細節,大家可以去查閲一下android gradle plugin在R文件上的處理過程。結論:R類id內聯後程序可運行,但並非所有的工程都會自動產生內聯現象,我們需要通過技術手段在合適的時機將R類id內聯到程序中,內聯完成後,由於不再依賴R類文件,則可以將R類文件刪除,在應用正常運行的同時,達到包瘦身目的,如圖9所示,在編譯完成後會產生大量的R文件:

 



圖9 項目R文件生成示意

整體方案如圖10所示:

 



圖10 R文件優化流程

注意事項:在替換階段一定要加入二次檢查,防止替換完,運行時出現ResourceNotFind異常,如下所示:

try {
    int value = RManager.checkInt(type, name);
}catch (Exception e){
    String errorMsg = "resource is not found(I),className="+className+",fieldName="+owner+"."+name;
    throw new ResourceNotFoundException(errorMsg);
}
try {
    int[] value = RManager.checkIntArray(type, name);
}catch (Exception e){
    String errorMsg = "resource is not found(I[]),className="+className+",fieldName="+owner+"."+name;
    throw new ResourceNotFoundException(errorMsg);
}

 

3-1-3 AndResGuard進行資源混淆

1.資源加載過程分析

開發過程中我們通過aapt生成的R.java中的常量來使用資源,而在編譯之後使用常量的地方都會被替換為常量的值,如下所示:

final View layout = inflater.inflate(2131165182, container, false);

也就是説我們通過Resource使用一個int數值來查找使用資源。那麼Resource是怎麼通過int數值找到具體的資源呢?我們解壓apk可以看到裏面有個resources.arsc文件,這個文件也是由aapt生成,文件中保存着資源id和資源key的映射關係,Resource就是按照這個映射關係找到資源的。

2.resources.arsc:

圖11是resources.arsc的裏存儲的映射關係,resources.arsc可以理解為一個資源映射數據庫,根據ID映射其中具體的路徑和名稱。

 



圖11 resources.arsc解析

通過解壓APK後,將資源文件名進行短鏈處理比如res/layout/hello.xml轉換為r/l/a.xml後,然後更改resources.arsc對應的value值,達到整體的瘦身效果。

AndResGuard[5]是微信推出資源優化工具,它的基本思想類似於 ProGuard 中的混淆,可以實現以上方案。

3-1-4 7zip壓縮

7zip命令解釋:

-t:指定壓縮類型,支持7z, xz, split, zip, gzip, bzip2, tar, ....

-m:指定壓縮算法,默認是Deflate

具體流程如下:

第一步:使用7z命令將未簽名包解壓到指定目錄:7za x ${未簽名包} -o${7z解壓目錄}

第二步:首先通過7z命令對解壓目錄進行全部壓縮:7za a -tzip -mx9 ${目標7z文件名} ${7z解壓目錄}

第三步:獲取存儲類型文件,通過Android SDK中的aapt命令獲取壓縮方式為Stored的文件列表:aapt l -v ${未簽名包}

第四步:更新存儲類型文件,通過7z命令將存儲類型文件更新到第二步操作中生成的7zip安裝包:7za a -tzip -mx0 ${目標7z文件名} ${存儲類型文件目錄}

3-1-5 配置CPU架構

根據不同的CPU架構,構建不同的類型的安裝包,目前主流設備都是64位機器,因此安卓市場上主要投放的是依據arm64-v8a編譯構建的安裝包

ndk {
    abiFilters arm64-v8a
}

3-1-6 arsc 壓縮

resources.arsc 的壓縮體積收益很高,但對其進行壓縮會影響啟動速度和內存指標。原因是:系統在加載 arsc 文件時,若 arsc 文件未壓縮,可使用 mmap 進行內存映射;若 arsc 文件被壓縮了,則需要將其解壓縮後讀取到RAM 緩衝區,會增加內存使用,也會拖慢啟動速度。

官方出於同樣的考慮,從 targetSdkVersion>=30後不能用這種方式 開始強制要求resources.arsc ,否則會直接安裝失敗,因此本文不在展開闡述。

3-1-7 國際化語言處理

京東金融App目前僅在國內市場運營,但是接入的大量SDK中加入了幾十種語言一樣,導致整個體積變大,經過評估可以通過配置 resConfigs 去除無用的語言資源。

defaultConfig {
    resConfigs "zh","en"
}

3-1-8 shrinkResources

shrinkResources:編譯過程中用來檢測並刪除無用資源文件,也就是沒有引用的資源

minifyEnabled:用來開啟刪除無用代碼,比如沒有引用到的代碼,所以如果需要知道資源是否被引用就要配合minifyEnabled使用,只有兩者都為true時才會起到真正的刪除無效代碼和無引用資源的目的。

其作用是將未被引用的資源文件替換為一個體積很小的格式文件(仍存在佔位體積,同時保留了該資源條目,所以 resources.arsc 體積並不會減少),可通過 res/raw/keep.xml 文件配置 shrinkMode 和白名單。

buildTypes {
   release {
      // 不顯示Log
      buildConfigField "boolean", "LOG_DEBUG", "false"
      //混淆
      minifyEnabled true
      // 移除無用的resource文件
      shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig sign.release
   }
}

3-1-9 編碼約束

•儘量少用枚舉類型,因為枚舉在編譯成字節碼後,會增加大量體積,如圖12所示(22行代碼編譯後字節碼是86行)

•

 

圖12 枚舉類型編譯後的字節碼對比

•刪除不必要的LOG日誌輸出

3-2 進階技術方案

SO庫動態下載和插件化技術,本質上都屬於動態下載的一個範疇,兩個方案可以在業務中長期持續使用,在具體使用過程中如何選擇,如圖13所示:

 



圖13 業務如何選擇進階方案

3-2-1 SO庫動態加載

APP中有部分業務不適合做插件化改造,經過拆解發現其中的SO庫佔比很大,因此可以考慮採用動態下載的方式進行改造,進而實現減小體積。

SO庫加載的兩種方式

第一種方式我們直接把SO庫下載並放到指定目錄就可以

第二種方式是通過環境變量設置的目錄中進行加載SO庫,因此我們需要追加指定的目錄到環境變量中,就可以正常加載SO庫

System.load("{安全路徑}/libxxx.so") 
System.load("xxx") 

1、如何設置APP中SO庫的環境變量位置(借鑑Tinker):

final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
final Object dexPathList = pathListField.get(classLoader);

final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");

List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
if (origLibDirs == null) {
    origLibDirs = new ArrayList<>(2);
}
final Iterator<File> libDirIt = origLibDirs.iterator();
while (libDirIt.hasNext()) {
    final File libDir = libDirIt.next();
    if (folder.equals(libDir)) {
        libDirIt.remove();
        break;
    }
}
origLibDirs.add(0, folder);

final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
if (origSystemLibDirs == null) {
    origSystemLibDirs = new ArrayList<>(2);
}

final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
newLibDirs.addAll(origLibDirs);
newLibDirs.addAll(origSystemLibDirs);

final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);

final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);

final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
nativeLibraryPathElements.set(dexPathList, elements);

2、如何刪除指定SO庫和整個加載流程,如圖14所示:

 



圖14 SO庫刪除和加載流程

3-2-2 插件化

什麼是插件化:

插件化是將一個Apk根據業務功能拆分成不同的子Apk(也就是不同的插件),每個子Apk可以獨立編譯打包,最終發佈上線的是集成後的Apk。在Apk使用時,每個插件是動態加載的,插件也可以進行熱修復和熱更新。

•宿主:主App可以用來加載插件也成為Host

•插件:插件App,被宿主加載的App,可以跟普通的App一樣的Apk文件

什麼形式的業務適合插件化改造:

•業務相對獨立,與宿主App解耦徹底

•改造成本低,收益相對較高

•佔用體積較大

經過一些列評估,視頻營業符合以上幾點,改造後的效果如圖15所示:

 



圖15 視頻營業廳插件化改造後效果

3-3 業務優化方案

隨着業務越來越多,一些陳舊的業務UV越來越低,因此制定了一套業務下線優化流程,如圖16所示:

 



圖16 業務優化方案流程

四、管控

瘦身方案的實施很重要,後續的管控不反彈更重要,我們一邊做瘦身治理,另一邊探索常態化的管控機制,最終沉澱了一套管控規範和管控機制。管控的目的不是限制業務迭代或者新增代碼,而是怎麼做到在有限的代碼中實現其功能,提升工程師日常編碼中的瘦身意識。

4.1 SDK接入規範

為防止SDK無序擴張,制定了SDK准入規範,在保證功能的前提下嚴控SDK體積大小,最大程度控制APP體積反彈。

4.2 管控流程

 

 



圖17 管控流程

根據增加內容、刪除內容、增大內容、減小內容、重複文件、代碼治理等資源文件的變動情況結合治理管控規範等進行治理,打包構建完成會跟歷史版本就行差量對比,獲取變化的內容來評估是否具有優化空間,並給出優化目標,待優化後重新構建打包集成。

五、成果與後續規劃

5.1 成果

通過以上措施,京東金融Android版本經過兩個季度5個版本的迭代,從117M到現在的74M(圖18),整體一直維持在可控的範圍內。同時在接下來的版本迭代中,我們會將APK瘦身常態化,始終維持包體積在可控的範圍內。

 



 

圖18 金融APP瘦身成果

5.2 後續規劃

持續技術手段優化:

業務的不斷堆積迭代,總會產生一些無用的資源,所以安裝包瘦身要定期清理這些無用文件和代碼;

做好各個版本的監控,對比版本之間的差異,發現可以在不影響業務情況下,使用技術手段優化。

線上管控平台搭建:

前期採用線下的管控治理,實施起來有點耗時,後續我們會完善線上管控平台的搭建,與整個App發佈構建平台進行融合,形成流水線的機制,做好管控。

小結:安裝包瘦身的探索還有很長的路走,本文也只是列舉了一些常用的瘦身方案,對於龐大的項目除了優化外,還有做好項目之間的治理,持續對APP進行體積優化,提升用户體驗。

【參考資料】

[1] 包大小與安裝轉化率
http://medium.com/googleplaydev/shrinking-apks-growing-installs-5d3fcba23ce2

[2] ProGuard http://www.guardsquare.com/proguard

[3] R8http://r8.googlesource.com/r8

[4] ProGuard與R8對比
http://www.guardsquare.com/blog/proguard-and-r8

[5] AndResGuardhttp://github.com/shwenzhang/AndResGuard

[6] AGPhttp://developer.android.com/studio/releases/gradle-plugin

[7] Pandora:基於去中心化技術的研發、測試階段能效提升工具