首頁>技術>

1.概述

隨著業務的快速迭代,抖音 Android 端的包大小爆發式增長。包大小直接影響到下載轉化率、推廣成本、執行記憶體和安裝時間等因素,因此對 apk 進行瘦身是一件很有必要且收益很大的事情。apk 主要由 dex、resource、asserts、native libraries 和 meta-data 組成,針對每一部分,都可以專項去做包大小優化。抖音 Android 端經過一段時間努力,包大小優化已經取得了階段性的成果。目前仍在持續的優化中。

其中,資源在 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 還被應用於位元組跳動旗下多個產品的圖片壓縮優化工作中,收益如下:

2.4 其他

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

大圖檢測。在 app/build/mcimage_result 目錄下會生成 mcimage_log.txt 日誌檔案,除了輸出轉換結果的日誌外,在最後還輸出了大畫素圖片和大體積圖片,閾值可在 McImageConfig 裡進行設定,方便大圖覆盤優化包大小;也支援編譯階段檢測,檢測到大圖直接 block 編譯,可及時發現大圖提交;壓縮演算法方便擴充套件。如果想接入其他壓縮演算法,只需要繼承 AbstractTask,實現 ITask 介面中的 work 方法即可;支援多執行緒壓縮。把所有 task 的執行放入執行緒池中執行,大大縮短了 mcimage 的執行時間;增加了圖片快取 cache,進一步縮短打包時間。在開啟多執行緒+圖片快取的情況下,全部命中快取的情況下,整個 mcimage 的過程不到 10s;快取路徑可配置;壓縮品質可配置,滿足不同的壓縮品質需求,快取檔案也會按照不同的壓縮品質進行儲存和命中;掃描不包含透明通道的圖片到 app/build/mcimage_result 目錄下。3.webp 無侵入式相容3.1 tinypng 和 webp 的選擇

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

掃描專案中 1960 張圖片,通過不同的演算法壓縮排行對比:從專案中找 490 張圖片,新建 demo,不同演算法壓縮圖片後比較打包 apk 的大小:

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

3.2 方案選型

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

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 的類註釋中也能看出:

由於系統載入解析 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 獲取對應的資源,所以對於對映關係中的資源路徑做名字混淆可以達到減少包體積的效果。

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 平臺,開發出了一套程式碼合入前置檢查系統,每個分支增量超過閾值不準合入;還開發了分業務線監控包大小的工具,便於監控每個業務線包大小增長和給各個業務線定包大小指標。

最新評論
  • 1 #

    其他公司多學學吧,學學人家是怎麼為使用者考慮的!某個天氣軟體都要佔用將近500M儲存空間,不知道你們的軟體部門是不是吃shǐ長大的!

  • 2 #

    感謝你們的優化,因為中低端手機有時候有點卡頓,高階手機又費電呢!好好優化喔

  • 3 #

    學習了,試試在專案中應用下

  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 用kubernetes資源物件建立Grafana Dashboard