抖音包大小優化:資源優化

1.概述

隨着業務的快速迭代,抖音 Android 端的包大小爆發式增長。包大小直接影響到下載轉化率、推廣成本、運行內存和安裝時間等因素,因此對 apk 進行瘦身是一件很有必要且收益很大的事情。apk 主要由 dex、resource、asserts、native libraries 和 meta-data 組成,針對每一部分,都可以專項去做包大小優化。

抖音 Android 端經過一段時間努力,包大小優化已經取得了階段性的成果。目前仍在持續的優化中。

- 優化前 優化後 百分比
抖音 73MB 61.5MB 15.7%
抖音 lite 10MB 4.9MB 51%

其中,資源在 apk 包體積中佔比很大,針對資源進行優化是包大小優化中很重要的部分。本着追求極致的原則,本文將詳細闡述抖音 Android 端針對資源部分的優化措施。

2.圖片壓縮

2.1 圖片壓縮原理

在不進行壓縮的情況下,圖片大小計算公式:圖片大小=長 x 寬 x 圖片位深。一張原始圖像(1920x1080),如果每個像素 32bit 表示(RGBA),那麼圖像需要的存儲大小 1920x1080x4 = 8294400Byte,大約 8M,一張圖這麼大是難以接受的。因此我們使用的圖片都是經過壓縮的。

圖片壓縮利用的是空間冗餘和視覺冗餘原理:

  • 空間冗餘利用的是圖像上各採樣點顏色之間存在着的空間連貫性,把本該一個一個像素存儲的數據,合併壓縮存儲,加載時進行解壓還原。通常無損壓縮利用的就是空間冗餘原理。
  • 視覺冗餘是指人類的視覺系統由於受生理特性的限制,對於圖像場的注意是非均勻的,人對細微的顏色差異感覺不明顯。例如,人類視覺的一般分辨能力爲 26 灰度等級,而一般的圖像的量化採用的是 28 灰度等級,即存在視覺冗餘。通常有損壓縮利用的是人的視覺冗餘原理,擦除了對人的眼睛來說冗餘的信息。

2.2 優勢

抖音 Android 研發團隊開發了 Gradle 插件 McImage,在編譯期間 hook 資源,採用開源的算法 pngquant/guetzli 進行壓縮,支持 webp 壓縮。與 tinypng 等一些已知的方案相比,存在以下優勢:

  • McImage 現支持 webp 壓縮,壓縮比高於 tinypng,不過 Android 上 webp 需要做兼容,下文會詳細介紹;
  • tinypng 不開源,每個賬號每個月只能免費壓縮 500 張;McImage 使用的壓縮算法都是基於開源算法;
  • McImage 不僅可以壓縮 module 中的圖片,還能壓縮 jar 和 aar 中的圖片;
  • McImage 支持壓縮算法擴展,有更優的壓縮算法選擇時擴展方便;
  • 和行業裏其他方案相比,McImage 還能夠支持壓縮包含透明度的 webp 圖片,並且兼容了 aapt2 對資源的 hook。

2.3 收益

McImage 支持兩種優化方式,這兩種優化方式不可同時使用:

  • Compress,pngquant 壓縮 png 圖片,guetzli 壓縮 jpg 圖片;
  • ConvertWebp,webp 壓縮 png\png 圖片。

webp 的壓縮比要高於 pngquant、guetzli,所以現在更推薦使用 ConvertWebp 這種壓縮方式。
McImage 還被應用於字節跳動旗下多個產品的圖片壓縮優化工作中,收益如下:

描述 收益
抖音-Compress 9.5MB
抖音-ConvertWebp 11.6MB
火山-ConvertWebp 3.6MB
Vigo-ConvertWebp 4MB
Vigo aab-Compress 1.2MB
vigo aab-ConvertWebp 3.2MB
多閃-ConvertWebp 3.5MB

2.4 其他

除了壓縮、優化圖片,McImage 還提供了以下功能:

  1. 大圖檢測。在 app/build/mcimage_result 目錄下會生成 mcimage_log.txt 日誌文件,除了輸出轉換結果的日誌外,在最後還輸出了大像素圖片和大體積圖片,閾值可在 McImageConfig 裏進行設置,方便大圖覆盤優化包大小;也支持編譯階段檢測,檢測到大圖直接 block 編譯,可及時發現大圖提交;
  2. 壓縮算法方便擴展。如果想接入其他壓縮算法,只需要繼承 AbstractTask,實現 ITask 接口中的 work 方法即可;
  3. 支持多線程壓縮。把所有 task 的執行放入線程池中執行,大大縮短了 mcimage 的執行時間;
  4. 增加了圖片緩存 cache,進一步縮短打包時間。在開啓多線程+圖片緩存的情況下,全部命中緩存的情況下,整個 mcimage 的過程不到 10s;緩存路徑可配置;
  5. 壓縮質量可配置,滿足不同的壓縮質量需求,緩存文件也會按照不同的壓縮質量進行保存和命中;
  6. 掃描不包含透明通道的圖片到 app/build/mcimage_result 目錄下。

3.webp 無侵入式兼容

3.1 tinypng 和 webp 的選擇

tinypng 與 webp 到底哪個壓縮比更高呢?在網上找不到兩種壓縮算法壓縮比的直接比較,需要更直觀的對比,於是做了如下的實驗:

  1. 掃描項目中 1960 張圖片,通過不同的算法壓縮進行對比:
描述 大小
原圖 13463.07KB
webp 壓縮 4177.18KB
tinypng 壓縮 6732.18KB
  1. 從項目中找 490 張圖片,新建 demo,不同算法壓縮圖片後比較打包 apk 的大小:
描述 大小
原圖 APK 9617.53KB
webp 壓縮 APK 3924.06KB
tinypng 壓縮 APK 5386.80KB

通過這兩組實驗對比,可以看出 webp 的壓縮比是優於 tinypng 的。之前也手動的使用 webp 工具壓縮過抖音工程中所有圖片,包大小減少了 1.6MB 左右。因此選擇了 Webp 壓縮算法。

3.2 方案選型

webp 壓縮算法,相較於 pngquant、guetzli、tinypng,webp 壓縮比更高,所以 webbp 壓縮圖片應該是更優的選擇。但是 Android 設備對 webp 的支持存在兼容性問題,在 4.3 以上才完全支持。通過官網我們知道,想在應用中直接使用帶有透明度的 webp,minSDK 至少需要是 18。

包括抖音、今日頭條在內的頭條系 Android 應用,大部分 minSDK 是 16,無法直接使用 webp 圖片,需要做低版本兼容。通過大量調研,找到了三種兼容的方式:

- 優點 缺點
提供特定 api 兼容 實現起來簡單 侵入性太強,必須用特定接口或特定 View 進行加載
LayoutInflater setFactory 進行兼容 實現起來簡單 需要針對所有的 ImageView 及子 View 處理,且必須有統一的 Activity、Fregment 的基類處理
運行時 hook 系統關鍵方法 方法替換,可以做到無侵入式 實現起來複雜些

3.3 方案實現

想要做到無侵入式的兼容,運行時 hook 不失爲一種最佳的選擇。但是運行時 hook 方案,需要解決以下幾點問題:

  • 選擇的 hook 方案要穩定可靠;
  • hook 點要足夠收斂,保證所有解析圖片的操作都能符合預期。

3.3.1 Hook 方案要穩定可靠

通過對 Xposed、AndFix、Cydia Substrate、dexposed 等常見的 Android Java hook 方案的調研對比,dexposed 具有不需要 root、又能 hook 系統方法的特點,最終選擇 dexposed:

  • dexposed 在 Dalvik 上比較穩定,只需要針對 4.3 以下的手機版本做 hook,不需要考慮版本兼容性問題和系統升級問題;
    ( 通過內部數據可以知道,抖音 4.3 以下的用戶並不多,在十萬級別,佔總用戶數的萬分之幾,風險較低。

3.3.2 Hook 點要足夠收斂

通過閱讀源碼,發現所有圖片被加載解析成 Bitmap 的過程,最終都調用到了 BitmapFactory 中的方法。比如 ImageView 的 setImageResource() 的調用路徑如下:

ImageView 的 setImageResource 過程,Bitmap 的創建是通過 BitmapFactory 來實現。如 View 的 setBackgroundResource(int resid)的源碼如下:

查閱所有加載圖片的 api,都會經歷 Resources 調用 getDrawable 的過程。會調用到 Drawable 的相關方法,然後通過 BitmapFactory 去解析不同的資源類型(File\ByteArray\Stream\FileDescriptory)爲 Bitmap。由此可以推斷出,BitmapFactory 是 Android 系統通過不同的資源類型加載成 Bitmap 的統一接口,這一點從 BitmapFactory 的類註釋中也能看出:

BitmapFactory註釋

由於系統加載解析 Bitmap 的過程已經足夠收斂,都是通過 BitmapFactory 來實現,因此 BitmapFactory 是一個非常不錯的 hook 點。

有了穩定的 Hook 方案和足夠收斂的 Hook 點,方案的實現起來就手到擒來了,利用 dexposed 對 BitmapFactory 裏的關鍵方法進行替換就可以了。

4.多 DPI 優化

Android 爲了適配各種不同分辨率或者模式的設備,爲開發者設計了同一資源多個配置的資源路徑,app 通過 resource 獲取圖片資源時,自動根據設備配置加載適配的資源,但這些配置伴隨着的問題就是高分辨率的設備包含低分辨率的無用圖片或者低分辨率的設備包含高分辨率的無用圖片。

一般情況下,針對國內應用市場,App 爲了減少包大小,會選用市場佔有率最高的一套 dpi(google 推薦 xxhdpi)兼容所有設備。而針對海外應用市場的 APP,大多會通過 AppBundle 打包上傳至 Google Play,能夠享受動態分發 dpi 這一功能,不同分辨率手機可以下載不同 dpi 的圖片資源,因此我們需要提供多套 dpi 來滿足所有設備。在項目中,我們的圖片有的只有一套 dpi,有的有多套 dpi,針對上述兩種場景,我們分別在打包時合併資源、複製資源,減少了包大小。

4.1 DPI 複製(bundle 打包)

在國內項目中,爲了減少圖片的佔用,一般都會對市場佔用率高的 dpi 進行適配,比如只保留 xxhdpi 分辨率的圖片。這樣就導致了兩個問題,一個是市場上 2k 分辨率手機越來越多,如果以後手機主流分辨率是 xxxhdpi,那麼項目中幾千張圖片修改成本會非常高。另一個問題是,公司不少海外產品是通過 AppBundle 打包上傳到 Google Play 的,能夠給不同設備用戶下發不同 dpi 的資源。但項目中只有 xxhdpi,仍然下發 xxhdpi 的圖片,無法通過降低 dpi 減小包大小。在巴西,我們 80%用戶都使用 xhdpi 和 hdpi 手機,xxhdpi 圖片相比 hdpi 佔用多了一倍,這部分收益相當高。

因此,我們通過壓縮分辨率的方式將高分辨率的圖片降低到低分辨,項目業務只存放最高 dpi 圖片,在打包的時候按需求複製篩選。我們在 hook 了圖片壓縮的 task,在圖片壓縮前,獲取到包括依賴庫在內的所有 PNG 圖片,利用 Graphics2D 降低圖片分辨率,放在對應分辨率文件夾中。之後再執行圖片壓縮 task,防止一些圖片重採樣後大小增加。

我們僅對圖片的分辨率進行縮放,並不降低圖片採樣率,因此在顯示效果上沒有區別。不同 dpi 具體應該調整到多少分辨率,我們根據 Google 的定義製作了一個表格:

我們複製一張 xxhdpi 的默認 logo 到所有 dpi,流程如下圖,xhdpi 和 mdpi 文件夾下沒有對應圖片,複製;在 hdpi 中有對應圖片,跳過;xxxhdpi 也沒有對應圖片,但爲了避免降低圖片精度,不能向更高分辨率文件夾複製,跳過。

最終收益如圖,公司內海外產品 TikTok 研發團隊在使用該方案優化時,ldpi 相比 xxhdpi 減少了 2.5M 包大小。同時,低分辨率手機加載圖片時直接加載對應 dpi 圖片資源,不再需要對高分辨率圖片進行縮放處理,提高了性能。

在複製時需要注意這些問題:爲了處理包括依賴庫中的所有圖片,在資源合併階段進行了複製,這樣會導致.cache 目錄的很多路徑下會多出大量圖片資源,因此這個插件我們在 CI 上開啓,避免本地打包新增大量圖片,提交到代碼倉庫。同時,由於.cache 中被複制了多份圖片,需要在 assemble 打包流程中進行多 dpi 去重。在 CI 上會有併發場景,同時複製和壓縮會導致.cache 目錄下同時存在 a.png 和 a.webp,出現 Duplicated 錯誤,因此最後需要掃描刪除同名的.png 文件。

4.2 多 DPI 去重(assemble 打包)

針對普通打包模式(直接產出 apk,比如抖音包),我們可以選擇只保留一份分辨率偏高的的圖片,這樣高分辨率設備可以拿到合適的圖片、低分辨率設備通過 Resource 獲取時會自動進行縮放,依然可以保證合理的運行內存。

多 dpi 圖片可以通過 Android 自帶的 resConfig 去重,但這個配置只對資源的 qualifier 去重,比如對像素密度和屏幕尺寸不會同時做去重,抖音使用基於 AndResguard 修改的方式對 drawable 去重,可以定義不同配置的優先級和作用範圍。根據優化配置確保留一份資源,優化方式如下圖(灰色數據表示會被刪除):

5.重複資源合併

隨着項目的迭代,項目中難免會出現相同的資源被重複添加到資源路徑中,對於這類文件,人工處理肯定是不可行的,可以在打包階段自動去重。

抖音選擇在 AndResguard 階段對所有的資源進行分析,對 md5 相同的資源文件保留一份,刪除其餘的重複的文件,然後在 AndResguard 寫入 arsc 文件時進行將刪除的資源文件對應的資源路徑指向唯一保留的一份資源文件。優化方式如下圖:

下圖是抖音 511 版本接入多 dpi 去重與重複資源合併功能的優化結果:

6.shrinkResource 嚴格模式

6.1 背景

隨着項目的開發迭代,我們會有許多資源已經不再使用了,但仍然存在於項目中。雖然我們可以使用公司開源的字節碼插件開發平臺 ByteX 開發插件在 ProGuard 之前掃描出一些無用資源,但因爲這一步沒有經過無用代碼刪除,因此掃描出的結果並不全。而 shrinkResources 是 google 官方提供的優化此類無用資源的方法,它運行在 Proguard 之後,能標記所有無用資源並將其優化。

6.2 收益

抖音 Android 在開啓 shrinkResources 嚴格模式後,shrink 資源數 600+,收益大小 0.57MB。

6.3 接入方法

shrinkResources 是由 Google 官方提供的工具,因此詳細的接入方式參考 Google Developer 上的文檔即可。

6.4shrinkResources 原理

默認情況下,Resource shrink 是 safe 模式的,即其會幫助我們識別類似val name = String.format("img_%1d", angle + 1)``val res = resources.getIdentifier(name, "drawable", packageName)這樣模式的代碼,從而保證我們在反射調用資源文件的時候,也是能夠安全返回資源的。從源碼來看,Resource shrink 時會幫助我們識別以下五種情況:

而 Resource shrink 使用了一種最笨但卻最安全的方法去獲取匹配的前綴/後綴字符串,那就是將應用中所有的字符串都認爲是可能的前綴/後綴匹配字符串。

所以這就造成了在安全模式下,不小心被某個字符串所匹配到的資源,即使沒有被使用也會被保留下來。以我們的項目爲例,在 com.ss.android.ugc.aweme.utils.PatternUtils 中,我們有以下代碼:

在安全模式下,這就造成了所有以 tt 開頭的無用資源都不會被 shrink 掉(這也就是爲什麼嚴格模式一開,ttlive_ 開頭的無用資源那麼多的原因)。

而嚴格模式打開後,其作用便是強行關閉這一段的字符匹配的過程:

當然這也就造成了我們在使用 getIdentifier() 的時候是不安全的,因爲嚴格模式下是不會匹配任何字符串的,所以在開啓嚴格模式之後,一定要嚴格檢查所有被 shrink 的資源,是否有自己需要反射的資源!

6.5 shrinkResources 兼容 Dynamic Feature

AppBundle 是 Google 近年來力推的一個功能,它能夠讓我們的 apk 按照不同的維度生成下發,也提供了一個動態下發功能的方式,Dynamic Feature。但是如果我們在開啓 Dynamic Feature 之後使用 shrinkResources,則提示以下錯誤:

由此看來 Google 官方並不支持 App Bundle 使用 Dynamic Feature 時使用 shrink resource。在 Google Issue Tracker 上發現已經有人對此提交過 Issue 了,相關 Issue。而 Google 的回覆也是簡單粗暴----計劃中,但是沒有時間:

但是正常來說,如果做的好的話,我們的 App Bundle 的 Dynamic Feature 模塊是很少會引用 Master 的資源的,即使有,使用 keep.xml 的方式也能將這種資源給保留下來。因此,理論上來說,單獨對 Master 模塊進行 shrinkResource 並注意反射調用的話,是沒多大問題的。Dynamic Feature 下檢查 shrinkResources 配置是在 Configuring 階段

因此我們的想法便是在配置階段不開啓 shrinkResources 開關,而在後面執行資源處理任務的時候自行插入 shrinkResources 的 Task:

這樣就能在 Dynamic Feature 下開啓 shrinkResources 的 Task 了,整個代碼編寫十分簡單,不到 50 行就能完成:

7.資源混淆(兼容 aab 模式)

資源 id 與資源全路徑的映射關係記錄在 arsc 文件中,app 通過資源 id 再通過 Resource 獲取對應的資源,所以對於映射關係中的資源路徑做名字混淆可以達到減少包體積的效果。

抖音啓用了微信開源的 AndResguard 進行資源混淆,在開源的基礎上進行了增加了 MD5 去、多 DPI 只保留一份資源等優化。由於公司內部有很多海外產品,在上架 Google Play 時需要走 aab,因此團隊做了資源混淆的 aab 兼容-- aabResguard(開源 | AabResGuard: AAB 資源混淆工具),已開源。

8.ARSC 瘦身

8.1 背景

resources.arsc 這個文件在很多項目中都佔用了相當多的空間。常見的優化方法是使用 AndResGuard 混淆減少文件名及目錄長度,7z 壓縮,如果有海外產品的話可以動態下發語言。我們在做完這些優化後,由於公司內部有很多海外產品,涉及到多語言的關係,ARSC 依然很大,我們決定嘗試進一步優化。

經過調研,最終我們對 3 個方面做了優化,分別是刪除無用 Name、合併字符串池中重複字符串、刪除無用文案,最終帶來的收益是 1.6MB。在此之前,我們還在 AndResGuard 的基礎上完成了重複 MD5 文件圖片合併,原理是一樣的。

8.2 原理

先貼一張 arsc 結構的圖,這個二進制文件的數據結構相當複雜,AndResGuard 其實只修改了這個文件的一小部分,至於更多的修改就無能爲力了,於是我們自己解析了這個文件進行分析。網上也有不少關於這個文件格式的說明,這裏就不贅述了。推薦老羅和尼古拉斯的博客以及 aapt2 源碼。google 提供的 android-arscblamer 和 apktool 的代碼也值得一看。

下面用一張圖簡單描述一下修改過程:

如圖,字符串其實是通過索引的方式來獲取的,所有字符串都保存在兩個字符串池中(單個 package),一個是全局字符串池,一個是 package 下的字符串池,我們只需要修改指向全局字符串的偏移值就行了。name 和 value 所在二進制位置如下圖。

8.3 方案

8.3.1 刪除無用 Name

AndResGuard 在今年的 7 月也增加了這個功能,我們來看一下實現原理。Name 對應的字符串池是 package 字符串池,由於這個字符串池中只包含所有 Name,我們操作可以稍微暴力一點,先做一份備份,然後清空字符串池,添加一個用於替換的字符串,賦值爲 [name_removed]。

首先要確定哪些 name 是通過 getIdentifier 調用,配置成白名單。遍歷 name 項,如果不在白名單,那麼把這一個 name 的偏移替換成 0,使其指向[name_removed]。如果 name 在白名單,那麼不應該刪除,我們通過備份的字符串池找到這個 name 對應的字符串,添加到字符串池中,把偏移指向對應下標即可。

抖音通過這個優化減少了包大小 70k。

8.3.2 合併重複字符串

value 所對應的是全局字符串池,雖然名字聽起來不會有重複值,但在我們掃描排序後發現其實有很多重複字符串(用 AppBundle 打包就不會存在這個問題)
在抖音項目中,這個字符串池裏有 1k+個重複字符串,合併這些字符串是非常必要的。

我們先遍歷所有數據,然後把字符串池的重複字符串合併,記錄偏移的修改,最後把需要修改的 value 的引用指向新的偏移。這個過程需要操作 arsc 數據結構的 ResValuel 和 ResTableMap,以保證所有 string 類型的值都能得到替換。

抖音通過這個優化減少了包大小 30k。

8.3.3 刪除無用文案

在打包過程中,其實所有 strings.xml 中保存的字符串都是不會被優化的,隨着項目逐漸變大,一些廢棄文案或者下個版本纔有用的文案被引入了 apk 中,我們在 Proguard 後再次掃描,發現了 3000+個無用字符串。在公司內部的一些海外項目中,有的文案被翻譯成 100 多個國家的語言,佔用了極大的空間。

刪除的方法和上面類似,都是指向替換的字符串所在偏移。如圖可能會存在兩個不同 name 指向同一個字符串,需要判斷待刪除的字符串是否還有其他引用。

不同項目收益可能不太一樣,公司內部海外項目對這些無用文案進行了替換,減少了 1.5M 包大小左右。

8.4 實現

如果是普通的 assemble 打包,直接在 ProcessResources 過程中獲取 ap_文件中的 arsc 文件,利用我們的工具修改即可。

如果是 AppBundle 方式打包,修改 ap_是沒有用的,因爲最後產物是用 aapt 以 proto 格式生成的 resources.pb 文件,要修改只能 hook aapt 過程。這個文件和 arsc 文件結構不太一樣,好在我們可以使用官方提供的 Resources 類解析、生成 pb 文件,使用相似的方法修改即可。

修改效果如圖:

8.5 進一步優化

arsc 中的偏移數組是有優化空間的,我們會在未來嘗試進行優化。用二進制編輯器打開 arsc 文件可以發現,這樣的 FF 值在文件中大量存在。

是什麼導致了這樣的空間浪費?我們可以看到下圖中框選的空白,每一個都代表了其字符串所在的偏移值,這裏並沒有值,賦值 FF FF FF FF 作爲默認偏移值,浪費了 4 字節空間。某些列(configuration)可能就只有幾個格子有值,如圖抖音中 drawable 有 4k+張圖片,有 24 列,大多數 configuration 只有幾張圖片,因此浪費了 4k*23*4≈380k。大致估算,抖音可以減少 1M 體積。(壓縮前)

如下圖 facebook 針對 arsc 文件的處理,我們可以把一行只有一個值的 id 抽出來,單獨放到一個 Resource Type 中,每一個 id 只有一個值,避免了上述空間浪費情況。但這樣做修改了 ID,因此對應的代碼中的 ID 也要修改,涉及了逆向 xml 以及 dex,提高了修改成本。還有一種思路是修改 aapt 源碼,沒有直接改 arsc 靈活。

9.總結

上述就是我們抖音 Android 端在包大小優化方面針對資源做的一些嘗試和積累,力求追求極致。

我們針對包大小優化,在其他方面還做了很多優化措施:針對 so 優化,做了 so 合併、stl 版本統一、精簡導出符號表和 so 壓縮等措施;針對代碼優化,細化混淆規則,開發 bytex 插件進行無用代碼掃描、acess 方法內聯、getter/setter 方法內聯、刪除行號等優化措施。

除了優化措施,良好的包大小監控系統是防止包大小劣化最重要的工具,否則包大小優化措施取得的收益抵不過業務快速迭代帶來的包大小增長。抖音 Android 端結合 CI、Cony 平臺,開發出了一套代碼合入前置檢查系統,每個分支增量超過閾值不準合入;還開發了分業務線監控包大小的工具,便於監控每個業務線包大小增長和給各個業務線定包大小指標。

本文轉載自公衆號字節跳動技術團隊(ID:toutiaotechblog)。

原文鏈接

https://mp.weixin.qq.com/s/xxrvRKXXDquJaezjrOlLwA

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