UITableView 的完美平滑滚动
我曾在最好的手机开发平台——iOS 上有几年经验。在此期间,我见过许多 iOS 应用和 iOS 工程师。
我们的世界从来就不缺优秀的开发者,但有时候我发现有些人却不知道如何使用这世界最流行的移动设备如何去做一些真正流畅的应用。
现在我将阐述我所了解的优化常识,来让 UITableViews 更快和更流畅。
随文章开始,其内容的难度和深度将会逐渐增加,所以我会从一些大家都比较熟悉的东西讲起。而更深层次的 iOS 的绘图系统和 UIKit 方面的内容放在文章最后。
内置工具
我确信大多数人看到这里时都知道这它,但有些人即使用过这些内置工具,都没能领会它们的正确用法。
第一点是即使要显示更多内容,也要重用 cell/header/footer 的单个实例(single instance)。这是优化 UIScrollView(UITableView 的父类)最显著的方式,苹果工程师已在回收机制上做了处理。正确的做法是,你应该持有唯一的 cell/header/footer 类,且只初始化了一次,并返回给 UITableView 来重用该对象。
我觉得在这里没必要做详细的回顾,因为重用 cell 的工作流程在文档有详细描述。
但有一点需要重视:需要在 UITableView 的数据源中实现 tableView:cellForRowAtIndexPath: 方法,在方法中调用每个 cell 并应尽可能让方法快速运行。所以你必须尽可能迅速地返回重用的 cell 对象。
在该方法中,不要进行数据绑定(perform data binding),因为在当前在界面上还没有 cell。为此,你可以在 UITableView 的委托(代理)中使用 tableView:willDisplayCell:forRowAtIndexPath: 方法,该方法会在 UITableView 的 bounds 里显示 cell 之前被准确调用。
第二点不是很难理解,但有一件事需要说明。
该工具只用于设置 UITableView 的固定行高是没意义的,但如果你的上下文出于某种原因需要动态设置 cell 的高度,那么你可能很容易得到起伏不定的滚动。
我们知道,UITableView 只是 UIScrollView 的子类,可以让用户在比可视范围更大的区域内进行交互。任何 UIScrollView 对象通过诸如 contentSize、contentOffset 等东西来显示符合要求的矩形给用户。
但 UITableView 哪里出问题了?正如前面讲到的,UITableView 不会同时保留所有的 cell。相反,它仅仅显示必要的 cell 给用户。
那么,UITableView 是如何得知它的 contentSize 的呢?它仅仅是计算了所有 cell 的高度总和。
UITableView 的 tableView: heightForRowAtIndexPath:委托方法会为每个 cell(即使它不显示)调用一次,所以你应当非常迅速返回当前 cell 的高度。
大多数人会犯巨大的错误,通过通过边界(bound)数据布局初始化过的 cell,并取得它们高度。如果你想提高滚动性能,那么就不该用这种方式去计算 cell 的高度,因为这样会造成一切的东西都极其的慢,60 FPS 是 iOS 的显示标准,这样做就会下降到 15~20,并且你的滚动操作会变得迟缓、卡顿。
如果我们没有 cell 对象,如何计算进一步的 cell 的高度呢?下面是 cell 代码的一个例子,它利用类方法,基于传递的宽度和显示数据(cell 的连接器)来返回高度:
它是使用这种方式返回值给 UITableViews 的:
在实现所有这些代码的过程中,你获得了多少乐趣?你们大多数会说一丢丢都没有啊。但我没说这是易事啊。当然,我们可以建立自己的类来进行手动布局和高度计算,但有时候,我们并没有足够的时间。你可以从在 Telegram 网站的 iOS 应用代码中找案例去实现它。
从 iOS8 开始,我们可以使用自动高度计算,而无需实现上面提到的 UITableViews 的委托方法。要做到这一点,你可以使用 AutoLayout 工具,并把 行高 设置为 UITableViewAutomaticDimension。更多的详细信息可以在 StackOverflow 网站上找到,上面有很好的解释。
尽管可以使用这些工具,但我还是强烈建议你不要这么做。另外,我建议不要使用更复杂的数学运算来确定 cell 的高度,而尽可能仅仅使用加、减、乘、除。
那 AutoLayout 呢?难道真如我所那么慢?也许你会感到惊讶,但这的确是事实。如果你的应用运行在所有的生命周期长的真实设备(特别是相对于安卓设备),想看到完美的平滑滚动,那么结果会是慢得令人难以置信的。而且你拥有越多的子视图,你的 AutoLayout 就运行得越慢。
AutoLayout 相对低性能的原因是在 Cassowary 布局引擎下处理的。你需要要处理的的子视图布局约束越多,你花费在返回 cell 给 UITableViews 的时间就越多。
什么是速度更快:执行一些数值较小的基本数学运算,或者处理大量的线性等式、不等式?现在想象一下,用户希望很快速滚动,然后每个 cell 的 AutoLayout 执行所有这些疯狂的计算。
使用内置工具优化 UITableViews 的正确方式:
-
重用 cell 对象:对于特定的 cell 类型,你应该只初始化一次
-
不要在 cellForRowAtIndexPath: 方法绑定数据,因为这时 cell 还没显示,而是使用 UITableViews 的委托方法 tableView:willDisplayCell:forRowAtIndexPath:。
-
进一步计算 cell 的高度要迅速。虽然这是工程师的日常事务,但是要想平滑滚动复杂的 cell,你要多点耐心。
我们探讨更深入的问题
上面提及的几点还不足以实现真正的平滑滚动,而且尤其当你需要实现复杂的 cell,有着许多梯度渐变、视图、交互元素、装饰元素等更多的内容时,其滚动效果更为突出。
此时,我们即使完成了上述的需求,也很容易造成 UITableViews 的卡顿。你给 UITableViewCell 添加的视图越多,其滚动时的 FPS 下降的幅度就越大。现在有了手工布局和高度计算优化,但现在问题不在于布局,而是渲染。
让我们把注意力转移到 UIView 的一个叫 “opaque” 的属性上。文档中写道,它有助于绘图系统,以确定 UIView 是否为透明。如果不是,绘图系统在呈现该视图时会做一些优化并提高性能。
我们所需的性能,会不会是它呢?用户可能很密集地滚动表格,例如使用 scrollsToTop 功能,且用户不一定有最新的 iPhone,因此 cell 必须要非常快地渲染。比“正常”的视图还要快。
最慢的一种渲染方式是混合。它在设备的 GPU 支持下执行,这种硬件正是为了混合渲染(不仅仅混合渲染)开发而产生的。
正如你可能已经猜到,提高性能的方法是减少混合操作。但在减少一些东西之前,你该找到它。我们来试试。
在 iOS 模拟器运行你的应用,点击“Debug”菜单,然后选择“Color Blended Layers”选项。这时 iOS 模拟器会将所有区域的显示划分成两种颜色:绿色和红色。
绿色区域是没有混合渲染的,红色区域是经过有混合渲染的。
如你所见,在 cell 里有至少两个区域是经过混合渲染的,但你可能没有看到之间有什么不同(而且这种混合渲染是多余的!)。
每次遇到这样的情况,你都应当仔细研究,并在不同的情况下,使用不同的方式来避免混合渲染。在我的这种情况下,将 背景色 设置不透明就已经实现目标了。
但有时候我们会遇到更复杂的事情。看这里:我们有一个梯度渐变,但混合是不存在的。
如果你想使用 CAGradientLayer 来实现这个效果,你会失望的,因为在 iPhone 6 其 FPS 会下降到 25~30,并且无法达到快速滚动。
它毕竟是会发生的,因为我们已经将两个不同图层的内容混合了,分别是 UILabel 的 CATextLayer 和我们构想的 CAGradientLayer。
当正确使用 CPU 和 GPU 资源,它们是负载均衡的,FPS 保持约为 60。如下所示:
当设备需要显示执行大量的混合操作时问题就会出现,GPU 将会满载,但 CPU 却会保持低负荷运行。
在2010的夏末,恰好是 iPhone 4 发布后,许多工程师都面临着这个问题。然后苹果推出了革命性的视网膜屏,但搭配相当普通的 GPU。然而,它却还有足够的能力去支持显示,但上述问题就出现得更频繁了。
这个决定的结果就是,你可以看到目前在 iOS 7 下的 iPhone 4 上的所有应用甚至是最简单的应用的操作都变得卡顿。无论如何,通过本文的所有建议,你将能够让你的应用即使在这种情况下都可以达到 60 FPS,尽管其过程有些难度。
那么那是做什么用的呢?事实上,解决方法就在这里:用 CPU 来渲染!去载 GPU,让它只在必须用 GPU 的地方执行混合渲染。例如,由 CALayers 执行动画的地方。
我们可以通过在 UIView 的 drawRect: 方法中使用 CoreGraphics 操作,如:
这些代码就很好?即使是我,也会说不是真的。另外这种方式是撤销所有在 UIView 上的缓存优化(在任何情况下,它们都是不必要的)。但正是这种做法将禁用一些混合操作,不加载 GPU 并能让 UITableViews 更流畅。
但要注意:使用这种方式增加渲染性能不是因为 CPU 比 GPU 快!而是因为 CPU 在多数情况下都不会 100% 满载,这种方式能让我们通过负载 CPU 来实现在一些渲染任务中去载 GPU。
优化混合操作的关键是平衡 CPU 和 GPU 的负载。
简单总结一下优化在 UITableViews 中绘制数据的操作:
-
减少 iOS 没必要的混合渲染区域:通过 iOS 模拟器和 Instruments 工具对应用进行检查,不使用透明背景;如果可以的话,不渲染梯度渐变,其效果会更好。
-
优化代码,实现 CPU 和 GPU 负载均衡。你应清晰了解哪一部分的渲染必须通过 GPU 来完成,哪一部分的渲染可以通过 CPU 来保持均衡。
-
根据不同类型 cell 写不同的代码。
像素探索
你知道像素长什么样的吗?我指的是,屏幕中的物理像素。我相信你知道的,但我还是要告诉你:
不同的屏幕有不同的制作工艺,但这其中都有共同之处。实际上,每个物理像素是通过红、绿、蓝三种颜色子像素组成的。
这就造成了每个像素并不是原子单元的,尽管在应用中看起来是这样。
直到搭配视网膜屏的 iPhone 4 出现,物理像素可以被描述为整形坐标。因为视网膜显示屏是多倍于屏幕点,而不是 Cocoa Touch 环境中的像素,而且他们可以为浮点数。
在完美(我们试图构建的)世界,屏幕点总是被处理成物理像素的整数坐标。但在现实生活中,它可能是浮点数值,例如,直线可能开始于横坐标的 0.25 处。并从这时,iOS 将对子像素进行着色。
当这种技术运用于特定类型的内容(如文本)确实比较合理。但如果我们只是绘制设计的平滑直线时就没必要使用这种技术。
如果你的所有的平滑直线都进行子像素渲染(这将是不可见的,因为你的直线只是设计成平滑的),那么你会让 iOS 做无用功,同时也降低了 FPS。
如何获得与不必要的子像素抗锯齿的问题?最经常发生的情况是在需要代码计算的视图中,其坐标系数值变成了浮点数值或者使用不适合的图片素材,图片尺寸不对齐屏幕的物理像素(例如,你有一张 60×61 而不是 60×60 的图片给视网膜屏显示)
就如前面内容提到的,在减少一些东西前,我们应去找到它。在 iOS 模拟器运行你的应用,并点选“Debug”菜单的“Color Misaligned Images”选项。
这次,模拟器上有两大高亮的区域:紫红色区域执行了子像素渲染,黄色区域渲染的图片尺寸没有对齐到它们所在的区域。
如何在你的代码中找到发生问题的地方?我总是使用部分自定义绘制进行手工布局的,所以对我来说没问题。如果你是使用 Interface Builder,那么我只能同情你了。
一般情况下,解决这个问题只需要简单使用下 ceilf、floorf 和 CGRectIntegral 来凑整你的坐标系即可。
通过探索的结果,我有以下几点建议:
-
凑整所有像素相关的数据:UIView 的点坐标、宽高和其他数据。
-
追踪你的图像资源:图片必须是像素完美的,否则当它们在视网膜屏上渲染时,它会做一些无必要的抗锯齿处理。
-
定期检查你的环境和上述的问题,因为它可以很快修正。
异步 UI
可能这看起来有点奇怪,但如果你知道自己在做什么,那么这是让 UITableViews 更平滑滚动的很有效的办法。
现在我们将讨论一些你应该做和可能要做的事情。
每个中等以上级别的应用都有必要使用带自定义多媒体内容的 cell,如:文本、图片、动画,甚至有时是视频。
而所有这些东西都被修饰:头像是圆的、文字带话题标签、用户名等。
我们注意到,对于需要尽快返回的 cell,在这点上我们有些麻烦: clipsToBounds 慢,图片从网络加载,字符串要关联话题标签等其他一些麻烦。
优化似乎是这样的:你应执行这些操作,但如果你执行在主线程,那么它不会让你快速返回 cell。
后台加载图片并处理圆角,然后将处理过的图片赋值给 UIImageView。
一次显示所有文本,但在后台关联话题标签,然后刷新带样式的文本。
具体的操作取决于你的 cell 的具体内容,但主要的想法是让它在后台运行大量的操作。这些操作不仅是网络相关代码并且使用 Instruments 工具来找出所在需要在代码运行的操作。
记得尽量迅速返回 cell。
有时候,我们即使用尽了上面所有的技术都无补于事。GPU 仍然不奏效(iPhone 4 + iOS 7),cell 中有大量内容,我们将要通过 CALayers 来实现动画,因为它真的很难通过 drawRect: 方法实现效果。
在这种情况下,我们应该在后台渲染其他的一切。除此之外,当用户快速滚动 UITableViews 时,它对提高 FPS 非常有效。
你比如说 Facebook 应用,它做的正是这一点。为了检测这一点,你可以充分往下滚动,然后点击状态栏。列表马上就滚动起来,这样你就可以清楚看到,cell 不会在此时渲染。如果要说的更精确下,cell 还不能及时获得。
你可以自己做一遍因为它足够简单。为此,你应该设置 CALayers 的 drawsAsynchronously 为 YES。
但是,我们可以检查此操作的必要性。在 iOS 模拟器运行应用,选择“Debug”菜单的“Color Offscreen-Rendered”选项。现在所有在后台渲染的区域会以黄色突出显示。
如果对一些层启用该模式,但它并没有高亮突出显示,那么这个地方是还不够慢。
为了找到 CALayers 的瓶颈并进一步缩小它,你可以使用 XCode Instruments 的 Time Profiler 工具。
下面是实现异步 UI 的操作列表:
-
找到那个不能让你快速返回 cell 的渲染瓶颈。
-
将操作移至后台并更新在主线程的显示内容。
-
最后一招是设置你的 CALayers 为异步显示模式(即使它们简单的文本或图片),者将帮助你提高 FPS。
总结
我试图解释 iOS 绘图系统的主要观点(不使用 OpenGL,因为它更少情况下才使用的)。当然,这其中的一些似乎模糊了,但实际最合适的方向是:研究你的代码,找到所有影响滚动性能的问题。
在不同的情况,可以用不同的方式进行优化,但原则是永远不会变的。
实现完美的平滑滚动的关键是非常特殊的代码,它允许你使用 iOS 设备所有的力量让应用变得真正的流畅。