iOS开发——实战UITableview深度优化

演示项目

演示项目下载地址:YYTableViewDemo
项目里的低性能版是常规写法实现的tableview,高性能版是做了相关优化后的tableview。

tableView滑动为什么会卡?

我们可以想象这样一个场景:
有一个老师、学生A、学生B、一个画板、一个橱窗。
每一秒钟,老师都要告诉学生A一个题目让他们作画,学生A负责研究这个题目表达的含义,然后告诉学生B应该画什么,学生B收到消息后,在画板上画出对应的画,在这一秒钟结束之时,把画贴到橱窗,供外面的人观看。然后继续下一秒的审题、画画的步骤。
正常情况下,学生A、B都能合同愉快,在规定的时间画好,但有时候,学生A审题太久,或者这一秒的量太多,学生B画得不够快,那么这一秒,甚至下几秒,橱窗里的画会保持上一次的画,直到他们画好下一张。
这里,
学生A就是CPU,负责视图相关的计算工作并告知GPU应该怎么绘图;
学生B就是GPU,进行图形的绘制、渲染等工作;
“每一秒钟”就是屏幕刷新周期,通常是1/60秒,即每秒屏幕刷新60次;
橱窗就是手机屏幕,用来显示GPU绘制好的内容;
“画得不够快,导致橱窗的画在接下来的几秒里一直是上一次的画”的情况,就是掉帧,就是卡的原因。
可以看出,不论是CPU,还是GPU的压力过大,都会在一个周期内完不成工作,都会导致掉帧的情况发生。
而在tableview滑动时,会频繁出现对象创建、属性修改、布局计算、文本绘制、图形生成等消耗资源的操作发生。
所以优化,就是想办法在这一秒的时间里,减轻它们的负荷,保证每一次都能“把画儿画完”。

优化的思路

首先我们来看看下面这个tableview的流程:

1、获取数据;
2、把数据转化成model、存进数组;
3、tableview调用reloadData刷新数据;
4、在代理方法cellForRowAtIndexPath里,创建自定义的cell,把model赋值给cell;
5、cell在对应的model的set方法里,根据拿到的model,设置图片的image,设置label的text等(控件都以懒加载形式初始化);
5、在代理方法heightForRowAtIndexPath里,根据model,算出当前行应该显示多少的高度;
6、在cell的layoutSubviews方法里,布局子控件。
1、避免主线程阻塞

1/2步里的获取数据、数据处理等耗时操作,应该放入后台线程异步处理,处理好后再通知主线程刷新界面。
常用的网络请求框架都是在后台线程完成的数据请求,但有时我们会忘了,在这些请求的回调里操作数据时,是在主线程里进行的操作,需要我们手动管理线程。
例如:AFNetworking使用时

[[AFHTTPSessionManager manager] POST:@"" parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        //移到异步线程做
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            //1、字典转模型
            //2、计算每个model的数据,布局参数等。
            dispatch_async(dispatch_get_main_queue(), ^{
                //3、回到主线程,刷新tableview等
            });
        });
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        
    }];

总之是能在异步操作的,都异步操作。
通常来说,UIKit和CoreAnimation相关操作必须在主线程中进行,其它的可以在后台线程异步执行。比方说图像的异步绘制等,具体的后面介绍。

2、避免频繁的对象创建

对象的创建会发送内存分配、属性调整等。
所以,首先,尽量用轻量的对象代替重量的对象。比如CALayer代替UIView。
接着,多利用缓存思想,对象创建后缓存起来,需要的时候再拿出来用。合理利用内存开销,减少CPU开销。
关于这一点,系统已经提供了很好的api来做cell的缓存

[tableView dequeueReusableCellWithIdentifier:ID];

但我们有时会忘了这样一种情况:


拼接的label.png

如图,这个label显示的内容由model的两个参数(时间、公里数)拼接而成,我们习惯在cell里model的set方法中这样赋值

    //时间
    NSDateFormatter* formatter = [[NSDateFormatter alloc] init];
    formatter.dateStyle = NSDateFormatterMediumStyle;
    formatter.timeStyle = NSDateFormatterShortStyle;
    [formatter setDateFormat:@"yyyy年MM月"];
    NSDate* date = [NSDate dateWithTimeIntervalSince1970:[model.licenseTime intValue]];
    NSString* licenseTimeString = [formatter stringFromDate:date];
    //公里数
    NSString *travelMileageString = (model.travelMileage != nil && ![model.travelMileage isEqualToString:@""]) ? [NSString stringWithFormat:@"%@万公里",model.travelMileage] : @"里程暂无";
    //赋值给label.text
    self.carDescribeLabel.text = [NSString stringWithFormat:@"%@ / %@",licenseTimeString,travelMileageString];

在tableview滚动的过程中,这些对象就会被来回的创建,并且这个计算过程是在主线程里被执行的。
我们可以把这些操作,移到第2步(字典转模型)来做,计算好这个label需要显示的内容,作为属性存进model中,需要的时候直接用。
这样,既可以避免主线程的阻塞,又可以避免对象的频繁创建。

而下面这个例子也是缓存思想的体现:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return 15.0 + 80.0 + 15.0;
}

修改为
static float ROW_HEIGHT = 15.0 + 80.0 + 15.0;
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return ROW_HEIGHT;
}

当然这不是减少对象的创建,而是减少了计算的次数,减少了频繁调用方法里的逻辑,从而达到更快的速度。

3、减少对象的属性赋值操作

尤其是UIView的frame/bounds等属性的赋值操作,会产生比较大的CPU消耗。

对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer:CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。
——摘自iOS 保持界面流畅的技巧

所以在cell的layoutSubviews里布局所有子控件对性能是有影响的,对于frame固定的UIView,在cell创建时(或者懒加载方法里)布局一次即可。

另外,有时候一个tableview的cell的样式存在频繁的变化但又有一定的规律(比方说有一个label的高度总是在两行、一行来回变化),这就免不了会频繁的设置它的高度。如果追求很高的性能,可以筛分成两个cell,从而避免频繁的更改frame。

4、异步绘制

文本渲染、图像绘制都是比较消耗性能的操作,而UILabel等控件都是在主线程进行的文本绘制。这会对性能产生比较大的影响。
UIKit和CoreAnimation相关操作必须在主线程中进行,其它的可以在后台线程异步执行
怎么来简单理解这句话呢?
比方说:为一个UIImageView设置image,

imageView.image = image;

以上代码必须在主线程进行,但这个image的绘制过程,可以在异步线程做

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    CGContextRef ctx = CGBitmapContextCreate(...);
    // 吧啦吧啦绘图
    CGImageRef imgRef = CGBitmapContextCreateImage(ctx);//位图
    UIImage *image = [UIImage imageWithCGImage:imgRef];//转成UIImage
    dispatch_async(dispatch_get_main_queue(), ^{
        //回到主线程
        imageView.image = image;//设置imageView的image
    });
});

所以异步绘制的思想,就是尽量把需要显示的内容,在异步线程绘制,绘制好后再通知主线程显示。
在这个项目里VVeboTableViewDemo,作者把cell里很多需要显示的内容都异步绘制成图片再显示,并实现了一个异步绘制的Label,是异步绘制思想一个很好的例子。
的确,优化性能会牺牲一些开发速度,那么如何相对高效的利用异步绘制技术呢?
推荐使用YYKit的相关组件,例如YYLabel。
YYLabel是一个可以异步绘制的用来显示文字的控件,它可以像UILabel一模一样的使用,也可以通过赋值它的textLayout(一个YYTextLayout对象)来显示内容,第二种方式拥有更高的性能。
举个例子,一般来说我们是这样来显示一段文字的

/** cell的.m文件 */

//懒加载一个UILabel
- (UILabel *)carVersionLabel
{
    if (!_carVersionLabel)
    {
        _carVersionLabel = [[UILabel alloc] init];
        [self.contentView addSubview:_carVersionLabel];
        _carVersionLabel.backgroundColor = self.contentView.backgroundColor;
        _carVersionLabel.font = [UIFont fontWithName:MAIN_CELL_TITLE_FONT_NAME size:15];
        _carVersionLabel.textColor = BLACK_TEXT_COLOR;
        _carVersionLabel.numberOfLines = 0;
        _carVersionLabel.textAlignment = NSTextAlignmentLeft;
    }
    return _carVersionLabel;
}

//model的set方法
- (void)setModel:(YYLowPerCarModel *)model
{
    _model = model;
    self.carVersionLabel.text = model.carName;
}

用YYLabel来重构的话,

/** model的.h文件 */

//声明YYTextLayout对象
@property (nonatomic,strong) YYTextLayout *carVersionLabelLayout;//车型Label的layout
/** model的.m文件 */

//这个方法在数据请求的方法里调用,字典转model完成后,调用这个方法来计算一些布局用的参数
- (void)setupViewModel
{
    //车型布局参数
    NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:self.carName];
    text.color = BLACK_TEXT_COLOR;
    text.font = CAR_VERSION_LABEL_FONT;
    text.lineSpacing = -4;
    YYTextContainer *container = [YYTextContainer containerWithSize:CGSizeMake(CAR_VERSION_LABEL_WIDTH, MAXFLOAT)];
    self.carVersionLabelLayout = [YYTextLayout layoutWithContainer:container text:text];
}
/** cell的.m文件 */

//懒加载Label
- (YYLabel *)carVersionLabel
{
    if (!_carVersionLabel)
    {
        _carVersionLabel = [[YYLabel alloc] init];
        [self.contentView addSubview:_carVersionLabel];
        _carVersionLabel.displaysAsynchronously = YES;//是否异步绘制
        _carVersionLabel.ignoreCommonProperties = YES;//通过设置textLayout来布局时,设置这个参数为YES可以获得更高的性能
        _carVersionLabel.fadeOnHighlight = NO;//高亮渐变效果
        _carVersionLabel.fadeOnAsynchronouslyDisplay = NO;//异步绘制渐变效果
    }
    return _carVersionLabel;
}

//model的set方法
- (void)setModel:(YYLowPerCarModel *)model
{
    _model = model;
    self.carVersionLabel.textLayout = model.carVersionLabelLayout;//设置layout,异步绘制
}

如果cell里的label都用YYLabel来实现的话,性能会得到显著的提升。
关于YYLabel或者YYkit相关组件的使用,还需要多实践踩坑、看博客、看YYKit的demo,感谢巨人的肩膀。

5、简化视图结构

GPU在绘制图像前,会把重叠的视图进行混合,视图结构越复杂,这个操作就越耗时,如果存在透明视图,混合过程会更加复杂。
所以,我们可以

  • 尽量避免复杂的图层结构
  • 少使用透明的视图
  • 不透明的视图,设置opaque = YES
  • 或者采用VVeboTableViewDemo的方法,把视图异步绘成一张图
6、减少离屏渲染
  • 什么是离屏渲染?
    回到文章开头的那个例子,同学B在画板上画画,这个画板,叫做屏幕缓冲区,一般的情况,GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行,这个叫做当前屏幕渲染(On-Screen Rendering),而由于某些特定条件,GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作,就是离屏渲染(Off-Screen Rendering)

  • 离屏渲染为什么耗性能?

  • 创建新缓冲区
    要想进行离屏渲染,首先要创建一个新的缓冲区。
  • 上下文切换
    离屏渲染的整个过程,需要多次切换上下文环境:先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上有需要将上下文环境从离屏切换到当前屏幕。而上下文环境的切换是要付出很大代价的。
    ——摘自iOS 事件处理机制与图像渲染过程
  • 离屏渲染触发条件
    --shouldRasterize(光栅化)
    --masks(遮罩)
    --shadows(阴影)
    --edge antialiasing(抗锯齿)
    --group opacity(不透明)
    --复杂形状设置圆角等
    --渐变

  • 怎么查看哪些控件发生了离屏渲染?
    利用Xcode自带的Instruments工具来观察。


    iOS开发——实战UITableview深度优化_第1张图片
    打开Instruments.jpeg

    iOS开发——实战UITableview深度优化_第2张图片
    选择app并开始监测.jpeg

    iOS开发——实战UITableview深度优化_第3张图片
    选择图中的选项.jpeg

    然后观察手机屏幕,黄色标识的地方,就发生了离屏渲染。

  • 老生常谈之圆角问题
    圆角是开发中经常使用到的美化方式,但一般的设置cornerRadius时会配合masksToBounds属性,这就会造成离屏渲染。
    关于这种问题的处理,大致有两个思路
    1、异步绘制一张圆角的图片来显示;
    2、用一个圆角而中空的图来盖住。
    演示项目里我选择了使用YYKit里的组件来切割图片的圆角。

其它小tips

  • 1、tableview需要刷新数据时,使用
[tableview beginUpdates];
[tableview insertRowsAtIndexPaths:indexArray withRowAnimation:UITableViewRowAnimationNone];
[tableview endUpdates];

而非

[tableview reloadData];

主要原因在于:
1、刷新更少的行,减少cpu压力;
2、使用YYLabel等异步绘制label时,使用reloadData会把之前的row也重绘一次,会造成“Label闪了一下的感觉”。

  • 2、NSDateFormatter这个对象的相关操作很费时,需要避免频繁的创建和计算

  • 3、对于固定行高的cell

 tableview.rowHeight = 50.0;

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return 50.0;
}

效率更高。

  • 4、Autolayout使用在越复杂的界面,CPU越吃力

参考文章

iOS 保持界面流畅的技巧
iOS 事件处理机制与图像渲染过程
离屏渲染相关的介绍
iOS 高效添加圆角效果实战讲解

你可能感兴趣的:(iOS开发——实战UITableview深度优化)