当与 UI 进行交互时,大部分用户才注意到性能问题。如果某个应用在数据同步和刷新上耗时较长,或用户交互不够稳定,那么应用会被认为是迟钝的。
功耗、网络使用率、本地存储等因素对用户来说是不可见的。因此,虽然这些因素是解决性能问题的要素,但 UI 却是应用的门面,如果 UI 反应迟钝,则必然会直接影响用户的反馈。
还有一些无法控制的外部因素,如下:
• 网络
弱网环境会增加同步所需的时间。
• 硬件
硬件越好,其提供的性能越高。与旧型号的 iPhone 相比,搭载新系统的新 iPhone执行速度更快。应用可以在不同的CPU上运行,这些CPU包括了32位1.3GHz到64位1.8GHz的所有型号,同时支持 1GB 到 2GB 的 RAM。
• 存储
应用可以在存储容量不同的设备上运行,存储容量小至 16GB,大到 128GB,它们限制了应用在本地离线缓存数据的规模。
应用还可以根据所处的运行环境作出不同的决策,保证用户交互的流畅性。
本章主要讨论如何最小化更新 UI 所需的时间。阅读完本章后,你应该可以找到对应的方法,让自己的应用以 60 帧每秒的帧率(fps)运行。这意味着应用会有 16.666 毫秒来完成向下一帧过渡的全部操作。如果执行一条指令需要 1×10-9 秒,应用在上述时间段大约可以执行 1000 万条指令。换一个角度来看,如果调用一个简单的、没有任何操作的方法需要大约 30 纳秒(包括设置栈帧、参数入栈、执行以及最终清理的时间),那么执行 50 多万个方法也是绰绰有余的。所以在这个时间段能够执行很多方法。
本章将关注以下部分:
• 视图控制器及其生命周期
• 视图渲染
• 自定义视图
• 布局
1.1. 视图控制器
在应用开发的最初阶段,视图控制器都较为精简,状态较好。随着时间的推移,这些视图
控制器慢慢变成了所有业务逻辑的垃圾场,代码量也增长至几千行。虽然逻辑的“总量”
是不可避免的,但将代码重构成短小、可复用的方法是很好的主意。这样不仅能解除耦
合,还可以发现无用的、重复的代码。
下面列举了创建视图控制器时需要遵循的一些较为基本的最佳实践。
• 保持视图控制器轻量。在 MVC 结构的应用中,控制器只是纽带,而不是存放所有业务逻辑的地方。它甚至不属于模型。业务逻辑应该属于服务层或业务逻辑组件。将它放在那里。
• 不要在视图控制器中编写动画逻辑。动画可以在独立的动画类中实现,该类接受视图作为参数传入,这些视图就是用来运行动画的视图。然后,视图控制器会将动画添加至视图或转场效果上。
有特殊用途的视图可以拥有自己的动画。例如,一个自定义的微调控制器会有它自己的动画。
• 使用数据源和委托协议,将代码按照数据检索、数据更新和其他的业务逻辑进行分离。
视图控制器只能用来选择正确的视图,并将它们连接到供应源。
• 视图控制器响应来自视图的事件,如按钮点击事件或列表单元格的选择事件,然后将它们连接至数据接收器。
• 视图控制器响应来自操作系统的 UI 相关事件,如方向变化或低内存警告。这可能会触发视图的重新布局。
• 不要编写自定义的 init 代码。 为什么呢?因为如果视图控制器被重新切换至 XIB 或故事板,那 init 方法永远都不会被调用。
• 不要在视图控制器中使用代码手工布局 UI,也不要在视图控制器中实现全部的 UI、视图创建和视图布局逻辑等操作。使用 nibs 或者故事板。
手工布局代码不会持续很久,因为应用在不断增长,并且设计也在改变。在重新设计方面,使用 Interface Builder 比根据像素坐标来手动编写代码更快。
此外,应用可能会在不同大小和形状的设备上运行。要想适应所有的形状,处理方向变化时的旋转操作,以及与每两三年就会变化的设计范例保持一致,通过扩展自定义代码来实现是比较难做到的。
同样,如果某一设计被分在独立的 nibs 和故事板,你就可以比较灵活地运行 A/B 测试,因为在不同的约束之间很容易选择最终需要的。
• 比较好的方式是,创建一个实现了公共设置的基类视图控制器,其他视图控制器从这里继承就好。
这种技术并非一直可用,因为有时可能需要在应用的不同部分继承不同的视图控制器。例如,在联系人列表中应该使用 UITableViewController,在用户配置文件中应该选择UIViewController。但是,如果有多个地方都需要在 UIWebView 中展示内容,那基类视图控制器会是一个不错的选择。如果需要显示含有隐私策略的 URL 或条款和条件页面,那么你是没必要继承的。但是,如果需要显示用户分享的图片或者视频(在信息应用中),你可以创建子类,在子类中实现自定义浏览器或对重写的东西进行控制。
• 在各视图控制器之间,使用 category 创建可复用的代码。如果父视图控制器不能满足使用(例如,在应用中需要不同种类的视图控制器),那就创建 category,并在 category中加上自定义的方法或属性。如此一来,你就不会被限制只能使用预定义的基类,同时还能得到复用带来的好处。
1.2. 视图加载
• 配置数据源来填充数据。
• 为视图绑定数据。
这是一个值得商榷的任务。根据不同的使用情况,你可以一次绑定数据,并提供一个刷新按钮,也可以每次调用 viewWillAppear 时绑定。使用后者的好处是,UI 总是展示最新的数据;缺点是,如果数据更新不频繁(例如,在新闻应用中),用户每次看到的都是不必要的刷新(例如,当 UITableView 重新绑定时)。
• 绑定视图的事件处理程序、数据源的委托和其他回调。
• 注册数据的观察者。
依据数据绑定到视图时的不同位置,数据的观察者可能也会发生变化。
• 从通知中心对通知进行监控。
• 初始化动画。
在执行过程中,应该尽量缩短在 viewDidLoad 方法上花费的时间。具体来讲,将要被渲染的数据应该是已经可用的,或是在其他线程进行加载的。在 viewDidLoad 的完成中发生的任何延迟,都将导致与视图控制器相关的 UI 展示发生延迟。用户会卡在应用启动或前一个视图控制器中。
1.3. 视图层级
展示出来的 UI 是由嵌套在树形结构中的各层次视图组成的,它们的位置受自动布局或其他编排方式的约束。视图结构和渲染包括以下步骤。
(1) 构造子视图。
(2) 计算并提供约束。
(3) 为子视图递归地执行步骤 1 和步骤 2。
(4) 递归渲染。
1.4. 视图可见性
视图控制器提供了四个生命周期方法,以接收有关视图可视性的通知。
• viewWillAppear:
当视图层级已经准备好,且视图即将被放入视图窗口时,此方法会被调用。在即将展示视图控制器或之前入栈(modal 或者其他)的视图控制器弹出时,这种情况就会发生。
在这个时刻,过渡动画还未开始,视图对终端用户也是不可见的。不要启动任何视图动画,因为没有任何作用。
• viewDidAppear:
当视图在视图窗口展示出来,且过渡动画完成后,此方法会被调用。
因为动画会耗费约 300 毫秒,所以,对比 viewWillAppear: 和 viewDidLoad:
• viewDidAppear:
和 viewWillAppear: 之间的时间差可能会比较大。
启动或恢复任何想要呈现给用户的视图动画。
• viewWillDisappear:
该方法表示视图将要从屏幕上隐藏起来。这可能是因为其他视图控制器想要接管屏幕,或该视图控制器将要出栈。
• viewDidDisappear:
当上一个 / 下一个视图控制器的过渡动画完成时,此方法会被调用。正如
viewDidAppear:,viewWillDisappear: 事件也会有约 300 毫秒的差值。
以下列举了一些高效使用生命周期事件的最佳实践。
• 无需多说,不要重写 loadView。
• 将 viewDidLoad 作为最后的检查点,查看来自数据源的数据是否可用。如果可用,则更新 UI 元素。
• 如果每次都需要展示最新的信息,那么就使用 viewWillAppear: 更新 UI 元素。
例如,在某一个消息应用中,如果在聊天会话中观看完共享视频后返回信息列表,那么用户必然希望看到最新的信息。
但在新闻应用中,你可能不希望立即刷新列表,以免用户找不到上下文。在后一种情况下,列表视图控制器一般会监听来自数据源的事件,同时,应尽量精准、尽量少地更新文章列表。
• 在 viewDidAppear: 中开始动画。如果有视频等流式内容,那么就可以开始播放了。订阅应用事件来检测动画 / 视频或其他持续更新视频的处理是应该继续还是停止。
不推荐在该方法中用最新的数据更新 UI。如果你这样做了,最终的效果是,在过渡动画完成之后,用户会过渡至旧的 UI,然后产生更新。这个体验不是很友好。
话虽如此,但在一些使用案例中,你不得不在 viewDidAppear: 中执行 UI 更新。如果用户体验尚可接受,那就勉强这样吧。
• 使用 viewWillDisappear: 来暂停或停止动画。同样,不要做其他多余的操作。
• 使用 viewDidDisappear:销毁内存中的复杂数据结构。
也可以在这里注销与视图控制器绑定的数据源通知,以及与动画、数据源、UI 更新有关的应用事件通知中心。
- 如果其他措施都不能优化载入时间,那么你可以在应用中增加一个精巧的动
画。如此一来,你将会有数十秒的额外时间来完成任务,让用户在使用应用
时不会有明显的延迟。需要注意的是,长时间的动画会惹恼用户,可能导致
用户流失。这应该作为最后的方案,需要慎用。 *
2.1. 视图
优化视图方面最具挑战性的部分是,很少有普适于所有视图的技术。每个视图都有其独特的用途,且大部分的优化技术都与特定的视图和暴露出的 API 有关。
以下是视图使用的一些基本规则。
• 尽量减少在主线程中所做的工作。任何额外代码的执行都意味着更高的丢帧概率。过多的丢帧会导致不流畅。
• 避免较大的 nibs 或故事板。故事板很强大,但整个 XML 在真正使用之前必须被加载(I/O)和解析(XML 处理)。应该最小化故事板中的单元数目。
如果需要的话,创建多个故事板或 nib 文件。这可以确保所有的屏幕并非都在应用启动时一次性加载,而是根据需要进行加载。这不仅有助于减少应用的启动时间,还能降低整体内存的消耗值。
当 nib 文件被载入内存时,nib 加载代码需要执行几个步骤,以确保 nib 文件的对
象被创建并正确地进行初始化。
当加载的 nib 文件中包含了对图片或声音资源的引用时,nib 加载代码会读取实
际的图像或声音文件,将其放入内存并缓存。……在 iOS 中,只有图像资源存储
在被命名的缓存中。
• 避免在视图层次结构中多层嵌套。尽量保持扁平化。嵌套是必要的“罪恶”,但仍然是“罪恶”。
在层次结构的任何位置添加视图时,它的祖先树节点会执行值为 YES 的 setNeedsLayout:方法,当事件队列正在执行时,该设置会触发 layoutSubviews:。这个调用代价较大,因为视图必须根据约束重新计算子视图的位置,而且在祖先树的每一层都会发生这种情况。
• 尽可能延迟加载视图并进行重用。更多的视图不仅会导致加载时间变长,还会使渲染时间变长,这些会影响内存和 CPU 的使用。
如果需要,你可以创建自己的视图缓存。这可能会高出 UITableView 和 UICollectionView已经提供的对单元格的复用支持。当视图不在视图窗口时,这些容器会释放视图。如果视图结构很复杂,并且比较耗费时间,那么实现自定义的视图缓存是个明智之举。如果使用 UIScrollView 呢?绝对是延迟加载。当滚动到位置 0 时才载入视图,然后通过建立自己的视图缓存,模仿 UITableView 的行为。将委托的 scrollViewDidScroll: 方法与 contentOffset(滚动位置)属性结合起来使用,以确定哪些视图需要渲染。
按照一般惯例,渲染的元素会超过视图窗口的屏幕高度,以避免滚动过程中出现抖动,因为滚动开始时,这些元素需要迅速地被渲染出来。
需要牢记的是,UITableView 继承自 UIScrollView,这意味着,如果 UITableView 可以做智能视图缓存,那自定义代码也可以实现。
• 对于复杂的 UI 而言,最好使用自定义绘图。这样只会触发一个视图进行绘制,而不是多个子视图,同时也避免了调用代价较高的 layoutSubviews 和 drawRect: 方法。
此外,要避免使用具有通用目的及功能丰富的组件而带来的消耗,你可以使用那些直接实现了绘制方法的视图来代替。
3.1. 自定义视图
在非游戏应用中或不以动画为核心的应用中,从头开始写自定义视图并不常见。比较常用的方法是,使用 Interface Builder 和自定义 nib 文件创建复合视图。
虽然这是一个极好的初级技巧,但是,一旦创建了更复杂的 UI,或在列表视图中使用了复合视图,那性能下降的问题就会暴露出来。
Twitter 团队在其应用开发的早期遇到了这个问题,他们摒弃了复合视图的使用,改为直接绘制视图,从而最大程度地减少了渲染视图所需的消耗。
需要注意的一点是:** 针对复杂视图,在动画过程中使用视图光栅化,包括但不限于滚动。通常情况下,滚动期间的视图布局是不改变的,在动画过程中,将 UIView
的 layer 的 shouldRasterize 属性设置为 YES,在动画完成之后,将其设置为 NO。 **
4.1. 自动布局
虽然自动布局是一个不错的方法,它允许你将元素的位置、大小交给核心引擎,而不是在代码中实现,但这却带来了性能开销。
在 Florian Kugler 的实验中,8 如果视图的数量增加至几百个,自动布局需要几十秒或更多的时间,而直接设置结构大小在毫秒级别就可完成。一般情况下,直接设置结构大小要比使用自动布局快 1000 倍左右。Kugler 的测试结果如图 6-14 所示。
结论就是从性能角度来讲,手动布局 > 自动布局使用本地约束(例如,元素彼此之间的位置关系)> 使用全局约束(其中全局约束指相对于父类视图的位置。)
对自动布局的最终裁决是什么?靠自己的判断。衡量展示视图和渲染视图所需的时间。如果超过了阈值,考虑使用自定义代码。