iOS 中关于列表滚动流畅的一些探讨

首发地址 iOS 中关于列表滚动流畅的一些探讨

近些年,App 越来越推崇体验至上,随随便便乱写一通的话已经很难让用户买帐了,顺滑的列表便是其中很重要的一点。如果一个 App 的页面滚动起来总是卡顿卡顿的,轻则被当作反面教材来吐槽或者衬托“我们的 App balabala...”,重则直接卸载。正好最近在优化这一块儿,总结记录下。

如果说有什么好的博客文章推荐,ibireme 的 iOS 保持界面流畅的技巧 这篇堪称业界经典,墙裂推荐反复阅读。这篇文章中讲解了很多的优化点,我自己总结了下收益最大的两个优化点:

  • 避免重复多次计算 cell 行高
  • 文本异步渲染

大家可以看看上面这张图的对比分析,数据是 iPhone6 的机子用 instruments 抓的,左边的是用 Auto Layout 绘制界面的数据分析,正常如果想平滑滚动的话,fps 至少需要稳定在 55 左右,我们可以发现,在没有缓存行高和异步渲染的情况下 fps 是最低的,可以说是比较卡顿了,至少是能肉眼感觉出来,能满足平滑滚动要求的也只有在缓存行高且异步渲染的情况下;右边的是没用 Auto Layout 直接用 frame 来绘制界面的数据分析,可以发现即使没有异步渲染,也能勉强满足平滑滚动的要求,如果开启异步渲染的话,可以说是相当的丝滑了。

避免重复多次计算 cell 行高

TableView 行高计算可以说是个老生常谈的问题了,heightForRowAtIndexPath: 是个调用相当频繁的方法,在里面做过多的事情难免会造成卡顿。 在 iOS 8 中,我们可以通过设置下面两个属性来很轻松的实现高度自适应:

self.tableView.estimatedRowHeight = 88;
self.tableView.rowHeight = UITableViewAutomaticDimension;

虽然很方便,不过如果你的页面对性能有一定要求,建议不要这么做,具体可以看看 sunnyxx 的 优化UITableViewCell高度计算的那些事。文中针对 Auto Layout,提供了个 cell 行高的缓存库 UITableView-FDTemplateLayoutCell,可以很好的帮助我们避免 cell 行高多次计算的问题。

如果不使用 Auto Layout,我们可以在请求完拿到数据后提前计算好页面每个控件的 frame 和 cell 高度,并且缓存在内存中,用的时候直接在 heightForRowAtIndexPath: 取出计算好的值就行,大概流程如下:

  • 模拟请求数据回调:
- (void)viewDidLoad {
    [super viewDidLoad];
    
    [self buildTestDataThen:^(NSMutableArray <FDFeedEntity *> *entities) {
        self.data = @[].mutableCopy;
        @autoreleasepool {
            for (FDFeedEntity *entity in entities) {
                FrameModel *frameModel = [FrameModel new];
                frameModel.entity = entity;
                [self.data addObject:frameModel];
            }
        }
        [self.tvFeed reloadData];
    }];
}
  • 一个简单计算 frame 、cell 行高方式:
//FrameModel.h

@interface FrameModel : NSObject

@property (assign, nonatomic, readonly) CGRect titleFrame;
@property (assign, nonatomic, readonly) CGFloat cellHeight;
@property (strong, nonatomic) FDFeedEntity *entity;

@end
//FrameModel.m

@implementation FrameModel

- (void)setEntity:(FDFeedEntity *)entity {
    if (!entity) return;
    
    _entity = entity;
    
    CGFloat maxLayout = ([UIScreen mainScreen].bounds.size.width - 20.f);
    CGFloat bottom = 4.f;
    
    //title
    CGFloat titleX = 10.f;
    CGFloat titleY = 10.f;
    CGSize titleSize = [entity.title boundingRectWithSize:CGSizeMake(maxLayout, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading attributes:@{NSFontAttributeName : Font(16.f)} context:nil].size;
    _titleFrame = CGRectMake(titleX, titleY, titleSize.width, titleSize.height);
    
    //cell Height
    _cellHeight = (CGRectGetMaxY(_titleFrame) + bottom);
}

@end
  • 行高取值:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    FrameFeedCell *cell = [tableView dequeueReusableCellWithIdentifier:FrameFeedCellIdentifier forIndexPath:indexPath];
    FrameModel *frameModel = self.data[indexPath.row];
    cell.model = frameModel;
    return cell;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    FrameModel *frameModel = self.data[indexPath.row];
    return frameModel.cellHeight;
}
  • 控件赋值:
- (void)setModel:(FrameModel *)model {
    if (!model) return;
    
    _model = model;
    
    FDFeedEntity *entity = model.entity;
    
    self.titleLabel.frame = model.titleFrame;
    self.titleLabel.text = entity.title;
}

优缺点

缓存行高方式有现成的库简单方便,虽然 UITableView-FDTemplateLayoutCell 已经处理的很好了,但是 Auto Layout 对性能还是会有部分消耗;手动计算 frame 方式所有的位置都需要计算,比较麻烦,而且在数据量很大的情况下,大量的计算对数据展示时间会有部分影响,相应的回报就是性能会更好一些。

文本异步渲染

当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。

幸运的是,想支持文本异步渲染也有现成的库 YYText ,下面来讲讲如何搭配它最大程度满足我们如丝般顺滑的需求:

Frame 搭配异步渲染

基本思路和计算 frame 类似,只不过把系统的 boundingRectWithSize:sizeWithAttributes: 换成 YYText 中的方法:

  • 配置 frame model:
//FrameYYModel.h

@interface FrameYYModel : NSObject

@property (assign, nonatomic, readonly) CGRect titleFrame;
@property (strong, nonatomic, readonly) YYTextLayout *titleLayout;

@property (assign, nonatomic, readonly) CGFloat cellHeight;

@property (strong, nonatomic) FDFeedEntity *entity;

@end
//FrameYYModel.m

@implementation FrameYYModel

- (void)setEntity:(FDFeedEntity *)entity {
    if (!entity) return;
    
    _entity = entity;
    
    CGFloat maxLayout = ([UIScreen mainScreen].bounds.size.width - 20.f);
    CGFloat space = 10.f;
    CGFloat bottom = 4.f;
    
    //title
    NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:entity.title];
    title.yy_font = Font(16.f);
    title.yy_color = [UIColor blackColor];
    
    YYTextContainer *titleContainer = [YYTextContainer containerWithSize:CGSizeMake(maxLayout, CGFLOAT_MAX)];
    _titleLayout = [YYTextLayout layoutWithContainer:titleContainer text:title];
    
    CGFloat titleX = 10.f;
    CGFloat titleY = 10.f;
    CGSize titleSize = _titleLayout.textBoundingSize;
    _titleFrame = (CGRect){titleX,titleY,CGSizeMake(titleSize.width, titleSize.height)};
    
    //cell Height
    _cellHeight = (CGRectGetMaxY(_titleFrame) + bottom);
}

@end

对比上面 frame,可以发现多了个 YYTextLayout 属性,这个属性可以提前配置文本的特性,包括 fonttextColor 以及行数、行间距、内间距等等,好处就是可以把一些逻辑提前处理好,比如根据接口字段,动态配置字体颜色,字号等,如果用 Auto Layout,这部分逻辑则不可避免的需要写在 cellForRowAtIndexPath: 方法中。

  • UITableViewCell 处理 :
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (!self) return nil;
  
    YYLabel *title = [YYLabel new];
    title.displaysAsynchronously = YES; //开启异步渲染
    title.ignoreCommonProperties = YES; //忽略属性
    title.layer.borderColor = [UIColor brownColor].CGColor;
    title.layer.cornerRadius = 1.f;
    title.layer.borderWidth = 1.f;
    [self.contentView addSubview:_titleLabel = title];
  
    return self;
}
  • 赋值:
- (void)setModel:(FrameYYModel *)model {
    if (!model) return;
    _model = model;
    
    self.titleLabel.frame = model.titleFrame;
    self.titleLabel.textLayout = model.titleLayout; //直接取 YYTextLayout
}

Auto Layout 搭配异步渲染

YYText 非常友好,同样支持 xib,YYText 继承自 UIView,所要做的事情也很简单:

  • 在 xib 中配置约束
  • 开启异步属性

开启异步属性可以代码里设置,也可以直接在 xib 里设置,分别如下:

self.titleLabel.displaysAsynchronously = YES;
self.subTitleLabel.displaysAsynchronously = YES;
self.contentLabel.displaysAsynchronously = YES;
self.usernameLabel.displaysAsynchronously = YES;
self.timeLabel.displaysAsynchronously = YES;

另外需要注意的一点是,多行文本的情况下需要设置最大换行宽:

CGFloat maxLayout = [UIScreen mainScreen].bounds.size.width - 20.f;
self.titleLabel.preferredMaxLayoutWidth = maxLayout;
self.subTitleLabel.preferredMaxLayoutWidth = maxLayout;
self.contentLabel.preferredMaxLayoutWidth = maxLayout;

优缺点

YYText 的异步渲染能极大程度的提高列表流畅度,真正达到如丝般顺滑,但是在开启异步时,刷新列表会有闪烁情况,仔细想想觉得也正常,毕竟是异步的,渲染也需要时间,这里作者给出了一些 方案,大家可以看看。

其它

关于圆角

列表中如果存在很多系统设置的圆角页面导致卡顿:

label.layer.cornerRadius = 5.f;
label.clipsToBounds = YES;

其实据我观察,只要当前屏幕内只要设置圆角的控件个数不要太多(大概十几个算个临界点),就不会引起卡顿。

还有就是只要不设置 clipsToBounds 不管多少个,都不会卡顿,比如你需要圆角的控件是白色背景色的,然后它的父控件也是白色背景色的,而且没有点击后高亮的,就没必要 clipsToBounds 了。

如何定位卡顿原因

我们可以利用 instruments 中的 Time Profiler 来帮助我们定位问题位置,选中 Xcode,command + control + i 打开:

我们选中主线程,去掉系统的方法,然后操作一下列表,再截取一段调用信息,可以发现我们自己实现的方法并没有消耗多少时间,反而是系统的方法很费时,这也是卡顿的原因:

另外有的人 instruments 看不到方法调用栈(右边一堆黑色的方法信息),去 Xcode 设置下就行了:

总结

YYText 和 UITableView-FDTemplateLayoutCell 搭配可以很大程度的提高列表流畅度:

  • 如果时间比较紧迫,可以直接采取 Auto Layout + UITableView-FDTemplateLayoutCell + YYText 方式

  • 如果列表中文本不包含富文本,仅仅显示文字,又不想引入这两个库,可以使用系统方式提前计算 Frame

  • 如果想最大程度的流畅度,就需要提前计算 Frame + YYText,具体大家根据自己情况选择合适的方案就行

最后附上 Demo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345