一个iOS性能优化组件

简介

  • LNAsyncKit是一个异步渲染工具,它提供了便捷的方法帮助你将多个元素(Element)异步渲染到一张图片上,让这个过程代替UIKit的视图构建过程,进而优化App性能;Prender提供预加载策略帮助你在Feed流中弥补异步渲染带来的延时;除构建视图外,Transaction提供更优雅的方式让主线程与子线程交互,并能根据机器状态控制并发数和主线程回调时机。

  • LNAsyncKit借(ji)鉴(cheng)了很多YYKit和Texture,如果对它们不是很了解可以戳这个比较详细的文章,这篇文章的作者是YY大神:iOS保持页面流畅的技巧。流畅性优化的思想基本上都如这篇文章所述。

它可以提供哪些帮助

  • 还没有找到方案优化圆角、边框、渐变的优化方案,LNAsyncKit可以异步解决这些。
  • Feed流需要预加载策略,LNAsyncKit提供预加载区域计算方案(这个方案也用来预合成)。
  • 提供一种与UIKit十分接近的方式构建需要预合成的图层,让你的复杂图层构建都放在子线程进行,且不会创建那么多UIView。
  • Demo展示了使用:AFNetworking/SDWebImage/IGListKit/YYModel/MJRefresh + LNAsyncKit搭建feed流的方法。除去LNAsyncKit,前面5个构成的这套体系已经比较完整,Demo中也提供了没有使用LNAsyncKit构建的Feed。因此,需要快速学习如何搭建一套Feed流的初学者可以参考这套三方。

Github链接

你可以直接下载这个链接并运行上面的Demo参考代码实现自己的异步列表,也可以直接使用Cocoapods

pod 'LNAsyncKit'

流畅性优化

网络上已经有了很多流畅性优化的文章,再逐一复述这些优化点意义不大;这个文章是为了表达如何在Feed流中实现那些优化思想,并把这个过程简化;所以,不再赘述这些优化点为什么好、好多少,只谈怎么实现它们;如果对这些优化点有疑问可以参考上面链接的文章,以下这些观点成立:

  • 图层少的列表比图层多的列表好。
  • 没有圆角、边框、渐变等复杂图层的比有的好。
  • 图片尺寸和控件尺寸一样大的好。
  • 模型解析放在子线程比放在主线程好。
  • 布局计算放在子线程比放在主线程好。
  • 有预加载比没有预加载好(见仁见智,也有喜欢无预加载列表的)。
  • Layer比View好(无手势时)。
  • 不透明图层比透明图层好。

在业务复杂度不变的前提下让这些优化工作变简单、自由就是LNAsyncKit的目标。

优化一个Cell

我们将一个Cell视为Feed流的最小优化单元,以一个Bilibili推荐Feed流中一个常规的Cell为例:

yXOQO0.png

这样一个小Cell中包含了:封面图、人数图标、人数Label、主播昵称、直播间名、[直播]、直播内容分类、负反馈按钮8个元素;除了这些元素外,还包括封面图底部一个黑色渐变的图层、[直播]的圆角、边框和整个Cell的圆角(好像还有些阴影);这个小Cell已经包含比较多的小元素了,我们在Demo中尝试复原一下并查看视图层级大致如下:

69ZH7q.jpg

具体构建代码这里不赘述了,使用LNAsyncKit可以简化这个Cell为如下这个样子:

69ZqA0.jpg

(右下角反馈Bug需要响应事件,通常这种控件会保持独立)

以“直播”标签为例,视图构建方式区别如下:

UIKit:

    self.liveTagLabel.layer.cornerRadius = 3.f;
    self.liveTagLabel.layer.borderColor = [UIColor colorWithRed:239.f/255.f green:91.f/255.f blue:156.f/255.f alpha:1.f].CGColor;
    self.liveTagLabel.layer.borderWidth = 1.f;
    self.liveTagLabel.text = @"直播";
    self.liveTagLabel.font = [UIFont systemFontOfSize:12.f];
    self.liveTagLabel.textColor = [UIColor colorWithRed:239.f/255.f green:91.f/255.f blue:156.f/255.f alpha:1.f];
    self.liveTagLabel.textAlignment = NSTextAlignmentCenter;
    [self.cellContentView addSubview:self.liveTagLabel];

LNAsyncKit:

    LNAsyncTextElement *liveTagElement = [[LNAsyncTextElement alloc] init];
    liveTagElement.cornerRadius = 3.f;
    liveTagElement.borderColor = [UIColor colorWithRed:239.f/255.f green:91.f/255.f blue:156.f/255.f alpha:1.f];
    liveTagElement.borderWidth = 1.f;
    liveTagElement.text = @"直播";
    liveTagElement.font = [UIFont systemFontOfSize:12.f];
    liveTagElement.textColor = [UIColor colorWithRed:239.f/255.f green:91.f/255.f blue:156.f/255.f alpha:1.f];
    liveTagElement.textAligment = NSTextAlignmentCenter;
    [cellContentElement addSubElement:liveTagElement];

经过LNAsyncKit渲染出与需要展示视图面积一样大的一张完整图片,复杂渲染逻辑全部被子线程消化,反馈到主线程只表现为一张与目标控件大小一致的图片。

原理

与UIKit类似,LNAsyncKit也使用视图树构建最终视图。区别是:

A. Element继承自NSObject,这些Element可以在子线程创建、渲染、销毁。可以将Element理解为“一个需要绘制图层”的描述物,它并不是一个实体,它与UIView/CALayer的区别就好像:UIView是你要买的一件物品;Element则是下单信息,里面包含这件物品的各种描述信息,多大、什么颜色等。

B. 所有的Element都是临时的,这些信息在构建出结果后就会被销毁,你可以在进入子线程之后创建这些Element,在渲染出真正的图片后销毁这些Element,然后在主线程返回需要的图片,像这样:

    dispatch_queue_t queue = dispatch_queue_create(0, 0);
    dispatch_async(queue, ^{
        LNAsyncElement *contentElement = [weakSelf rebuildElements];
        [LNAsyncRenderer traversalElement:contentElement];
        UIImage *image = contentElement.renderResult;
        contentElement.renderResult = nil;
        dispatch_async(dispatch_get_main_queue(), ^{
            weakSelf.imageView.image = image;
        });
    });

rebuildElement的过程可以构建出很复杂的一棵树,但对主线程来说,这并不会造成问题!不在主线程出现Element也是LNAsyncKit推荐的使用方法(拿到resultImage后,把Element.resultImage置为空),当然,出现了一般也无所谓,NSObject的消耗相对于UIView来讲是很小的。

C.Element是逐层渲染的:实际上是后续遍历,把A的子Element先渲染出来,然后渲染A,再把A当做一个子节点渲染父节点,LNAsyncRendererTraversalStack就是遍历时使用的栈、LNAsyncRenderer.traversal函数是遍历方法。遍历中自带了环检测,不会渲染重复Element,像这样:

    LNAsyncRendererTraversalStack *stack = [[LNAsyncRendererTraversalStack alloc] init];
    [stack pushElements:@[element]];
    
    NSMutableSet  *repeatDetectMSet = [[NSMutableSet alloc] init];
    while (!stack.isEmpty) {
        LNAsyncElement *topElement = [stack top];
        if (topElement.getSubElements.count > 0 && (![repeatDetectMSet containsObject:topElement])) {
            [repeatDetectMSet addObject:topElement];
            [stack pushElements:topElement.getSubElements.reverseObjectEnumerator.allObjects];
        } else {
            [stack pop];
            [self renderElement:topElement];
            for (LNAsyncElement *subElement in topElement.getSubElements) {
                subElement.renderResult = nil;
            }
        }
    }

LNAsync自带了一些Element:

  • LNAsyncElement: 对应于UIKit的UIView,是其他Element的基类,包含了背景色、frame、和常用的边界、圆角等属性。
  • LNASyncImageElement: 对应于UIImageView,渲染一张图片、提供三种填充方式。
  • LNAsyncTextElement: 对应于UILabel,渲染一段文字,提供常规文字属性、支持折行。
  • LNAsyncLinerGradientElement: 对应于CAGradientLayer,渲染一段渐变色。

自定义Element:

除原生Element外,我们也推荐封装自己的Element,例如:一个AvatarElement,可以将用户头像、VIP标识、头像边框等修饰物渲染在一起,重写- (void)renderSelfWithContext:(CGContextRef)context,在这个方法中分别绘制这三个元素。

自定义Element的意义在于,所有自定义过的Element都是可复用、可组合的,这样方便保持整个App风格统一,也会适当减少开发成本。

Feed流

上面我们已经介绍过单个Cell、单张图片如何异步渲染以优化性能,但性能问题往往不是单张图片所能引发,LNAsyncKit更倾向于性能敏感的场景:Feed流;渲染Feed流相比渲染单个视图需要考虑的事情要多一些:Cell复用、渲染好的图片缓存、多张图片下载和结果合并等问题;除此之外,也考虑使用预加载、预渲染功能来优化用户体验。

使用到的三方库:

  • AFNetworking 网络
  • IGListKit Feed流框架,可以拆分各个模块业务
  • SDWebImage 图片下载
  • YYModel 字典转模型
  • MJRefresh 上拉/下拉刷新组件
  • 一位大佬写的免费API ,虽然我不认识这位大佬,但这些接口确实非常方便,在这里面朝空气感谢一下~

这些都是非常成熟的三方框架,直接拿来用会减少不少开发时间;这里主要是介绍如何将LNAsyncKit融进这个体系中去。Demo中已经提供了默认的Feed流和异步的Feed流代码,如果遇到了一些奇怪的Bug可以参考Demo中的实现,目前这两个Demo都可以正常运行。

  • 默认Demo:我们用此Demo展示一个常规Feed流实现过程,没有使用任何修饰手法或设计思想,可以理解为实现一个Feed流所需要做的最少工作。
  • 异步Demo:我们用此Demo将使用LNAsyncKit实现Feed流时与通常情况下的实现的进行对比,了解从普通Feed转异步Feed的修改点和差异之处。

默认Feed流实现:

  1. ViewDidLoad中使用AFNetworking请求一页数据,使用YYModel解析成Model类型数据,赋值给VC。
  2. VC调用CollectionView/IGList刷新列表,将Model赋值到Cell内部。
  3. Cell内部赋值懒加载的Label、ImageView调用sd_setImage下载图片展示。

异步Feed流的优化:

1.图片下载放在Model中进行

A.因为异步Feed不仅仅需要下载图片,也需要将多个原始图片进行预合成,所以这个过程在Model中进行可以保证不会因Cell复用问题导致同一时间合成多次,如果你在Cell中异步进行图层合成,那可能每次赋值Model都会合成一次,但在Model中合成后可以一直存放在Model中(Model只持有弱引用,存在全局的NSCache中)。

B.考虑预加载,我们认为图层的预加载和预合成是两种优先级的事情,通常距离屏幕焦点区域较远的区域只需要进行图片预下载,而距离较近的地方则需要预合成,不论是哪种方式,Cell通常只会在展示在屏幕上的时间点附近才能拿到,如果图片下载放在Cell中进行,是很难实现“预”的。

MVC中Model的职责之一是提供View展示需要的数据,所以在Model中下载图片并非错误或不恰当的做法。

2.模型解析和布局计算视为网络请求的一部分

通常,在使用AFNetworking进行网络请求时,我们通常在成功回调中进行模型解析和列表刷新,列表刷新时走CollectionView的dataSource协议计算布局。

异步列表不推荐这样做:模型解析的过程没有想象中的那样简单,通常进行模型解析时需要逐层遍历Dictionary,然后创建大量Model和子Model,虽然单个NSObject开销不大,但列表视图的模型总是堆积起来的,创建如此多的对象也是个不小的开销。

计算布局的耗时是公认的,所以一般表视图优化都推荐缓存行高,但即便缓存行高,第一次在主线程中的计算也是有一定耗时的。

我们推荐在AFNetworking回调中异步进行模型解析和布局计算,将这两个操作视为网络请求的一部分,这并不会对网络请求的整体响应时间有较大的影响,因为网络回调时间单位通常要比屏幕刷新时间单位高出一个数量级。况且,预加载技术完全可以弥补这段小延时。

在请求回调中赋值给Model的LayoutObj就是对这个过程的封装,像这样:

- (void)transferFeedData:(NSDictionary *)dic comletion:(DemoFeedNetworkCompletionBlock)completion
{
    LNAsyncTransaction *transaction = [[LNAsyncTransaction alloc] init];
    
    [transaction addOperationWithBlock:^id _Nullable{
        DemoFeedModel *feedModel = [DemoFeedModel yy_modelWithDictionary:dic];
        for (DemoFeedItemModel *item in feedModel.result) {
            DemoAsyncFeedDisplayLayoutObjInput *layoutInput = [[DemoAsyncFeedDisplayLayoutObjInput alloc] init];
            layoutInput.contextString = item.title;
            layoutInput.hwScale = 0.3f + ((random()%100)/100.f)*0.5f; 
            DemoAsyncFeedDisplayLayoutObj *layoutObj = [[DemoAsyncFeedDisplayLayoutObj alloc] initWithInput:layoutInput];
            item.layoutObj = layoutObj;
        }
        return feedModel;
    } priority:1 queue:_transferQueue completion:^(id  _Nullable value, BOOL canceled) {
        if (completion) {
            completion(YES, value, nil);
        }
    }];
    
    [transaction commit];
}
3.在Model中布局

这听起来有点诡异,在Model中下载图片也就算了,为什么视图操作也在Model中进行?

我们已经解释了Element的职责,它只是负责描述的类。使用element构建视图的过程就是:Model想好要怎么构建(Element),把想法交付LNAsyncRenderer,renderer交付我们image,Model把image反回给View显示出来。就像我们在开始的时候讲述的那样。

4.预加载

预加载主要内容包括两个方面:预加载下一页信息和预加载图片。这里提到的预加载主要是指预加载图片:

根据上面我们提到了图片加载都是在Model中进行的,所以,每个Model都需要一个必要的参数来标记自身所持有的资源已经到了那种紧急的程度了,如果距离当前用户焦点还很远,说明自己的资源目前不是很紧急,可以先静观其变;如果距离用户焦点有点近了,说明自己可能需要开始考虑先把图片下载下来;如果距离用户焦点已经相当近了,就要立刻开始准备把已有资源预合成了。类似这样:

- (void)setStatus:(DemoFeedItemModelStatus)status
{
    if (status > _status) {
        _status = status;
    }
    [self checkCurrentStatus];
}

- (void)checkCurrentStatus
{
    if (self.status >= DemoFeedItemModelStatusPreload) {
        //需要预加载图片
        [self preloadImage];
    }
    
    if (self.status >= DemoFeedItemModelStatusDisplay) {
         //需要渲染视图
         [self renderView];
    }
}

LNAsyncCollectionViewPrender提供了一套资源紧急程度标记策略,将距离当前屏幕中心较远的资源标记为不紧急,较近的资源标记为紧急,Model受紧急程度标记影响自主进行预加载或预渲染。

这套智能预加载机制来自于Texture,非常的实用,我将它修改为Objective-C实现,并做了简化处理。你甚至可以参照这个区间计算思路制作一个滚动列表曝光打点类,来计算那些更符合用户视距的曝光区间,而不是仅仅简单依赖cell/View的生命周期,说到这不得不提一嘴:我曾经见过一个埋点系统迭代了两年多依然没啥卵用。

5.图片一致性校验

异步Cell渲染图片回调设置图片需要进行渲染的模型与当前模型是否一致的校验,复用可能会导致一个Cell先后被设置两个Model,这样两个Model在异步渲染结束后都可能通知Cell刷新数据,所以需要一致性校验。同步不存在这个问题,后来的内容总是会覆盖掉先来的图片。像这样:

    NSObject *model = self.model;
    __weak DemoAsyncFeedCell *weakSelf = self;
    [self.model demoAsyncFeedItemLoadRenderImage:^(BOOL isCanceled, UIImage * _Nullable resultImage) {
        if (!isCanceled && resultImage && model == weakSelf.model) {
            weakSelf.contentView.layer.contents = (__bridge id)resultImage.CGImage;
        }
    }];
6.渲染缓存

与SDWebImage下载的原生Image不同,渲染后的图片存储在额外的一个渲染缓存中,Model弱引用持有,缓存内部使用LRU管理;不能使用Model强引用,因为有些Feed流是常驻的,我们不希望内存浪费在不是主要消费场景的常驻页面中。LNAsyncCache是统一的存放的地方,你可以在渲染成功后把图片存在这里,使用弱指针指向它,如果被删除了,就重新渲染、存储。

7.减少渲染次数

SD下载图片时附带AvoidDecode参数,因为合成过程会将Image渲染到一块内存中,这个过程本身就包含了解码,且也是在子线程中进行;使用这个参数可以减少图片刚下载好时的那次渲染。像这样:

[[SDWebImageManager sharedManager] loadImageWithURL:[NSURL URLWithString:weakSelf.image]
                                            options:SDWebImageAvoidDecodeImage 
                                           progress:nil 
                                          completed:nil];

总结

LNAsyncKit优化的内容就如上所述:

  • 从主线程的角度来看:除了刷新CollectionView和计算预加载区域外基本上没有耗时工作,布局计算和模型解析转移到了子线程统一进行,Element创建销毁操作主线程基本上没有感知。
  • 从CPU的角度来看:圆角、边框、渐变等工作都在图层合成的时候异步消化了,返回的图片大小和Layer控件大小也是一致的,图层的复杂层级也被子线程异步消化。
  • 从子线程角度看:子线程有很多。

写异步Feed流比普通Feed流难度要稍微大一些,平均开发的时间成本也会有所上升;从效率上来讲,每个需求的开发效率确实降低了,但这将会省去在未来单独成立一个性能优化小组进行优化的效率要高得多。平台类型的开发人员往往没有业务开发对业务更熟悉,因此需要频繁交流确认优化点、改动范围、影响等等。而且,有时遇到优化点时业务受限,可能不敢大刀阔斧地纠正,导致优化后的结果和优化前对比并不明显。LNAsyncKit让业务线从一开始做需求时就考虑到优化内容,从而省去了专项优化的时间。当然,如果App整体不考虑性能问题,选择正常的开发方式就好。

杂谈

iPhone手机硬件越来越强,常规业务不进行优化一般也能达到流畅性标准,端内的卡顿只要不是特别严重产品经理通常也都能接受;我在需求中使用了类似的方式进行性能优化,开发时间确实很紧。当然,如果你的公司只考虑需求产出,他们通常不会给你这些时间,你可以在自己的编码追求和实际情况之间决定是否要额外做这些事。

LNAsyncKit可以直接使用,也可以将它当做你更深层次了解性能优化、Texture的垫脚石;总之,它能起到任何帮助,我都将十分荣幸。

你可能感兴趣的:(一个iOS性能优化组件)