深入探索 Android 包體積優化(匠心製作)

前言

成爲一名優秀的Android開發,需要一份完備的 知識體系,在這裏,讓我們一起成長爲自己所想的那樣~。

Android 性能優化的知識體系當中,包體積優化一直被排在優先級比較低的位置,從而導致很多開發同學對自身應用的大小並不重視。在項目發展的歷程中,一般可劃分爲如下三個階段:

初創期 => 成長期 => 成熟期

通常來說,當應用處於成長期的中後階段時,纔會考慮去做系統的包體積優化,因此,只有在這個階段及之後,包體積優化帶來的收益纔是可觀的。

那麼,包體積優化能夠給我們帶來哪些 收益 呢?如何全面對應用的包體積進行 系統分析針對性優化 呢?在這篇文章中,我們將一起進行深入地分析與探索。

思維導圖大綱

目錄

  • 一、瘦身優化及 Apk 分析方案
    • 1、瘦身優勢
    • 2、APK 組成
    • 3、APK 分析
  • 二、代碼瘦身方案探索
    • 1、Dex 探祕
    • 2、ProGuard
    • 3、D8 與 R8 優化
    • 4、去除 debug 信息與行號信息
    • 5、dex 分包優化
    • 6、使用 XZ Utils 進行 Dex 壓縮
    • 7、三方庫處理
    • 8、移除無用代碼
    • 9、避免產生 Java access 方法
    • 10、利用 ByteX Gradle 插件平臺中的代碼優化插件
    • 11、小結
  • 三、資源瘦身方案探索
    • 1、冗餘資源優化
    • 2、重複資源優化
    • 3、圖片壓縮
    • 4、使用針對性的圖片格式
    • 5、資源混淆
    • 6、R Field 的內聯優化
    • 7、資源合併方案
    • 8、資源文件最少化配置
    • 9、儘量每張圖片只保留一份
    • 10、資源在線化
    • 11、統一應用風格
  • 四、So 瘦身方案探索
    • 1、So 移除方案
    • 2、So 移除方案優化版
    • 3、使用 XZ Utils 對 Native Library 進行壓縮
    • 4、對 Native Library 進行合併
    • 5、刪除 Native Library 中無用的導出 symbol
    • 6、So 動態下載
  • 五、其它優化方案
    • 1、插件化
    • 2、業務梳理
    • 3、轉變開發模式
  • 六、包體積監控
    • 1、包體積監控的緯度
  • 七、瘦身優化常見問題
    • 1、怎麼降低 Apk 包大小?
    • 2、Apk 瘦身如何實現長效治理?
  • 八、總結

下面,我們就先來了解下爲什麼要進行瘦身優化以及如何對 Apk 大小進行分析。

一、瘦身優化及 Apk 分析方案介紹

1、瘦身優勢

我們首先來介紹下,爲什麼我們需要做 APK 的瘦身優化?

APK 瘦身優化的原因

主要有 三個方面 的原因:

1、下載轉化率

APK 瘦身優化在實際的項目中優先級是比較低的,因爲做了之後它的好處不是那麼明顯,尤其是那些還沒有到 穩定期 的項目,我們都知道,App 的發展歷程是從 項目初期 => 成長期 => 穩定期,對於處於 發展初期與成長期 的項目而言,可能會做 啓動優化、卡頓優化,但是一般不會做 瘦身優化瘦身優化 最主要的好處是對應用 下載轉化率 的影響,它是 App 業務運營的重要指標之一,在項目精細化運營的階段是非常重要的。因爲如果你的 App 與其它同類型的 App 相比 Apk 體積要更小的話,那麼你的 App 下載率就可能要高一些。而且,包體積越小,用戶下載等待的時間也會越短,所以下載轉換成功率也就越高。所以,安裝包大小與下載轉化率的關係 大致是成反比 的,即安裝包越大,下載轉換率就越小。一個 80MB 的應用,用戶即使點了下載,也可能因爲網絡速度慢、突然反悔導致下載失敗。而對於一個 20MB 的應用,用戶點了下載之後,在猶豫要不要下的時候可能就已經下載完了。

而且,現在很多大型的 App 一般都會有一個 Lite 版本的 App,這個也是出於下載轉化率方面的考慮。

2、應用市場

Google Play 應用市場強制要求超過 100MB 的應用只能使用 APK 擴展文件方式 上傳。當使用 APK 擴展文件方式 上傳時,Google Play 會爲我們的應用 託管 擴展文件,並將其 免費提供 給設備。擴展文件將保存到設備的共享存儲位置(SD 卡或可安裝 USB 的分區;也稱爲“外部”存儲),應用可以在其中訪問它們。在大多數設備上,Google Play 會在下載 APK 的同時下載擴展文件,因此應用在用戶首次打開時便擁有了所需的一切。但是,在某些情況下,我們的應用必須在應用啓動時從 Google Play 下載文件。如果您想避免使用擴展文件,並且想要應用程序的下載大小大於100 MB,則應該使用 Android App Bundles 上傳應用程序,此時應用程序最多可提供150 MB的壓縮下載大小Android App Bundles 就是 Android 應用程序捆綁包,它能夠讓 App添加動態功能模塊的方式 去解決 APK 大小較大的問題。如下,就是由一個基本模塊和兩個動態功能模塊組成的 Android App Bundle APK 的組成結構圖:

image

3、渠道合作商的要求

此外,還有一個原因,當我們的 App 做大之後,可能需要跟各個手機廠商合作預裝,這些 渠道合作商會對你的 App 做詳細的要求,只有達到相應的要求後才允許你的 App 預裝到手機上。而且,越大的 App 其單價成本也會越高。所以,瘦身也是我們項目做大之後一定會遇到的一個問題。

體積過大對 App 性能的影響

此外,包體積除了會影響 應用的下載轉化率 之外,主要還會對 App 三個方面 的性能有一定的影響,如下所示:

  • 1)、安裝時間:比如 文件拷貝、Library 解壓,並且,在編譯 ODEX 的時候,特別是對於 Android 5.0 和 6.0 系統來說,耗費的時間比較久,而 Android 7.0 之後有了 混合編譯,所以還可以接受。最後,App 變大後,其 簽名校驗 的時間也會變長
  • 2)、運行時內存:Resource 資源、Library 以及 Dex 類加載都會佔用應用的一部分內存
  • 3)、ROM 空間:如果應用的安裝包大小爲 50MB,那麼啓動解壓之後很可能就已經超過 100MB 了。並且,如果 閃存空間不足,很可能出現“寫入放大”的情況,它是閃存和固態硬盤(SSD)中一種不良的現象,閃存在可重新寫入數據前必須先擦除,而擦除操作的粒度與寫入操作相比低得多,執行這些操作就會多次移動(或改寫)用戶數據和元數據。因此,要改寫數據,就需要讀取閃存某些已使用的部分,更新它們,並寫入到新的位置,如果新位置在之前已被使用過,還需連同先擦除;由於閃存的這種工作方式,必須擦除改寫的閃存部分比新數據實際需要的大得多。即最終可能導致實際寫入的物理資料量是寫入資料量的多倍

2、APK 組成

我們都知道,Android 項目最終會編譯成一個 .apk 後綴的文件,實際上它就是一個 壓縮包。因此,它內部還有很多不同類型的文件,這些文件,按照大小,共分爲如下幾類:

  • 1)、代碼相關classes.dex,我們在項目中所編寫的 java 文件,經過編譯之後會生成一個 .class 文件,而這些所有的 .class 文件呢,它最終會經過 dx 工具編譯生成一個 classes.dex
  • 2)、資源相關resassets、編譯後的二進制資源文件 resources.arsc 和 清單文件 等等。resassets 的不同在於 res 目錄下的文件會在 .R 文件中生成對應的資源 ID,而 assets 不會自動生成對應的 ID,而是通過 AssetManager 類的接口來獲取。此外,每當在 res 文件夾下放一個文件時,aapt 就會自動生成對應 id 並保存在 .R 文件中,但 .R 文件僅僅只是保證編譯程序不會報錯,實際上在應用運行時,系統會根據 ID 尋找對應的資源路徑,而 resources.arsc 文件就是用來記錄這些 ID 和 資源文件位置對應關係 的文件
  • 3)、So 相關lib 目錄下的文件,這塊文件的優化空間其實非常大。

此外,還有 META-INF,它存放了應用的 簽名信息,其中主要有 3個文件,如下所示:

  • 1)、MANIFEST.MF:其中每一個資源文件都有一個對應的 SHA-256-Digest(SHA1) 簽名,MANIFEST.MF 文件的 SHA256(SHA1) 經過 base64 編碼的結果即爲 CERT.SF 中的 SHA256(SHA1)-Digest-Manifest 值。
  • 2)、CERT.SF:除了開頭處定義的 SHA256(SHA1)-Digest-Manifest 值,後面幾項的值是對 MANIFEST.MF 文件中的每項再次 SHA256(SHA1) 經過 base64 編碼後的值。
  • 3)、CERT.RSA:其中包含了公鑰、加密算法等信息。首先,對前一步生成的MANIFEST.MF使用了SHA256(SHA1)-RSA算法,用開發者私鑰簽名。然後,在安裝時使用公鑰解密。最後,將其與未加密的摘要信息(MANIFEST.MF文件)進行對比,如果相符,則表明內容沒有被修改。

3、APK分析

下面,我們就來學習 APK 分析的 四種常用方式

1、使用 ApkTool 反編譯工具分析 APK

第一種方式,就是使用 ApkTool 這個反編譯工具,它的官網地址如下:

ApkTool 官方網站

其具體的 反編譯命令 如下所示:

apktool d xxx.apk

下面,我們就來使用 ApkTool 來對應用進行反編譯。

ApkTool反編譯實戰

1、下載並配置apktool

apktool 下載配置官方文檔

我這裏僅介紹 Mac OS X 平臺上的下載配置,其它平臺請點擊上方鏈接查看。

  • 1)、下載腳本,保存爲 apktool 文件。
  • 2)、下載最新版 apktool.jar(需要翻牆)
  • 3)、將下載的 jar 包重命名爲 apktool.jar
  • 4)、配置環境變量,這裏有兩種方案,如下所示:
    • 第一種是直接將 apktoolapktool.jar 移到 /usr/local/bin 目錄,但是這裏需要 root 權限,命令前加 sudo,回車後輸入密碼即可。
    • 第二種是在 ~/.bash_profile 文件下配置,首先新建 apktool 文件夾,將兩個文件放到這個文件下,打開終端,使用 vim 加上環境配置,其命令如下所示:
    // 1、使用vim命令在命令行打開.bash_profile文件,並可以在命令行
    // 上編輯,當然,你也可以直接打開.bash_profile文件
    vim ~/.bash_profile
    // 2、在.bash_profile最後加上這一行即可
    export PATH=前面路徑/apktool:$PATH
    // 3、使編輯後的配置生效
    source ~/.bash_profile
  • 5)、最後,使用以下命令將兩個文件權限設置爲 可執行 即可:

    sudo chmod a+x file

2、使用ApkTool分析APK

我們在命令行下輸入以下命令對 APK 進行反編譯,如下所示:

java -jar apktool_2.3.4.jar apktool d app-release.apk

反編譯完成之後,它就會在當前的文件夾下面生成 app-release 的目錄,目錄結構如下所示:

image

這樣我們就可以看到當前 App的具體組成 了。下面,我們介紹下第二種 APK 分析 的方式。

2、使用AS 2.2之後提供的Analyze APK

Analyze APK 具有如下功能:

  • 1)、可以直觀地查看到 APK 的組成,比如大小、佔比等等
  • 2)、查看 dex 文件的組成
  • 3)、對不同的 APK 進行對比分析

下面,我們就來具體實戰一下,需要注意的是,我們可以 直接將電腦上的 apk 拖進 AS 中就可以自動使用 Analyze APK 打開 apk。然後,我們就可以看到 APK 文件的絕對大小以及各組成文件的百分佔比,如下圖所示:

image

可以看到,Awesome-WanAndroid 應用的 classes.dex 的大小爲 3.3MB,總佔比爲 42.2%。並且,libres 目錄也有 1.9MB,總佔比大概爲 25%,因此,對於 Awesome-WanAndroid App的優化方向就應該是 dex 爲主、so 和 res 爲輔 了。此外,我們還可以查看 classes.dex 中還包含有哪些類,如下圖所示:

image

我們平時在做 競品分析 的時候,就能夠很方便地來 看一下我們 App 的競品用到了哪些第三方 SDK。同時,我們也可以從清單文件中很方便地查看 APK 文件的最終版本,因爲 Analyze APK 能夠直接對清單文件進行解析。

此外,在應用右上角還有一個 Compare with previos APK 的按鈕,我們點擊它之後,就可以 將當前的 APK 與別的版本的 APK 進行對比,這樣就可以對新舊兩個版本的 APK 文件大小進行對比。

接下來,我們再介紹下第三種 APK 分析的方式。

3、使用 nimbledroid 進行 APK 性能分析

nimbledroid官網

nibledroid 是美國哥倫比亞大學的博士創業團隊研發出來的分析 Android App 性能指標的系統,分析的方式有靜態和動態兩種方式,如下所示:

  • 1)、靜態分析:可以分析出APK安裝包中大文件排行榜,Dex 方法數和知名第三方 SDK 的方法數及佔代碼整體的比例
  • 2)、動態分析:可以給出 冷啓動時間, 列出 Block UI 的具體方法, 內存佔用, 以及 Hot Methods, 從這些分析報告中, 可以 定位出具體的優化點

它的使用方式其實非常簡單,只需要直接上傳APK 即可。然後,nimbledroid 網站的後臺就會自動對 APK 進行分析,並最終給出一份 全面的 APK 分析報告

下面,我們再來介紹最後一種 APK 分析工具,即二進制檢查工具 android-classshark

4、使用 android-classshark 進行 APK 分析

android-classshark項目地址

android-classshark 是一個 面向 Android 開發人員的獨立二進制檢查工具,它可以 瀏覽任何的 Android 可執行文件,並且檢查出信息,比如類的接口、成員變量等等,此外,它還可以支持多種格式,比如說 APK、Jar、Class、So 以及所有的 Android 二進制文件如清單文件等等。下面,我們就來使用 android-classshark 來進行一下實戰。

android-classshark 實戰

首先,我們從它的 Github 地址上下載對應的 ClassyShark.jar,地址如下所示:

ClassyShark.jar-下載地址

然後,我們雙擊打開 ClassShark.jar拖動我們的 APK 到它的工作空間即可。接下來,我們就可以看到 Apk 的分析界面了,這裏我們點擊 classes 下的 classes.dex,在分析界面 左邊 可以看到該 dex 的方法數和文件大小,並且,最下面還顯示出了該 dex 中包含有 Native Call 的類。如下圖所示:

image

此外,我們點擊左上角的 Methods count 還可以切換到 方法數環形圖標統計界面,我們不僅可以 直觀地看到各個包下的方法數和相對大小,還可以看到各個子包下的方法數和相對大小。如下圖所示:

image

二、代碼瘦身方案探索

在講解如何對 Dex 進行優化之前,可能有很多同學對 Dex 還沒有足夠的瞭解,這裏我們就先詳細地瞭解下 Dex

1、Dex 探祕

DexAndroid 系統的可執行文件,包含 應用程序的全部操作指令以及運行時數據。因爲 Dalvik 是一種針對嵌入式設備而特殊設計的 Java 虛擬機,所以 Dex 文件與標準的 Class 文件在結構設計上有着本質的區別。當 Java 程序被編譯成 class 文件之後,還需要使用 dx 工具將所有的 class 文件整合到一個 dex 文件中,這樣 dex 文件就將原來每個 class 文件中都有的共有信息合成了一體,這樣做的目的是 保證其中的每個類都能夠共享數據,這在一定程度上 降低了信息冗餘,同時也使得 文件結構更加緊湊與傳統 jar 文件相比,Dex 文件的大小能夠縮減 50% 左右。關於 Class 文件與 Dex 文件的結果對比圖如下所示:

image

如果想深入地瞭解 Dex 文件格式,可以參見Google 官方教程 - Dex格式

Dex 一般在應用包體積中佔據了不少比重,並且,Dex 數量越多,App 的安裝時間也會越長。所以,優化它們可以說是 重中之重。下面,我們就來看看有哪些方式可以優化 Dex 這部分的體積。

2、ProGuard

Java 是一種跨平臺的、解釋型語言,而 Java 源代碼被編譯成 中間 ”字節碼” 存儲於 Class 文件之中。

那麼,我們爲什麼要使用代碼混淆呢?

由於跨平臺的需要,Java 字節碼 中包括了很多源代碼信息,如變量名、方法名,並且通過這些名稱來訪問變量和方法,這些 符號帶有許多語義信息,很 容易被反編譯成 Java 源代碼。爲了防止這種現象,我們可以使用 Java 混淆器對 Java 字節碼進行混淆。

代碼混淆也被稱爲 花指令,它 將計算機程序的代碼轉換成一種功能上等價,但是難以閱讀和直接理解的形式。混淆就是對發佈出去的程序進行重新組織和處理,使得處理後的代碼與處理前代碼完成相同的功能,而混淆後的代碼很難被反編譯,即使反編譯成功也很難得出程序的真正語義。混淆器的 作用 不僅僅是 保護代碼,它也有 精簡編譯後程序大小 的作用,其 通過縮短變量和函數名以及丟失部分無用信息等方式,能使得應用包體積減小

代碼混淆的形式

目前,代碼混淆的形式主要有 三種,如下所示:

  • 1)、將代碼中的各個元素,比如類、函數、變量的名字改變成無意義的名字。例如將 hasValue 轉換成單個的字母 a。這樣,反編譯閱讀的人就無法通過名字來猜測用途。
  • 2)、重寫 代碼中的 部分邏輯,將它變成 功能上等價,但是又 難以理解 的形式。比如它會 改變循環的指令、結構體
  • 3)、打亂代碼的格式,比如多加一些空格或刪除空格,或者將一行代碼寫成多行,將多行代碼改成一行。

Proguard 的作用

Android SDK 裏面集成了一個工具 — Proguard,它是一個免費的 Java 類文件 壓縮、優化、混淆、預先校驗 的工具。它的 主要作用 大概可以概括爲 兩點,如下所示:

  • 1)、瘦身:它可以檢測並移除未使用到的類、方法、字段以及指令、冗餘代碼,並能夠對字節碼進行深度優化。最後,它還會將類中的字段、方法、類的名稱改成簡短無意義的名字
  • 2)、安全:增加代碼被反編譯的難度,一定程度上保證代碼的安全

所以說,混淆不僅是保障 Android 程序源碼安全第一道門檻,而且在一定程度上,使用它能夠減小 優化字節碼 的大小。優化字節碼 的處理流程如下圖所示:

image

而它的作用具體可以細分三點,如下所示:

1、壓縮(Shrinking)

默認開啓,以減小應用體積,移除未被使用的類和成員,並且 會在優化動作執行之後再次執行,因爲優化後可能會再次暴露一些未被使用的類和成員。我們可以使用如下規則來關閉壓縮:

-dontshrink 關閉壓縮

2、優化(Optimization)

默認開啓,在 字節碼級別執行優化,讓應用 運行的更快。使用如下規則可進行優化相關操作:

-dontoptimize 關閉優化
-optimizationpasses n 表示proguard對代碼進行迭代優化的次數,Android一般爲5

3、混淆(Obfuscation)

默認開啓,增大反編譯難度,類和類成員會被隨機命名,除非用 優化字節碼 等規則進行保護。使用如下規則可以關閉混淆:

-dontobfuscate 關閉混淆

Proguard 的優化細節

Proguard 中所做的優化包括 內聯、修飾符、合併類和方法等 30 多種優化項,在特定的情況下,它儘可能地做了相應的優化,下面列出了部分的 優化細節

  • 1)、優化了 Gson 庫的使用
  • 2)、把類都標記爲 final
  • 3)、把枚舉類型簡化爲常量
  • 4)、把一些類都垂直合併進當前類的結構中
  • 5)、把一些類都水平合併進當前類的結構中
  • 6)、移除 write-only 字段
  • 7)、把類標記爲私有的
  • 8)、把字段的值跨方法地進行傳遞
  • 9)、把一些方法標記爲私有、靜態或 final
  • 10)、解除方法的 synchronized 標記
  • 11)、移除沒有使用的方法參數

Proguard 的配置

混淆之後,默認會在工程目錄 app/build/outputs/mapping/release 下生成一個 mapping.txt 文件,這就是 混淆規則,所以我們可以根據這個文件把混淆後的代碼反推回原本的代碼。要使用混淆,我們只需配置如下代碼即可:

buildTypes {
    release {
        // 1、是否進行混淆
        minifyEnabled true
        // 2、開啓zipAlign可以讓安裝包中的資源按4字節對齊,這樣可以減少應用在運行時的內存消耗
        zipAlignEnabled true
        // 3、移除無用的resource文件:當ProGuard 把部分無用代碼移除的時候,
        // 這些代碼所引用的資源也會被標記爲無用資源,然後
        // 系統通過資源壓縮功能將它們移除。
        // 需要注意的是目前資源壓縮器目前不會移除values/文件夾中
        // 定義的資源(例如字符串、尺寸、樣式和顏色)
        // 開啓後,Android構建工具會通過ResourceUsageAnalyzer來檢查
        // 哪些資源是無用的,當檢查到無用的資源時會把該資源替換
        // 成預定義的版本。主要是針對.png、.9.png、.xml提供了
        // TINY_PNG、TINY_9PNG、TINY_XML這3個byte數組的預定義版本。
        // 資源壓縮工具默認是採用安全壓縮模式來運行,可以通過開啓嚴格壓縮模式來達到更好的瘦身效果。
        shrinkResources true
        // 4、混淆文件的位置,其中 proguard-android.txt 爲sdk默認的混淆配置,
        // 它的位置位於android-sdk/tools/proguard/proguard-android.txt,
        // 此外,proguard-android-optimize.txt 也爲sdk默認的混淆配置,
        // 但是它默認打開了優化開關。並且,我們可在配置混淆文件將android.util.Log置爲無效代碼,
        // 以去除apk中打印日誌的代碼。而 proguard-rules.pro 是該模塊下的混淆配置。
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        signingConfig signingConfigs.release
    }
}

首先,在註釋1處,我們可以通過配置 minifyEnabled 來決定是否進行混淆。

然後,在註釋2處,通過 配置 zipAlignEnabled 爲 true 可以讓安裝包中的資源按 4 字節對齊,這樣可以減少應用在運行時的內存消耗

接着,在註釋3處,配置 shrinkResourcestrue 可以移除無用的 resource 文件:當 ProGuard 把部分無用代碼移除的時候,這些代碼所引用的資源也會被標記爲無用資源,然後,系統會通過資源壓縮功能將它們移除。需要注意的是 目前資源壓縮器目前不會移除 values / 文件夾中定義的資源(例如字符串、尺寸、樣式和顏色)。開啓後,Android 構建工具會通過 ResourceUsageAnalyzer 來檢查哪些資源是無用的,當檢查到無用的資源時會把該資源替換成預定義的版本。主要是針對 .png、.9.png、.xml 提供了 TINY_PNG、TINY_9PNG、TINY_XML 這 3 個 byte 數組的預定義版本。資源壓縮工具默認是採用 安全壓縮模式 來運行,可以通過開啓 嚴格壓縮模式 來達到 更好的瘦身效果

最後,在註釋
4處,我們可以配置混淆文件的位置,其中 proguard-android.txtsdk 默認的混淆配置,它的位置位於 android-sdk/tools/proguard/proguard-android.txt,此外,proguard-android-optimize.txt 也是 sdk 默認的混淆配置,但是它 默認打開了優化開關。此外,我們也可以在配置混淆文件將 android.util.Log 置爲無效代碼,以去除 apk 中打印日誌的代碼。而 proguard-rules.pro 是該模塊下的混淆配置。

在執行完 ProGuard 之後,ProGuard 都會在 ${project.buildDir}/outputs/mapping/${flavorDir}/ 生成以下文件:

文件名 描述
dump.txt APK中所有類文件的內部結構
mapping.txt 提供原始與混淆過的類、方法和字段名稱之間的轉換,可以通過proguard.obfuscate.MappingReader來解析
seeds.txt 列出未進行混淆的類和成員
usage.txt 列出從APK移除的代碼

下面,我們再回顧混淆的基本規則。

混淆的基本規則

# * 表示僅保持該包下的類名,而子包下的類名還是會被混淆
-keep class com.json.chao.wanandroid.*
# ** 表示把本包和所含子包下的類名都保持
-keep class com.json.chao.wanandroid.**

# 既保持類名,又保持裏面的內容不被混淆
-keep class com.json.chao.wanandroid.* {*;}

# 也可以使用Java的基本規則來保護特定類不被混淆,比如extend,implement等這些Java規則
-keep public class * extends android.app.Activity

# 保留MainPagerFragment內部類JavaScriptInterface中的所有public內容不被混淆
-keepclassmembers class com.json.chao.wanandroid.ui.fragment.MainPagerFragment$JavaScriptInterface {
    public *;
}

# 僅希望保護類下的特定內容時需使用匹配符
<init>;     //匹配所有構造器
<fields>;   //匹配所有字段
<methods>;  //匹配所有方法
# 還可以在上述匹配符前面加上private 、public、native等來進一步指定不被混淆的內 容
-keep class com.json.chao.wanandroid.app.WanAndroidApp {
    public <fields>;
}
# 也可以加入參數,以下表示用java.lang.String作爲入參的構造函數不會被混淆
-keep class com.json.chao.wanandroid.app.WanAndroidApp {
    public <init>(java.lang.String);
}

# 不需要保持類名,僅需要把該類下的特定成員保持不被混淆時使用keepclassmembers
# 如果擁有某成員,要保留類和類成員使用-keepclasseswithmembers

瞭解完上面的這些混淆規則之後,相信我們已經能夠根據我們當前的應用寫出相應的混淆規則了。需要注意的是,在 AndroidMainfest 中的類默認不會被混淆,所以四大組件和 Application 的子類和 Framework 層下所有的類默認不會進行混淆,並且自定義的 View 默認也不會被混淆。因此,我們不需要手動在 proguard-rules.pro 中去添加如下代碼:

-keep public class * extends android.app.Activity
-keep public class * extends android.app.Appliction
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class * extends android.view.View
-keep public class com.android.vending.licensing.ILicensingService

Application 和四大組件是必須在 AndroidMainfest 中進行註冊的,所以如果想要通過 混淆四大組件和 Application、自定義 View 的方式去減小APK的體積是行不通的,因爲沒有規則去配置如何混淆四大組件和 Application。因此,對於混淆的優化,我們能做的只能是
儘量保證 keep 範圍的最小化,以此實現應用混淆程度的最大化
。在混淆配置中添加下列規則還可以在混淆之後輸出最終的混淆配置:

# 輸出 ProGuard 的最終配置
-printconfiguration configuration.txt

混淆實戰

這裏,我們就對 Awesome-WanAndroid 應用進行混淆,看看該應用混淆前後 APK 的體積變化。

下面這張圖是 Awesome-WanAndroid 混淆前的 APK 組成結構圖,可以看到佔用了大概 8.3MB 的體積,其中 dex 部分佔用了 3.6MB

image

混淆之後,APK 的體積會如何變化呢?我們看看 混淆後的 APK 組成結構圖,如下所示:

image

可以看到,原先兩個 dex 文件變爲了一個,而且 dex 的大小也縮減到了 2.2MB,大小整整縮減了 1.4MBdex 部分的壓縮效果將近 40%APK 整體的壓縮效果也有 17%。所以,混淆的確是 APK 瘦身的首選手段。此外,在 Android Studio 3.1 或之後的版本都會默認採用 D8 作爲 Dex 的編譯器,並且,在2019年10月,被認作爲混淆的替代品的 R8 就已經默認集成進 Android Gradle plugin 中了。下面,我們就看看 D8 與 R8 到底是如何優化 APK 的 dex 部分的。

3、D8 與 R8 優化

D8 優化

優化效果

D8 的 優化效果 總的來說可以歸結爲如下 四點

  • 1)、Dex的編譯時間更短
  • 2)、.dex文件更小
  • 3)、D8 編譯的 .dex 文件擁有更好的運行時性能
  • 4)、包含 Java 8 語言支持的處理

開啓 D8

Android Studio 3.0
需要主動在 gradle.properties 文件中新增:

android.enableD8 = true

Android Studio 3.1 或之後的版本 D8 將會被作爲默認的 Dex 編譯器。

R8 優化

R8 官方文檔

R8 是 Proguard 壓縮與優化部分的替代品,並且它仍然使用與 Proguard 一樣的 keep 規則。目前已經開源,如果我們僅僅想在 Android Studio 中使用 R8,當我們在 build.gradle 中打開混淆的時候,R8 就已經默認集成進 Android Gradle plugin 中了,我們 只需要在 gradle.properties 中配置如下代碼讓 App 的混淆去支持 R8,如下所示:

android.enableR8=true
android.enableR8.libraries=true

那麼,R8 與混淆相比優勢在哪裏呢?

ProGuardR8 都應用了基本名稱混淆:它們 都使用簡短,無意義的名稱重命名類,字段和方法。他們還可以 刪除調試屬性。但是,R8 在 inline 內聯容器類中更有效,並且在刪除未使用的類,字段和方法上則更具侵略性。例如,R8 本身集成在 ProGuard V6.1.1 版本中,在壓縮 apk 的大小方面,與 ProGuard8.5% 相比,使用 R8 apk 尺寸減小了約 10%。並且,隨着 Kotlin 現在成爲 Android 的第一語言,R8 進行了 ProGuard 尚未提供的一些 Kotlin 的特定的優化。

從表面上看,ProGuardR8 非常相似。它們都使用相同的配置,因此在它們之間進行切換很容易。放大來看的話,它們之間也存在一些差異。R8 能更好地內聯容器類,從而避免了對象分配。但是 ProGuard 也有其自身的優勢,具體有如下幾點:

  • 1)、ProGuard 在將枚舉類型簡化爲原始整數方面會更加強大。它還傳遞常量方法參數,這通常對於使用應用程序的特定設置調用的通用庫很有用。ProGuard 的多次優化遍歷通常可以產生一系列優化。例如,第一遍可以傳遞一個常量方法參數,以便下一遍可以刪除該參數並進一步傳遞該值。刪除日誌代碼時,多次傳遞的效果尤其明顯。ProGuard 在刪除所有跟蹤(包括組成日誌消息的字符串操作)方面更有效
  • 2)、ProGuard 中應用的模式匹配算法可以識別和替換短指令序列,從而提高代碼效率併爲更多優化打開了機會。在優化遍歷的順序中,尤其是數學運算和字符串運算可從中受益
  • 3、最後,ProGuard 具有獨特的能力來優化使用 GSON 庫將對象序列化或反序列化爲 JSON 的代碼。該庫嚴重依賴反射,這很方便,但效率低下。而 ProGuard 的優化功能可以 通過更高效,直接的訪問方式 來代替它。

R8 優化實戰

接下來,我們就來看看 Awesome-WanAndroid 使用 R8 後,APK 體積的變化,如下圖所示:

image

可以看到,相較於僅使用混淆後的 APK 而言,大小減少了 0.1MBDex 部分的優化效果大概爲 5%APK 整體的壓縮效果也有 1.5% 左右。雖然從減少的 APK 大小來看,0.1MB 很少,但是比例並不小,如果你負責的是一個像微信、淘寶等規模的 App,它們的體積一般都將近 100MB,使用 R8 後也能減小 1.5MB 的大小。

此外,如果想單獨對 Dex 或 jar 包 使用 R8,可以根據最上面的官方文檔可以很快的在 python 環境下運行起來,其具體步驟如下所示:

1、確保本地已經安裝了python 2.7或更高版本。

2、由於R8項目使用chromium項目提供的depot_tools管理依賴,因此先安裝depot_tools。(下面僅介紹Mac版的安裝)

  • 1)、獲取 depot_tools

    git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

  • 2)、獲取 depot_tools 當前目錄

    pwd

  • 3)、添加環境變量

    • vim ~/.bash_profile:打開最後一行添加,如無此文件,可添加此文件
    • export PATH="$PATH:/PWD/depot_tools" : 其中 PWD 爲剛纔第二步獲取的路徑
  • 4)、生效環境變量

    source ~/.bash_profile

3、Downloading and building R8項目

git clone https://r8.googlesource.com/r8
cd r8
tools/gradle.py d8 r8

4、tools/gradle.py 腳本將會生成兩個 jar 文件,即 build/libs/d8.jar 與 build/libs/r8.jar。

5、下面的代碼是使用 R8 在 out 目錄下去生成優化後的 dex 文件:

java -jar build/libs/r8.jar --release --output out --pg-conf proguard.cfg input.jar

D8 與 R8 的作用非常強大,而 Jake Wharton 大神最近一年多也在研究 D8 與 R8 的知識,如果想對 D8 與 R8 的實現細節有更多地瞭解,可以看看他的 個人博客

4、去除 debug 信息與行號信息

在講解什麼是 deubg 信息與行號信息之前,我們需要先了解 Dex 的一些知識。

我們都知道,JVM 運行時加載的是 .class 文件,而 Android 爲了使包大小更加緊湊、運行時更加高效就發明了 DalvikART 虛擬機,兩種虛擬機運行的都是 .dex 文件,當然 ART 虛擬機還可以同時運行 oat 文件。

所以 Dex 文件裏的信息內容和 Class 文件包含的信息是一樣的,不同的是 Dex 文件對 Class 中的信息做了去重,一個 Dex 包含了很多的 Class 文件,並且在結構上有比較大的差異,Class 是流式的結構,Dex 是分區結構,Dex 內部的各個區塊間通過 offset 來進行索引

爲了在應用出現問題時,我們能在調試的時候去顯示相應的調試信息或者上報 crash 或者主動獲取調用堆棧的時候能通過 debugItem 來獲取對應的行號,我們都會在混淆配置中加上下面的規則:

-keepattributes SourceFile, LineNumberTable

這樣就會保留 Dex 中的 debug 與行號信息,此時的 Dex 結構圖 如下所示:

image

從圖中可以看到,Dex 文件的結構主要分爲 四大塊:header 區,索引區,data 區,map 區。而我們的 debug 與行號信息就保存在 data 區中的 debugItems 區域。而 debug_items 裏面主要包含了 兩種信息,如下所示:

  • 1)、調試的信息:包含函數的參數和所有的局部變量
  • 2)、排查問題的信息:包含所有的指令集行號與源文件行號的對應關係

根據 Google 官方的數據,debugItem 一般佔 Dex 的比例有 5% 左右,如果我們能去除 debug 與行號信息,就能更進一步對 Dex 進行瘦身,但是會失去調試信息的功能,那麼,有什麼方式可以去掉 debugItem,同時又能讓 crash 上報的時候能拿到正確的行號呢?

我們可以嘗試直接修改 Dex 文件,保留一小塊 debugItem,讓系統查找行號的時候指令集行號和源文件行號保持一致,這樣任何監控上報的行號都直接變成了指令集行號

每一個方法都會有一個 debugInfoItem,每一個 debuginfoItem 裏面都有一個指令集行號和源文件行號的映射關係,這了我們直接把多餘的 debugInfoItem 全部刪掉,只保留了一個 debugInfoItem,這樣所有的方法都會指向同一個 debugInfoItem,並且這個 debugInfoItem 中的指令集行號和源文件行號保持一致,這樣不管用什麼方式來查找行號,拿到的都是指令集行號。需要注意的是,採用這種方案 需要兼容所有虛擬機的查找方式,因此 僅僅保留一個 debugInfoItem 是不夠的,需要對 debugInfoItem 進行分區,並且 debugInfoItem 表不能太大

關於如何去除 Dex 中的 Debug 信息是通過 ReDexStripDebugInfoPass 來完成的,其配置如下所示:

{
    "redex" : {
        "passes" : [
            "StripDebugInfoPass",
            "RegAllocPass"
        ]
    },
    "StripDebugInfoPass" : {
        "drop_all_dbg_info" : false,
        "drop_local_variables" : true,
        "drop_line_numbers" : false,
        "drop_src_files" : false,
        "use_whitelist" : false,
        "cls_whitelist" : [],
        "method_whitelist" : [],
        "drop_prologue_end" : true,
        "drop_epilogue_begin" : true,
        "drop_all_dbg_info_if_empty" : true
    },
    "RegAllocPass" : {
        "live_range_splitting": false
    }
}

關於 debuginfo 的實戰我們下面馬上會開始,在此之前,我們先講講 Dex 分包中的另一個優化點。

5、Dex 分包優化

Dex 分包優化原理

當我們的 APK 過大時,Dex 的方法數就會超過65536個,因此,必須採用 mutildex 進行分包,但是此時每一個 Dex 可能會調用到其它 Dex 中的方法,這種 跨 Dex 調用的方式會造成許多冗餘信息,具體有如下兩點:

  • 1)、多餘的 method id:跨 Dex 調用會導致當前dex保留被調用dex中的方法id,這種冗餘會導致每一個dex中可以存放的class變少,最終又會導致編譯出來的dex數量增多,而dex數據的增加又會進一步加重這個問題
  • 2)、其它跨dex調用造成的信息冗餘:除了需要多記錄被調用的method id之外,還需多記錄其所屬類和當前方法的定義信息,這會造成 string_ids、type_ids、proto_ids 這幾部分信息的冗餘

爲了減少跨 Dex 調用的情況,我們必須 儘量將有調用關係的類和方法分配到同一個 Dex 中。但是各個類相互之間的調用關係是非常複雜的,所以很難做到最優的情況。所幸的是,ReDexCrossDexDefMinimizer 類分析了類之間的調用關係,並 使用了貪心算法去計算局部的最優解(編譯效果和dex優化效果之間的某一個平衡點)。使用 “InterDexPass” 配置項可以把互相引用的類儘量放在同個 Dex,增加類的 pre-verify,以此提升應用的冷啓動速度

ReDex 中使用 Dex 分包優化跨 dex 調用造成的信息冗餘的配置代碼如下所示:

{
    "redex" : {
        "passes" : [
            "InterDexPass",
            "RegAllocPass"
        ]
    },
    "InterDexPass" : {
        "minimize_cross_dex_refs": true,
        "minimize_cross_dex_refs_method_ref_weight": 100,
        "minimize_cross_dex_refs_field_ref_weight": 90,
        "minimize_cross_dex_refs_type_ref_weight": 100,
        "minimize_cross_dex_refs_string_ref_weight": 90
    },
    "RegAllocPass" : {
        "live_range_splitting": false
    },
    "string_sort_mode" : "class_order",
    "bytecode_sort_mode" : "class_order"
}

爲了衡量優化效果,我們可以使用 Dex 信息有效率 這個指標,公式如下所示:

Dex 信息有效率 = define methods數量 / reference methods 數量

如果 Dex 有效率在 80% 以上,就說明基本合格了

使用 ReDex 進行分包優化、去除 debug 信息及行號信息

下面,我們就使用 Redex 來對上一步生成的 app-release-proguardwithr8.apk 進行進一步的優化。(macOS 環境下

1、首先,我們需要輸入一下命令去去安裝 Xcode 命令行工具

xcode-select --install

2、然後,使用 homebrew 安裝 redex 項目使用到的依賴庫

brew install autoconf automake libtool python3
brew install boost jsoncpp

需要注意的是嗎,2020年2月10號版本源碼的 redex 需要的 boost 版本爲 V1.71 及以上,當你使用 brew install boost 安裝 boost 時可能獲取到的 boost 版本會低於 V1.71,此時可能是 brew 版本需要更新,使用 brew upgrade 去更新 brew 倉庫的版本 或者可以直接從 boost 官網下載最新的 boost 源碼/usr/local/Cellar/ 目錄下,我當前使用的是 boost V1.7.2源碼下載地址 中的 boost_1_72_0.zip。從 深入探索 Android 啓動優化 時就提及到了 Redex 的類重排優化,當時卡在這一步,所以一直沒法真正完成類的重排優化。

3、接着,從 Github 上獲取 ReDex 的源碼並切換到 redex 目錄下

git clone https://github.com/facebook/redex.git
cd redex

4、下一步,使用 autoconf 和 make 去構建 ReDex

# 如果你使用的是 gcc, 請使用 gcc-5
autoreconf -ivf && ./configure && make -j4
sudo make install

5、然後,配置 Redex 的 config 代碼

在 Redex 在運行的時候,它是根據 redex/config/default.config 這個配置文件中的通道 passes 中添加不同的優化項來對 APK 的 Dex 進行處理的,我們可以參考 redex/config/default.config 這個默認的配置,裏面的 passes 中不同的配置項都有特定的優化。爲了優化 App 的包體積,我們再加上 interdex_stripdebuginfo.config 中的配置項去刪除 debugInfo 和減少跨 Dex 調用的情況,最終的 interdex_stripdebuginfo.config 配置代碼 如下所示:

{
    "redex" : {
        "passes" : [
            "StripDebugInfoPass",
            "InterDexPass",
            "RegAllocPass"
        ]
    },
    "StripDebugInfoPass" : {
        "drop_all_dbg_info" : false,
        "drop_local_variables" : true,
        "drop_line_numbers" : false,
        "drop_src_files" : false,
        "use_whitelist" : false,
        "cls_whitelist" : [],
        "method_whitelist" : [],
        "drop_prologue_end" : true,
        "drop_epilogue_begin" : true,
        "drop_all_dbg_info_if_empty" : true
    },
    "InterDexPass" : {
        "minimize_cross_dex_refs": true,
        "minimize_cross_dex_refs_method_ref_weight": 100,
        "minimize_cross_dex_refs_field_ref_weight": 90,
        "minimize_cross_dex_refs_type_ref_weight": 100,
        "minimize_cross_dex_refs_string_ref_weight": 90
    },
    "RegAllocPass" : {
        "live_range_splitting": false
    },
    "string_sort_mode" : "class_order",
    "bytecode_sort_mode" : "class_order"
}

6、最後,執行相應的 redex 優化命令

這裏我們使用 Redex 命令對上一 Dex 優化中得到的 app_release-proguardwithr8.apk 進行 Dex 分包優化和去除 debugInfo,它使用了貪心這種局部最優解的方式去減少跨 Dex 調用造成的信息冗餘,命令如下所示(注意,在 redex 的前面可能需要加上 Android sdk 的路徑,因爲 redex 中使用到了sdk下的zipalign工具):

ANDROID_SDK=/Users/quchao/Library/Android/sdk redex --sign -s wan-android-key.jks -a wanandroid -p wanandroid -c ~/Desktop/interdex_stripdebuginfo.config -P app/proguard-rules.pro -o ~/Desktop/app-release-proguardwithr8-stripdebuginfo-interdex.apk ~/Desktop/app-release-proguardwithr8.apk

上述 redex 命令的 關鍵參數含義 如下所示:

  • –sign:對生成的apk進行簽名
  • -s:配置應用的簽名文件
  • -a: 配置應用簽名的 key_alias
  • -p:配置應用簽名的 key_password
  • -c:指定 redex 進行 Dex 處理時需要依據的 CONFIG 配置文件
  • -o:指定生成 APK 的全路徑

使用上面的 redex 命令我們就可以對優化後的 APK 進行 再簽名和混淆,等待一會後(如果你的 APKDex 數量和體積很大,可能會比較久),就會生成 優化後的 APK:app-release-proguardwithr8-stripdebuginfo-interdex.apk,如下圖所示:

image

可以看到,我們的 APK 大小几乎沒有變化,這是因爲當前的 APK 只有一個 Dex,並且 第一個 Dex 默認不會優化。爲了能實際看到 redex 的優化效果,我們採用了一個新項目來進行實驗,項目地址如下所示:

redex 優化 Apk 項目地址

首先,引入一大堆開源庫,嘗試把 Dex 數量變多一些。然後直接通過 assembleDebug 編譯即可。此外,爲了可以更加清楚流程,我們可以在 命令行輸入 export TRACE=2 以便可以輸出 redex 的日誌。最後,我們輸入下面的 redex 命令刪除 dex 中的 debugInfo 和減少跨 dex 調用的情況,如下所示:

redex --sign -s ReDexSample/keystore/debug.keystore -a androiddebugkey -p android -c redex-test/interdex_stripdebuginfo.config -P ReDexSample/proguard-rules.pro  -o redex-test/strip_output.apk ReDexSample/build/outputs/apk/debug/ReDexSample-debug.apk

最終,我們看到前後的 APK 體積對比圖如下所示:

image

image

可以看到,APK 的大小從 14.2MB 減少到了 12.8MB,優化效果大概有10%,效果還是比較明顯的。此外,如果你的 App 的 Dex 數量越多,那麼優化的效果就會越大

6、使用 XZ Utils 進行 Dex 壓縮

XZ Utils 官文文檔

XZ Utils 是具有高壓縮率的免費通用數據壓縮軟件,它同 7-Zip 一樣,都是 LZMA Utils 的後繼產品,內部使用了 LZMA/LZMA2 算法。LZMA 提供了高壓縮比和快速解壓縮,因此非常適合嵌入式應用LZMA主要功能 如下:

  • 1)、壓縮速度:在3 GHz雙核CPU上爲3 MB / s。
  • 2)、減壓速度:在現代3 GHz CPU(Intel,AMD,ARM)上爲20-50 MB / s。在簡單的1 GHz RISC - CPU(ARM,MIPS,PowerPC)上爲5-15 MB / s。
  • 3)、解壓縮的較小內存要求:8-32 KB + DictionarySize。
  • 4)、用於解壓縮的代碼大小:2-8 KB(取決於速度優化)。

相對於典型的壓縮文件而言,XZ Utils 的輸出比 gzip 小 30%,比 bzip2 小 15%。在 FaceBookApp 中就使用了 Dex 壓縮 的方式,而且它 將 Dex 壓縮後的文件都放在了 assets 目錄中,如下圖所示:

image

image

我們先看到上圖中的 classes.dex,其中僅包含了啓動時要用到的類,這樣可以爲 Dex 壓縮文件 secondary.dex.jar.xzs 的解壓爭取時間

此外,在 secondary.dex.jar.xzs 文件的下面,我們注意到,有一系列的 secondary-x.dex.jar.xzs.tmp~.meta 文件,它保存了壓縮前每一個 Dex 文件的映射元數據信息,在應用首次啓動解壓的時候我們還需要用到它

儘管 classes.dex 爲首次啓動解壓 Dex 壓縮文件爭取了時間,但是由於文件太大,在低端機上的解壓時間可能會有 3~5s

而且,當 Dex 非常多的時候會增加應用的安裝時間,如果還使用了壓縮 Dex 的方式,那麼首次生成 ODEX 的時間可能就會超過1分鐘。爲了解決這個問題,Facebook 使用了 oatmeal 這套工具去 根據 ODEX 文件的格式,自己生成了一個 ODEX 文件。而在 正常的流程 下,系統會使用 fork 子進程的方式去處理 dex2oat 的過程

但是,oatmeal 採用了 代理 dex2oat 省去 fork 進程所帶來耗時 的這種方式,如果在1個 10MB 的 Dex,可以將 dex2oat 的耗時降至 100ms,而在 Android 5.0 上生成一個 ODEX 的耗時大約在 10 秒以上,在 Android 8.0 使用 speed 模式也需要 1 秒左右的時間。但是由於 每個 Android 系統版本的 ODEX 格式都有一些差異,oatmeal 需要分版本適配,因此 Dex 壓縮的方案我們可以先壓壓箱底。

7、三方庫處理

實際的開發過程中,我們會用到各種各樣的三方庫。尤其當項目變大之後,開發人員衆多,因此引入的三方庫也會非常多,比如說,有人引入了一個 Fresco 圖片庫,然後這個庫你可能不熟悉,你會引入一個 Glide,並且另一個人它可能又會引入他熟悉的圖片庫 Picasso,所以項目中可能會存在多個相同功能的三方 SDK,這一點,在大型項目當中一定會存在。因此,我們在做代碼瘦身的時候,需要將三方庫進行統一,比如說 將圖片加載庫、網絡庫、數據庫以及其他基礎庫進行統一,去掉冗餘的庫

同時,在選擇第三方 SDK 的時候,我們可以將包大小作爲選擇的指標之一,我們應該 儘可能地選擇那些比較小的庫來實現相同的功能。例如,對於圖片加載功能來說,Picasso、Glide、Fresco 它們都可以實現,但是你引入 Fresco 之後會導致包大小增加很多,而 Picasso 卻只增加了不到 100kb,所以引入不同的三方 SDK 對包大小的影響是不一樣的。這裏,我們可以使用 AS 插件 Android Methods Count,安裝之後,它會自動在 build.gradle 文件中顯示你引入的三方庫的方法數

最後,如果我們引入三方庫的時候,可以 只引入部分需要的代碼,而不是將整個包的代碼都引入進來。很多庫的代碼結構都設計的比較好,比如 Fresco,它將圖片加載的各個功能,如** webp、gif 功能進行了剝離,它們都處於單個的庫當中**。如果我們只需要 Frescowebp 功能,那我們可以將除 webp 之外的別的庫都給刪掉,這樣你引入的三方庫就很小了,包大小就降下來了。如下所圖所示,我們可以僅僅保留 Fresco 的 webp 功能,其它依賴都可以去掉。

image

如果你引入的三方庫 沒有進行過結構剝離,就需要 修改源碼,只提取出來你需要的功能即可

8、移除無用代碼

移除無用代碼時我們經常會碰到下面兩個問題:

  • 1)、業務代碼只增不減
  • 2)、代碼太多不敢刪除

這裏,有一個很好的方法可以 準確地判斷哪些類在線上環境下用戶肯定不會用到了。我們可以通過 AOP 的方式來做,對於 Activity 來說,其實非常簡單,我們只需要 在每個 Activity 的 onCreate 當中加上統計 即可,然後到了線上之後,如果這個 Activity 被統計了,就說明它還在被使用。而對於那些 不是 Activity 的類,我們可以 利用 AOP 來切它們的構造函數,一個類如果它被使用,那它的構造函數肯定會被調用到。例如,下面就是 使用 AspectJ 對某個包下的類進行構造函數切面 的代碼:

@After("execution(org.jay.launchstarter.Task.new(..)")
public void newObject(JoinPoint point) {
    LogHelper.i(" new " + point.getTarget().getClass().getSimpleName());
}

其中,new 表示是 切的構造函數,括號中的 … 表示的是 匹配所有構造參數。此外,我們也可以直接使用 coverage 插件 來做 線上無用代碼分析,需要注意的是,在註冊上報數據的時候記得把服務器名改爲自己的

最後,我們也可以在線下使用 Simian工具掃描出重複的代碼

9、避免產生 Java access 方法

access 方法是什麼?

爲了能提供內部類和其外部類直接訪問對方的私有成員的能力,又不違反封裝性要求,Java 編譯器在編譯過程中自動生成 package 可見性的靜態 access$xxx 方法,並且在需要訪問對方私有成員的地方改爲調用對應的 access 方法

避免產生 access 方法的方式

主要有 兩種方式 避免產生 access 方法:

  • 1)、在開發過程中需要注意在可能產生 access 方法的情況下適當調整,比如去掉 private,改爲 package 可見性
  • 2)、使用 ASM 在編譯時刪除生成的 access 方法

因爲優化效果不是很明顯,這裏就不多介紹了,具體的實現細節可參見 西瓜視頻 apk 瘦身之 Java access 方法刪除,此外,在 ReDex 中也提供了 access-marking 這個功能去除代碼中的 Access 方法,並且,在 ReDex 還有 type-erasure 的功能,它 與 access-marking 的優化效果一樣,不僅能減少包大小,也能提升 App 的啓動速度

10、利用 ByteX Gradle 插件平臺中的代碼優化插件

如果你想在項目的編譯階段去除 access 方法,這裏我更加建議直接使用 ByteXaccess_inline 插件。除了 access_inlie 之外,在 ByteX 中還有 四個 很實用的代碼優化 Gradle 插件可以幫助我們有效減小 Dex 文件的大小,如下所示:

11、小結

回顧下我們上述使用的各種 Dex 優化方式,其中,不少優化項都使用到了 ReDex。對於 ReDex 來說,目前它提供的比較強大的功能有 五種,分別如下所示:

  • 1)、Interdex:類重排和文件重排、Dex 分包優化。其中對於類重排和文件重排,Google 在 Android 8.0 的時候引入了 Dexlayout,它是一個用於分析 dex 文件,並根據配置文件對其進行重新排序的庫。與 ReDex 類似,Dexlayout 通過將經常一起訪問的部分 dex 文件集中在一起,程序可以因改進文件位置從而擁有更好的內存訪問模式,以節省 RAM 並縮短啓動時間。不同於ReDex的是它使用了運行時配置信息對 Dex 文件的各個部分進行重新排序。因此,只有在應用運行之後,並在系統空閒維護的時候纔會將 dexlayout 集成到 dex2oat 的設備進行編譯
  • 2)、Oatmeal:直接生成 Odex 文件
  • 3)、StripDebugInfo:去除 Dex 中的 Debug 信息
  • 4)、源碼中 access-marking 模塊:刪除 Java access 方法
  • 5)、源碼中 type-erasure 模塊:類型擦除

可以看到,ReDex 的功能非常強大,如果能夠深入瞭解 ReDex 源碼中的各個功能模塊的實現,你將具有非常強硬的技術資本

最近,抖音 Android 團隊 已經將上述部分模塊的實現以 Gradle Transform + ASM 的形式集成進了 ByteX,建議掌握其實現原理後,大家可以直接在這個字節碼插件開發平臺上開發自己的 Gradle 插件。

最後,還有一些 代碼編寫方面的優化,如可以在開發過程 儘量減少 enum 的使用,每減少一個 enum 可以減少大約 1.0 到 1.4 KB 的大小

三、資源瘦身方案探索

衆所周知,Android 構建工具鏈中使用了 AAPT 工具來對資源進行處理,Manifest、Resources、Assets 的資源經過相應的 ManifesMerger、ResourcesMerger、AssetsMerger 資源合併器將多個不同 moudule 的資源合併爲了 MergedManifest、MergedResources、MergedAssets。然後,它們被 AAPT 處理後生成了 R.java、Proguard Configuration、Compiled Resources。如下圖左上方所示:

image

其中 Proguard Configuration、Compiled Resources作用 如下所示:

  • Proguard Configuration:這是AAPT工具爲Manifest中聲明的四大組件與佈局文件中使用的各種Views所生成的混淆配置,該文件通常存放在 ${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/proguard-rules/${flavorName}/${buildType}/aapt_rules.txt
  • Compiled Resources:它是一個Zip格式的文件,這個文件的路徑通常爲 ${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/res/resources-${flavorName}-${buildType}-stripped.ap_。在經過 zip 解壓之後,可以發現它 包含了res、AndroidManifest.xml和resources.arsc 這三部分。並且,從上面的 APK 構建流程中可以得知,Compiled Resources 會被 apkbuilder 打包到 APK 包中,它其實就是 APK資源包。因此,我們可以 通過 Compiled Resources 文件來修改不同後綴文件資源的壓縮方式來達到瘦身效果的。但是需要注意的是,resources.arsc 文件最好不要壓縮存儲,如果壓縮會影響一定的性能,尤其是在冷啓動時間方面造成的影響。並且,如果在 Android 6.0 上開啓了 android:extractNativeLibs=”false” 的話,So 文件也不能被壓縮

1、冗餘資源優化

1、使用 Lint 的 Remove Unused Resource

APK 的資源主要包括圖片、XML,與冗餘代碼一樣,它也可能遺留了很多舊版本當中使用而新版本中不使用的資源,這點在快速開發的 App 中更可能出現。我們可以通過點擊右鍵,選中 Refactor,然後點擊 Remove Unused Resource => preview 可以預覽找到的無用資源,點擊 Do Refactor 可以去除冗餘資源。如下圖所示:

image

需要注意的,Android Lint 不會分析 assets 文件夾下的資源,因爲 assets 文件可以通過文件名直接訪問,不需要通過具體的引用,Lint 無法判斷資源是否被用到

2、優化 shrinkResources 流程真正去除無用資源

resources.arsc 中可能會存在很多 無用的資源映射,我們可以使用 android-arscblamer,它是一個命令行工具,能夠 解析 resources.arsc 文件並檢查出可以優化的部分,比如一些空的引用。

此外,當我們通過 shrinkResources true開啓資源壓縮,資源壓縮工具只會把無用的資源替換成預定義的版本而不是移除。那麼,如何高效地對無用資源自動進行去除呢?

我們可以 在 Android 構建工具執行 package${flavorName}Task 之前通過修改 Compiled Resources 來實現自動去除無用資源,具體的實現原理如下:

1)、首先,收集 Compiled Resources 中被替換的預定義版本的資源名稱

通過查看 Zip 格式資源包中每個 ZipEntry 的 CRC-32 checksum 來尋找被替換的預定義資源,預定義資源的 CRC-32 定義在 ResourceUsageAnalyze 中,如下所示:

// A 1x1 pixel PNG of type BufferedImage.TYPE_BYTE_GRAY
public static final long TINY_PNG_CRC = 0x88b2a3b0L;

// A 3x3 pixel PNG of type BufferedImage.TYPE_INT_ARGB with 9-patch markers
public static final long TINY_9PNG_CRC = 0x1148f987L;

// The XML document <x/> as binary-packed with AAPT
public static final long TINY_XML_CRC = 0xd7e65643L;

2)、然後,使用 android-chunk-utils 把 resources.arsc 中對應的定義移除。

3)、最後,刪除資源包中對應的資源文件即可。

2、重複資源優化

在大型 App 項目的開發中,一個 App 一般會有多個業務團隊進行開發,其中每個業務團隊在資源提交時的資源名稱可能會有重複的,這將會 引發資源覆蓋的問題,因此,每個業務團隊都會爲自己的 資源文件名添加前綴。這樣就導致了這些資源文件雖然 內容相同,但因爲 名稱的不同而不能被覆蓋,最終都會被集成到 APK 包中。這裏,我們還是可以 在 Android 構建工具執行 package${flavorName}Task 之前通過修改 Compiled Resources 來實現重複資源的去除,具體放入實現原理可細分爲如下三個步驟:

  • 1)、首先,通過資源包中的每個ZipEntry的CRC-32 checksum來篩選出重複的資源
  • 2)、然後,通過android-chunk-utils修改resources.arsc,把這些重複的資源都重定向到同一個文件上
  • 3)、最後,把其它重複的資源文件從資源包中刪除,僅保留第一份資源

具體的實現代碼如下所示:

variantData.outputs.each {
    def apFile = it.packageAndroidArtifactTask.getResourceFile();

    it.packageAndroidArtifactTask.doFirst {
        def arscFile = new File(apFile.parentFile, "resources.arsc");
        JarUtil.extractZipEntry(apFile, "resources.arsc", arscFile);

        def HashMap<String, ArrayList<DuplicatedEntry>> duplicatedResources = findDuplicatedResources(apFile);

        removeZipEntry(apFile, "resources.arsc");

        if (arscFile.exists()) {
            FileInputStream arscStream = null;
            ResourceFile resourceFile = null;
            try {
                arscStream = new FileInputStream(arscFile);

                resourceFile = ResourceFile.fromInputStream(arscStream);
                List<Chunk> chunks = resourceFile.getChunks();

                HashMap<String, String> toBeReplacedResourceMap = new HashMap<String, String>(1024);

                // 處理arsc並刪除重複資源
                Iterator<Map.Entry<String, ArrayList<DuplicatedEntry>>> iterator = duplicatedResources.entrySet().iterator();
                while (iterator.hasNext()) {
                    Map.Entry<String, ArrayList<DuplicatedEntry>> duplicatedEntry = iterator.next();

                    // 保留第一個資源,其他資源刪除掉
                    for (def index = 1; index < duplicatedEntry.value.size(); ++index) {
                        removeZipEntry(apFile, duplicatedEntry.value.get(index).name);

                        toBeReplacedResourceMap.put(duplicatedEntry.value.get(index).name, duplicatedEntry.value.get(0).name);
                    }
                }

                for (def index = 0; index < chunks.size(); ++index) {
                    Chunk chunk = chunks.get(index);
                    if (chunk instanceof ResourceTableChunk) {
                        ResourceTableChunk resourceTableChunk = (ResourceTableChunk) chunk;
                        StringPoolChunk stringPoolChunk = resourceTableChunk.getStringPool();
                        for (def i = 0; i < stringPoolChunk.stringCount; ++i) {
                            def key = stringPoolChunk.getString(i);
                            if (toBeReplacedResourceMap.containsKey(key)) {
                                stringPoolChunk.setString(i, toBeReplacedResourceMap.get(key));
                            }
                        }
                    }
                }

            } catch (IOException ignore) {
            } catch (FileNotFoundException ignore) {
            } finally {
                if (arscStream != null) {
                    IOUtils.closeQuietly(arscStream);
                }

                arscFile.delete();
                arscFile << resourceFile.toByteArray();

                addZipEntry(apFile, arscFile);
            }
        }
    }
}

然後,我們再看看圖片壓縮這一項。

3、圖片壓縮

一般來說,1000行代碼在APK中才會佔用 5kb 的空間,而圖片呢,一般都有 100kb 左右,所以說,對圖片做壓縮,它的收益明顯是更大的,而往往處於快速開發的 App 沒有相關的開發規範,UI 設計師或開發同學如果忘記了添加圖片時進行壓縮,添加的就是原圖,那麼包體積肯定會增大很多。對於圖片壓縮,我們可以在 tinypng 這個網站進行圖片壓縮,但是如果 App 的圖片過多,一個個壓縮也是很麻煩的。因此,我們可以 使用 TinyPngPluginTinyPIC_Gradle_Plugin 來對圖片進行自動化批量壓縮。但是,需要注意的是,在 Android 的構建流程中,AAPT 會使用內置的壓縮算法來優化 res/drawable/ 目錄下的 PNG 圖片,但這可能會導致本來已經優化過的圖片體積變大,因此,可以通過在 build.gradle設置 cruncherEnabled 來禁止 AAPT 來優化 PNG 圖片,代碼如下所示:

aaptOptions {
    cruncherEnabled = false
}

此外,我們還要注意對圖片格式的選擇,對於我們普遍使用更多的 png 或者是 jpg 格式來說,相同的圖片轉換爲 webp 格式之後會有大幅度的壓縮。對於 png 來說,它是一個無損格式,而 jpg 是有損格式。jpg 在處理顏色圖片很多時候根據壓縮率的不同,它有時候會去掉我們肉眼識別差距比較小的顏色,但是 png 會嚴格地保留所有的色彩。所以說,在圖片尺寸大,或者是色彩鮮豔的時候,png 的體積會明顯地大於 jpg

下面,我們就着重講解下如何針對性地選擇圖片格式。

4、使用針對性的圖片格式

在** Google I/O 2016** 中,講到了如何選擇相應的圖片格式。首先,如果能用 VectorDrawable 來表示的話,則優先使用 VectorDrawable;否則,看是否支持 WebP,支持則優先用 WebP;如果也不能使用 WebP,則優先使用 PNG,而 PNG 主要用在展示透明或者簡單的圖片,對於其它場景可以使用 JPG 格式。簡單來說可以歸結爲如下套路:

VD(純色icon)->WebP(非純色icon)->Png(更好效果) ->jpg(若無alpha通道)

圖形化 的形式如下所示:

image

使用矢量圖片之後,它能夠有效的減少應用中圖片所佔用的大小,矢量圖形在 Android 中表示爲 VectorDrawable 對象。它 僅僅需100字節的文件即可以生成屏幕大小的清晰圖像,但是,Android 系統渲染每個 VectorDrawable 對象需要大量的時間,而較大的圖像需要更長的時間。 因此,建議 只有在顯示純色小 icon 時才考慮使用矢量圖形。(我們可以利用這個 在線工具 將矢量圖轉換成 VectorDrawable)。

最後,如果要在項目中使用 VD,則以下幾點需要着重注意:

  • 1)、必須通過 app:arcCompat 屬性來使用 svg,如果通過 src,則在低版本手機上會出現不兼容的問題

  • 2)、可能會不兼容selector,在 Activity 中手動兼容即可,兼容代碼如下所示:

    static { AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) }

  • 3)、不兼容第三方庫

  • 4)、性能問題:當Vector比較簡單時,效率肯定比Bitmap高,複雜則效率會不如Bitmap

  • 5)、不便於管理:建議原則爲同目錄多類型文件,以前綴區別,不同目錄相同類型文件,以意義區分

VD 類似,還有一種矢量圖標 iconFont,即 字體圖標,圖標就在字體文件裏面,它看着是個圖標,其實卻是個文字。它的 優勢 有如下三個方面:

  • 1)、同 VD 一樣,由於 IconFont 是矢量圖標,所以可以輕鬆解決圖標適配問題
  • 2)、圖標以 .ttf 字體文件的形式存在項目中,而 .ttf 文件一般放在 assets 文件夾下,它的體積很小,可以減小 APK 的體積
  • 3)、一套圖標資源可以在不同平臺使用且資源維護方便

它的 缺點 也很明顯,大致有如下三個方面:

  • 1)、需要自定義 svg 圖片,並將其轉換爲 ttf 文件,圖標製作成本比較高
  • 2)、添加圖標時需要重新制作 ttf 文件
  • 3)、只能支持單色,不支持漸變色圖標

如果你想要使用 iconfont,可以在阿里的 iconfont 上尋找資源。此外,使用 Android-Iconics 可以在你的應用中便於使用任何的 iconfont 或 .svg 圖片作爲 drawable。最後,如果我們 僅僅想提取僅需要的美化文字,以壓縮 assets 下的字體文件大小,可以使用 FontZip 字體提取工具

如果不是純色小 icon 類型的圖片,則建議使用 WebP。只要你的 AppminSdkVersion 高於 14(Android 4.0+) 即可。WebP 不僅支持透明度,而且壓縮率比 JPEG 更高,在相同畫質下體積更小。但是,只有 Android 4.2.1+ 才支持顯示含透明度的 WebP,此外,它的 兼容性不好,並且不便於預覽,需使用瀏覽器打開

對於應用之前就存在的圖片,我們可以使用 PNG轉換WebP 的轉換工具來進行轉換。但是,一個一個轉換開發效率太低,因此我們可以 使用WebpConvert_Gradle_Plugin 這個 gradle 插件去批量進行轉換,它的實現原理是 在 mergeXXXResource Task 和 processXXXResource Task 之間插入了一個 WebpConvertPlugin task 去將 png、jpg 圖片批量替換成了 webp 圖片

此外,在 Gradle 構建 APK 的過程中,我們可以判斷當前 AppminSdkVersion 以及圖片文件的類型來選用是否能使用 WebP,代碼如下所示:

boolean isPNGWebpConvertSupported() {
    if (!isWebpConvertEnable()) {
        return false
    }

    // Android 4.0+
    return GradleUtils.getAndroidExtension(project).defaultConfig.minSdkVersion.apiLevel >= 14
    // 4.0
}

boolean isTransparencyPNGWebpConvertSupported() {
    if (!isWebpConvertEnable()) {
        return false
    }

    // Lossless, Transparency, Android 4.2.1+
    return GradleUtils.getAndroidExtension(project).defaultConfig.minSdkVersion.apiLevel >= 18
    // 4.3
}

def convert() {
    String resPath = "${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/res/merged/${variant.dirName}"
    def resDir = new File("${resPath}")
    resDir.eachDirMatch(~/drawable[a-z0-9-]*/) { dir ->
        FileTree tree = project.fileTree(dir: dir)
        tree.filter { File file ->
            return (isJPGWebpConvertSupported() && (file.name.endsWith(SdkConstants.DOT_JPG) || file.name.endsWith(SdkConstants.DOT_JPEG))) || (isPNGWebpConvertSupported() && file.name.endsWith(SdkConstants.DOT_PNG) && !file.name.endsWith(SdkConstants.DOT_9PNG))
        }.each { File file ->
            def shouldConvert = true
            if (file.name.endsWith(SdkConstants.DOT_PNG)) {
                if (!isTransparencyPNGWebpConvertSupported()) {
                    shouldConvert = !Imaging.getImageInfo(file).isTransparent()
                }
            }
            if (shouldConvert) {
                WebpUtils.encode(project, webpFactorQuality, file.absolutePath, webp)
            }
        }
    }
}   

最後,這裏再補充下在平時項目開發中對 圖片放置優化的大概思路,如下所示:

  • 1)、聊天表情出一套圖 => hdpi
  • 2)、純色小 icon 使用 VD => raw
  • 3)、背景大圖出一套 => xhdpi
  • 4)、logo 等權重比較大的圖片出兩套 => hdpi,xhdpi
  • 5)、若某些圖在真機中有異常,則用多套圖
  • 6)、若遇到奇葩機型,則針對性補圖

然後,我們來講解下資源如何進行混淆。

5、資源混淆

同代碼混淆類似,資源混淆將 資源路徑混淆成單個資源的路徑,這裏我們可以使用 AndroidResGuard,它可以使冗餘的資源路徑變短,例如將 res/drawable/wechat 變爲 r/d/a

AndroidResGuard 項目地址

下面,我們就使用 AndroidResGuard 來對資源進行混淆。

1、AndroidResGuard 實戰

1、首先,我們在項目的根 build.gradle 文件下加入下面的插件依賴:

classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.17'

2、然後,在項目 module 下的 build.gradle 文件下引入其插件:

apply plugin: 'AndResGuard'

3、接着,加入 AndroidResGuard 的配置項,如下是默認設置好的配置:

andResGuard {
    // mappingFile = file("./resource_mapping.txt")
    mappingFile = null
    use7zip = true
    useSign = true
    // 打開這個開關,會keep住所有資源的原始路徑,只混淆資源的名字
    keepRoot = false
    // 設置這個值,會把arsc name列混淆成相同的名字,減少string常量池的大小
    fixedResName = "arg"
    // 打開這個開關會合並所有哈希值相同的資源,但請不要過度依賴這個功能去除去冗餘資源
    mergeDuplicatedRes = true
    whiteList = [
        // for your icon
        "R.drawable.icon",
        // for fabric
        "R.string.com.crashlytics.*",
        // for google-services
        "R.string.google_app_id",
        "R.string.gcm_defaultSenderId",
        "R.string.default_web_client_id",
        "R.string.ga_trackingId",
        "R.string.firebase_database_url",
        "R.string.google_api_key",
        "R.string.google_crash_reporting_api_key"
    ]
    compressFilePattern = [
        "*.png",
        "*.jpg",
        "*.jpeg",
        "*.gif",
    ]
    sevenzip {
        artifact = 'com.tencent.mm:SevenZip:1.2.17'
        //path = "/usr/local/bin/7za"
    }

    /**
    * 可選: 如果不設置則會默認覆蓋assemble輸出的apk
    **/
    // finalApkBackupPath = "${project.rootDir}/final.apk"

    /**
    * 可選: 指定v1簽名時生成jar文件的摘要算法
    * 默認值爲“SHA-1”
    **/
    // digestalg = "SHA-256"
}

4、最後,我們點擊右邊的項目 module/Tasks/andresguard/resguardRelease 即可生成資源混淆過的 APK。如下圖所示:

image

APK 生成目錄如下:

image

對於 AndResGuard 工具,主要有 兩個功能,一個是 資源混淆,一個是* 資源的極限壓縮*。下面,我們就來分別瞭解下它們的實現原理。

2、AndResGuard 的資源混淆原理

資源混淆工具主要是通過 短路徑的優化,以達到 減少 resources.arsc、metadata 簽名文件以及 ZIP 文件大小 的效果,其效果分別如下所示:

  • 1)、resources.arsc:它記錄了資源文件的名稱與路徑,使用混淆後的短路徑 res/s/a,可以減少文件的大小
  • 2)、metadata 簽名文件:簽名文件 MANIFEST.MF 與 CERT.SF 需要記錄所有文件的路徑以及它們的哈希值,使用短路徑可以減少這兩個文件的大小
  • 3)、ZIP 文件:ZIP 文件格式裏面通過其索引記錄了每個文件 Entry 的路徑、壓縮算法、CRC、文件大小等等信息。短路徑的優化減少了記錄文件路徑的字符串大小

3、AndResGuard 的極限壓縮原理

AndResGuard 使用了 7-Zip 的大字典優化APK整體壓縮率可以提升 3% 左右,並且,它還支持針對 resources.arsc、PNG、JPG 以及 GIF 等文件進行強制壓縮(在編譯過程中,這些文件默認不會被壓縮)。那麼,爲什麼 Android 系統不會去壓縮這些文件呢?主要基於以下 兩點原因

  • 1)、壓縮效果不明顯:上述格式的文件大部分已經被壓縮過,因此,重新做 Zip 壓縮效果並不明顯。比如 重新壓縮 PNGJPG 格式只能減少 3%~5% 的大小。
  • 2)、基於讀取時間和內存的考慮:針對於 沒有進行壓縮的文件,系統可以使用 mmap 的方式直接讀取,而不需要一次性解壓並放在內存中。

然後,我們再看看資源瘦身的其它方案。

6、R Field 的內聯優化

我們可以通過內聯 R Field 來進一步對代碼進行瘦身,此外,它也解決了 R Field 過多導致 MultiDex 65536 的問題。要想實現內聯 R Field,我們需要 通過 Javassist 或者 ASM 字節碼工具在構建流程中內聯 R Field,其代碼如下所示:

ctBehaviors.each { CtBehavior ctBehavior ->
    if (!ctBehavior.isEmpty()) {
        try {
            ctBehavior.instrument(new ExprEditor() {
                @Override
                public void edit(FieldAccess f) {
                    try {
                        def fieldClassName =  JavassistUtils.getClassNameFromCtClass(f.getCtClass())
                        if (shouldInlineRField(className, fieldClassName) && f.isReader()) {
                            def temp = fieldClassName.substring(fieldClassName.indexOf(ANDROID_RESOURCE_R_FLAG) + ANDROID_RESOURCE_R_FLAG.length())
                            def fieldName = f.fieldName
                            def key = "${temp}.${fieldName}"

                            if (resourceSymbols.containsKey(key)) {
                                Object obj = resourceSymbols.get(key)
                                try {
                                    if (obj instanceof Integer) {
                                        int value = ((Integer) obj).intValue()
                                        f.replace("\$_=${value};")
                                    } else if (obj instanceof Integer[]) {
                                        def obj2 = ((Integer[]) obj)
                                        StringBuilder stringBuilder = new StringBuilder()
                                        for (int index = 0; index < obj2.length; ++index) {
                                            stringBuilder.append(obj2[index].intValue())
                                            if (index != obj2.length - 1) {
                                                stringBuilder.append(",")
                                            }
                                        }
                                        f.replace("\$_ = new int[]{${stringBuilder.toString()}};")
                                    } else {
                                        throw new GradleException("Unknown ResourceSymbols Type!")
                                    }
                                } catch (NotFoundException e) {
                                    throw new GradleException(e.message)
                                } catch (CannotCompileException e) {
                                    throw new GradleException(e.message)
                                }
                            } else {
                                throw new GradleException("******** InlineRFieldTask unprocessed ${className}, ${fieldClassName}, ${f.fieldName}, ${key}")
                            }
                        }
                    } catch (NotFoundException e) {
                    }
                }
            })
        } catch (CannotCompileException e) {
        }
    }
}

這裏,我們可以 直接使用蘑菇街的 ThinRPlugin。它的實現原理爲:android 中的 R 文件,除了 styleable 類型外,所有字段都是 int 型變量/常量,且在運行期間都不會改變。所以可以在編譯時,記錄 R 中所有字段名稱及對應值,然後利用 ASM 工具遍歷所有 Class,將除 R$styleable.class 以外的所有 R.class 刪除掉,並且在引用的地方替換成對應的常量,從而達到縮減包大小和減少 Dex 個數的效果。此外,最近 ByteX 也增加了 shrink_r_classgradle 插件,它不僅可以在編譯階段對 R 文件常量進行內聯,而且還可以 針對 App 中無用 Resource 和無用 assets 的資源進行檢查

7、資源合併方案

我們可以把所有的資源文件合併成一個大文件,而 一個大資源文件就相當於換膚方案中的一套皮膚。它的效果 比資源混淆的效果會更好,但是,在此之前,必須要解決 解析資源管理資源 的問題。其相應的解決方案如下所示:

  • 模擬系統實現資源文件的解析:我們需要使用自定義的方式把 PNG、JPG 以及 XML 文件轉換爲 Bitmap 或者 Drawable
  • 使用 mmap 加載大資源與資源緩存池管理資源:使用 mmap 加載大資源的方式可以充分減少啓動時間與系統內存的佔用。而且,需要使用 Glide 等圖片框架的資源緩存池 ResourceCache 去釋放不再使用的資源文件

8、資源文件最少化配置

我們需要 根據 App 目前所支持的語言版本去選用合適的語言資源,例如使用了 AppCompat,如果不做任何配置的話,最終 APK 包中會包含 AppCompat 中所有已翻譯語言字符串,無論應用的其餘部分是否翻譯爲同一語言。對此,我們可以 通過 resConfig 來配置使用哪些語言,從而讓構建工具移除指定語言之外的所有資源。同理,也可以使用 resConfigs 去配置你應用需要的圖片資源文件類,如 “xhdpi”、“xxhdpi” 等等,代碼如下所示:

android {
    ...
    defaultConfig {
	    ...
        resConfigs "zh", "zh-rCN"
        resConfigs "nodpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"
    }
    ...
}    

此外,我們還以 利用 Density Splits 來選擇應用應兼容的屏幕尺寸大小,代碼如下所示:

android {
    ...
    splits {
        density {
            enable true
            exclude "ldpi", "tvdpi", "xxxhdpi"
            compatibleScreens 'small', 'normal', 'large', 'xlarge'
        }
    }
    ...
}

9、儘量每張圖片只保留一份

比如說,我們統一隻把圖片放到 xhdpi 這個目錄下,那麼 在不同的分辨率下它會做自動的適配,即 等比例地拉伸或者是縮小

10、資源在線化

我們可以 將一些圖片資源放在服務器,然後 結合圖片預加載 的技術手段,這些 既可以滿足產品的需要,同時可以減小包大小

11、統一應用風格

如設定統一的 字體、尺寸、顏色和按鈕按壓效果、分割線 shape、selector 背景 等等。

四、So 瘦身方案探索

對於主要由 C/C++ 實現的 Native Library 而言,常規的優化方式就是 去除 Debug 信息,使用 C++_shared 等等。下面,對於 So 瘦身,我們看看還有哪些方案。

1、So 移除方案

SoAndroid 上的動態鏈接庫,在我們 Android 應用開發過程中,有時候 Java 代碼不能滿足需求,比如一些 加解密算法或者音視頻編解碼功能,這個時候就必須要通過 C 或者是 C++ 來實現,之後生成 So 文件提供給 Java 層來調用,在生成 So 文件的時候就需要考慮生成市面上不同手機 CPU 架構的文件。目前,Android 一共 支持7種不同類型的 CPU 架構,比如常見的 armeabi、armeabi-v7a、X86 等等。理論上來說,對應架構的 CPU 它的執行效率是最高的,但是這樣會導致 在 lib 目錄下會多存放了各個平臺架構的 So 文件,所以 App 的體積自然也就更大了。

因此,我們就需要對 lib 目錄進行縮減,我們 在 build.gradle 中配置這個 abiFiliters 去設置 App 支持的 So 架構,其配置代碼如下所示:

defaultConfig {
    ndk {
        abiFilters "armeabi"
    }
}

一般情況下,應用都不需要用到 neon 指令集,我們只需留下 armeabi 目錄就可以了。因爲 armeabi 目錄下的 So 可以兼容別的平臺上的 So,相當於是一個萬金油,都可以使用。但是,這樣 別的平臺使用時性能上就會有所損耗,失去了對特定平臺的優化

2、So 移除方案優化版

上面我們說到了想要完美支持所有類型的設備代價太大,那麼,我們能不能採取一個 折中的方案,就是 對於性能敏感的模塊,它使用到的 So,我們都放在 armeabi 目錄當中隨着 Apk 發出去,然後我們在代碼中來判斷一下當前設備所屬的 CPU 類型,根據不同設備 CPU 類型來加載對應架構的 So 文件。這裏我們舉一個小栗子,比如說我們 armeabi 目錄下也加上了 armeabi-v7 對應的 So,然後我們就可以在代碼當中做判斷,如果你是 armeabi-v7 架構的手機,那我們就直接加載這個 So,以此達到最佳的性能,這樣包體積其實也沒有增加多少,同時也實現了高性能的目的,比如 微信和騰訊視頻 App 裏面就使用了這種方式,如下圖所示:

image

看到上圖中的 libimagepipeline_x86.so,下面我們就以這個 so 爲例來寫寫加載它的僞代碼,如下所示:

String abi = "";
// 獲取當前手機的CPU架構類型
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
    abi = Buildl.CPU_ABI;
} else {
    abi = Build.SUPPORTED_ABIS[0];
}

if (TextUtils.equals(abi, "x86")) {
    // 加載特定平臺的So
    
} else {
    // 正常加載
    
}

接下來,我們再瞭解下 So 優化當中別的優化方式。

3、使用 XZ Utils 對 Native Library 進行壓縮

Native LibraryDex 一樣,也可以使用 XZ Utils 進行壓縮,對於 Native Library 的壓縮,我們 只需要去加載啓動過程相關的 Library,而其它的都可以在應用首次啓動時進行解壓,並且,壓縮效果與 Dex 壓縮的效果是相似的

此外,關於 Nativie Library 壓縮之後的解壓,我們也可以使用 Facebook 的 so 加載庫 SoLoader,它 能夠解壓應用的 Native Library 並能遞歸地加載在 Android 平臺上不支持的依賴項。由於這套方案對啓動時間的影響比較大,所以先把它壓箱底下吧。

4、對 Native Library 進行合併

在 Android 4.3(API 17) 之前,單個進程加載的 SO 數量是有限制的,在 Google 的 linker.cpp 源碼中有很明顯的定義,如下圖所示:

image

爲了解決這個問題,FaceBook 寫了一個 合併Native Library的demo,我們可以 按照自身 App 的 so 情況來配置需要合併哪些對象。由於合併共享對象(即 .so 文件)在原先的構建流程中是無法實現的,因此 FaceBook 更改了鏈接庫的方式,並把它集成到了構建系統 Buck 中。該功能允許每個應用程序指定應合併的 .so 庫,從而避免意外引入不必要的依賴關係。然後,Buck 負責爲每個合併的 .so 庫收集所有對象(文件),並將它們與適當的依賴項鍊接在一起。

5、刪除 Native Library 中無用的導出 symbol

我們可以去 分析代碼中的 JNI 方法以及不同 Library 庫的方法調用,然後找出無用的 symbol 並刪除,這樣 Linker 在編譯的時候也會把 symbol 對應的無用代碼給刪除。在 Buck 有 NativeRelinker 這個類,它就實現了這個功能,其 類似於 Native Library 的 ProGuard Shrinking 功能

至此,可以看到,FaceBook 出品的 Buck 同 ReDex 一樣,裏面的功能都十分強大,Buck 除了實現 Library Merge 和 Relinker 功能之外,還實現了三大功能,如下所示:

  • 1)、多語言拆分
  • 2)、分包支持
  • 3)、ReDex 支持

如果有相應需求或對 Buck 感興趣的同學可以去看看它們的實現源碼。

6、So 動態下載

我們可以 將部分 So 文件使用動態下發的形式進行加載。也就是在業務代碼操作之前,我們可以先從服務器下載下來 So,接下來再使用,這樣包體積肯定會減少不小。但是,如果要把這項技術 穩定落地到實際生產項目中需要解決一些問題,具體的 so 動態化關鍵技術點和需要避免的坑可以參見 動態下發 so 庫在 Android APK 安裝包瘦身方面的應用
,這裏就不多贅述了。

五、其它優化方案

1、插件化

我們可以使用插件化的手段 對代碼結構進行調整,如果我們 App 當中的每一個功能都是一個插件,並且都是可以從服務器下發下來的,那 App 的包體積肯定會小很多。插件化相關的知識非常多而且不屬於我們的重點,並且,插件化嚴格來說屬於 基礎架構研發 這塊的知識,掌握它是成爲 Android 架構師的必經之路,關於 Android 架構師的學習路線 可以參考 Awesome-Android-Architecture,預計今年會完成部分學習內容,盡情期待。

2、業務梳理

我們需要 回顧過去的業務,合理地去 評估並刪除無用或者低價值的業務

3、轉變開發模式

如果所有的功能都不能移除,那就可能需要去轉變開發模式,比如可以更多地 採用 H5、小程序 這樣開發模式。

六、包體積監控

對於應用包體積的監控,也應該和內存監控一樣,去作爲正式版本的發佈流程中的一環,並且應該 儘量地去實現自動化與平臺化。(這裏建議 任何大於 100kb 的功能都需要審批,特別是需要引入第三方庫時,更應該慎重)

1、包體積監控的緯度

包體積的監控,主要可以從如下 三個緯度 來進行:

  • 1)、大小監控:通常是記錄當前版本與上一個或幾個版本直接的變化情況,如果當前版本體積增長較大,則需要分析具體原因,看是否有優化空間
  • 2)、依賴監控:包括J ar、aar 依賴
  • 3)、規則監控:我們可以把包體積的監控抽象爲無用資源、大文件、重複文件、R 文件等這些規則

包體積的 大小監控依賴監控 都很容易實現,而要實現 規則監控 卻得花不少功夫,幸運的是 Matrix 中的 ApkChecker 就實現了包體積的規則監控,其 使用文檔與實現原理 微信團隊已經寫得很清楚了,這裏就不再一一贅述,有興趣的同學可以去研究下。

七、瘦身優化常見問題

瘦身優化是性能優化當中不那麼重要的一個分支,不過對於處於穩定運營期的產品會比較有幫助。下面我們就來看看對於瘦身優化有哪些常見問題。

1、怎麼降低 Apk 包大小?

我們在回答的時候要注意一些 可操作的乾貨,同時注意結合你的 項目週期。主要可以從以下 三點 來回答:

  • 1)、代碼:Proguard、統一三方庫、無用代碼刪除
  • 2)、資源:無用資源刪除、資源混淆
  • 3)、So:只保留 Armeabi、更優方案

在項目初期,我們一直在不斷地加功能,加入了很多的代碼、資源,同時呢,也沒有相應的規範,所以說,UI 同學給我們很多 UI 圖的時候,都是沒有經過壓縮的圖片,長期累積就會導致我們的包體積越來越大。到了項目穩定期的時候,我們對各種運營數據進行考覈,發現 APK 的包大小影響了用戶下載的意願,於是我們就着手做包體積的優化,我們採用的是 Android Studio 自帶的 Analyze APK 來做的包體積分析,主要就是做了代碼、資源、So 等三個方面的重點優化。

首先,針對於代碼瘦身,第一點,我們首先 使用 Proguard 工具進行了混淆,它將程序代碼轉換爲功能相同,但是不容易理解的形式。比如說將一個很長的類轉換爲字母 a,同時,這樣做還有一個好處,就是讓代碼更加安全了。第二點呢,我們將項目中使用到的一些 第三方庫進行了統一,比如說圖片庫、網絡庫、數據庫等,不允許項目中出現功能相同,但是卻實現不一樣的庫。同時也做了 規範,之後引入的三方庫,需要去考量它的大小、方法數等,而且呢,如果只是需要一個很大庫的一個小功能,那我們就修改源碼,只引入部分代碼即可。第三點,我們將項目中的 無用代碼進行了刪減,我們使用了 AOP 的方式統計到了哪些 Activity 以及 fragment 在真實的場景下沒有用戶使用,這樣你就可以刪除掉了。對於那些不是 Activity 或者是 Fragment 的類,我們切了很多類的構造函數,這樣你就可以統計出來這些類在線上有沒有真正被調用到。但是,對於代碼的瘦身效果,實際上不是很明顯

接下來,我們做了資源的瘦身。首先,我們 移除了項目當中冗餘的資源文件,這一點在項目當中一定會遇到。然後,我們做了 資源圖片的壓縮,UI 同學給我們資源圖片的時候,需要確認已經是壓縮過的圖片,同時,我們還會做一個 兜底策略,在打包的時候,如果圖片沒有被壓縮過,那我們就會再來壓縮一遍,這個效果就非常的明顯。對於資源,我們還做了 資源的混淆,也就是將冗餘的資源名稱換成簡短的名字,資源壓縮的效果要比代碼瘦身的效果要好的多

最後,我們做了 So 的瘦身。首先,我們只保留了 armeabi 這個目錄,它可以 兼容別的 CPU 架構,這點的優化效果非常的明顯。移除了對別的架構適配 So 之後,我們還做了另外一個處理,對於項目當中使用到的視頻模塊的 So,它對性能要求非常高,所以我們採用了另外一種方式,我們將所有這個模塊下的 So 都放到了 armeabi 這個目錄下,然後在代碼中做判斷,如果是別的 CPU 架構,那我們就加載對應 CPU 架構的 So 文件即可。這樣即減少了包體積,同時又達到了性能最佳。最後,通過實踐可以看出 So瘦身的效果一般是最好的

2、Apk 瘦身如何實現長效治理?

主要可以從以下 兩個方面 來進行回答:

  • 1)、發版之前與上個版本包體積對比,超過閾值則必須優化
  • 2)、推進插件化架構改進

在大型項目中,最好的方式就是 結合 CI,每個開發同學 在往主幹合入代碼的時候需要經過一次預編譯,這個預編譯出來的包對比主幹打出來的包大小,如果超過閾值則不允許合入,需要提交代碼的同學自己去優化去提交的代碼。此外,針對項目的 架構,我們可以做 插件化的改造,將每一個功能模塊都改造成插件,以插件的形式來支持動態下發,這樣應用的包體積就可以從根本上變小了

八、總結

在本篇文章中,我們主要從以下 七個方面 講解了 Android 包體積優化相關的知識:

  • 1)、瘦身優化及 Apk 分析方案:瘦身優勢、APK 組成、APK 分析
  • 2)、代碼瘦身方案探索:Dex 探祕、ProGuard、D8 與 R8 優化、去除 debug 信息與行號信息、Dex 分包優化、使用 XZ Utils 進行 Dex 壓縮、三方庫處理、移除無用代碼、避免產生 Java access 方法、利用 ByteX Gradle 插件平臺中的代碼優化插件
  • 3)、資源瘦身方案探索:冗餘資源優化、重複資源優化、圖片壓縮、使用針對性的圖片格式、資源混淆、R Field 的內聯優化、資源合併方案、資源文件最少化配置、儘量每張圖片只保留一份、資源在線化、統一應用風格
  • 4)、So 瘦身方案探索:So 移除方案、So 移除方案優化版、使用 XZ Utils 對 Native Library 進行壓縮、對 Native Library 進行合併、刪除 Native Library 中無用的導出 symbol、So 動態下載
  • 5)、其它優化方案:插件化、業務梳理、轉變開發模式
  • 6)、包體積監控
  • 7)、瘦身優化常見問題

如果要想對包體積做更深入的優化,我們就必須對 APK 組成,Dex、So 動態庫以及 Resource 文件格式,還有 APK 的編譯流程 有深入地瞭解,這樣我們纔能有 足夠的內功素養 去實現包體積的深度優化。此外,在做性能優化過程中,爲了提升研發效率,降低研發成本,我漸漸發現 AOP 編譯插樁、Gradle 自動化構建 的知識越來越重要;並且,一旦涉及 Native 層甚至 Android 內核層的深度優化時,就越發感覺到功力不足。因此,爲了 補充深度優化所需的養分,從下篇開始,筆者將暫停更新 深入探索 Android 性能優化系列文章。下篇文章,筆者的分享將會從先從 編譯插樁 相關的知識開始,敬請期待~

參考鏈接:


1、Top團隊大牛帶你玩轉Android性能分析與優化 第10章 App瘦身優化

2、極客時間之Android開發高手課 包體積優化

3、《Android性能優化最佳實踐》第七章 安裝包大小優化

4、android-arscblamer

5、Android SVG to VectorDrawable

6、App瘦身最佳實踐

7、使用Simian工具掃描重複代碼

8、FontZip

9、Android-Iconics

10、iconfont

11、IconFont在Android中的使用

12、TinyPngPlugin

13、我是如何通過 nimbledroid 做android app性能優化的

14、Apktool Install Instructions

15、nimbledroid

16、android-classyshark

17、Android混淆從入門到精通

18、APK Expansion Files

19、寫入放大

20、D8 dexer and R8 shrinker

21、Android新Dex編譯器D8與新混淆工具R8

22、Comparison of ProGuard vs. R8: October 2019 edition

23、支付寶 App 構建優化解析:Android 包大小極致壓縮

24、Redex

25、Redex 初探與 Interdex:Andorid 冷啓動優化

26、Dalvik 可執行文件格式

27、InterDex.cpp 貪心算法部分

28、CrossDexRefMinimizer.cpp 跨dex調用優化

29、我是如何通過 nimbledroid 做android app性能優化的

30、XZ Embedded

31、oatmeal

32、SoLoader

33、buck

34、android-native-library-merging-demo

35、Redex優化demo

36、android-chunk-utils

37、ResourceUsageAnalyzer.java

38、Android安裝包相關知識彙總

39、安裝包立減1M–微信Android資源混淆打包工具

40、AndResGuard

41、Android APK 簽名原理

42、西瓜視頻apk瘦身之 Java access 方法刪除

43、動態下發 so 庫在 Android APK 安裝包瘦身方面的應用

Contanct Me

● 微信:

歡迎關注我的微信:bcce5360

● 微信羣:

微信羣如果不能掃碼加入,麻煩大家想進微信羣的朋友們,加我微信拉你進羣。

● QQ羣:

2千人QQ羣,Awesome-Android學習交流羣,QQ羣號:959936182, 歡迎大家加入~

About me

很感謝您閱讀這篇文章,希望您能將它分享給您的朋友或技術羣,這對我意義重大。

希望我們能成爲朋友,在 Github掘金上一起分享知識。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章