[iOS]一次立竿見影的首頁渲染時間優化

@NewPan 貝聊科技 iOS 菜鳥工程師git

你們好,我是 NewPan,我以前寫過一篇 iOS一次立竿見影的啓動時間優化 - 簡書,從標題也能夠看得出來,那篇文章是關於啓動時間優化的,獲得了你們不錯的反響。此次咱們來說講如何優化首頁的渲染時間。github

01. 貝聊首頁頁面介紹

上圖是貝聊家長版首頁的設計圖,從上圖能夠看出,這個首頁仍是很複雜的,郭耀源在他的 深刻理解RunLoop | Garan no dou 裏提到:objective-c

UI 線程中一旦出現繁重的任務就會致使界面卡頓,這類任務一般分爲3類:排版,繪製,UI 對象操做。數組

  1. 排版一般包括計算視圖大小、計算文本高度、從新計算子式圖的排版等操做。
  2. 繪製通常有文本繪製 (例如 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 對象操做」。app

接下來祭出」Time Profiler「,找到耗時的代碼,若是有使用 Time Profiler 的問題,請參考我以前寫的文章 iOS用 TimeProfiler 揪出那些耗時函數 - 簡書async

上圖有一個 -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 中加載圖片資源,解碼數據,最後根據用戶的設備分辨率的不一樣渲染到屏幕上。咱們知道這個過程可能會很耗時,尤爲當圖片文件很大的時候,因此 SDWebImageAFNetworkingYYWebImage把圖片解碼這樣的操做都放到了子線程。

文檔上面沒有寫 -imageNamed:這個方法是不是線程安全的,經評論裏朋友提醒,再仔細看了一下文檔,In iOS 9 and later, this method is thread safe.,也就是說 iOS 9 之後這個方法是線程安全的。受這些第三方庫的啓發,我開始嘗試把 -imageNamed:這個方法放到子線程運行,並在各個機型上測試,發現沒有出現問題。

咱們都知道, -imageNamed:這個方法會有緩存,只要加載過一次,再次加載就會獲得緩存的優化。因而,我開始嘗試將本地資源圖片提早進行預加載。

那爲何這個預加載可行呢?由於這個時機很重要,從 -application:didFinishLaunchingWithOptions: 到首頁請求回來這個時間,恰好 CPU 和 IO 都是空閒的(或者你能夠經過其餘手段把這段時間的 CPU 和 IO 預留出來,具體請參考 iOS一次立竿見影的啓動時間優化 - 簡書),這段時間你就能夠把本地圖片資源都加載好,等請求回來的時候,首頁須要調用的 -imageNamed:方法都已預加載過一遍,再次加載都會享受高速緩存的優化,這樣就能達到優化的效果。

04. 具體實現

實現思路大體以下:

    1. 自行 hook -imageNamed:方法到自定義的實現,在這個實現中把圖片名字緩存到本地。
    1. 再次啓動時,在 -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%,效果很是明顯。

相關文章
相關標籤/搜索