文章:皮拉夫大王在此 - iOS應用瘦身方法思路整理
一、iOS 內置資源的集中方式
1.1 將圖片存放在 bundle
這是一種很常見的方式,項目中各類文件分類放在各個 bundle 下,項目既整潔又能達到隔離資源的目的。采用 bundle 的加載方式為 [UIImage imageNamed:"xx.bundle/xx.png"]。
這種方式有比較明顯的缺點:
iOS 系統不會對其進行壓縮存儲,造成了應用體積的增大。
使用 bundle 存儲圖片放棄了 APP thinning。明顯的表現是 2 倍屏手機和 3 倍屏手機下載的應用包大小一樣。如果能夠實現 APP thinning,那么往往 2 倍屏幕的手機包大小會小於 3 倍屏手機的,起到差異性優化的目的。
在調研過程中發現,應用的體積與圖片資源的數量密切相關。換句話說,iPhone 的 rom 存在 4K 對齊的情況,一張 498B 大小的圖片在應用包中也要占據 4KB 大小。因此項目中每添加一張圖片就至少增大了 4KB。
下面來證實。首先創建空應用,其大小在 iPhone7 上為 131KB ,引入一張 3KB 的圖片前后對比如下:
以上未經過 App Store 上線認證,僅僅通過本地真機運行測試,僅供參考。
1.2 使用 .ttf 字體文件替代圖標
使用字體文件替代圖片也是一種比較常見的資源內置方式。很多應用都使用過這種方案,如淘寶、愛奇藝等知名應用。
使用字體文件的好處是顯而易見的,如果 APP 中某個圖片比較大,那么為了保證清晰度,UI 可能會提供比較大的圖標。使用字體文件會避免這個問題,而且不必導入 @2x 和 @3x 圖片,一套字體文件就能保證 UI 的清晰度。
字體文件使用起來比較簡單,但是使用方法與 png 圖片的使用方法有很大的不同,因為字體文件實際所展示的圖標都是 UTF8 編碼轉來的字符串。因此當我們需要展示一個圖標的時候不再是使用 UIImageView 了,而是 UILabel。
UILabel * iconLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 50, 50)];
iconLabel.font = [UIFont fontWithName:@"icomoon" size:50];
iconLabel.text = [NSString stringWithUTF8String:"\ue902"];
由於使用了字體來替代圖片,所以可以通過設置字體的顏色來改變圖標的顏色。之前經常會遇到一個場景,如兩個一模一樣的圖標但是由於顏色不同,UI 就需要提供兩套圖片,每套圖片中包含 @2x 和 @3x 圖片。如果采用了字體替代簡單的圖標,那么 UI 只需要提供一套字體即可,並且拉伸后也不會失真。
優點:
- 可以降低應用圖片內置資源的體積。
- 可以隨意縮放和修改顏色。
缺點:
- 圖標的查找和替換比較麻煩,不如直接使用圖片那樣簡單。
- 有些情況無法替換之前存在的圖片,只能起到縮小增量的目的,無法減小全量。
任何一種需要大刀闊斧改革的優化都是一種不明智的行為。
1.3 圖片存在 Assets.xcassets
使用 Assets.xcassets 是蘋果推薦的一種方式。Assets.xcassets 是 iOS7 推出的一種圖片資源管理工具,將圖片內置到Assets.xcassets 下系統會對圖片資源進行壓縮,並且支持 APP thinning。
二、優化
項目優化不能脫離場景,很多很好的方案由於場景的限制並不能起到優化的作用。
為了達到跨團隊快速開發的目的,項目很早就利用 cocoapods 實現組件化。項目中存在多個業務 pod,每個 pod 都有各自的團隊維護,各個團隊的代碼彼此不開放,各個 pod 最終會被編譯為 .a 的形式。
與 .a 相對應的是 .framework,它們之間有一個重要的區別就是資源的問題。.framework 中可以存放資源,但 .a 不可以,因此生成 .a 的 pod 下的資源會被轉移到 main bundle 下,這為資源沖突造成了隱患。采用的 bundle 管理資源大大降低了資源沖突的可能性,因為 bundle 名很少會重復。
優化的前提之一也是不破壞這種組件化開發的模式,換句話說也就是各個業務線不產生資源耦合、業務線的 RD 不必擔心彼此資源的沖突、業務 Pod 下的資源文件彼此隔離。
先要拋出兩個問題:
- cocoapods 是否支持使用 Assets.xcassets。
- 各個 pod 維護自己的 Assets.xcassets 會不會造成資源沖突。
為了弄清楚上面兩個問題,先要看下 podspec 的幾個重要參數:
s.source_files :源文件路徑。
s.public_header_files :表明了哪些路徑下的文件可以在 framework 外被引用。
s.resources :資源文件路徑及文件類型。
s.resource_bundles :資源文件路徑及類型,同時資源文件會被打成 bundle。(推薦使用)。
實驗發現各個 pod 下都可以創建自己的 xcassets,因此問題 ① 確定。
如果我們在各個業務 pod 下都創建 .xcassets 文件內置圖片,那么 cocoapods 的腳本會在編譯時將各個目錄下的 xcassets 文件內容提取出來,合並到一個 xcassets 中並生成一個 .car 文件。這樣的話如果資源文件重名,那么很可能其中某一個文件會被覆蓋替換。因此我們主要是要解決問題 ②。
查看 podspec 的寫法發現 s.resource_bundles 貌似是我們所需要的法寶。
最終打包結果很理想,確實能夠生成 Demo.bundle,並且 bundle 下存在 Assets.car。
運行發現通過 [UIImage imageNamed:@"Demo.bundle/1"];加載不出來圖片。必須使用 [UIImage imageNamed:@"1" inBundle:bundle compatibleWithTraitCollection:nil]; 才能加載出來。也就是說如果 Assets.car 不在 main bundle 下,那么加載圖片需要指定 bundle。
既然需要指定 bundle 加載圖片,那么如何獲取這個 bundle 呢?換句話說如何才能低成本的將項目中的圖片放到特定 bundle 下的 Assets.car 文件中呢?對此我們提出了一個解決方案:
- 在 pod 下新建一個空文件夾。找出該 pod 存放圖片的所有 bundle,在新建文件夾下創建與 bundle 數量相等的 Asset。
- 修改 podspec 文件,設置 resource_bundles 將 Asset 指定為資源,並指定 bundle 名稱,如 A.bundle,其對應的 Asset 最終資源 bundle 為 A_Asset.bundle。
- 新增方法 imageWithName:,從符合 xx.bundle/yy.png 特征的參數中獲取 bundle 名和圖片名 xx_Asset.bundle 和 yy.png,獲取圖片並返回。
- 查找並全部替換 imageNamed: 和 imageWithContentOfFile: 為 imageWithName:。
只要能拿到原來代碼中 imageNamed: 的參數就能知道現在圖片存在哪個 bundle 下,這樣就能通過 imageNamed:inBundle: 獲取到圖片,其思路如下圖所示:
看到這里已經應該能遇見這種優化的成本了。加載圖片都需要指定 bundle 也就意味着成千上萬處的 API 需要修改。我們最初探討到這里的時候首先想到的是腳本,但是這個方案很快就被否定了,因為項目中存在大量的 XIB,XIB 中設置圖片我們無法通過腳本替換 API。
為了解決 XIB 設置圖片的問題,我們首先想到了 AOP。通過 hook Xib 加載圖片的方法將方法偷偷替換為 imageNamed:inBundle:,但是很遺憾 hook 了 UIImage 所有加載圖片的方法,沒有一個方法能拿到 XIB 上所設置的圖片名,也就意味着我們無法得知優化后的圖片在哪個 bundle 下,也就不知道圖片該如何加載。雖然有坎坷,但是我們始終堅信 XIB 一定是通過某些方法將圖片加載出來的,我們一定能拿到這個過程!為了驗證這個問題,首先定義一個 UIImageView 的子類,並將XIB 上的 UIImageView 指定為這個子類。大家都知道通過 XIB 加載的視圖都一定會執行 initWithCoder: 方法。
發現在執行 [super initWithCoder:aDecoder] 之前通過 lldb 查看 self.image 是 nil。當執行完這行代碼后 self.image 就有值了。因此推斷圖片的信息(圖片名稱、路徑等信息)都在 aDecoder 中!在網上搜索了一些資料后發現aDecoder 有一些固定的 key,可以通過這些固定的 key 得到一部分信息。如
很顯然通過 UIImage 這個 key 能拿到圖片,但是很遺憾經過多次嘗試沒能找到圖片的路徑信息。因此這個問題的關鍵是怎么找到合適的 key,為了解決這個問題,最好是能拿到 aDecoder 的解碼過程。因此 hook aDecoder 的解碼方法 decodeObjectForKey:是個不錯的選擇。如果能拿到 xib 上設置的圖片名稱,那么我們就可以根據圖片名稱獲取到正確的圖片路徑。經過斷點查看 aDecoder 是 UINibDecoder(私有類)類型。
- (id)swizzle_decodeObjectForKey:(NSString *)key
{
Method originalMethod = class_getInstanceMethod([HookTool class], @selector(swizzle_decodeObjectForKey:));
IMP function = method_getImplementation(originalMethod);
id (*functionPoint)(id, SEL, id) = (id (*)(id, SEL, id)) function;
id value = functionPoint(self, _cmd, key);
return value;
}
打印系統 decode 的所有 key 后發現有個 key 為 UIResourceName,value 為圖片的名稱。也就是說我們能得到 XIB 上設置的圖片名稱了。但是這個圖片的名稱怎么傳遞給這個 XIB 對應的 UIImageView 對象呢?換句話說也就是說我們怎么把圖片傳給這個 XIB 對應的 view 呢?為了將圖片名稱傳給 UIImageView,需要給 aDecoder 添加一個 block 的關聯引用。
在 hook 到的 decodeObjectForKey: 方法中將圖片名稱回傳給 initWithDecoder: 方法。
這里需要注意的是一點是:XIB 默認設置圖片是在 rentun value 之后,也就是說如果我們回調過早有可能圖片被替換為 nil。因此需要 dispatch_after 一下,等 return 之后再回調圖片名稱並設置圖片。受此啟發,我們也可以 hook UIImage 的imageNamed: 方法,根據參數的規則到 xxxCopy.bundle 下獲取圖片,並返回圖片。這就意味着放棄通過腳本修改 API,減少了代碼的改動。看到這里似乎是沒有什么問題,但是我們忽略了一個很嚴重的問題 aDecoder 對象和 UIImageView 類型的對象是一一對應的嗎?一個 imageView 它的 aDecoder 是它唯一擁有的嗎?帶着這個問題,我們先來看下打印信息:
重復生成對象並打印后發現 aDecoder 的地址都相同,也就是說存在一個 aDecoder 對應多個 UIImageView 的現象。因此異步方案不適用,需要同步進行設置圖片,因此全局變量最為合適。其實這一點很容易理解,aDecoder 是與 XIB 對應的,XIB 是不變的所以 aDecoder 是不變的。因此異步回調的方案不適用,需要同步進行設置圖片,在這種情況(主線程串行執行)下跨類傳值全局變量最為合適。
- (id)swizzle_decodeObjectForKey:(NSString *)key
{
Method originalMethod = class_getInstanceMethod([HookTool class], @selector(swizzle_decodeObjectForKey:));
IMP function = method_getImplementation(originalMethod);
id (*functionPoint)(id, SEL, id) = (id (*)(id, SEL, id)) function;
id value = functionPoint(self, _cmd, key);
NSString* propKey = @"emaNecruoseRIU";
// 反轉字符串
propKey = [XUtil stringByReversed:propKey];
if ([key isEqualToString:propKey]) {
if (normal_imageName) {
select_imageName = value;
}
else {
normal_imageName = value;
}
}
return value;
}
hook UIImageView 的 initWithCoder:
- (id)swizzle_imageView_initWithCoder:(NSCoder *)aDecoder
{
// 執行順序:initWithCoder -》DecoderWithKey -》setImage:,所以每次給 imageView 設置圖片時,需要將之前的置空。
// tabbarItem 的圖片設置不會執行 initWithCoder,如果不置空,會導致 imageView 設置成和 tabbarItem 一樣的圖片。
normal_imageName = nil;
select_imageName = nil;
UIImageView * instance = (UIImageView *)[self swizzle_imageView_initWithCoder:aDecoder];
if (normal_imageName && [normal_imageName isKindOfClass:[NSString class]] && normal_imageName.length > 0) {
UIImage * normalImage = [HookTool imageAfterSearch:normal_imageName];
// 賦值
if (normalImage) {
instance.image = normalImage;
}
normal_imageName = nil;
select_imageName = nil;
}
return instance;
}
上面兩段代碼僅僅介紹思路。同理 hook 項目中 UIImage 所用到的加載圖片的 API 即可加載圖片。如果將所有的 hook 方法放到一個類中,那么只要將這個類拖入到項目中,並將項目中所有的 bundle 下的圖片都放到對應的 Assets.xcassets 文件下那么無需修改一行代碼即可將所有的圖片遷移到 Assets.xcassets 下,達到應用瘦身的目的。
但是我們組內老練的架構師們指出:項目中 hook 如此重要的 API 對增加了項目維護的難度。這也引發了對項目中 AOP 場景的思考,項目中到底 hook 了多少 API?為此特地趕制了一個基於 fishhook 的一個 hook 打印工具,檢測和統計項目中的 AOP 情況。但是缺點是必須調整編譯順序保證工具類最先被 load。
hook method_exchangeImplementations 方法。
檢測方法(字典寫入時不要忘了加鎖)。
這種方式不能區分 image 和 backgroundImage、normal 和 Selected。目前根據觀察順序應該是:
UIResourceName : normal - image(前景圖)
UIResourceName : normal - backgroundImage(背景圖)
UIResourceName : selected - image(前景圖)
UIResourceName : selected - backgroundImage(背景圖)