聊聊iOS 應(yīng)用瘦身方法思路
1. 前言
前段時(shí)間注意到我們 APP 的包大小超過(guò)100MB了,所以隨口跟呂老板說(shuō)了下能否采用字體文件(.ttf)替代 PNG 圖片,呂老板對(duì)應(yīng)用瘦身很感興趣因此讓我做下技術(shù)調(diào)研。這篇文章主要是將我們的各個(gè)技術(shù)方案的思路做一下整理和總結(jié),希望對(duì)大家有所幫助。
2. iOS 內(nèi)置資源的集中方式
在介紹技術(shù)方案前我們先來(lái)看下 iOS 內(nèi)置圖片資源都有哪些常見(jiàn)的方式:
2.1 將圖片存放在 bundle 下
這是一種非常常見(jiàn)的方式,項(xiàng)目中各類文件分類放在各個(gè) bundle 下,項(xiàng)目既整潔又能達(dá)到隔離資源的目的。我們項(xiàng)目中圖片絕大多數(shù)都是這樣內(nèi)置的,其加載方式為 [UIImage imageNamed:"xxx.bundle/xxx.png"](請(qǐng)記住這個(gè)字符串的規(guī)則,因?yàn)檫@種規(guī)則非常非常重要!!!"xxx.bundle/xxx.png")。
但是這種方式有比較明顯的缺點(diǎn):首先使用 bundle 存儲(chǔ)圖片 iOS 系統(tǒng)不會(huì)對(duì)其進(jìn)行壓縮存儲(chǔ),造成了應(yīng)用體積的增大。其次是使用 bundle 存儲(chǔ)圖片放棄了 APP thinning,其明顯的表現(xiàn)是使用2倍屏手機(jī)的用戶和使用3倍屏手機(jī)的用戶下載的應(yīng)用包大小一樣。
如果能夠?qū)崿F(xiàn) APP thinning,那么往往 2 倍屏幕的手機(jī)包大小會(huì)小于 3 倍屏手機(jī)的包大小,起到差異性優(yōu)化的目的。在調(diào)研過(guò)程中我們還發(fā)現(xiàn),應(yīng)用的體積與圖片資源的數(shù)量密切相關(guān)(聽(tīng)起來(lái)好像是廢話)。
換句話說(shuō),iPhone 的 rom 存在 4K 對(duì)齊的情況,一張 498B 大小的圖片在應(yīng)用包中也要占據(jù) 4KB 大小。因此項(xiàng)目中每添加一張圖片就至少增大了 4KB。
為了證實(shí)這個(gè)觀點(diǎn)特地創(chuàng)建空應(yīng)用進(jìn)行測(cè)試。首先創(chuàng)建空應(yīng)用,其大小在 7P 上為 213KB,引入一張 498B 的圖片前后對(duì)比如下:
一張498B的圖片
占據(jù) 4KB 磁盤空間
未添加資源的應(yīng)用
添加圖片資源后的大小
上述實(shí)驗(yàn)未經(jīng)過(guò) App Store 上線認(rèn)證,僅僅通過(guò)本地打包測(cè)試,因此觀點(diǎn)僅供參考。
2.2 使用 .ttf 字體文件替代圖標(biāo)
使用字體文件替代圖片也是一種比較常見(jiàn)的資源內(nèi)置方式。很多應(yīng)用都使用過(guò)這種方案,如淘寶、愛(ài)奇藝等知名應(yīng)用,都采用過(guò)這種方式。
使用字體文件的好處是顯而易見(jiàn)的,如果 APP 中某個(gè)圖片比較大,那么為了保證清晰度,UI 可能會(huì)提供比較大的圖標(biāo)。
使用字體文件會(huì)避免這個(gè)問(wèn)題,而且不必導(dǎo)入 @2x 和 @3x 圖片,一套字體文件就能保證 UI 的清晰度。關(guān)于如何生成 .ttf 文件在這里就不在贅述了(因?yàn)槲也⒉幌矚g這個(gè)方案),我們只要如何使用就可以了。
字體文件使用起來(lái)比較簡(jiǎn)單,但是使用方法與 png 圖片的使用方法有很大的不同,因?yàn)樽煮w文件時(shí)機(jī)所展示的圖標(biāo)都是 UTF8 編碼轉(zhuǎn)來(lái)的字符串。因此當(dāng)我們需要展示一個(gè)圖標(biāo)的時(shí)候不再是使用 UIImageView 了,而是 UILabel。
字體文件展示圖片的代碼示例
由于我們使用了字體來(lái)替代圖片,所以我們可以通過(guò)設(shè)置字體的顏色來(lái)改變圖標(biāo)的顏色。我們之前經(jīng)常會(huì)遇到一個(gè)場(chǎng)景,如兩個(gè)一模一樣的圖標(biāo)但是由于顏色不同,UI 同學(xué)就需要提供 2 套圖片,每套圖片中包含 @2x 和 @3x 圖片。如果采用了字體替代簡(jiǎn)單的圖標(biāo),那么 UI 只需要提供一套字體即可,并且拉伸后也不會(huì)失真。
使用字體文件的好處總結(jié)起來(lái)主要有兩點(diǎn):
- 可以降低應(yīng)用圖片內(nèi)置資源的體積。
- 可以隨意放縮和修改顏色。
但是其缺點(diǎn)也很明顯:
- 圖標(biāo)的查找和替換比較麻煩,不如直接使用圖片那樣簡(jiǎn)單。
- 最重要的是如果在 58 同城 APP 中使用,則意味著無(wú)法替換之前存在的圖片,只能起到縮小增量的目的,無(wú)法減小全量。
ps:任何一種需要大刀闊斧改革的優(yōu)化都是一種不明智的行為。
2.3圖片存在Assets.xcassets下(蘋果推薦,我也推薦)
使用 Assets.xcassets 是蘋果推薦的一種方式。Assets.xcassets 是 iOS7 推出的一種圖片資源管理工具,將圖片內(nèi)置到 Assets.xcassets 下系統(tǒng)會(huì)對(duì)圖片資源進(jìn)行壓縮,并且支持 APP thinning。
APP Slicing
項(xiàng)目?jī)?yōu)化不能脫離場(chǎng)景,很多很好的方案由于場(chǎng)景的限制并不能起到優(yōu)化的作用。因此先簡(jiǎn)單介紹下我們的項(xiàng)目場(chǎng)景:為了達(dá)到跨團(tuán)隊(duì)快速開(kāi)發(fā)的目的,我們項(xiàng)目很早就利用cocoapods 實(shí)現(xiàn)組件化。項(xiàng)目中存在多個(gè)業(yè)務(wù) pod,每個(gè) pod 都有各自的團(tuán)隊(duì)維護(hù),各個(gè)團(tuán)隊(duì)的代碼彼此不開(kāi)放,各個(gè) pod 最終會(huì)被編譯為.a的形式。
這里需要說(shuō)明一下我為什么要強(qiáng)調(diào) .a,與 .a 相對(duì)應(yīng)的還有一個(gè) .framework,他們之間有一個(gè)重要的區(qū)別就是資源的問(wèn)題。framework 中可以存放資源,但是 .a 卻不可以,因此生成 .a 的 pod 下的資源會(huì)被轉(zhuǎn)移到 main bundle 下,這為資源沖突造成了隱患,為了避免這種沖突我們之前采用的使用 bundle 管理資源,bundle 名很少會(huì)重復(fù)這樣就大大降低了資源沖突的可能性。
優(yōu)化的前提之一也是不破壞這種組件化開(kāi)發(fā)的模式,換句話說(shuō)也就是各個(gè)業(yè)務(wù)線不產(chǎn)生資源耦合、業(yè)務(wù)線的 RD 不必?fù)?dān)心彼此資源的沖突、業(yè)務(wù) Pod 下的資源文件彼此隔離。哪怕招聘團(tuán)隊(duì)中存在 a.png,房產(chǎn)團(tuán)隊(duì)中也存在 a.png 也不會(huì)有什么問(wèn)題。所以我們先要拋出兩個(gè)問(wèn)題:
1. cocoapods 是否支持使用 Assets.xcassets。
2. 各個(gè) pod 各自維護(hù)自己的 Assets.xcassets 會(huì)不會(huì)造成資源沖突。
為了弄清楚上面兩個(gè)問(wèn)題,我們先要看下 podspec 的幾個(gè)重要參數(shù):
podspec
s.public_header_files :表明了哪些路徑下的文件可以在 framework 外被引用。
source_files :源文件路徑。
s.resources :資源文件路徑及文件類型。
s.resource_bundles :資源文件路徑及類型,同時(shí)資源文件會(huì)被打成 bundle。(推薦使用)。
實(shí)驗(yàn)發(fā)現(xiàn)各個(gè) pod 下都可以創(chuàng)建自己的 xcassets,因此問(wèn)題1不算問(wèn)題是問(wèn)題。如果我們?cè)诟鱾€(gè)業(yè)務(wù) pod 下都創(chuàng)建 .xcassets 文件內(nèi)置圖片,那么 cocoapods 的腳本會(huì)在編譯時(shí)將各個(gè)目錄下的 xcassets 文件內(nèi)容提取出來(lái),合并到一個(gè) xcassets 中并生成一個(gè) .car 文件。
這樣的話如果資源文件重名,那么很可能其中某一個(gè)文件會(huì)被覆蓋替換。因此我們主要是要解決問(wèn)題2。查看 podspec 的寫法發(fā)現(xiàn) s.resource_bundles 貌似是我們所需要的法寶。為此我們天真的以為問(wèn)題馬上就要解決了:
將指定路徑下的資源打包成bundle
最終打包結(jié)果很理想,確實(shí)能夠生成 ImagesBundle.bundle,并且 bundle 下存在Assets.car。
mainbundle下存在ImagesBundle
ImageBundle.bundle下存在Assets.car
事情到這里可能已經(jīng)看到曙光了,但是我們發(fā)現(xiàn)通過(guò)
- [UIImage imageNamed:@"ImagesBundle.bundle/1"];
加載不出來(lái)圖片。必須使用
- [UIImageimageNamed:@"1"inBundle:[WBIMViewControllericonBundle]compatibleWithTraitCollection:nil];
才能加載出來(lái)。
圖片加載失敗
指定bundle后加載成功
也就是說(shuō)只有 Assets.car 如果不在 main bundle 下,那么加載圖片都需要指定bundle。
既然需要指定 bundle 加載圖片,那么如何獲取這個(gè) bundle 呢?換句話說(shuō)如何才能低成本的將現(xiàn)在項(xiàng)目中的圖片放到特定 bundle下的 Assets.car 文件中呢?
對(duì)此我們提出了一個(gè)解決方案:
1. 在 pod 下新建一個(gè)空文件夾。找出該 pod 存放圖片的所有 bundle,在新建文件夾下創(chuàng)建與 bundle 數(shù)量相等的 Asset。
2. 修改 podspec 文件,設(shè)置 resource_bundles 將 Asset 指定為資源,并指定 bundle名稱。如 A.bundle,其對(duì)應(yīng)的 Asset 最終資源 bundle 為 A_Asset.bundle。
3. 新增方法,imageWithName:,從符合 xxx.bundle/yyy.png 特征的參數(shù)中獲取 bundle 名和圖片名 xxx_Asset.bundle 和 yyy.png,獲取圖片并返回。
4. 查找并全部替換 imageNamed: 和 imageWithContentOfFile: 為 imageWithName:
只要能拿到原來(lái)代碼中 imageNamed: 的參數(shù)就能知道現(xiàn)在圖片存在那個(gè) bundle 下,這樣就能通過(guò) imageNamed:inBundle: 獲取到圖片,其思路如下圖所示:
imageWithName:方法內(nèi)部處理
打包后bundle情況
看到這里老司機(jī)們已經(jīng)應(yīng)該能遇見(jiàn)這種優(yōu)化的成本了。加載圖片都需要指定 bundle 也就意味著成千上萬(wàn)處的 API 需要修改。我們最初探討到這里的時(shí)候首先想到的是腳本,但是這個(gè)方案很快就被否定了,因?yàn)轫?xiàng)目中存在大量的 XIB,XIB 中設(shè)置圖片我們無(wú)法通過(guò)腳本替換 API。
為了解決 XIB 設(shè)置圖片的問(wèn)題,我們首先想到了AOP。通過(guò) hook XIb 加載圖片的方法將方法偷偷替換為 imageNamed:inBundle: ,但是很遺憾我們 hook 了 UIImage 所有加載圖片的方法,沒(méi)有一個(gè)方法能拿到 XIB 上所設(shè)置的圖片名稱,也就意味著我們無(wú)法得知優(yōu)化后的圖片在哪個(gè) bundle 下,也就不知道圖片該如何加載。
雖然有坎坷,但是我們始終堅(jiān)信 XIB 一定是通過(guò)某些方法將圖片加載出來(lái)的,我們一定能拿到這個(gè)過(guò)程!為了驗(yàn)證這個(gè)問(wèn)題,首先定義一個(gè) UIImageView 的子類,并將 XIB 上的 UIImageView 指定為這個(gè)子類。
大家都知道通過(guò)XIB加載的視圖都一定會(huì)執(zhí)行 initWithCoder: 方法
UIImageView的子類加載
我們發(fā)現(xiàn)在得到執(zhí)行 [super initWithCoder:aDecoder] 之前通過(guò) lldb 查看 slef.image 是nil。當(dāng)執(zhí)行完這行代碼后 self.image 就有值了。
因此推斷圖片的信息(圖片名稱、路徑等信息)都在 aDecoder 中!在網(wǎng)上搜索了一些資料后發(fā)現(xiàn) aDecoder 有一些固定的key,可以通過(guò)這些固定的 key 得到一部分信息。如
aDecoder可以通過(guò)某些key得到其中信息
很顯然通過(guò) “UIImage” 這個(gè) key 能拿到圖片,但是很遺憾經(jīng)過(guò)多次嘗試沒(méi)能找到圖片的路徑信息。因此這個(gè)問(wèn)題的關(guān)鍵是怎么找到合適的 key,為了解決這個(gè)問(wèn)題,最好是能拿到 aDecoder 的解碼過(guò)程。
因此 hook aDecoder 的解碼方法 decodeObjectForKey:是個(gè)不錯(cuò)的選擇。如果能拿到 xib 上設(shè)置的圖片名稱那么我們就可以根據(jù)圖片名稱獲取到正確的圖片路徑。經(jīng)過(guò)斷點(diǎn)查看 aDecoder 是UINibDecoder(私有類)類型。
aDecoder
hook UINibDecoder的decode方法
打印系統(tǒng) decode 的所有 key 后發(fā)現(xiàn)有個(gè) key 為 UIResourceName,value 為圖片的名稱。也就是說(shuō)我們能得到 XIB 上設(shè)置的圖片名稱了。但是這個(gè)圖片的名稱怎么傳遞給這個(gè) XIB 對(duì)應(yīng)的UIImageView 對(duì)象呢?
換句話說(shuō)也就是說(shuō)我們?cè)趺窗褕D片傳給這個(gè) XIB 對(duì)應(yīng)的 view 呢?為了將圖片名稱傳給 UIImageView,需要給 aDecoder 添加一個(gè) block 的關(guān)聯(lián)引用。
UIImageView在initWithCoder:的時(shí)候設(shè)置回調(diào)
在 hook 到的 decodeObjectForKey: 方法中將圖片名稱回傳給 initWithDecoder: 方法:
aDecoder hook到圖片名稱后回調(diào)給UIImageView類
這里需要注意的是一點(diǎn)是:XIB 默認(rèn)設(shè)置圖片是在 rentun value 之后,也就是說(shuō)如果我們回調(diào)過(guò)早有可能圖片被替換為 nil。因此需要 dispatch_after 一下,等 return 之后再回調(diào)圖片名稱并設(shè)置圖片。
受此啟發(fā),我們也可以 hook UIImage 的 imageNamed: 方法,根據(jù)參數(shù)的規(guī)則到 xxxCopy.bundle 下獲取圖片,并返回圖片。這就意味著放棄通過(guò)腳本修改 API,減少了代碼的改動(dòng)。
看到這里似乎是沒(méi)有什么問(wèn)題,但是我們忽略了一個(gè)很嚴(yán)重的問(wèn)題 aDecoder 對(duì)象和 UIImageView 類型的對(duì)象是一一對(duì)應(yīng)的嗎?一個(gè) imageView 它的 aDecoder 是它唯一擁有的嗎?帶著這個(gè)問(wèn)題,我們先來(lái)看下打印信息:
重復(fù)生成UIImageView對(duì)象和aDecoder對(duì)照關(guān)系
重復(fù)生成對(duì)象并打印后發(fā)現(xiàn) aDecoder 的地址都相同,也就是說(shuō)存在一個(gè) aDecoder 對(duì)應(yīng)多個(gè)UIImageView 的現(xiàn)象。因此異步方案不適用,需要同步進(jìn)行設(shè)置圖片,因此全局變量最為合適。
其實(shí)這一點(diǎn)很容易理解,aDecoder 是與 XIB 對(duì)應(yīng)的,XIB 是不變的所以 aDecoder 是不變的。
因此異步回調(diào)的方案不適用,需要同步進(jìn)行設(shè)置圖片,在這種情況(主線程串行執(zhí)行)下跨類傳值全局變量最為合適:
hook UINibDecoder的decodeObjectForKey
hook UIImageView 的initWithCoder:
上面兩段代碼僅僅介紹思路,可能加載圖片的代碼并不是十分的嚴(yán)謹(jǐn),請(qǐng)讀者自己鑒別。同理hook 項(xiàng)目中 UIImage 所用到的加載圖片的API即可加載圖片。
如果將所有的hook方法放到一個(gè)類中,那么只要將這個(gè)類拖入到項(xiàng)目中,并將項(xiàng)目中所有的bundle下的圖片都放到對(duì)應(yīng)的 Assets.xcassets 文件下那么無(wú)需修改一行代碼即可將所有的圖片遷移到 Assets.xcassets 下,達(dá)到應(yīng)用瘦身的目的。
但是我們組內(nèi)老練的架構(gòu)師們指出:項(xiàng)目中 hook 如此重要的 API 對(duì)增加了項(xiàng)目維護(hù)的難度。這也引發(fā)了我對(duì)項(xiàng)目中 AOP 場(chǎng)景的思考,項(xiàng)目中到底 hook 了多少 API?
可能在我場(chǎng)多年的老司機(jī)們都難以回答了,為此特地趕制了一個(gè)基于 fishhook 的一個(gè) hook 打印工具,檢測(cè)和統(tǒng)計(jì)項(xiàng)目中的 AOP 情況。但是缺點(diǎn)是必須調(diào)整編譯順序保證工具類最先被load。
hook method_exchangeImplementations 方法
檢測(cè)方法(字典寫入時(shí)不要忘了加鎖)