[iOS]一次立竿见影的首页渲染时间优化

1,468 阅读9分钟

@NewPan 贝聊科技 iOS 菜鸟工程师

大家好,我是 NewPan,我之前写过一篇 iOS一次立竿见影的启动时间优化 - 简书,从标题也可以看得出来,那篇文章是关于启动时间优化的,得到了大家不错的反响。这次我们来讲讲如何优化首页的渲染时间。

01. 贝聊首页页面介绍

上图是贝聊家长版首页的设计图,从上图可以看出,这个首页还是很复杂的,郭耀源在他的 深入理解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 揪出那些耗时函数 - 简书

上图有一个 -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%,效果非常明显。