@NewPan 貝聊科技 iOS 菜鳥工程師
大家好,我是 NewPan,我之前寫過一篇 iOS一次立竿見影的啟動時間優化 - 簡書,從標題也可以看得出來,那篇文章是關於啟動時間優化的,得到了大家不錯的反響。這次我們來講講如何優化首頁的渲染時間。
01. 貝聊首頁頁面介紹
上圖是貝聊家長版首頁的設計圖,從上圖可以看出,這個首頁還是很複雜的,郭耀源在他的 深入理解RunLoop | Garan no dou 裡提到:
UI 執行緒中一旦出現繁重的任務就會導致介面卡頓,這類任務通常分為3類:排版,繪製,UI 物件操作。
- 排版通常包括計算檢視大小、計算文字高度、重新計運算元式圖的排版等操作。
- 繪製一般有文字繪製 (例如
CoreText
)、圖片繪製 (例如預先解壓)、元素繪製 (Quartz)等操作。 3.UI 物件操作通常包括UIView
/CALayer
等 UI 物件的建立、設定屬性和銷燬。
貝聊這個首頁已經把“排版,繪製,UI 物件操作”這三個方面耗時操作全部涵蓋了,如果直接基於 UIKit
那一套去寫的話,需要花很多時間去做效能調優。所以貝聊的首頁直接採用了 AsyncDisplayKit,雖然需要重新去學習 AsyncDisplayKit
那套 boxing 佈局規則,但是效果很明顯,我們的列表在很老的 iPhone 5 上快速滾動都不會出現明顯示卡頓。
02. 貝聊首頁渲染耗時分析
我們先看一下優化前的首頁耗時,這個耗時是指從後臺資料載入到裝置以後進行解析最後渲染成 UI 這個過程的耗時。測試裝置為我自己的 iPhone 6s Plus(國行 64GB),我總共測試了十組資料。
// 第 1 組.
2018-08-14 16:20:38.831014+0800 beiliao[2429:991848] 從資料載入完成到首頁開始渲染耗時: 1.172843
// 第 2 組.
2018-08-14 16:21:15.409550+0800 beiliao[2431:992484] 從資料載入完成到首頁開始渲染耗時: 1.199685
// 第 3 組.
2018-08-14 16:21:50.329775+0800 beiliao[2433:993092] 從資料載入完成到首頁開始渲染耗時: 1.203976
// 第 4 組.
2018-08-14 16:22:30.805793+0800 beiliao[2435:993740] 從資料載入完成到首頁開始渲染耗時: 1.022340
// 第 5 組.
2018-08-14 16:23:10.874299+0800 beiliao[2437:994402] 從資料載入完成到首頁開始渲染耗時: 1.127660
// 第 6 組.
2018-08-14 16:23:43.988901+0800 beiliao[2439:994997] 從資料載入完成到首頁開始渲染耗時: 0.991278
// 第 7 組.
2018-08-14 16:24:19.291121+0800 beiliao[2441:995581] 從資料載入完成到首頁開始渲染耗時: 0.970286
// 第 8 組.
2018-08-14 16:24:53.831283+0800 beiliao[2444:996330] 從資料載入完成到首頁開始渲染耗時: 0.550910
// 第 9 組.
2018-08-14 16:25:30.564408+0800 beiliao[2446:996948] 從資料載入完成到首頁開始渲染耗時: 1.339828
// 第 10 組.
2018-08-14 16:26:07.003846+0800 beiliao[2452:997656] 從資料載入完成到首頁開始渲染耗時: 0.978076
複製程式碼
可以看到,資料範圍從 0.550910 - 1.339828,平均值為 1.05563。而且這裡有個特點就是,不管是 iPhone X 還是更舊的裝置,都一樣的耗時,因為這裡阻塞的是 UI 執行緒。
上一小節說貝聊的首頁採用的是 AsyncDisplayKit
,對於排版,繪製,UI 物件操作這三項,前兩項已經被 AsyncDisplayKit
扔到後臺執行緒,最後前兩項的結果會被同步到 UI 執行緒進行檢視渲染。所以影響貝聊首頁渲染的應該是“UI 物件操作”。
接下來祭出”Time Profiler“,找到耗時的程式碼,如果有使用 Time Profiler 的問題,請參考我之前寫的文章 iOS用 TimeProfiler 揪出那些耗時函式 - 簡書。
上圖有一個 -fetchAnimationImages
方法,它的實現是下面這樣的。就是對一組序列幀進行載入,這個方法耗時 0.284 秒。
+ (NSArray<NSString *> *)fetchAnimationImageNames {
NSMutableArray<NSString *> *names = @[].mutableCopy;
for (int i = 2; i <= 23; i++) {
[names addObject:[NSString stringWithFormat:@"BLDKLoadMoreAnimation-000%02d", i]];
}
return names.copy;
}
+ (NSArray<UIImage *> *)fetchAnimationImages {
NSMutableArray<UIImage *> *images = @[].mutableCopy;
for (NSString *imageName in [self fetchAnimationImageNames]) {
[images addObject:[UIImage imageNamed:imageName]];
}
return images.copy;
}
複製程式碼
同樣的,我又分析了其他的方法,最後,這些方法都呼叫了一個系統的方法 -imageNamed:
。於是我把 -fetchAnimationImages
中呼叫-imageNamed:
的地方註釋掉。
+ (NSArray<UIImage *> *)fetchAnimationImages {
NSMutableArray<UIImage *> *images = @[].mutableCopy;
for (NSString *imageName in [self fetchAnimationImageNames]) {
// [images addObject:[UIImage imageNamed:imageName]];
}
return images.copy;
}
複製程式碼
再次開啟”Time Profiler“,看到耗時函式裡已經沒有 -fetchAnimationImages
這個方法了。
至此,我們驗證了,影響首頁渲染耗時的最大凶手是 -imageNamed:
這個方法。
03. 優化策略
我們天天都在用這個方法載入 UI 元素,但是從來沒想過這個方法是壓死駱駝的最後一根稻草。
從系統文件來看,這個方法會去 bundle
中載入圖片資源,解碼資料,最後根據使用者的裝置解析度的不同渲染到螢幕上。我們知道這個過程可能會很耗時,尤其當圖片檔案很大的時候,所以 SDWebImage
、AFNetworking
、YYWebImage
把圖片解碼這樣的操作都放到了子執行緒。
文件上面沒有寫 ,經評論裡朋友提醒,再仔細看了一下文件,-imageNamed:
這個方法是否是執行緒安全的In iOS 9 and later, this method is thread safe.
,也就是說 iOS 9 以後這個方法是執行緒安全的。受這些第三方庫的啟發,我開始嘗試把 -imageNamed:
這個方法放到子執行緒執行,並在各個機型上測試,發現沒有出現問題。
我們都知道, -imageNamed:
這個方法會有快取,只要載入過一次,再次載入就會得到快取的優化。於是,我開始嘗試將本地資源圖片提前進行預載入。
那為什麼這個預載入可行呢?因為這個時機很重要,從 -application:didFinishLaunchingWithOptions:
到首頁請求回來這個時間,剛好 CPU 和 IO 都是空閒的(或者你可以通過其他手段把這段時間的 CPU 和 IO 預留出來,具體請參考 iOS一次立竿見影的啟動時間優化 - 簡書),這段時間你就可以把本地圖片資源都載入好,等請求回來的時候,首頁需要呼叫的 -imageNamed:
方法都已預載入過一遍,再次載入都會享受快取記憶體的優化,這樣就能達到優化的效果。
04. 具體實現
實現思路大致如下:
-
- 自行 hook
-imageNamed:
方法到自定義的實現,在這個實現中把圖片名字快取到本地。
- 自行 hook
-
- 再次啟動時,在
-application:didFinishLaunchingWithOptions:
方法中開始預載入上次 APP 啟動快取好的圖片。具體應該使用 GCD 併發的在子執行緒中載入。
- 再次啟動時,在
具體實現程式碼如下:
BLImagePreloadManager.h
檔案如下:
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface BLImagePreloadManager : NSObject
/**
* 手動新增需要預載入的圖片名(圖片名陣列).
*
* @warning 在 load 方法中新增才能執行到.
*/
+ (void)preloadImagesWithImageNames:(NSArray<NSString *> *)imageNames;
/**
* 手動新增需要預載入的圖片名.
*
* @warning 在 load 方法中新增才能執行到.
*/
+ (void)preloadImageWithImageName:(NSString *)imageName;
/**
* 嘗試預載入 `-imageName:` 的圖片(方法會自動切換到子執行緒).
*/
+ (void)preloadImagesIfNeed;
/**
* 儲存預載入的圖片名稱.
*/
+ (void)storeImageNameForPreload:(NSString *)imageName;
@end
NS_ASSUME_NONNULL_END
複製程式碼
BLImagePreloadManager.m
檔案如下:
#import "BLImagePreloadManager.h"
#import "UIImage+ImageDetect.h"
#import "BLGCDExtensions.h"
static NSString *const kBLImagePreloadManagerStoreKey = @"com.ibeiliao.preload.images.store.key.www";
static BOOL _isStoreTimeTick = NO;
static NSTimeInterval const kBLImagePreloadManagerStoreImageTimeInterval = 10;
static NSMutableSet<NSString *> *_kImageNameCollectSetM = nil;
static dispatch_queue_t _ioQueue;
static NSMutableArray<NSString *> *_manualPreloadImageNames = nil;
@implementation BLImagePreloadManager
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_ioQueue = dispatch_queue_create("com.ibeiliao.image.preload.queue", DISPATCH_QUEUE_SERIAL);
});
}
+ (void)preloadImagesWithImageNames:(NSArray<NSString *> *)imageNames {
NSParameterAssert(imageNames.count);
BLAssertMainThread;
if (!imageNames.count) {
return;
}
[self manualPreloadArrayInitIfNeed];
NSAssert(_manualPreloadImageNames, @"新增預載入行為時機太晚, 預載入已經完成, 請在 load 方法中執行新增預載入圖片行為");
if (!_manualPreloadImageNames) {
return;
}
[_manualPreloadImageNames addObjectsFromArray:imageNames];
}
+ (void)preloadImageWithImageName:(NSString *)imageName {
NSParameterAssert(imageName);
if (!imageName.length) {
return;
}
if (![imageName isKindOfClass:[NSString class]]) {
return;
}
[self preloadImagesWithImageNames:@[imageName]];
}
+ (void)preloadImagesIfNeed {
if (@available(iOS 9.0, *)) {
[self manualPreloadArrayInitIfNeed];
NSArray<NSString *> *imageNames = [[NSUserDefaults standardUserDefaults] valueForKey:kBLImagePreloadManagerStoreKey];
if (imageNames.count) {
[_manualPreloadImageNames addObjectsFromArray:imageNames];
}
if (!_manualPreloadImageNames || !_manualPreloadImageNames.count) {
return;
}
BOOL bl_imageWithNameEnable = [UIImage respondsToSelector:@selector(bl_imageNamed:)];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
for (NSString *imageName in _manualPreloadImageNames) {
if ([imageName isKindOfClass:[NSString class]]) {
bl_imageWithNameEnable ? [UIImage bl_imageNamed:imageName] : [UIImage imageNamed:imageName];
}
}
});
}
}
+ (void)storeImageNameForPreload:(NSString *)imageName {
NSParameterAssert(imageName);
if (![imageName isKindOfClass:[NSString class]]) {
return;
}
if (_isStoreTimeTick) {
return;
}
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_kImageNameCollectSetM = [NSMutableSet set];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kBLImagePreloadManagerStoreImageTimeInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
_isStoreTimeTick = YES;
[self internalFinishCollectImageName];
});
});
dispatch_async(_ioQueue, ^{
if (_kImageNameCollectSetM && imageName.length) {
[_kImageNameCollectSetM addObject:imageName];
}
});
}
+ (void)internalFinishCollectImageName {
if (!_kImageNameCollectSetM || !_kImageNameCollectSetM.count) {
[self releaseResources];
return;
}
dispatch_async(_ioQueue, ^{
[[NSUserDefaults standardUserDefaults] setObject:[_kImageNameCollectSetM allObjects] forKey:kBLImagePreloadManagerStoreKey];
[self releaseResources];
});
}
+ (void)releaseResources {
_kImageNameCollectSetM = nil;
_ioQueue = nil;
_manualPreloadImageNames = nil;
}
+ (void)manualPreloadArrayInitIfNeed {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if(!_manualPreloadImageNames && !_isStoreTimeTick) {
_manualPreloadImageNames = @[].mutableCopy;
}
});
}
@end
複製程式碼
05. 優化效果
有了這一層優化以後,仍然在我的 iPhone 6s Plus 上進行十組測試,我們一起來看下優化後的結果:
// 第 1 組.
2018-08-14 18:37:03.434442+0800 beiliao[2603:1056626] 從資料載入完成到首頁開始渲染耗時: 0.253540
// 第 2 組.
2018-08-14 18:38:11.953393+0800 beiliao[2608:1057951] 從資料載入完成到首頁開始渲染耗時: 0.265548
// 第 3 組.
2018-08-14 18:38:41.851729+0800 beiliao[2610:1058585] 從資料載入完成到首頁開始渲染耗時: 0.263075
// 第 4 組.
2018-08-14 18:39:13.515297+0800 beiliao[2612:1059171] 從資料載入完成到首頁開始渲染耗時: 0.293209
// 第 5 組.
2018-08-14 18:39:47.610475+0800 beiliao[2614:1059832] 從資料載入完成到首頁開始渲染耗時: 0.268341
// 第 6 組.
2018-08-14 18:40:55.798904+0800 beiliao[2618:1061142] 從資料載入完成到首頁開始渲染耗時: 0.263902
// 第 7 組.
2018-08-14 18:41:25.785528+0800 beiliao[2621:1061772] 從資料載入完成到首頁開始渲染耗時: 0.257506
// 第 8 組.
2018-08-14 18:41:56.550695+0800 beiliao[2623:1062409] 從資料載入完成到首頁開始渲染耗時: 0.291573
// 第 9 組.
2018-08-14 18:42:27.200791+0800 beiliao[2625:1063009] 從資料載入完成到首頁開始渲染耗時: 0.233717
// 第 10 組.
2018-08-14 18:42:58.853888+0800 beiliao[2627:1063666] 從資料載入完成到首頁開始渲染耗時: 0.299298
複製程式碼
可以看到,資料範圍從 0.253540 - 0.299298,平均值為0.268981。比優化前的平均值1.05563,減少 75%,效果非常明顯。