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