如何讓雲音樂iOS包體積減少87MB

雲音樂技術團隊發表於2022-03-11
本文作者:大鵬

  雲音樂iOS客戶端是自2013年開始的老專案,經歷近十年的業務滾動發展,從單體音樂APP發展至今,多種業務加持,儼然已經成為類似於平臺級的巨型APP,並且包體積也隨著業務的發展越來越臃腫,影響使用者的實際體驗,甚至是品牌的口碑,在筆者開始優化之前雲音樂在AppStore顯示的包體積已經達到了420MB之多,在這種情況下,團隊開啟了包體積優化的專項。

  包體積優化是客戶端開發的老命題了,基本上作為iOS開發同學多多少少都瞭解大體該怎麼做,但隨著蘋果的發展,一些原來可行的措施在新版本已經不在適用,所以本篇文章則側重於優化過程中的一些最新的實踐經驗,以及在大專案中是如何落地的,那麼話不多說,下面就開始。

口徑

  在開始做優化之前,我們首先需要摸清楚包體積的各種口徑以及它們之間的關係,因為後續的一些優化措施會導致不同口徑此消彼長的情況,所以首先要確定最終目標口徑是什麼。
首先,我們可以在蘋果後臺看到自己APP具體的安裝大小和下載大小的具體情況,還包含了不同的機型版本。

包體積版本

  那麼蘋果後臺的下載大小和安裝大小是如何生成的呢,請看下圖,在上傳後,蘋果官方對對我們上傳的IPA包解包後,對二進位制進行了DRM加密(此項也會導致包體積的增長)和AppThinning,AppThinning會根據不同的機型對原始包的資源和程式碼進行不同程度的裁剪,從而生成適配具體機型的版本。此外蘋果還會生成一個包含全集的通用版本,但並沒有啥實際用處。關於DRM和AppThinning此處不展開,文章末尾有連結。

包體積生成流程

如上圖所示,在上傳前後我們有三個指標:

  • APP原始包體積: 上傳前IPA解包後,實際APP的大小
  • 下載體積: AppStore中流量下載時提示框的大小
  • 安裝體積: AppStore中APP詳情中顯示的大小

在摸清了各指標的關係後,我們最終選擇了使用者感知最強的安裝體積作為核心指標,以其作為最終目標進行優化。

分析

  雖然已經確定了目標,但在優化之前,還需要對現狀進行分析,找到最大的劣化點,從而有針對性的進行優化,獲取最大的收益比,那麼下圖就是雲音樂iOS包的基本情況,可以看到紅色的資源部分佔到了一半以上的體積,而二進位制則次之也佔到了四分之一多,那麼後續優化的側重點可以放到資源和二進位制原始碼。
包體積生成流程

資源

  對於資源的處理其實方式就是常規那麼幾種:資源清理、資源整理、資源壓縮、資源雲端遷移、資源合併等等,總之就是想盡一切辦法去降低資源所佔用的本地空間,下面簡單介紹下我們在雲音樂所做的工作。

資源清理

  在開始做整體的資源優化之前,第一步是需要清理已經不在使用的資源,包括圖片、配置檔案、音視訊等等,檢測無用資源的主要思路就是通過靜態檢測判斷資源是否有被引用,例如使用ImageName來判斷圖片是否被使用,當然線上檢測的方式是更準確了,但在資源這裡沒有必要,不過雲音樂作為老業務,使用圖片的姿勢也各式各樣,例如引用的檔名不規範、未使AssetCatelog、手動拼接圖片名稱2x3x等問題,這就需要稍微定製化的方式進行查詢,排除異常情況,其他APP根據自身實際情況調整即可,思路都一樣,網上也有現成工具。

  雲音樂經過幾輪清理之後,先後清理圖片等型別檔案1200+,獲得收益12+MB左右的原始包體積下降,還是比較可觀的。

資源整理

  資源整理其實就是把合適的資源用合適的方式管理,這裡主要指的就是Asset.car檔案,眾所周知,蘋果自iOS7之後推出了AssetCatelog檔案,幫助開發者管理資源,其中最主要的就是圖片資源,在編譯之後會生成Asset.car檔案並打入IPA包中,前文也說過,雲音樂是老工程,所以還有部分資源圖片是非Asset管理的方式,而使用Asset會給包體積帶來收益,所以就需要對現有資源進行遷移,使用Asset進行管理;但這裡有個問題,為什麼使用Asset會帶來包體積收益呢?

  要回答上面的問題,首先要從Asset的原理說起,在AssetCatelog的編譯過程中,以ImageSet型別為例,首先會對Asset中的ImageSet型別圖片進行無失真壓縮,並且會把多張ImageSet圖片合成一張大圖,故在編譯後,是無法通過bundle path的方式讀取圖片的,必須使用蘋果的ImageName的API,因為它是通過座標等方式,從合成後的大圖中獲取具體的圖片資訊的;那麼這樣做的好處就是,在壓縮和合成的過程中會有圖片體積的收益;但是經過我們研究,發現並不是所有圖片都會有此收益,一些大體積的圖片在經過無失真壓縮和合成後,產生的體積更大,我們猜測這可能和合成大圖有關,越小的圖片收益的可能性越高。

  另外就是動圖最好不要使用ImageSet的型別,因為在壓縮和合成的過程中動圖會出現問題,導致通過ImageName讀取出來的資料不對,會產生無法播放等問題;但是可以使用DataSet的型別,DataSet是不參與合成和壓縮的,所以不會影響,對於其他的資源型別,一般也都可以使用DataSet的方式,而讀取的時候使用NSDataAsset即可。那如何知道Asset處理過的資源的情況呢,可以使用下面命令解析編譯好的Asset.car,獲取其中資源編譯後的資訊。

xcrun --sdk iphoneos assetutil --info Assets.car

DataSet

ImageSet

  從上圖可以看到,對於Data型別的資源,是沒有壓縮的;而對於Image型別,是有標註出具體的壓縮演算法的,以及一些圖片資訊。另外在過程中我們也發現,對於不同的圖片蘋果使用的壓縮演算法都是不同的,並且會被壓縮成多份,這也是為什麼我們在把一部分資源從bundle中移入AssetCatalog中後,IPA體積還變大了的原因,但沒關係,安裝體積是會下降的,是因為使用Asset的最大的收益其實是來源於前文提到過的蘋果的AppThinning,蘋果的瘦身機制會把Asset.car根據不同的機型進行分發,例如1x2x3x都有不同應對的裝置機型,所以雖然會被壓縮成多份,但每臺機器實際使用的只有一份,這也是為什麼即便是IPA變大了,但其實安裝體積會變小。

  最後要說的是,因為沒辦法一個一個圖片去進行Asset編譯對比編譯前後的大小,從概率來講,更推薦小圖(5k以內)以及有多版本(2x3x)的圖片放入AssetCatalog管理,其他資源其實單獨儲存更自由,而非使用DataSet,因為單獨儲存更方便使用各種壓縮手段而不擔心會被蘋果的處理而影響到,這個點在後續資源壓縮會詳細說到。

  經過這項優化,雲音樂iOS客戶端遷移各種尺寸的圖片資源2400+,實現安裝體積收益22+MB。

資源壓縮

  資源壓縮很好理解,顧名思義就是對資源進行各種方式的壓縮,在雲音樂中最主要的資源就是圖片,其他型別佔比很小,常見的圖片資源格式主要是png、apng、webp等,雲音樂包裡絕大部分圖片也是以上幾種格式;因為經過上一步的工作,幾乎所有圖片都在AssetCatalog中管理,而上文也提到了蘋果會對AssetCatalog的圖片資源進行無失真壓縮,所以如果我們本身對圖片資源所施加的無失真壓縮是沒有效果的,因為蘋果會再壓一遍,最終結果是以他為準。所以要在壓縮這裡拿到優化結果,就要實質性的降低圖片的大小,那麼就得做有失真壓縮。對於常規圖片格式,我們使用了pngquant、tinypng等演算法及工具進行壓縮,在使用pngquant時,經過先後大資料樣本測試,最終選擇80%的有損比率,因為此時是比率是收益曲線最高同時相對圖片質量影響較小的時候,但對於不同的工程這個曲線也許是不一樣的,因為每個工程的實際資源情況是有區別的,所以要自行去獲取工程的資料,具體的做法是可以過指令碼去嘗試不同的壓縮率並記錄壓縮結果從而形成一張曲線圖。另外在我們包裡還有很多遺留的體積較大的webp動圖,一般的方式都無法進行壓縮,經過一定的調研最終發現谷歌官方提供了Webpmux可以對webp動圖進行拆解和逐幀壓縮以及合成,基於此我們編寫了一個可以壓縮webp動圖的指令碼,實現了對webp動圖的壓縮。最終我們把所有常見格式的圖片壓縮能力整合在一個大指令碼中,對包內所有的圖片資源進行壓縮,此指令碼對於後續防劣化也有用處。

  經過此項,整體壓縮各尺寸png圖片5000+,apng動圖100+,webp動圖100+,總體收益42+MB(原始包體積)。

資源雲端遷移

  在經過清理、整理、壓縮後,資源部分還是有不少包體積的佔用,所以我們啟動了大資源雲端遷移專項,之所以是大資源是因為大資源帶來的收益比最高,經過討論,結合雲音樂的實際情況,最終定下了50kb的基線,大於50kb則會被界定為大資源。我們不是沒有考慮資源統一遷移統一下載的方案,但從雲音樂的體驗以及成本層面考慮,最終還是選擇以傳統方式處理ROI高的部分。經過篩選後雲音樂包內有150+的case符合大資源的情況,其中85%以上是可以遷移至雲端的。對於資源是否要放在本地還是雲端,我們和設計同學共同制定了相關資源圖片\動畫的使用規範,純技術資源則由技術同學判斷。

  在遷移專項做完後,總體遷移了100+的大資源,收益約在31+MB(原始包體積)。

資源合併

資源合併其實主要是二點,一個是單個相似圖片的去重,我們花了一定功夫使用相似圖的分析演算法對雲音樂所有的資源圖片進行了檢測,結果和我們預期並不相符,實際上並沒有太多相似的圖片、包括icon,此部分並無收益。另外一個是AssetCatalog合併,結合雲音樂的實際情況,此項也並無收益,主要是雲音樂的資源目前是集中化管理。

二進位制

  每個APP程式最終都會被編譯出一個主體二進位制檔案,所有的靜態庫依賴都會被連結進來,此部分的大小主要由程式碼量以及編譯引數影響,下文的優化思路也是集中於減少程式碼量以及優化編譯引數。

無用程式碼檢測

  想要降低程式碼量,首先想到的就是清理無用程式碼,那麼哪些程式碼又是無用的呢?這就有了無用程式碼檢測,一般檢測的方式分為線上動態檢測和線下靜態檢測,動態檢測的準確率要遠高於靜態檢測,並且靜態程式碼編譯器已經支援了一些裁剪方式,例如DeadCode優化;那麼基於此我們採用了更準確的線上大資料動態檢測,唯一的缺點就是獲取資料的週期較長,需要上線執行。

  最初我們的想法是通過hook類初始化方法+initialize來判斷某個類是否被使用,但這種方案有幾個問題:第一是啟動時機的問題,因為我們使用了AB取樣,那麼必須在AB初始化後某個時間點開啟,那麼AB初始化之前的類就沒法記錄,除非所有使用者都記錄,只是在上傳的時候取樣,但這樣會影響未被灰度的使用者;第二是+initialize本身呼叫時機的問題,並不是所有類的+initialize都會被呼叫。之後我們採用了另外一種方案,在OBJC中,每個類都有自己的後設資料,在後設資料中的一個標記位儲存著自己是否被初始化,這個標記位不受任何因素影響,只要有被初始化就會打標記,在objc的原始碼中獲取標記位的方式如下:

struct objc_class : objc_object {
    bool isInitialized() {
        return getMeta()->data()->flags & RW_INITIALIZED;
    }
}

  但這個方法APP是無法直接呼叫的,它是objc的方法;但是並不代表RW_INITIALIZED這個標記位的資料不存在,資料還是在的,所以我們可以通過已有的介面以及可以閱讀的原始碼資訊來模擬上述程式碼,從而獲得標記位資料確定某個類是否是初始化的,程式碼如下:

#define FAST_DATA_MASK  0x00007ffffffffff8UL
#define RW_INITIALIZED  (1<<29)
- (BOOL)isUsedClass:(NSString *)cls {
    Class metaCls = objc_getMetaClass(cls.UTF8String);
    if (metaCls) {
        uint64_t *bits = (__bridge void *)metaCls + 32;
        uint32_t *data = (uint32_t *)(*bits & FAST_DATA_MASK);
        if ((*data & RW_INITIALIZED) > 0) {
            return YES;
        }
    }
    return NO;
}

通過上面的模擬程式碼,我可以獲取某個類是否是被使用的,進而上報資訊後,基於大資料分析出哪些類是已經可以清理的,通過此種方式,我們檢測出了數千個未被使用的類,但這些並不代表實際是能夠清理的,比如有的在做AB,有的是預埋業務等等,所以資料結果還需要業務側進行一遍過濾,最終我們處理了1200+個類,成功清理了300+,收益在2+MB(二進位制章節所有口徑均為原始包口徑)左右,剩餘未處理的仍在處理中,作為長線進行優化。

二三方庫下線

  基於上面的未被使用的類資料,可以通過聚類分析,得到已經不在使用的業務元件或者二三方庫,在優化過程中我們識別出了數個可以下線的二三方庫,收益在4+MB。

動態庫依賴裁剪

  除了業務程式碼的處理,本身雲音樂也依賴了一些動態庫,並且這些動態庫因為歷時原因,有些靜態依賴是重複的,具體如下圖所示:
動態庫裁剪

這是比較極端的一個Case,在主程式中、動態庫A中、動態庫B中分別有一份OpenSSL的符號,那麼這種就造成了重複,佔用二進位制體積;那麼這種問題最好的解決方案就是動轉靜,把動態庫轉化為靜態庫,都連結在主程式中,解除原來的依賴,都使用主二進位制中的Symbol,這樣還可以一定程度的提升啟動速度,因為減少了動態庫的數量。通過對類似這種問題的解決,總體收益是3+mb。

編譯優化

  在通過各種方式優化裁剪程式碼之後,就要開始優化另外一個影響二進位制體積的因素了,就是編譯引數,編譯引數有很多,可以分為編譯期引數以及連結期引數,接下來我將整理基本上所有會影響二進位制體積的引數供讀者參考使用

Asset Catalog Compiler Optimization

  Asset編譯優化可以降低Asset.car產物體積,此項雲音樂之前只開啟了主工程,未開啟元件的編譯引數,經過優化後收益未2.1MB

EXPORTED_SYMBOLS_FILE

  對於APP來講可以看做是一個大的“動態庫”,使用者在點選開啟APP的時候系統就開始載入這個動態庫,那麼動態庫總會有向外暴露的符號也就是Exported Symbols,但是對於APP而言一般不會在iOS系統裡還有別的地方呼叫,更多的是APP呼叫系統的服務,所以我們可以把Exported Symbols給Trim掉,還好編譯器提供了EXPORTED_SYMBOLS_FILE可以讓我們限制輸出的符號,從而降低二進位制的體積;具體的方式是新建一個txt檔案,放入工程目錄中(僅工程目錄,無需加入到xcode工程中,會成為資源影響包體積),把EXPORTED_SYMBOLS_FILE指向這個檔案,那麼如果是空檔案則所有的exported符號段都會被裁剪掉,可以通過在txt檔案裡指明具體要留下的符號,編譯器就會裁剪掉未宣告的部分。
ESF-1
ESF-2

下圖為開啟後被裁減掉符號段
ESF-3

值得注意的是,如果APP使用了Firebase,則不能全部裁剪掉,會導致Firebase啟動不成功,進而無法獲取Crash資訊,原因是Firebase依賴上圖Export Info中的__mh_execute_header這部分符號,所以可以在上文提到的txt檔案中加入__mh_execute_header,則編譯器在裁剪時會保留__mh_execute_header的部分。

  此項為連結期優化,只需主工程開啟即可,雲音樂在開啟後,此項收益是2.4MB。

Link-Time Optimization

  LTO的優化主要體現在跨檔案的廢棄程式碼裁剪優化、永遠不會執行的空邏輯優化、內聯優化,意思是直接複製函式,減少內聯層級,提升函式棧的執行效率和空間利用率。詳情請檢視LLVM的官方文件,此處不在贅述。

LTO

  另外經過測試驗證了LTO只對靜態語言生效,OC是動態語言,所有函式方法有可能在執行時被動態呼叫,所以是不可能裁剪的,這就是為什麼在連結靜態庫時,如果是C庫,那麼看起來原來二進位制很大,實際上被實際連結進來的只有真實使用的小部分,但是如果是OC庫則基本上全部會連結。所以如果你的APP原始碼中C或者C++程式碼較多的話在此項上收益可能會大一些。

  雖然LTO名稱看起來是連結期優化,但實際上是編譯期也需要參與的,否則會沒有效果,這和跨檔案的優化有關,在編譯期就要產出部分資訊,提供連結期優化使用。

  經過LTO的優化,雲音樂獲得的收益是1MB。

GCC_OPTIMIZATION_LEVEL

  此項意通過更激進的GCC編譯優化,進而產生更低的二進位制產物,Xcode預設是Debug設定O0,Release設定為Os,但其實還可以使用Oz模式,從而達到更小的體積。
GOL

  其實Oz的原理和上面的在內聯(inline)還是外聯(outline)上的思路LTO剛好相反,Oz是想通過更多的外聯來降低函式的內聯層級,但這樣就會是函式的呼叫棧變得很深,進而會降低函式的執行效率,如上圖所示會變得比較“慢”,其實本質上也是時間和空間的博弈;另外如果要想開啟此項可參考抖音的文章,他們有遇到一些objc_retainAutoreleaseReturnValue的問題,但截至目前,我們在實際實踐的過程中暫時並未發現,不過基於穩定性的考慮,此專案前還未在雲音樂上線,只是在debug環境開啟進行測試,還在持續觀察中。
如果開啟此項,經過測試預估的收益在10+MB左右。

其他編譯優化項

  • Enable C++ Exceptions以及Enable Objective-C Exceptions,關閉掉此項可以帶來二進位制體積上的收益,但是會影響TryCatch,酌情使用,雲音樂未開啟
  • Architectures,架構指令集,此部分需要注意一些二三方的Framework是否包含不需要的指令集
  • Strip Symbols,裁剪符號相關,此處不展開,下方為相關設定

    • Strip Linked Product = YES
    • Strip Style = All Symbols,注:在Strip Linked Product未開啟時,此項設定不生效
    • Deployment Postprocessing 注: 此項在打包是無論怎麼設定,蘋果會預設設定為YES
  • Symbols Hidden by Default = YES,設定符號可見性
  • Make Strings Read-Only = YES
  • Dead Code Stripping = YES,編譯期檢測判定未使用程式碼進行裁剪
  • Optimization Level,一般debug設定為None,Release設定為Os

二進位制小結

  除了以上各種優化二進位制的措施外,其實在業界還有不少其他措施,但云音樂因各種原因並未採用,例如通過重新命名_Text程式碼段,進而繞過蘋果的DRM加密,來降低二進位制大小,但此項在iOS13之後蘋果已經意識到這個問題,並一定程度上解決了,所以這個優化方法基本上已經失效了;還有二進位制段壓縮,從風險和收益的角度考量,也是暫未使用;還有屬性動態化,主要是針對有大量屬性的模型屬性進行動態優化,動態新增get/set方法,從而獲得省略這部分方法的收益,此項收益估算也很小,也就並沒有使用。其實總結來說優化方法是很多的,但對於具體的APP根據實際情況選擇最合適的措施即可,並不一定非要如何如,畢竟要有ROI的考量。

防劣化

  在優化的過程中,我們發現工程的實際劣化速度也很快,甚至達到了每個迭代優化量的40%~50%,也就是說,我們假定一個迭代優化了10MB,但是這個迭代的劣化達到了4~5MB,所以我們不得不在治理的同時就開啟防劣化的工作,我們制定了一些防劣化措施,其中一部分已經上線,剩下的還在開發中,目前已經取得了很好的效果,體積的劣化情況已經得到了比較有效的遏制,也保住了優化的成果,具體措施如下:

  • 大資源卡口:在程式碼合入時進行資源檢測,並強制卡口
  • 二方庫三方庫卡口:在程式碼合入時進行二方庫三方庫的檢測,包含新增和升級
  • 自動壓縮:對於資源合入進行自動壓縮,但首推還是放在遠端,非常必要的情況下再放本地
  • 定期資源情況檢測:定期自動化進行全APP的資源摸查,問題追溯
  • 定期程式碼檢測:定期自動化的進行全APP的程式碼摸查,無用程式碼下線
  • 和UED共同推出圖片動畫動效資源使用規範,規定哪些可以在本地,哪些必須遠端,以及動效的優化方案

結果

  在經過一段時間的各種優化後,雲音樂的安裝體積下降87MB,從原先的420MB+降低到現在的330MB+,整體感官上還是有區別的,下載體積下降65MB,突破了200MB的蘋果OTA限制,達到了160+MB。

相關資料

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章