有道詞典Android客戶端包體積最佳化之路

有道AI情报局發表於2022-04-21

1 背景

有道詞典從移動網際網路之初就憑藉小巧快速、功能強大的印象讓使用者愛上翻譯查詞,愛上學習。隨著業務不斷地迭代以及功能不斷完善,有道詞典不再是單純的查詞軟體,而是變成了使用者的綜合學習平臺。我們探索過社群、問答、直播、資訊流等業務,目前也承載著音訊、視訊、課程、背單詞、寫作批改等等的功能。詞典已經發展成為一個綜合性的學習平臺,小巧快速的初心仍然指引著我們不斷進行啟動速度以及包體積優化。

經過了不斷的效能優化,目前我們的冷啟動時間已經能維持在業界標準水平3s以內。我們近一個季度主要的效能優化工作集中在安裝包體積優化上面。經過一系列的努力,我們包體積減少了23.7%,安裝包體積從177MB減少到135MB,整體少了42MB。

image.png

以下詳細介紹我們的分析以及實現細節。

2 分析

介紹下包體積包含的內容以及優化方法概述

一般的APK安裝包包含了以下一些目錄和資源:

META-INF/ 簽名檔案

assets/ 程式使用的輔助資原始檔

res/ 沒有編譯進入resources.arsc 資原始檔,一般是圖片

lib/ 依賴的不同native平臺的庫檔案

resource.arsc 編譯之後的文案、色值、大小、主題等資源索引

classes.dex 編譯後的程式碼

AndroidMenifest.xml 應用的名稱、版本、訪問許可權和引用的庫檔案資訊

image.png
可以看出佔比較大的部分主要是分別是assets/、lib/、res/、classes.dex以及resources.arsc,大概對應的就是資源、庫檔案、程式碼以及資源索引。我們主要的優化思路如下(其中藍色框部分為目前已經處理部分):

image.png

3 技術實現細節

3.1 圖片壓縮

在APK打包的過程中,aapt 工具會預設對圖片進行無失真壓縮,不過預設的壓縮並不能達到一個很好的壓縮效果,經過了對比webp以及tinypng的壓縮效果,我們最終選擇了使用tinypng對圖片進行壓縮。並且我們編寫了編譯工具,對圖片進行自動化壓縮。

有損webp > tinypng > 無損webp

image.png

比如這張啟動圖,原大小724KB,壓到75%左右的質量只有23.7KB。效果上有一點點差異,但可以接受。那麼我們是否可以把全部png圖壓成有損webp呢?答案是否定的,可以看看下面的例子:

image.png

壓縮前:

image.png

壓縮後:

image.png

可以看到,相同的壓縮質量下(75%),這個圖就變得十分模糊,哪怕選擇到了99%的壓縮質量,漸變區域依然會出現一些沒有自然過渡的條紋。

image.png
對於上述的情況,用tinypng方案更好

原圖:643KB,

tinypng: 152KB,

webp:339KB

綜上,對於有損webp,無法找到一個固定的壓縮質量來適配所有場景。有損webp有些時候甚至比tinypng還大,但顯示質量更差。

我們最初使用的抖音的McImage外掛對圖片進行處理,不過這個方案存在一些明顯的問題:

  1. 方案採用有損webp,有損webp無法定一個通用的壓縮質量適應所有場景。
  2. 每次打包都要對所有圖進行壓縮,嚴重影響迭代效率。打包機要40分鐘,且經常OOM。
  3. 沒有對assets目錄的圖片進行處理。

針對以上問題,我們自己開發了一套使用tinypng的自動化圖片壓縮工具,做出以下調整:

1.對於大圖png,用手工壓成有損webp。收益大,且風險可控。
2.對於非大圖,開發了一個image-optimization外掛進行壓縮。該外掛方案為:

· png轉tinypng。雖然是有損的,但從抽樣來看,肉眼完全看不到明顯變化。

· 對assets進行處理。assets內有前端png圖,轉tinypng不轉webp的好處是不需要單獨改html、js等檔案,且對低版本系統相容性更友好。flutter相關專案的flutter_assets圖片比較大且沒注意壓縮。外掛統一處理可以不需要開啟flutter工程單獨優化、重新打包。

· 對於已壓縮的圖片,做快取處理,不需要重新壓縮,打包的時候動態替換。壓縮快取跟隨詞典工程提交到gitlab統一管理。

以下是我們圖片自動化壓縮外掛處理的流程圖:

image.png

這裡壓縮圖是否可用判斷,主要是大小判斷,如果壓出來比原圖大,那麼將捨棄。比如crunchPng壓縮就存在這種情況

附加1:因為已經用了tinypng統一壓縮,那麼google官方自帶的crunchPng建議關閉,否則打包速度變慢,而且優化好的圖片也可能又變大,加入這行即可:

buildTypes.all {\
  isCrunchPngs = false\
}

附加2:無損webp和tinypng對比

如圖所示,全量換tinypng比全量換webp(包含assets)少7.7MB。如果考慮到assets內的14.7MB其實是不能簡單換webp的,差距會更大。
image.png

附加3:tinypng已經是最好的方案嗎?

參考另一個ImageOptim工具,它結合OptiPNG, PNGCrush, AdvanceComp, PNGOUT, Jpegoptim + Jpegtran, 和 Gifsicle 等幾個工具提供最好的優化效果,而且是幾乎無損的。對於小部分圖片ImageOptim壓出來小,看起來沒有差別。不過壓縮速度非常慢。

所以,如果做到極致的話,可以進行多種壓縮方案,選最佳的圖作為替換。且我們的image-optimization外掛從一開始設計的時候就預留了這種可擴充套件性。

附加4:AndResGuard優化對比

試了一下效果不明顯,且出現部分資源丟失而崩潰的情況。效果不明顯的原因,猜測是目前R8對資源名也有混淆壓縮(以前proguard沒有),所以AndResGuard現在的作用比較微弱。至於7zip的壓縮沒有開,理論上會導致啟動速度變慢,覺得得不償失(另外會導致Google Pay的Patch優化演算法失效)。

3.2 resources.arsc優化

  • 語言包優化

image.png

開啟resources.arsc的string,我們可以看到如下表格,會發現大量空的地方(如上圖)。這些空白的地方,其實是用FF FF FF…字元進行佔位的,佔用了很多空間(如下圖)。由於有道詞典沒有進行國際化翻譯(有一個國際化版本叫U-Dictionary,歡迎支援),因此刪掉不必要的語言版本有助於減少體積。

image.png

android {\
    defaultConfig {\
        resConfigs "zh"\
    }\
}
  • 如上所示,增加一行,保留中文即可。收穫比想象中大,直接減少了3MB。
  • dimens優化檢視了最近幾個版本的arsc體積,發現有一個版本增加了5MB。
  • 在這個版本我們做了平板適配功能,由於我們採用的是SmallestWith限定符適配方案(可以先了解下這個螢幕適配方案),因此產生大量的尺寸資源。

image.png

一共是有3000多個資源,每一個資源有“values-sw300dp”到"values-sw1200dp"共90個版本,這塊存在較大的優化空間。

sqb_px_xx”這一項是用於字型適配的,但詞典用到最大的字型是“sqb_px_144”,所以優化了生成規則,減少了這一類資源。

優化後,資源數量由3012變成1662,減少了近一半。直接減少了2.5MB。

3.3 業務程式碼刪除

由於Proguard以及lint等工具是從程式碼引用的角度進行分析和程式碼裁剪,如果一些廢棄的程式碼不先進行刪除會影響後續工作的效果。對於一些已經廢棄沒有入口的業務,不進行處理的話那麼程式碼、資源會只增不減。業務刪減應該是所有包體積流程的第一步,否則後面的去掉無用資源、圖片壓縮、混淆等等效果都要打一個折扣。如果時間有限的話,那麼刪最近的需求會比刪遠古時代的需求收益會大點,原因是越靠近現在的專案,圖片資源、字型資源,以及用到so庫都會比較大(尤其是音視訊)。

這部分工作主要是對業務功能的整理以及溝通部分陳舊業務是否可以進行刪除,除此之外就是需要細緻的引用分析將廢棄業務相關程式碼剝離出來進行刪除。

一個良好的專案架構對於日後業務程式碼的剝離有很大好處。目前新開發的功能我們採用的是分層分模組的組織架構,功能模組之間不存在相互依賴,因此以後對於業務的抽離或者刪除會更加方便。

image.png

3.4 無用資源刪除

對於無用資源刪除我們主要使用了兩個方法,一個是通過 lint 工具找到應用中可能沒有使用的資源並逐一進行判斷確認沒有使用後進行刪除,第二個是在build.gradle檔案中加入shrinkResources在編譯階段使用R8工具進行刪除

buildTypes {\
        release {\
            // Zipalign優化\
            zipAlignEnabled true\
            // 移除無用的resource檔案\
            shrinkResources true\
            // 移除沒用的程式碼\
            minifyEnabled true\
        }\
}

使用 lint 工具需要注意對以下一些場景進行再次判斷確認

  1. 對於反射性引用資源,可能會被識別成無用資源,比如push用到的通知欄icon
  2. DataBinding用到的layout資源會被識別成無用資源

3.5 壓縮混淆

使用R8工具在編譯階段對程式碼進行壓縮混淆,從而達到壓縮安裝包體積的效果。主要分為以下4個步驟:

  1. 壓縮(shrink) 移除未使用的類、方法、欄位等;
  2. 優化(optimize) 優化位元組碼、簡化程式碼等操作;
  3. 混淆(obfuscate) 使用簡短的、無意義的名稱重新命名類名、方法名、欄位等;
  4. 預校驗(preverify) 為class新增預校驗資訊。

我們在兩年前就引入了Proguard,不過考慮到混淆帶來的問題使用了-dontobfuscate配置取消混淆。我們發現之前的規則中從依賴庫中繼承了 -dontoptimize 的配置導致優化也沒有生效。這次優化中,我們全面解決了混淆帶來的眾多問題,全面開啟了優化以及混淆。

由於我們之前已經開啟過了壓縮,因此需要使用到的類已經在proguard中進行了保留。開啟混淆後還需要處理以下一些問題:

  • getIdentifier 通過名稱獲取資源問題。如果是普通模式,則會自動不去掉相關資源:

image.png

  • 檢查Resources.getValue 相關邏輯
  • 檢查AssetManager.open相關邏輯
  • 反射,全域性搜一下反射包,修改相關位置 java.lang.reflect
  • 處理Retrofit報錯問題(https://github.com/square/ret...),目前使用升級Gradle外掛版本進行解決
Caused by: java.lang.IllegalArgumentException: Method return type must not include a type variable or wildcard: ho8<su3<?>>\
    for method CheckInApi.popupConfig\
    at retrofit2.Utils.methodError(SourceFile:5)\
    at retrofit2.Utils.methodError(SourceFile:1)\
    at retrofit2.ServiceMethod.parseAnnotations(SourceFile:7)\
    at retrofit2.Retrofit.loadServiceMethod(SourceFile:4)\
    at retrofit2.Retrofit$1.invoke(SourceFile:6)\
    at java.lang.reflect.Proxy.invoke(Proxy.java:1006)\
    at $Proxy23.popupConfig(Unknown Source)\
    at com.youdao.dict.checkin.CheckInPopupManager.requestPopupConfig(SourceFile:3)\
    at java.lang.reflect.Method.invoke(Native Method)

Proguard的規則會很大程度上影響R8對程式碼壓縮和混淆帶來的效果,因此對壓縮規則的回顧以及整理可以幫助進一步的體積壓縮。

3.6 字型優化

字型優化這部分是在之前的版本已經實現過的,取得的效果也挺明顯,這裡補充說明一下。

- 字型裁剪

一般的字型庫大小會有十幾二十兆。但實際上用到的字元只有很少一部分,因此針對實際的使用場景對字型庫進行適當的裁剪,收益非常大。

常用字列表:https://github.com/DavidSheh/...

字型壓縮工具:https://github.com/forJrking/...

- 字型合併

一般來說,我們開發都會模組化,不同的團隊採用在開發不同功能的時候,有可能用到相同的字型。如果稍不注意就會複製成兩份、三份,檔案大大增加。詞典這邊的方案是把共有的字型下沉到底層core基礎庫,供各個模組引用。

4 展望

經過了上述的工作,目前詞典的安裝包體積優化了23.7%,整體減少了42MB。在接下來的Q2,我們將準備做兩方面的事情。

4.1 包體積監控

在包體積優化的過程中,我們在含辛茹苦地砍掉一點體積之後,轉過頭來發現別的同學又隨隨便便扔進去幾MB的大圖。因此,如何堅守勝利的果實,讓包體積保持最佳狀態成了重中之重。

打包任務增加了是否檢查包大小限制(預設都要檢查) 的選項;merge request之後,詞典的打包任務會觸發自動構建;

打包任務完成之後,如果需要檢查包大小,那就開始觸發apkcheck步驟;具體如下:

  1. 打包任務完成之後增加指令碼操作,把本次構建的資料(如apk檔案地址,mapping檔案地址,R文字地址等)寫入臨時檔案;
  2. 打包任務構建後操作增加 Trigger parameterized build on other projects,觸發apk 大小檢查任務;
  3. 開始檢查流程,檢查流程根據引數對apk進行檢查任務,並且把任務結果生成html;

4.2 動態分發

- 整體業務分發

可以使用外掛化以及動態載入等技術,不過這些可能不是最難的,最難的是如何把一些祖傳的、低頻的、而又相互依賴的程式碼抽離出來,形成獨立模組去做分發、動態載入。

- 業務子功能分發(預計可優化39.6MB)

  1. 資料庫(單詞鎖屏8MB)

單詞鎖屏可以保留幾百kb資料在本地讓使用者備用,同時再下載完整的詞庫。

  1. OCR引擎資料(22.5MB)

使用者應該可以按需下載訓練模型,而不是直接內建;當沒有訓練模型的時候,可以直接網路請求。

  1. 字型(9.1MB)

除了查詞等高頻業務,低頻業務的字型可以動態分發,有則顯示,無則使用系統的即可。但emoji的相容庫比較特殊,主要用在首頁資訊流的帖子、UGC發帖等。如果沒有相容庫,使用者在遇到特別的emoji中可能會顯示“豆腐塊”,這個時候如果emoji字型庫還沒下載完成,需要進行替換兜底處理。另外哪怕使用者系統已經有這個emoji內建字型,也有可能顯示效果各個手機不太一樣,需要跟UI確認一下是要替換掉,還是暫時這樣顯示。

保持住詞典小巧快速、功能強大的初心是我們不停進行效能優化的動力,在接下來的工作中,我們會對啟動速度、安裝包體積以及記憶體佔用等多方面進行持續優化和改進,歡迎大家繼續關注和支援!

5 參考

  • Reduce your app size
  • Shrink, obfuscate, and optimize your app
  • 抖音圖片壓縮外掛McImage
  • 騰訊包體積監控ApkChecker

相關文章