起因只是想確認一句話對不對:「classes.dex 在 APK 裡是未壓縮的」。答案是不對——但順著 APK Analyzer 往下挖,發現有三個函式庫各自只為了一件小事被留在 app 裡,拆掉之後 arm64 release APK 少了 29%。這篇記錄怎麼量、砍了什麼、以及過程中踩到的兩個坑。
APK Analyzer 對 classes.dex 會同時顯示「raw」和「download」兩個數字,很容易混在一起講。實際用 unzip -lv 看 release APK:
Length Method Size Cmpr Name
6217104 Defl:N 2970374 52% classes.dex
2066756 Stored 2066756 0% resources.arsc
266664 Stored 266664 0% lib/arm64-v8a/libadblock-client.so所以 classes.dex 在 APK 裡是 有壓縮的(52%),6.2 MB 是裝到手機上解開後的大小,下載時只佔 3 MB。反而是 resources.arsc 真的一個位元組都沒壓——targetSdk 30 之後系統要直接 mmap 它,這是規定——所以那 2.07 MB 全都是實打實的下載量。後面砍資源表的效果這麼明顯,原因就在這裡。
另一個好用的工具是 apkanalyzer dex packages --defined-only --proguard-mapping mapping.txt:把 R8 混淆後的 dex 還原成套件名稱,再按頂層套件加總 bytes,就能列出「誰佔了多少」。一開始的排行榜是這樣:
| 套件 | dex bytes | 備註 |
|---|---|---|
| info.plateaukao.einkbro | 1,001 KB | app 自己的程式碼 |
| androidx.compose.* | ~1,500 KB | UI 框架,留著 |
| com.tom_roush.pdfbox + fontbox | 349 KB | 另存 PDF 功能,留著 |
| com.google.android.material | 178 KB | 沒有任何 XML layout 在用 |
| androidx.work | 133 KB | 只有廣告過濾清單下載在用 |
| androidx.appcompat | 197 KB | 只剩 AlertDialog 和 theme parent |
| androidx.recyclerview | 96 KB | 程式碼裡一個 RecyclerView 都沒有 |
| okhttp3 + okio | 195 KB | Edge TTS 的 WebSocket 需要,留著 |
「library-level cut」指的就是這種:整個函式庫或它的一大塊,因為還有某個地方碰到它所以 R8 沒辦法自己丟掉。找到那個「某個地方」,通常比想像中小。
app 自己宣告了 material:1.12,用到的地方總共三個:一個沒有人呼叫的 setBottomSheetBehavior()、兩個 dialog style 的 parent、還有一個沒被引用的 CustomSnackbar。把 style 的 parent 改成 AppCompat 的版本,其它刪掉,就可以拿掉依賴。
但拿掉之後 dex 裡還是有 Material。追 gradle dependencies 才發現 ad-filter 模組依賴一個叫 SmoothProgressAnimator 的函式庫,而它拖進了 Material 1.2.1、RecyclerView 和 androidx.transition。ad-filter 用它做什麼?只有一行:
import io.github.edsuns.smoothprogress.BuildConfig
…
raw.replace(DEBUG_FLAG, if (BuildConfig.DEBUG) "" else "//")它拿的是別人 AAR 裡的 BuildConfig.DEBUG,那永遠是 false。所以注入 JS 裡那些 {{DEBUG}} console.log(...) 從來沒有在任何版本亮過。改用模組自己的 BuildConfig,debug build 終於會印了,依賴也一起消失。
resources.arsc 裡有 88 個語系設定,但 app 本身只翻了 31 種語言。多出來的 57 個全是 AppCompat / Material 帶進來的字串——日期選擇器、時間選擇器、chip、badge 的翻譯——app 根本不會顯示。而且每個語系設定在 string type 裡都有一張約 3 KB 的 entry offset 表,不管它定義了幾條字串。
androidResources {
localeFilters += listOf("af", "ar", "ca", …, "zh-rCN", "zh-rTW")
}這等於是側載 APK 版本的 Play 語言分割。這一刀下去 APK 從 6.19 MB 變 4.89 MB,其中 resources.arsc 從 2.07 MB 掉到 1.16 MB。
WorkManager 在 app 裡只做一件事:DownloadWorker → InstallationWorker 這條鏈,下載廣告過濾清單再餵給 native 的 ad-block client。值得保留的特性有三個:離線時等網路(NetworkType.CONNECTED)、同一個 id 進行中時忽略第二次呼叫(ExistingWorkPolicy.KEEP)、以及 process 死掉之後工作還在。
新的 FilterUpdater 是一個 SupervisorJob + Dispatchers.IO 的 scope,每個 filter id 一個 Job:
ConnectivityManager.registerNetworkCallback 包成 suspend function。registerDefaultNetworkCallback 比較簡單但要 API 24,ad-filter 的 minSdk 是 23。download() 加 @Synchronized,job 還活著就直接 return。Filter 的 downloadState 本來就會存進 SharedPreferences。啟動時看到還停在 ENQUEUED / DOWNLOADING / INSTALLING 的,代表上次是被殺掉而不是真的失敗,就重新排進去。真正 FAILED 的不碰——壞掉的 URL 只會試一次,不會每次開 app 都重試。一個意外的副作用:WorkManager 自己的 manifest 裡宣告了 ACCESS_NETWORK_STATE,而 app 狀態列的 Wi-Fi 圖示一直是靠它合併進來的。現在得自己宣告,同時 WAKE_LOCK、RECEIVE_BOOT_COMPLETED、FOREGROUND_SERVICE 三個沒在用的權限也從合併後的 manifest 消失了。
重寫時也修了兩個舊 bug:安裝成功後新資料現在會立刻載進 detector(原本那行 enableFilter() 被註解掉了,得重開 app 才生效);下載中途的狀態更新不再把 isEnabled 和 checksum 清掉。
這個 app 沒有任何 XML layout,全部是 Compose,所以 AppCompat 能做的事本來就不多。清點完全部用法:九個 AlertDialog、AppCompatDialogFragment、一個 AppCompatActivity、兩個 AppCompatImageButton、一個 ContextThemeWrapper、setDefaultNightMode(FOLLOW_SYSTEM),加上 theme 的 parent。minSdk 24 之後這些在 framework 都有對應:android.app.AlertDialog、DialogFragment、FragmentActivity、ImageButton、Theme.Material.Light.NoActionBar(values-night 放 Theme.Material.NoActionBar)。日夜模式本來就是靠 values-night 加系統 uiMode 在切,FOLLOW_SYSTEM 做的就是這件事。
Theme 屬性要搬到 android: namespace,87 個用 ?attr/colorControlNormal 上色的向量圖也要改成 ?android:attr/colorControlNormal。改完 build 過、測試過,但接下來踩了兩個坑。
程式碼已經沒有任何地方碰 AppCompat,dex 裡卻還留著 131 個 class:Toolbar、ActionMenuView、MenuBuilder……它們出現在 R8 的 seeds.txt 裡。原因是 AGP 會替所有合併進來的 layout XML 產生 keep rule,包含 AppCompat 自己的 abc_*.xml;resource shrinker 之後雖然會把那些 layout 刪掉,但 R8 已經先把 class 留下來了。
所以要從 constraintlayout 和 koin-android 把 androidx.appcompat exclude 掉,從 adblock-client(宣告了但沒用)移除,再拿掉沒人用的 koin-android-compat。這一步才是大頭:又少了 120 KB 的 dex,而且 AppCompat 的 drawable、layout、theme 全都不再打包,APK 的檔案數從 653 掉到 368。
在模擬器上跑第一輪看起來都正常,直到打開主選單:原本黑色的圓角框線消失了,其它 Compose dialog 也一樣。視窗背景是 background_with_border_margin.xml,框線顏色讀的就是 colorControlNormal。
追到 DialogFragment.setStyle(STYLE_NO_FRAME, 0):theme 傳 0 並不是「沒有 theme」,androidx 會偷偷代入 android.R.style.Theme_Panel。Theme.Panel 繼承自最古老的 base Theme,而那個 Theme 把 colorControlNormal 定義成 @color/legacy_control_normal,dialog 的 ContextThemeWrapper 會把整條鏈疊在 activity theme 上面——於是框線被畫成幾乎是白色。以前 AppCompat 的屬性在 app 自己的 namespace,這條 overlay 碰不到它,所以一直是「剛好能動」。
<style name="EinkPanelDialogTheme" parent="@android:style/Theme.Panel">
<item name="android:colorControlNormal">@android:color/black</item>
<item name="android:colorPrimary">@android:color/white</item>
<item name="android:colorAccent">@android:color/black</item>
…
</style>保留 Theme.Panel 的視窗行為,把 app 的顏色釘回去,values-night 再放一份深色版,明確傳給 setStyle()。另外兩個小細節:Theme.Material.Light.Dialog.Alert 是完整 theme 不是 overlay,不重新指定 android:colorAccent 的話 radio button 會變成 Material 的青綠色;Widget.Material.Button.ButtonBar.AlertDialog 是 private style,dialog 按鈕改用公開的 Widget.Material.Button.Borderless。
| 函式庫 | dex | 為什麼留著 |
|---|---|---|
| pdfbox + fontbox | 360 KB | 另存 PDF 的目錄與合併。fontbox 從 PDDocument → PDResources → fonts 可達,R8 砍不掉;BouncyCastle 和 4.6 MB 的字型資源早就 exclude 了。 |
| OkHttp + okio | 195 KB | 27 個 newCall、OpenAI 的 SSE、Google Drive 的 multipart,以及 Edge TTS 的 WebSocket。Android 沒有 java.net.http,自己寫 wss client 不合理。 |
| Compose | ~1.3 MB | 整個 UI。 |
到這裡已經沒有「只為一件小事存在」的函式庫了,剩下的都是功能本身的成本。
unzip -lv,再相信任何「X 佔了 Y MB」的說法。 dex 有壓縮、arsc 沒有,下載量和安裝量是兩件事。BuildConfig.DEBUG 的 import 帶進了三個函式庫,而且它的值永遠是 false。三個 commit:6466dafcf(Material + 語系)、758815b18(WorkManager)、6600c4aba(AppCompat)。每一個都有對應的 ADR 記錄細節。