美文网首页精华iOS开发攻城狮的集散地iOS && Android
[iOS]一次立竿见影的首页渲染时间优化

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

作者: e2f2d779c022 | 来源:发表于2018-08-14 19:05 被阅读1052次

    @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%,效果非常明显。

    06. 最后插播一个招聘广告

    贝聊科技招聘 iOS 开发工程师,坐标广州。如果你想和我一起共事,机会不等人,赶紧动手吧!简历发到 13246884282@163.com

    相关文章

      网友评论

      • 李某lkb:其实app的性能就这样的就到头了。
      • e2f2d779c022:怎么就没人看出来这是一个招聘贴???
      • 龙子陵:您好,有个小问题,您这样把图片缓存到本地,对内存的消耗比较大吧,请问您针对加载图片过大过多,内存增长的情况是如何优化的呢?
        e2f2d779c022:@龙子陵 good
        龙子陵:可以,是我理解错了,你只是把首页首次需要展示的本地图片缓存了,反正这些图片肯定需要加载,这些内存逃不掉
        e2f2d779c022:@龙子陵 你先把 imageNamed: 文档看一遍吧,基础都不看明白,有什么好问的。
      • 费宇超:我们的项目还需要支持iOS8,imageName放到子线程的做法可能不太合适,打算将首页的imageName替换成contentOffile,这个做法会有坑吗?
        e2f2d779c022:你们 iOS 8 很多用户吗?没人用你花那精力折腾啥?
      • Maj_sunshine:大佬 我们项目也是优化到最长耗时在imageName 谢谢大神指点
        e2f2d779c022:@学污直径 贝聊正在找人,要不要考虑下。
        Maj_sunshine:@NewPan 现在在tableView滑动的时候,大量耗时在移除九宫格视图所有子视图,重新创建图片九宫格视图。请问这个要怎么优化。项目中没用AsyncDisplayKit。AsyncDisplayKit里面对九宫格的话是怎么操作的,和你们app的九宫格一样
        e2f2d779c022:@学污直径 用起来,嗨起来
      • PGOne爱吃饺子:大佬,你写的这个类如何调用的啊。救急啊
        PGOne爱吃饺子:大佬 就不能正视一下菜鸟么
        e2f2d779c022:PG One 不爱回答简单问题
      • 秋天的田野:膜拜,善于总结和分享才能更快的提高自己

      本文标题:[iOS]一次立竿见影的首页渲染时间优化

      本文链接:https://www.haomeiwen.com/subject/kqxbbftx.html