天天看點

玩轉APK:實作Android APK瘦身99.99%

摘要:  如何瘦身是 APK 的重要優化技術。APK 在安裝和更新時都需要經過網絡下載下傳到裝置,APK 越小,使用者體驗越好。本文作者通過對 APK 内在機制的詳細解析,給出了對 APK 各組成成分的優化方法及技術,并實作了一個基本 APK 的最小化過程 正文: 如何瘦身是 APK 的重要優化技術。APK 在安裝和更新時都需要經過網絡下載下傳到裝置,APK 越小,使用者體驗越好。本文作者通過對 APK 内在機制的詳細解析,給出了對 APK 各組成成分的優化方法及技術,并實作了一個基本 APK 的最小化過程。高爾夫運動中,分數最小者勝出。 讓我們将這一原則應用到 Android App 開發中。我們将玩轉一個稱為“ApkGolf”的 APK,目的是建立一個盡可能具有最少位元組數的 App,并可安裝在運作 Oreo 的裝置上。 基線測定 一開始,我們用 Android Studio 生成一個預設的 App,建立密鑰庫(Keystore) ( https://developer.android.com/studio/publish/app-signing.html#generate-key )  并對 App 簽名,然後使用指令 stat -f%z $filename 測定生成 APK 檔案的位元組數大小。 進一步,為確定該 APK 工作正常,我們将在一台運作 Oreo 的 Nexus 5x 手機上安裝它。

玩轉APK:實作Android APK瘦身99.99%

看上去挺漂亮。但是現在我們的 APK 大小近乎 1.5Mb。 APK Analyser 考慮到我們 App 的功能非常簡單,1.5Mb 的規模看上去過于臃腫了。是以,我們要深入了解一下該項目,看看是否有一些能立竿見影地削減檔案大小的地方。Android Studio 生成了:

  • 擴充AppCompatActivity而得到的MainActivity;
  • 使用根視圖ConstraintLayout的布局檔案;
  • Value 檔案,其中包含三種顔色、一個字元串資源(Resource)和一個主題(Theme);
  • AppCompat和ConstraintLayout的支援庫;
  • 一個AndroidManifest.xml檔案;
  • PNG 格式的啟動圖示,分别是正方形、圓形和前台的。

看上去首當其沖的目标是啟動圖示檔案,因為 APK 中共包含了 15 個圖像檔案,并且在 mipmap-anydpi-v26 下還有兩個 XML 檔案。下面,讓我們使用 Android Studio 的 APK Analyser ( https://developer.android.com/studio/build/apk-analyzer.html )  對該 APK 檔案做一個定量分析。

玩轉APK:實作Android APK瘦身99.99%

給出的結果與我們的最初假設大相徑庭,其中顯示 Dex 檔案是大頭,而上述資源僅占 APK 大小的 20%。 檔案 大小占比 classes.dex 74% res 20% resources.arsc 4% META-INF 2% AndroidManifest.xml <1% 下面讓我們逐個分析每個檔案的行為。   Dex 檔案 看上去罪魁禍首是 classes.dex 檔案,它占據了 73% 的空間,因而它成為我們的首要削減目标。該檔案為 Dex 格式  ( https://source.android.com/devices/tech/dalvik/dex-format ) , 其中包含了我們的全部編譯後代碼,以及對 Android 架構和支援庫中外部方法的引用。 然而 android.support 軟體包中引用了超過 13000 種的方法,對于一個簡單的“Hello World”App 而言,完全沒有必要。   資源 目錄“res”中包含了大量的布局(Layout)檔案、Drawable 和動畫,它們并非在 Android Studio UI 中立刻可見。同樣,它們也是由支援庫推入其中的,約占 APK 規模的 20%。

玩轉APK:實作Android APK瘦身99.99%

在 resources.arsc 檔案中,還包含了對每個資源的引用。   簽名 目錄“ META-INF ”中包含有 CERT.SF 、 MANIFEST.MF 和 CERT.RSA 檔案, 這些檔案都需要 v1 APK 簽名  ( https://source.android.com/security/apksigning/v2#v1-verification ) 。 如果有攻擊者修改了我們 APK 中的代碼,簽名就會不比對。這一機制保障了使用者能避免執行第三方惡意軟體的風險。 在 MANIFEST.MF 檔案中列出了 APK 中的所有檔案。其中, CERT.SF 檔案中包含了檔案清單的摘要,以及每個檔案的獨立摘要。 CERT.RSA 檔案中包含了一個公鑰,用于驗證 CERT.SF 檔案的完整性。

玩轉APK:實作Android APK瘦身99.99%

在簽名檔案中,沒有目标明顯可優化。   AndroidManifest 檔案 看上去 AndroidManifest 檔案非常類似于我們的原始輸入檔案。唯一差别在于,檔案中的字元串和 Drawable 等資源被整數資源 ID 所替代,這些 ID 以 0x7F 開頭。 啟用最小化功能(Minification) 我們尚未在 App 的 build.gradle 檔案中設定允許最小化(Minification)和資源收縮(Resource Shrinking)。我們現在做此設定: android {     buildTypes {         release {             minifyEnabled true            shrinkResources true            proguardFiles getDefaultProguardFile(               'proguard-android.txt' ), 'proguard-rules.pro'        }    } } -keep class com . fractalwrench .** { *; } 将 minifyEnabled 屬性設定為“true”值,這将啟用 Proguard ( https://www.guardsquare.com/en/proguard ) , 該功能将從 App 中剝離出那些未使用的代碼,并對符号的名稱做模糊化處理,使得 App 難以被反向工程。 設定 shrinkResources 屬性,将會在 APK 中移除任何并非直接引用的資源。這時如果我們使用反射機制間接地通路資源,就會導緻問題,但是本文給出的 App 并不存在這樣的問題。 優化為 786 Kb(削減 50%) 我們已經實作了 APK 規模減半,并未對我們的 APP 有任何可見的影響。

玩轉APK:實作Android APK瘦身99.99%

對于那些尚未在 App 中啟用 AndroidManifest.xml 和 shrinkResources 的開發人員,這是本文給出的最需要重視的并應學會的技巧。他們僅花費數小時做配置和測試,就能輕松地削減數兆的規模。 我們尚未了解 AppCompat 的工作機制 現在 classes.dex 檔案已削減到占用 APK 的 57%。在我們的 Dex 檔案中,大多數方法引用屬于 android.support 軟體包,是以我們将要去除該支援庫。具體做法為:

  • 從build.gradle中徹底清除依賴塊。

    dependencies{

     implementation'com.android.support:appcompat-v7:26.1.0'

     implementation'com.android.support.constraint:constraint-layout:1.0.2'

    }

  • 更新MainActivity,以擴充android.app.Activity。

public class MainActivity extends Activity

  • 更新布局,使用單一的TextView。

<? xml version= "1.0" encoding= "utf-8" ?> < TextView xmlns:android = "http://schemas.android.com/apk/res/android"     android:layout_width = "match_parent"     android:layout_height = "match_parent"     android:gravity = "center"     android:text = "Hello World!" />

  • 删除styles.xml檔案,并從AndroidManifest檔案的<application>元素中移除android:theme屬性。
  • 删除colors.xml檔案。
  • 在 gradle 同步時做 50 次上推(push-up)。

優化為 108 Kb(削減 87%) 天哪,我們剛剛實作了近十倍的削減,即從 786Kb 削減到 108Kb。唯一可見的更改是工具條(Toolbar)的顔色,現在它使用了預設的 OS 主題。

玩轉APK:實作Android APK瘦身99.99%

目錄“res”現在占用 APK 規模約 95%,原因是所有的加載圖示。如果這些 PNG 圖檔是由我們自己的設計師所給出的,那麼我們可以嘗試 将它們轉換為 WebP 格式,該格式更加高效,并被 API 15 及以上所支援。 幸運的是,Google 已經優化了我們的 Drawable。即便沒有這種優化,ImageOptim 也可優化 PNG 并從中剝離不必要的中繼資料。 讓我們當一次壞人,将我們所有的加載圖示替換為單一的單像素黑點,并置于未驗證的 res/drawable 目錄中。圖檔大小約 67 個位元組。 優化為 6808 位元組(削減 94%) 我們已經移除了幾乎全部的資源,是以毫不奇怪 APK 規模已經削減了約 95%。但是 resources.arsc 依然引用了如下項:

  • 一個布局檔案;
  • 一個字元串資源;
  • 一個調用圖示。

讓我們從第一項着手。   布局檔案(優化為 6262 位元組,削減 9%) Android 架構會膨脹我們的 XML 檔案  ( https://developer.android.com/reference/android/view/LayoutInflater.html ) , 并自動建立一個 TextView 對象,用于 Activity 對象的 contentView 。 我們可以嘗試一些跳過中間的過程,具體做法是移除 XML 檔案,并使用程式設定 contentView 。這樣會降低資源的規模,因為我們減少了一個 XML 檔案。但是 Dex 檔案将會增大,因為我們引用了額外的 TextView 方法。 TextView textView = new TextView( this ); textView.setText( "Hello World!" ); setContentView(textView); 讓我們檢視一下這一權衡做法的工作情況,它削減了 5710 個位元組。   App 名稱(優化為 6034 位元組,削減 4%) 下面我們将删除 strings.xml 檔案,并将 AndroidManifest 中的 android:label 屬性值更改為“A”。這看上去是一個小更改,但是它從 resources.arsc 中删除了一項,削減了 Manifest 檔案中的字元數,并從“res”目錄中移除了一個檔案。略有裨益,我們削減了 228 個位元組。   加載圖示(優化為 5300 位元組,削減 13%) Android Platform 代碼庫中的 resources.arsc 的文檔 ( https://android.googlesource.com/platform/frameworks/native/+/jb-dev/libs/utils/README )  告訴我們,APK 中的每個資源通過 resources.arsc 中的一個整數 ID 引用。這些 ID 具有兩個命名空間(Namespace): 0x01 : 系統資源(預裝在 framework-res.apk 中);

0 x7f : 應用資源(捆綁在應用的.apk 檔案中)。 那麼如果在 0x01 命名空間中引用了一個資源,我們的 APK 發生了什麼?我們應該可以在削減檔案規模的同時,得到一個更漂亮的圖示。 android:icon= "@android:drawable/btn_star"

玩轉APK:實作Android APK瘦身99.99%

雖然文檔是這樣說的,但是在一個生産 App 中,我們應該保持“永遠不要信任系統資源”這一原則。該步驟會導緻 Google Play 驗證失敗,而且考慮到我們知道某些制造商已經重定義了白色  ( https://www.reddit.com/r/androiddev/comments/71fpru/android_color_resources_not_safe/ ), 是以在具體操作時需要慎重。   Manifest 檔案(優化為 5252 位元組,削減 1%) 目前為止,我們尚未對 Manifest 檔案下手。 android:allowBackup= "true" android:supportsRtl= "true" 移除這些屬性将會削減 48 個位元組。   防止破解(優化為 4984 位元組,削減 5%) 看上去 Dex 檔案中依然包括 BuildConfig 和 R 。 -keep class com . fractalwrench . MainActivity { *; } 如果我們精煉 Proguard 規則,就會清除掉這些類。   命名混淆(優化為 4936 位元組,削減 1%) 現在對我們的 Activity 賦予一個混淆後的名字。對于正常類,Proguard 可自動實作混淆功能,但是考慮到 Activity 類名會通過 Intents 喚醒,是以預設情況下不要混淆 Activity 的名字。 MainActivity - > c .java

com .fractalwrench.apkgolf - > c .c   META-INF(優化為 3307 位元組,削減 33%) 目前在 App 簽名中,我們使用了 v1 和 v2 簽名。看上去這完全是浪費,尤其是 v2 會對整個 APK 做哈希,提供了更進階的保護能力和性能  ( https://source.android.com/security/apksigning/#apk-signing-schemes )。 在 APK Analyser 中,v2 簽名并不可見,因為它在 APK 檔案本身中以二進制塊的形式存在。v1 簽名是可見的,它是以 CERT.RSA  和  CERT.SF 檔案的形式給出。 Android Studio UI 中提供了 v1 簽名的複選框,我們需要去除該選擇,并生成一個簽名的 APK。我們也需要做相反的過程。 簽名 大小(位元組) v1 3511 v2 3307 看上去從此以後我們使用的是 v2。 下面的操作将無需 IDE 的支援 現在我們要手工編輯我們的 APK 了。我們将使用如下指令: # 1. 建立一個未簽名的 APK。 ./gradlew assembleRelease

# 2. 解壓縮歸檔檔案。 unzip app-release- unsigned .apk -d app

# 對檔案進行編輯。

# 3. 壓縮歸檔檔案 zip -r app app.zip

# 4. 運作 zipalign。 zipalign -v -p 4 app-release- unsigned .apk app-release-aligned.apk

# 5. 使用 v2 簽名運作 apksigner。 apksigner sign --v1-signing-enabled false --ks $HOME/fake.jks -- out signed -release.apk app-release- unsigned .apk

# 6. 驗證簽名。 apksigner verify signed -release.apk 此連結 ( https://developer.android.com/studio/publish/app-signing.html#sign-manually )  詳細概述了 APK 簽名過程。總而言之,gradle 生成了一個未簽名的歸檔檔案,zipalign 更改了未壓縮資源的位元組對齊方式,用于改進加載 APK 時的 RAM 使用,最後 APK 将被加密簽名。 未簽名且未對齊的 APK 大小為 1902 位元組,這意味着簽名和對齊過程增加了約 1 Kb。   檔案大小差異(優化為 2608 位元組,削減 21%) 很奇怪!我們對未對齊的 APK 解壓縮并手工簽名,并手動移除了 META-INF/MANIFEST.MF ,這削減了 543 位元組。如果有人知道原因,請告訴我! 現在我們的簽名 APK 中隻有三個檔案,當然還可以去除 resources.arsc ,因為我們并未定義任何資源! 這将使我們僅保留 Manifest 和 classes.dex 檔案,兩個檔案大小相當。   壓縮破解(Compression Hack)(優化為 2599 個位元組,削減 0.5%) 讓我們将剩餘的字元串都更改為‘c’,更新版本為 26,然後生成一個簽名的 APK。 compileSdkVersion 26    buildToolsVersion "26.0.1"    defaultConfig {         applicationId "c.c"        minSdkVersion 26        targetSdkVersion 26        versionCode 26        versionName "26"    } < manifest xmlns:android = "http://schemas.android.com/apk/res/android"     package = "c.c" >

    < application         android:icon = "@android:drawable/btn_star"         android:label = "c"        >         < activity android:name = "c.c.c" > 這将削減 9 個位元組。 盡管檔案中的字元數并未改變,但是我們更改了‘c’字元的頻次。這使得壓縮算法可以進一步降低檔案的大小。   你好,ADB(優化到 2462 位元組,削減 5%) 通過移除```Activity````的 Launch Intent Filter,我們可以進一步優化 Manifest。此後,我們将使用如下指令加載 App: adb shell am start -a android.intent.action. MAIN -n c . c /. c 下面給出新的 Manifest 檔案: < manifest xmlns:android = "http://schemas.android.com/apk/res/android"     package = "c.c" >

    < application >         < activity             android:name = "c"             android:exported = "true" />     </ application > </ manifest > 我們還移除了加載圖示。   削減方法引用(優化為 2179 位元組,削減 12%) 我們最初需求是生成一個可安裝在裝置上的 APK。現在是運作“Hello World”的時候了。 我們的 App 引用了 TextView 、 Bundle 和 Activity 中的方法。通過移除 Activity ,并替換為使用者定義的 Application 類,我們可以進一步削減 Dex 檔案大小。現在我們的 Dex 檔案應該僅引用了單一的方法,即 Application 的構造函數。 現在我們的源檔案如下: package c.c; import android.app.Application; public class c extends Application {} < manifest xmlns:android = "http://schemas.android.com/apk/res/android"     package = "c.c" >     < application android:name = ".c" /> </ manifest > 我們可以使用 adb 驗證該 APK 是可以成功安裝的,也可以通過 Setting App 做驗證。

玩轉APK:實作Android APK瘦身99.99%

  Dex 優化(優化為 1961 位元組,削減 10%) 在此次優化中,我花費了多個小時研究 Dex 檔案格式  ( https://source.android.com/devices/tech/dalvik/dex-format )  意在了解諸如校驗碼和偏移量等各種機制,它們是手工編輯檔案中的難點。 但是長話短說,被我證明的是,隻要存在 classes.dex 檔案,APK 檔案就能安裝。是以,隻要簡單地删除原始檔案并在終端運作 touch classes.dex ,使用這一空檔案就能獲得近 10% 的規模削減。 有時看上去最愚蠢的方法反而最有效。   了解 Manifest 檔案(優化為 1961 位元組,削減 0%) 非簽名 APK 中的 Manifest 檔案是二進制的 XML 格式,該格式看上去并沒有官方的文檔。我們可以使用 HexFiend 編譯器去修改檔案内容 ( https://github.com/ridiculousfish/HexFiend ) 。 我們可以猜測出位于檔案頭部的數個感興趣項。頭四個位元組編碼了 38 ,是與 Dex 檔案所使用的版本相同。随後的兩個位元組編碼為 660 ,這無疑是檔案的大小。 下面,我們嘗試通過設定 targetSdkVersion 為 1 并更新檔案大小頭部為 659 ,去删除一個位元組。不幸的是,Android 系統拒絕了這個非法的 APK,是以看上去這裡另有玄機。   無需了解 Manifest 檔案(優化為 1777 位元組,削減 9%) 下面我們讓我們對整個檔案輸入虛字元,然後在不更改檔案大小的情況下嘗試安裝 APK。這将确定校驗碼是否發揮作用,以及更改是否使得檔案頭部的偏移值失效。 令人驚奇的是,下圖的 Manifest 檔案被解釋為一個有效的 APK,可運作在運作 Oreo 的 Nexus 5X 手機上:

玩轉APK:實作Android APK瘦身99.99%

我想我聽到了負責維護 BinaryXMLParser.java 的 Android Framework 工程師對着枕頭在大聲尖叫。 為最大化收益,我們将使用空位元組(Null)替換這些虛字元。這可使簡化使用 HexFiend 檢視檔案的重要部分,也将使前期的壓縮破解可削減一些位元組。   UTF-8 格式的 Manifest 檔案 下圖給出了一些 Manifest 檔案中的重要成分。如果沒有這些成分,APK 将會安裝失敗。

玩轉APK:實作Android APK瘦身99.99%

一些事情即刻是很明顯的,例如 Manifest 檔案和軟體包标記。在字元串池中還可以找到軟體包名稱和 versionCode。   十六進制的 Manifest 檔案

玩轉APK:實作Android APK瘦身99.99%

以十六進制檢視檔案可顯示檔案頭部的值,這些值描述了字元串池及其它值,例如 0x9402 是檔案的大小。字元串也具有一種有意思的編碼。如果字段超出了 8 個位元組,它們的總長度将在随後的兩個位元組中指定。 但是,看上去我們并不能從中做更進一步的削減。 大功告成?(優化為 1757 位元組,削減 1%) 讓我們檢視一下最終的 APK。

玩轉APK:實作Android APK瘦身99.99%

終歸,我們使用 v2 簽名在 APK 中留名。讓我們建立一個利用壓縮破解的新密鑰庫。

玩轉APK:實作Android APK瘦身99.99%

這可削減 20 個位元組。 第五階段:最終采納 現在的 1757 個位元組是相當的小。據我所知,這是最小的現有 APK。 但是我完全有理由确信,Android 社群中會有人能再做進一步的優化,并打破我的記錄。如果你設法打破了 1757 個位元組的記錄,請 向這個放置最小 APK 的代碼庫發送一個 PR,或者 通過 Twitter 聯系我。 連結:

  • https://github.com/fractalwrench/ApkGolf
  • https://twitter.com/fractalwrench

檢視英文原文: https://fractalwrench.co.uk/posts/playing-apk-golf-how-low-can-an-android-app-go/