EinkBro · 開發紀錄 · 2026-08-22 / 23

EinkBro APK 瘦身記:一天內從 6.19 MB 減到 4.38 MB

起因只是想確認一句話對不對:「classes.dex 在 APK 裡是未壓縮的」。答案是不對——但順著 APK Analyzer 往下挖,發現有三個函式庫各自只為了一件小事被留在 app 裡,拆掉之後 arm64 release APK 少了 29%。這篇記錄怎麼量、砍了什麼、以及過程中踩到的兩個坑。

6.19 → 4.38 MB
arm64 release APK(-29%)
6.22 → 5.26 MB
classes.dex(解壓後)
2.07 → 1.05 MB
resources.arsc(APK 內不壓縮)
7531 MB6.19起點v16.3.24.89Material + 語系-1.30 MB4.79WorkManager-0.10 MB4.38AppCompat-0.41 MB
arm64 release APK 大小(MB),每根長條是一個 commit 之後的量測結果。

先把量尺弄對

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.einkbro1,001 KBapp 自己的程式碼
androidx.compose.*~1,500 KBUI 框架,留著
com.tom_roush.pdfbox + fontbox349 KB另存 PDF 功能,留著
com.google.android.material178 KB沒有任何 XML layout 在用
androidx.work133 KB只有廣告過濾清單下載在用
androidx.appcompat197 KB只剩 AlertDialog 和 theme parent
androidx.recyclerview96 KB程式碼裡一個 RecyclerView 都沒有
okhttp3 + okio195 KBEdge TTS 的 WebSocket 需要,留著

「library-level cut」指的就是這種:整個函式庫或它的一大塊,因為還有某個地方碰到它所以 R8 沒辦法自己丟掉。找到那個「某個地方」,通常比想像中小。

第一刀:Material Components,而且它其實被引了兩次

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 換成 coroutine

WorkManager 在 app 裡只做一件事:DownloadWorker → InstallationWorker 這條鏈,下載廣告過濾清單再餵給 native 的 ad-block client。值得保留的特性有三個:離線時等網路(NetworkType.CONNECTED)、同一個 id 進行中時忽略第二次呼叫(ExistingWorkPolicy.KEEP)、以及 process 死掉之後工作還在。

新的 FilterUpdater 是一個 SupervisorJob + Dispatchers.IO 的 scope,每個 filter id 一個 Job:

一個意外的副作用:WorkManager 自己的 manifest 裡宣告了 ACCESS_NETWORK_STATE,而 app 狀態列的 Wi-Fi 圖示一直是靠它合併進來的。現在得自己宣告,同時 WAKE_LOCK、RECEIVE_BOOT_COMPLETED、FOREGROUND_SERVICE 三個沒在用的權限也從合併後的 manifest 消失了。

重寫時也修了兩個舊 bug:安裝成功後新資料現在會立刻載進 detector(原本那行 enableFilter() 被註解掉了,得重開 app 才生效);下載中途的狀態更新不再把 isEnabled 和 checksum 清掉。

第三刀:AppCompat 換成 framework 的 Material theme

這個 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 得整個離開 classpath

程式碼已經沒有任何地方碰 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 + fontbox360 KB另存 PDF 的目錄與合併。fontbox 從 PDDocument → PDResources → fonts 可達,R8 砍不掉;BouncyCastle 和 4.6 MB 的字型資源早就 exclude 了。
OkHttp + okio195 KB27 個 newCall、OpenAI 的 SSE、Google Drive 的 multipart,以及 Edge TTS 的 WebSocket。Android 沒有 java.net.http,自己寫 wss client 不合理。
Compose~1.3 MB整個 UI。

到這裡已經沒有「只為一件小事存在」的函式庫了,剩下的都是功能本身的成本。

幾個可以帶走的結論

  1. 先看 unzip -lv,再相信任何「X 佔了 Y MB」的說法。 dex 有壓縮、arsc 沒有,下載量和安裝量是兩件事。
  2. 一個 import 就能把整個函式庫留在 app 裡。 那個 BuildConfig.DEBUG 的 import 帶進了三個函式庫,而且它的值永遠是 false。
  3. R8 拿掉的是「沒被引用的 class」,不是「沒被用到的函式庫」。 函式庫自己的 layout 會透過 aapt keep rule 把 View class 釘住;要省這塊只能讓它離開 classpath。
  4. 語系過濾是側載 APK 版的 Play 語言分割。 函式庫帶進來的翻譯通常比 app 自己的還多。
  5. 每一刀都要在模擬器上看過對話框。 單元測試抓不到框線變白這種事。

三個 commit:6466dafcf(Material + 語系)、758815b18(WorkManager)、6600c4aba(AppCompat)。每一個都有對應的 ADR 記錄細節。