iOS 之 UITableView 性能优化(二)
于 UITableView 的优化技巧,相信这块是难点也是痛点,所以决定详细的整理下我对优化 UITableView 的理解。
UITableView 作为 iOS 开发中最重要的控件之一,其中的实现原理很是考究。Apple 在这块的优化水平直接决定了 iOS 的体验能甩安卓几条街,首先来谈谈我对 UITableView 的认识。
UITableView 最核心的思想就是 UITableViewCell 的重用机制。简单的理解就是:UITableView 只会创建一屏幕(或一屏幕多一点)的 UITableViewCell,其他都是从中取出来重用的。每当 Cell 滑出屏幕时,就会放入到一个集合(或数组)中(这里就相当于一个重用池),当要显示某一位置的 Cell 时,会先去集合(或数组)中取,如果有,就直接拿来显示;如果没有,才会创建。这样做的好处可想而知,极大的减少了内存的开销。
这种机制下系统默认有一个可变数组 NSMutableArray *visiableCells
用来保存当前显示的 Cell,一个可变字典 NSMutableDictnery *reusableTableCells
用来保存可重复利用的 Cell,之所以用字典是因为可重用的 Cell 有不止一种样式,我们需要根据它的 reuseIdentifier
也就是所谓的重用标示符来查找是否有可重用的该样式的 Cell 。
看到这里,想必大家也都能隐约察觉到,UITableView 优化的首要任务是要优化上面两个回调方法。事实也确实如此,下面按照我探讨进阶的过程,来研究如何优化:
动态高度计算
请看以下一段代码:
1 | - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { |
看到这段代码,对于新手来说,觉得还是蛮巧妙的,但巧归巧,但是如果当 Cell 非常复杂的时候,直接卡出翔了。依据上面 UITableView 原理的分析,我们先来分析它为什么卡?
这样写,在 Cell 赋值内容的时候,会根据内容设置布局,当然也就可以知道 Cell 的高度,想想如果1000行,那就会调用1000次 tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
方法,而我们对 Cell 的处理操作,都是在这个方法里的,什么赋值、布局等等。开销自然很大,所以这种方案直接 Pass。继续改进代码。
改进代码后:
1 | - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { |
这段代码的思路是把赋值和计算布局分离。这样让 tableView:cellForRowAtIndexPath:
方法只负责赋值,tableView:heightForRowAtIndexPath:
方法只负责计算高度。
注意:两个方法尽可能的各司其职,不要重叠代码!两者都需要尽可能的简单易算。Run 一下,会发现 UITableView 滚动果然流畅了很多。
基于上面的实现思路,我们可以在获得数据后,直接先根据数据源计算出对应的布局,并缓存到数据源中,这样在 tableView:heightForRowAtIndexPath:
方法中就直接返回高度,而不需要每次都计算了。
1 | - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { |
其实上面的改进方法并不是最佳方案,但基本能满足简单的界面,如果遇到类似于微信朋友圈,新浪微博这张极其复杂的界面,很显然这种方案还是扛不住的。其一,会进行大量的高度计算,引起性能损耗导致卡顿;其二,代码量成倍增长,对于这种现象,我是一致划为0容忍的。
我们知道,UITableView 是个 UIScrollView,就像平时使用 UIScrollView 一样,加载时指定 contentSize 后它才能根据自己的 bounds、contentInset、contentOffset 等属性共同决定是否可以滑动以及滚动条的长度。而 UITableView 在一开始并不知道自己会被填充多少内容,于是询问 data source 个数和创建 Cell,同时询问 delegate 这些 Cell 应该显示的高度,这就造成它在加载的时候浪费了多余的计算在屏幕外边的 Cell 上。
在iOS 8中,苹果引入了 UITableView 的一项新功能 Self Sizing Cells
,对于不少开发者来说这是新 SDK 中一项非常有用的新功能。在 iOS8 之前,如果想在表视图中展示可变高度的动态内容时,你需要手动计算行高,而 Self Sizing Cells
为展示动态内容提供了一个解决方案。
1 | tableView.estimatedRowHeight = 44.0; |
只需要在 UITableView 初始化的时候加入这两行代码,仅有两行代码,你通知表视图计算单元格的尺寸以匹配内容和和动态进行渲染。嗯,听起来貌似挺靠谱的,但是,问题又出现了,当遇到负责的界面的时候,并且快速进行滑动,还是会有轻微的卡顿,这是什么原因导致的呢?上面我们已经提过,heightForRowAtIndexPath
这个方法是 UITableView 中调用频率最高的方法之一,我们要不停的通过询问这个方法来得到 Cell 的高度。不停的滑动,也就意味着要不停的计算,每次都要计算每次都要计算,哪怕是 1000 个相同的 Cell,也会进行 1000 次计算,这就导致了侵害滑动的流畅性。
对此,百度知道团队的开源项目可以很方便的帮你实现这一点:FDTemplateLayoutCell。用 TableView 的预估高度的功能,并把每个 Cell 高度缓存下来,相同的 Cell 便不会在进行计算。
用 CALayer 代替 UIView 来绘制
UIView 很重,创建和销毁都比 CALayer 要耗费资源, 不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择。
事实上 UIView 是 CALayer 的一层轻量级的封装,当你使用 UIView 的时候便是在与 CALayer 打交道,UIView 提供了 CAlayer 所没有的处理用户交互的能力。
CALayer 类在概念上和 UIView 类似,同样也是被层级关系树管理的矩形块,同样也可以包含一些内容 (像图片,文本或者背景色),管理子图层的位置。它们有一些方法和属性用来做动画和变换。和 UIView 最大的不同是 CALayer 不处理用户的交互。
我见过无数份新手的代码,生成 height 为 1 的 UIView 来绘制分割线。对于没有触控响应事件需求的视图,尽量用 CALayer 代替 UIView 来节省损耗。
快速滑动下按需加载
当界面滚动很快时,只加载目标范围内的 Cell,这样按需加载,就能极大的提高流畅度。如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定 3 行加载。
UITableView 继承于 UIScrollView,所以我们直接用 UIScrollView 的代理方法。
targetContentOffset 是 TableView 减速到停止的地方, velocity 表示速度。
1 | - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{ |
Cell 的自定义绘制
我们在 Cell 上添加系统控件的时候,实质上系统都需要调用底层的接口进行绘制,当我们大量添加控件时,对资源的开销也会很大,所以我们可以索性直接绘制,提高效率。
1 | - (void)drawRect:(CGRect)rect |
上述代码只是一个 Cell 的 drawRect 重写方法,在界面中展示 Hello Word,写得比较死,但大体的思路都是一样的,各个信息都是根据之前算好的布局进行绘制的。这里是需要异步绘制,但如果在重写 drawRect 方法就不需要用 GCD 异步线程了,因为 drawRect 本来就是异步绘制的。
图文混排使用 CoreText
对于图文混排的绘制,可以使用 CoreText 可以将文本绘制在一个 CGContextRef 上,最后再通过 UIGraphicsGetImageFromCurrentImageContext()
生成图片,再将图片赋值给 cell.contentView.layer
,从而达到减少 Cell 层级的目的。
我们在屏幕上能看到的所有文本内容控件,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是用最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算 (调整 UILabel 大小时算一遍、UILabel绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。
在使用 CoreText 编写代码之前, 需要先了解一些基础知识,下图是 CoreText 的基础框架。
- CTFrame 可以想象成画布, 画布的大小范围由 CGPath 决定;
- CTFrame 由很多 CTLine 组成,CTLine 表示为一行;
- CTLine 由多个 CTRun 组成,CTRun相当于一行中的多个块,但是 CTRun 不需要你自己创建,由NSAttributedString的属性决定,系统自动生成。每个 CTRun 对应不同属性;
- CTFramesetter 是一个工厂,创建 CTFrame,一个界面上可以有多个 CTFrame。
我们先来看看 CoreText 的文本布局,CoreText 的布局同 UIKit 布局不太相同,CoreText 中布局大体思路是确定文本绘制区域,接着得到文本实际大小 (frame)
其具体步骤如下:
- 首先要确定布局时绘制的区域,其对应的类为 CG**(Mutable)**PathRef
- 设置文本内容,其对应的类为 NS**(Mutable)**AttributedString
- 根据文本内容配置其 CTFramesetterRef
- 利用 CTFramesetterRef 得到 CTFrame