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

NewPan發表於2019-03-01

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

大家好,我是 NewPan,我之前寫過一篇 iOS一次立竿見影的啟動時間優化 – 簡書,從標題也可以看得出來,那篇文章是關於啟動時間優化的,得到了大家不錯的反響。這次我們來講講如何優化首頁的渲染時間。

01. 貝聊首頁頁面介紹

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

上圖是貝聊家長版首頁的設計圖,從上圖可以看出,這個首頁還是很複雜的,郭耀源在他的 深入理解RunLoop | Garan no dou 裡提到:

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 物件操作”。

接下來祭出”Time Profiler“,找到耗時的程式碼,如果有使用 Time Profiler 的問題,請參考我之前寫的文章 iOS用 TimeProfiler 揪出那些耗時函式 – 簡書

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

上圖有一個 -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 這個方法了。

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

至此,我們驗證了,影響首頁渲染耗時的最大凶手是 -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%,效果非常明顯。

相關文章