隨着業務的快速迭代,抖音 Android 端的包大小爆發式增加。包大小直接影響到下載轉化率、推廣成本、運行內存和安裝時間等因素,所以對 apk 進行瘦身是一件頗有必要且收益很大的事情。apk 主要由 dex、resource、asserts、native libraries 和 meta-data 組成,針對每一部分,均可以專項去作包大小優化。
抖音 Android 端通過一段時間努力,包大小優化已經取得了階段性的成果。目前仍在持續的優化中。html
- | 優化前 | 優化後 | 百分比 |
---|---|---|---|
抖音 | 73MB | 61.5MB | 15.7% |
抖音 lite | 10MB | 4.9MB | 51% |
其中,資源在 apk 包體積中佔比很大,針對資源進行優化是包大小優化中很重要的部分。本着追求極致的原則,本文將詳細闡述抖音 Android 端針對資源部分的優化措施。android
在不進行壓縮的狀況下,圖片大小計算公式:圖片大小=長 x 寬 x 圖片位深。一張原始圖像(1920x1080),若是每一個像素 32bit 表示(RGBA),那麼圖像須要的存儲大小 1920x1080x4 = 8294400Byte,大約 8M,一張圖這麼大是難以接受的。所以咱們使用的圖片都是通過壓縮的。
圖片壓縮利用的是空間冗餘和視覺冗餘原理:git
抖音 Android 研發團隊開發了 Gradle 插件 McImage,在編譯期間 hook 資源,採用開源的算法 pngquant/guetzli 進行壓縮,支持 webp 壓縮。與 tinypng 等一些已知的方案相比,存在如下優點:github
McImage 支持兩種優化方式,這兩種優化方式不可同時使用:web
webp 的壓縮比要高於 pngquant、guetzli,因此如今更推薦使用 ConvertWebp 這種壓縮方式。算法
McImage 還被應用於字節跳動旗下多個產品的圖片壓縮優化工做中,收益以下:api
描述 | 收益 |
---|---|
抖音-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 |
除了壓縮、優化圖片,McImage 還提供瞭如下功能:數組
tinypng 與 webp 到底哪一個壓縮比更高呢?在網上找不到兩種壓縮算法壓縮比的直接比較,須要更直觀的對比,因而作了以下的實驗:緩存
描述 | 大小 |
---|---|
原圖 | 13463.07KB |
webp 壓縮 | 4177.18KB |
tinypng 壓縮 | 6732.18KB |
描述 | 大小 |
---|---|
原圖 APK | 9617.53KB |
webp 壓縮 APK | 3924.06KB |
tinypng 壓縮 APK | 5386.80KB |
經過這兩組實驗對比,能夠看出 webp 的壓縮比是優於 tinypng 的。以前也手動的使用 webp 工具壓縮過抖音工程中全部圖片,包大小減小了 1.6MB 左右。所以選擇了 Webp 壓縮算法。安全
webp 壓縮算法,相較於 pngquant、guetzli、tinypng,webp 壓縮比更高,因此 webbp 壓縮圖片應該是更優的選擇。可是 Android 設備對 webp 的支持存在兼容性問題,在 4.3 以上才徹底支持。經過官網咱們知道,想在應用中直接使用帶有透明度的 webp,minSDK 至少須要是 18。
- | 優勢 | 缺點 |
---|---|---|
提供特定 api 兼容 | 實現起來簡單 | 侵入性太強,必須用特定接口或特定 View 進行加載 |
LayoutInflater setFactory 進行兼容 | 實現起來簡單 | 須要針對全部的 ImageView 及子 View 處理,且必須有統一的 Activity、Fregment 的基類處理 |
運行時 hook 系統關鍵方法 | 方法替換,能夠作到無侵入式 | 實現起來複雜些 |
想要作到無侵入式的兼容,運行時 hook 不失爲一種最佳的選擇。可是運行時 hook 方案,須要解決如下幾點問題:
經過對 Xposed、AndFix、Cydia Substrate、dexposed 等常見的 Android Java hook 方案的調研對比,dexposed 具備不須要 root、又能 hook 系統方法的特色,最終選擇 dexposed:
經過閱讀源碼,發現全部圖片被加載解析成 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 裏的關鍵方法進行替換就能夠了。
Android 爲了適配各類不一樣分辨率或者模式的設備,爲開發者設計了同一資源多個配置的資源路徑,app 經過 resource 獲取圖片資源時,自動根據設備配置加載適配的資源,但這些配置伴隨着的問題就是高分辨率的設備包含低分辨率的無用圖片或者低分辨率的設備包含高分辨率的無用圖片。
通常狀況下,針對國內應用市場,App 爲了減小包大小,會選用市場佔有率最高的一套 dpi(google 推薦 xxhdpi)兼容全部設備。 而針對海外應用市場的 APP,大多會經過 AppBundle 打包上傳至 Google Play,可以享受動態分發 dpi 這一功能,不一樣分辨率手機能夠下載不一樣 dpi 的圖片資源,所以咱們須要提供多套 dpi 來知足全部設備。 在項目中,咱們的圖片有的只有一套 dpi,有的有多套 dpi,針對上述兩種場景,咱們分別在打包時合併資源、複製資源,減小了包大小。
在國內項目中,爲了減小圖片的佔用,通常都會對市場佔用率高的 dpi 進行適配,好比只保留 xxhdpi 分辨率的圖片。這樣就致使了兩個問題,一個是市場上 2k 分辨率手機愈來愈多,若是之後手機主流分辨率是 xxxhdpi,那麼項目中幾千張圖片修改爲本會很是高。 另外一個問題是,公司很多海外產品是經過 AppBundle 打包上傳到 Google Play 的,可以給不一樣設備用戶下發不一樣 dpi 的資源。但項目中只有 xxhdpi,仍然下發 xxhdpi 的圖片,沒法經過下降 dpi 減少包大小。在巴西,咱們 80%用戶都使用 xhdpi 和 hdpi 手機,xxhdpi 圖片相比 hdpi 佔用多了一倍,這部分收益至關高。
所以,咱們經過壓縮分辨率的方式將高分辨率的圖片下降到低分辨,項目業務只存放最高 dpi 圖片,在打包的時候按需求複製篩選。 咱們在 hook 了圖片壓縮的 task,在圖片壓縮前,獲取到包括依賴庫在內的全部 PNG 圖片,利用 Graphics2D 下降圖片分辨率,放在對應分辨率文件夾中。以後再執行圖片壓縮 task,防止一些圖片重採樣後大小增長。
咱們僅對圖片的分辨率進行縮放,並不下降圖片採樣率,所以在顯示效果上沒有區別。 不一樣 dpi 具體應該調整到多少分辨率,咱們根據 Google 的定義製做了一個表格:
- | LDPI | MDPI | HDPI | XHDPI | XXHDPI | XXXHDPI |
---|---|---|---|---|---|---|
分辨率(廣泛) | 240x320 | 320x480 | 480x800 | 720x1280 | 1080x1920 | 2k |
倍率 | 3 | 4 | 6 | 8 | 12 | 16 |
咱們複製一張 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 文件。
針對普通打包模式(直接產出 apk,好比抖音包),咱們能夠選擇只保留一份分辨率偏高的的圖片,這樣高分辨率設備能夠拿到合適的圖片、低分辨率設備經過 Resource 獲取時會自動進行縮放,依然能夠保證合理的運行內存。
多 dpi 圖片能夠經過 Android 自帶的 resConfig 去重,但這個配置只對資源的 qualifier 去重,好比對像素密度和屏幕尺寸不會同時作去重,抖音使用基於 AndResguard 修改的方式對 drawable 去重,能夠定義不一樣配置的優先級和做用範圍。 根據優化配置確保留一份資源,優化方式以下圖(灰色數據表示會被刪除):
隨着項目的迭代,項目中不免會出現相同的資源被重複添加到資源路徑中,對於這類文件,人工處理確定是不可行的,能夠在打包階段自動去重。
抖音選擇在 AndResguard 階段對全部的資源進行分析,對 md5 相同的資源文件保留一份,刪除其他的重複的文件,而後在 AndResguard 寫入 arsc 文件時進行將刪除的資源文件對應的資源路徑指向惟一保留的一份資源文件。 優化方式以下圖:
下圖是抖音 511 版本接入多 dpi 去重與重複資源合併功能的優化結果:
MD5 文件去重 |
DPI 圖片去重 |
MD5 文件去重減小文件數量 |
MD5 文件去重減小文件整體積 |
DPI 圖片文件去重減小文件數量 |
DPI 圖片文件去重減小文件整體積 |
apk 大小 |
相比於原始包減小 |
---|---|---|---|---|---|---|---|
false |
false |
- |
- |
- |
- |
85,030,636 |
- |
true |
false |
171 |
156.6KB |
- |
- |
84,883,829 |
143KB |
true |
true |
171 |
156.6KB |
391 |
312.9KB |
84,507,008 |
511KB |
false |
true |
- |
- |
422 |
434.5KB |
84,523,236 |
495KB |
true |
true(配置全開) |
171 |
156.6KB |
463 |
465.4KB |
84,352,272 |
662KB |
隨着項目的開發迭代,咱們會有許多資源已經再也不使用了,但仍然存在於項目中。雖然咱們可使用公司開源的字節碼插件開發平臺 ByteX 開發插件在 ProGuard 以前掃描出一些無用資源,但由於這一步沒有通過無用代碼刪除,所以掃描出的結果並不全。而 shrinkResources 是 google 官方提供的優化此類無用資源的方法,它運行在 Proguard 以後,能標記全部無用資源並將其優化。
抖音 Android 在開啓 shrinkResources 嚴格模式後,shrink 資源數 600+,收益大小 0.57MB。
shrinkResources 是由 Google 官方提供的工具,所以詳細的接入方式參考 Google Developer 上的文檔便可。
默認狀況下,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 的資源,是否有本身須要反射的資源!
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 行就能完成:
資源 id 與資源全路徑的映射關係記錄在 arsc 文件中,app 經過資源 id 再經過 Resource 獲取對應的資源,因此對於映射關係中的資源路徑作名字混淆能夠達到減小包體積的效果。
抖音啓用了微信開源的 AndResguard 進行資源混淆,在開源的基礎上進行了增長了 MD5 去、多 DPI 只保留一份資源等優化。 因爲公司內部有不少海外產品,在上架 Google Play 時須要走 aab,所以團隊作了資源混淆的 aab 兼容-- aabResguard,已開源。
resources.arsc 這個文件在不少項目中都佔用了至關多的空間。常見的優化方法是使用 AndResGuard 混淆減小文件名及目錄長度,7z 壓縮,若是有海外產品的話能夠動態下發語言。 咱們在作完這些優化後,因爲公司內部有不少海外產品,涉及到多語言的關係,ARSC 依然很大,咱們決定嘗試進一步優化。
通過調研,最終咱們對 3 個方面作了優化,分別是刪除無用 Name、合併字符串池中重複字符串、刪除無用文案,最終帶來的收益是 1.6MB。 在此以前,咱們還在 AndResGuard 的基礎上完成了重複 MD5 文件圖片合併,原理是同樣的。
先貼一張 arsc 結構的圖,這個二進制文件的數據結構至關複雜,AndResGuard 其實只修改了這個文件的一小部分,至於更多的修改就無能爲力了,因而咱們本身解析了這個文件進行分析。 網上也有很多關於這個文件格式的說明,這裏就不贅述了。推薦老羅和尼古拉斯的博客以及 aapt2 源碼。google 提供的 android-arscblamer 和 apktool 的代碼也值得一看。
下面用一張圖簡單描述一下修改過程:
如圖,字符串實際上是經過索引的方式來獲取的,全部字符串都保存在兩個字符串池中(單個 package),一個是全局字符串池,一個是 package 下的字符串池,咱們只須要修改指向全局字符串的偏移值就好了。name 和 value 所在二進制位置以下圖。
AndResGuard 在今年的 7 月也增長了這個功能,咱們來看一下實現原理。 Name 對應的字符串池是 package 字符串池,因爲這個字符串池中只包含全部 Name,咱們操做能夠稍微暴力一點,先作一份備份,而後清空字符串池,添加一個用於替換的字符串,賦值爲 [name_removed]。
首先要肯定哪些 name 是經過 getIdentifier 調用,配置成白名單。 遍歷 name 項,若是不在白名單,那麼把這一個 name 的偏移替換成 0,使其指向[name_removed]。 若是 name 在白名單,那麼不該該刪除,咱們經過備份的字符串池找到這個 name 對應的字符串,添加到字符串池中,把偏移指向對應下標便可。
抖音經過這個優化減小了包大小 70k。
value 所對應的是全局字符串池,雖然名字聽起來不會有重複值,但在咱們掃描排序後發現其實有不少重複字符串(用 AppBundle 打包就不會存在這個問題) 在抖音項目中,這個字符串池裏有 1k+個重複字符串,合併這些字符串是很是必要的。
咱們先遍歷全部數據,而後把字符串池的重複字符串合併,記錄偏移的修改,最後把須要修改的 value 的引用指向新的偏移。這個過程須要操做 arsc 數據結構的 ResValuel 和 ResTableMap,以保證全部 string 類型的值都能獲得替換。
抖音經過這個優化減小了包大小 30k。
在打包過程當中,其實全部 strings.xml 中保存的字符串都是不會被優化的,隨着項目逐漸變大,一些廢棄文案或者下個版本纔有用的文案被引入了 apk 中,咱們在 Proguard 後再次掃描,發現了 3000+個無用字符串。在公司內部的一些海外項目中,有的文案被翻譯成 100 多個國家的語言,佔用了極大的空間。
刪除的方法和上面相似,都是指向替換的字符串所在偏移。 如圖可能會存在兩個不一樣 name 指向同一個字符串,須要判斷待刪除的字符串是否還有其餘引用。
不一樣項目收益可能不太同樣,公司內部海外項目對這些無用文案進行了替換,減小了 1.5M 包大小左右。
若是是普通的 assemble 打包,直接在 ProcessResources 過程當中獲取 ap_文件中的 arsc 文件,利用咱們的工具修改便可。
若是是 AppBundle 方式打包,修改 ap_是沒有用的,由於最後產物是用 aapt 以 proto 格式生成的 resources.pb 文件,要修改只能 hook aapt 過程。這個文件和 arsc 文件結構不太同樣,好在咱們可使用官方提供的 Resources 類解析、生成 pb 文件,使用類似的方法修改便可。
修改效果如圖:
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 靈活。
上述就是咱們抖音 Android 端在包大小優化方面針對資源作的一些嘗試和積累,力求追求極致。
咱們針對包大小優化,在其餘方面還作了不少優化措施:針對 so 優化,作了 so 合併、stl 版本統1、精簡導出符號表和 so 壓縮等措施;針對代碼優化,細化混淆規則,開發 bytex 插件進行無用代碼掃描、acess 方法內聯、getter/setter 方法內聯、刪除行號等優化措施。
除了優化措施,良好的包大小監控系統是防止包大小劣化最重要的工具,不然包大小優化措施取得的收益抵不過業務快速迭代帶來的包大小增加。抖音 Android 端結合 CI、Cony 平臺,開發出了一套代碼合入前置檢查系統,每一個分支增量超過閾值不許合入;還開發了分業務線監控包大小的工具,便於監控每一個業務線包大小增加和給各個業務線定包大小指標。
最後,抖音 Android 誠招對技術有無限熱情的小夥伴。感興趣的小夥伴均可以經過 字節跳動招聘官網查詢抖音 Android 相關職位 或簡歷發送至 shipeiqing@bytedance.com。
抖音BoostMultiDex優化實踐:Android低版本上APP首次啓動時間減小80%(二)
抖音BoostMultiDex優化實踐:Android低版本上APP首次啓動時間減小80%(一)
歡迎關注字節跳動技術團隊