这一部分的文章是我早年间参与iOS和macOS性能优化这本书翻译时的原稿, 当时的水平有限, 翻译的可能会有一些纰漏, 还请多多指教. 将文章同步到个人博客主要是为了同步和备份.

实际上,图形程序设计本身就是一个完整成熟的领域,有许多值得一读的书籍,网上资料也非常丰富,所以本书不可能涵盖并详述所有知识点。比如,我们会粗略了解下 OpenGL,学习非游戏、用户端应用程序中常见的图形技巧,仅此而已。

陷阱

响应性方面,最大的陷阱之一是在主线程中执行较长或不可预测的操作。所有 I/O 都存在该问题,我们从之前章节中了解到,即使是最小的 I/O 操作也可能耗费很长时间。另一方面,单纯将这些操作放到后台线程执行,而不想办法提升 I/O 响应性,这只会更加糟糕,关于这点我们之前也见过不少了:用户可以操作所有界面,但没有任何反应,并且也没有显示旋转光标(译者:系统卡主时出现的进度指示器,俗称“风火轮”)。在这种情况下,操作放置在主线程执行更合适一些,至少主线程操作会触发系统繁忙的旋转光标,告知用户发生了什么。

对于图形来说,预先渲染所有图形资源并以位图格式进行传递是提供图形最普遍的技术之一,同时这也是最大和最明显的错误。最极端的例子就是 iPad 上一些杂志类应用程序,用 Adobe 发布工具直接发布到应用里,杂志的每一页都是预先渲染好的整页位图,同时适配横屏和竖屏。由于 I/O 是现代计算机体验中最慢的一部分,不管是使用(蜂窝数据)无线数据还是直接从硬盘读取,哪怕是固态硬盘,用户体验都差强人意。视网膜高清屏出来以后,预先渲染的位图在高清屏上看起来就会非常模糊,效果更差了。

随着高分辨率视网膜显示屏的出现,让一切都看起来像是艺术品,任何数字生成的东西都应该可以直接作为矢量图使用,除去那些直接用代码绘制的部分。而位图问题显而易见:本身太大了而且无法缩放,必须适配每个分辨率版本,不过 “像素完美” 的理念依然会经久不衰。

这个理念将一直延续下去。

iPhone 6 Plus 有 1,920×1,080 个面板,但是实际渲染是分辨率的 3 倍,即 2,208×1,242 像素,由于两个分辨率不匹配,因此像素对显示的分辨率下采样 1.15 倍。无论是通过下采样的手法(Quartz 和合适的设备会自动处理)还是单独一个步骤专门对整个渲染帧缓冲区进行下采样并不(很)重要,不管采取哪种方式,都不符合 “像素完美” 的预渲染设计理念。其中这也不重要 —— 400 dpi 的屏幕分辨率,也没人会在单个像素点上较真,用户十分喜爱 6 Plus 的屏幕。

如果必须要使用位图格式的图形,也存在多种优化技术供我们选择。即使是 Xcode 生成的 “优化过” 的 PNG 文件,也能借助这些优化技术减少文件大小以及文件加载的时间。我们将在第 17 章中详细讨论这些。

在绘图方面,最大的问题往往是过度绘制(多次绘制相同的像素)和重绘没有变化的屏幕区域。幸运的是,第 15 章介绍的那些工具将助你诊断和解决这些问题,请使用这些工具!

技巧

要获得良好的图形性能,通常意味着要从需要展示的屏幕和像素入手,进行逆向操作,而不是从更改模型作为开始并将其展示在屏幕上。

一方面,绘制过程涉及到 AppKit、UIKit 提供的脏矩形,另一方面,每当 UI 层收到改动通知都需要设置这些矩形。“过多通信导致安装缓慢” 一节包含了一个庞大的示例,展示为了实现较好的效果,在项目中需要克服的一些障碍,以及如何将性能从难以忍受的缓慢提升到几乎无法测量的快速。

译者注:脏矩形就是每一帧绘制图形界面的时候,需要重新绘制、有变化的区域。

大型复杂的路径一直是 Quartz 的巨大难题,由于需要计算自相交以实现抗锯齿效果,所使用的几何图形算法是分段数的二次项(检查每段上的相交点,对比其他的段)。底层算法已经改进了不少,所以这也不再是一个大问题了,但是还是要考虑路径的长度,最好是使用中等长度的路径。

不再重复绘制形状,使用 Quartz 图样设备。对渐变来说都是一样的:使用内置的内容。在绘制性能方面,CGLayer 并没有太大的帮助。自 NextStep 起图像绘制问题就没有得到改善,而 CGImage 重写了基本图形绘制部分,尽可能加快了绘制速度。不过,CGLayer 在生成 PDF 时仍具有优势,得益于保留了重复元素的向量信息。如果你打算打印或者生成 PDF 里有重复的元素,那么 CGLayer 是个好东西。

考虑到 15 章里介绍的 OpenGL 潜在的性能优势,以及现在的游戏在高帧率下呈现的超级复杂场景,使用 OpenGL 加速绘制绝对是无脑行为。虽然概念上讲得通,实际上实现类似加速绘制的行为绝对是无脑行为。苹果公司曾多次尝试在系统级上将 OpenGL 的加速机制融入 Quartz 中,据我所知,所有的尝试都以失败告终。额外的执行操作将会吃掉图形加速所获的所有好处。还有一个问题非常明显,Quartz API 的调用/返回性质,能很好的映射到 OpenGL API 上,但是不能很好的映射到实际的硬件接口上(参见第 14 章 Metal 章节里的 OpenGL 和 Metal 的讨论。)

另外一个原因是图形原语不同:Quartz 使用填充和描边的贝塞尔路径,这些路径必须经过昂贵的细分,将其转换成阴影三角形供图形硬件使用,同时开放应用程序接口暴露给外部调用。 如果形状被重复使用,那么转换成本可以被其他使用者均摊,不过接口并未真正提供该功能。

过多通信导致安装缓慢

一项调查结果出乎意料:Mac OS X 新版本下,某个安装程序按照预期的步骤执行安装数千个小文件,竟然出现了进度减慢的神秘现象。在新版本下,安装程序速度减缓了几倍,几乎降了一个数量级。I/O 速率比磁盘子系统慢了很多,CPU 的使用率可以忽略不计,所以看起来好像没什么瓶颈可以用来解释这种性能下降的情形。

节流显示

在使用了大量工具和抓耳挠腮之后,终于发现安装器提供显示了状态更新信息。它正在显示每个安装文件名称,为了确保用户有机会看到每一个文件名,每次都会刷新屏幕上的内容。这样能满足用户了解过程进度的渴望,却与 Mac OS X 图形子系统的节流机制相冲突,限制图形的更新频率,现在设置成了大约 60 Hz。试着更频繁的刷新屏幕,就能轻而易举地阻塞你的程序,正如示例 16.1 所示。

示例 16.1 在 60 Hz 下运行

import Cocoa
class AppController: NSObject, NSApplicationDelegate {
var mainWindow: NSWindow?
func applicationDidFinishLaunching(n: NSNotification) {
let window = NSWindow(contentRect: NSMakeRect(0, 0, 320, 200),
styleMask: NSTitledWindowMask,
backing: NSBackingStoreType.Buffered,
defer: false)
window.orderFrontRegardless()
self.mainWindow = window
NSApp.activateIgnoringOtherApps(true)
dispatch_async(dispatch_get_main_queue()) {
for i in 1...60 {
self.drawSomething(i)
}
NSApp.terminate(true)
}
}
func drawSomething( i:Int ) {
let window=self.mainWindow!
window.contentView?.lockFocus()
NSColor.redColor().set()
NSBezierPath.fillRect( NSMakeRect( 10,10,200,120 ))
let labels=String(i)
let label:NSString=labels
NSColor.blackColor().set()
label.drawAtPoint( CGPoint(x:20,y:20), withAttributes:nil)
window.contentView?.unlockFocus()
window.flushWindow()
} }
NSApplication.sharedApplication()
let controller = AppController()
NSApp.delegate = controller
NSApp.run()

在 8 核 的 Mac Pro 上执行这段代码 600 次只花费了 10.0 秒,MacBook Pro 同样如此 ,两个系统 CPU 的空闲超过了 90%。

注意一下,Quartz 足够聪明可以分辨是否有任何绘图行为,因此只要在循环中调用 flushWindow() 就会立即得到返回结果,因为在这种情况下实际并未产生刷新行为。我们需要实际地绘制一些东西,但绘制内容并非总是不同。

使用节流显示

虽然节流显示的使用方式有很多,例如游戏里使用 OpenGL 来获得更高的刷新频率,请牢记以下要点:实际状态更新的频次可能比用户知悉的要多得多。比如,我通过 NSURLSession DataTask 的代理方法进行测试,在家用 6-MBit/s 的 DSL 光缆条件下,显示状态更新次数为每秒 273 次。

我相信用户并不关心,也不想知道每秒进行了 273 次状态更新 —— 换做我也是如此 —— 实际上我们也无法读取刷新如此频繁的字节数。就字节计数文本而言,每秒一次足矣,当然如果加快更新速率会使得进度条更加平滑,例如每秒钟刷新10次。

至此,我已经使用了三种技术来避免过高的 UI 刷新频率,其中有两种是有效的。第一种技术适合用在连续的进度监视上,比如下载或磁盘进度。使用了大约 10 Hz 的计时器来查询进度并更新显示。除了解决进度显示的平滑问题以及一些限制,该技术避免了模型→视图通信,从架构角度而言是可取的。 定时器技术的缺点是它必须独立于底层操作启动和停止。

第二种技术是批量处理更新请求,避免引入显示计时器,但不能避免 模型→视图 通信。实现批量处理更新请求的一种方式是预先缓存,然后发送更新消息,在某个点比如 0.1 秒之后及时地传递该更新消息,如在下章节示例 16.4 中展示了这种操作。

第三种技术我本以为会有效的,结果还是图样图森破了,performSelector:afterDelay: 取消了之前的请求,实际上我曾多次见过该项技术,哎,实际上没起作用,至少如果有足够的负载使间隔时间短于延迟,这样的话,能一直取消更新信息的发送直到更新停止为止。

今日安装程序和进度报告

考虑到这个特定问题是如此糟糕,你可能认为该情况很少见,甚至根本不可能发生。那你就错了—— 在 2016 年 1 月,据报道,当呈现进度条时,节点包管理器 npm 速度减慢了 50% 到 200%,微软的自动更新仍然占用了 150% 的 CPU 资源(这是在双核机器上使用 top 命令测量得到的结果),归结原因是在更新过程中开启了进度显示条。

在 OS X 10.9 和 iOS 7 中,苹果引入了 NSProgress 类和 NSProgressReporting 协议,明确支持长时间运行任务的报告进度。基本实现思路如下:让对象汇报每个活动的进度,然后将这些单独的进度汇总在一起,组成总的进度。在上世纪 90 年代末期就实现了相似的系统,干得好!

哎,谈及进度报告,苹果实际上完全失误了,或许就是熟视无睹。指示 UI 进度的推荐方法实际上就是监听 NSProgress 对象的 percentCompleted 属性。这并不能解决我们遇到的问题,还会引入 KVO 通知的问题,KVO 传递通知的线程和改变进度条状态使用的线程是同一个。

实际上,苹果公司警告过:“不要在紧密循环中更新 completedUnitCount”,看来苹果公司知悉该问题,意识到了实际上并没有解决问题。

iPhone 无法承受之重(Overwhelming an iPhone)

几年前,有人请我帮忙开发一款新闻类应用程序,管理一些类似 RSS 里的 Feed 条目,使用常见的 UITableView 进行展示。每个条目对其所处的不同状态都显示不同的 UI:尚未下载,下载了一些元数据,缩略图已接收,有无音频。另外,在下载音频数据时需要展示下载进度,这些文件可能要很久才能下载完毕。

只要我们使用一个 feed 且条目数量小于 10,就不会出现什么问题。但是一旦超过 10 个 feed 或者 30 个条目,应用程序就会遇到明显的性能问题。我们尝试了很多常见的优化措施,比如,将长时间运行的操作(如生成缩略图)放到后台线程上,避免不必要的工作,例如为每个条目的状态更改都同步到数据库中,这些措施都无济于事。

具体的症状表现为:UI 界面可能会很长一段时间僵住不动,然后才会恢复使用。应用程序第一次启动时最容易发生,因为此时正在读取、更新 feed 下的所有条目。之后启动应用程序就不太会遇到该问题了,因为数据已经缓存在应用里了。即便如此,也给用户的第一印象造成了不好的影响。

分析表明,UI 界面僵住主要是 UIKit 花时间在执行绘制代码(绘制文本、table 布局),Cell 的复用机制如期运行,创建 Cell 没有什么额外的消耗。这里也没有什么可优化的对吧?

不对!实际上问题出现在示例 16.2 的这段代码里。

示例 16.2 模型改变时通知视图代码

-(void)notifyChanged
{
[[NSNotificationCenter defaultCenter]
postNotificationName: @"UserStatusChanged"
object:nil];
}

尽管这段代码看起来没什么问题,和很多 NSNotificationCenter 示例代码都相似,实际上会产生两个问题。首先,缺少之前章节讨论的更新节流(update-throttling),其次未给出具体细节:只说有些东西改变了,却没有明确指出是什么。缺少这么重要的上下文信息,UI 元素(这个例子中的 table view)收到通知后,无奈只能更新所有的 UI 元素。不仅要更新可见的元素,还要更新不可见的元素,有些甚至不在当前的界面里!

有个非常简单的方法可以解决该问题,就是在通知中指明当前的对象,幸运的是 NSNotification 可以实现该需求。示例 16.3 显示了实现的代码:

示例 16.3 模型改变时通知视图,并传递上下文

-(void)notifyChanged
{
[[NSNotificationCenter defaultCenter]
postNotificationName: @"UserStatusChanged"
object:self];
}

接收通知的代码能够从通知中获取有问题的对象,看一下是否相关(属于当前的 table),接着只更新这一行。示例 16.4 的代码在之前章节所说的批量更新中,将根据上下文内容进行了单独的更新。假设 table view 只能显示一屏的 “条目”,客户端调用 -refreshItemsFromBackground:,例如,通过 NSNotification 确定该条目的索引值,然后使用该值。

示例 16.4 批量更新 table view 条目

@property (retain) NSMutableSet *indexesToRefresh;
-(void)refreshAccumulatedItems
{
NSSet *items=nil;
@synchronized(self) {
itemIndexes=[self indexesToRefresh];
[self setIndexesToRefresh:nil];
}
[tableview reloadRowsAtIndexPaths:[itemIndexes allObjects]
withRowAnimation:UITableViewRowAnimationNone];
}
-(void)triggerRefresh
{
[self performSelector:@selector(refreshAccumulatedItems)
withObject:nil afterDelay:0.2];
}
-(void)refreshItemFromBackground:item
{
NSIndexPath* index=[self indexPathForItem:item];
if ( index ) {
@synchronized(self) {
if ( !indexesToRefresh ) {
[self setIndexesToRefresh:[NSMutableSet setWithObject:index]];
[self performSelectorOnMainThread:@selector(triggerRefresh)
withObject:nil waitUntilDone:NO];
} else {
[indexesToRefresh addObject:index];
}
}
}
}

批量更新代码基本上按照如下步骤执行:如果没有可供刷新的批量集合,则新建一个然后再安排刷新;如果存在,则只需要将结果添加到批量操作的队列中。启动过程也很简单 —— 获得需要更新的批量集合,清除并刷新 table view。请注意,如果发生任何事情导致这些索引失效,你将要清除当前更新批次。

一切都是假象

关于处理 UI 性能问题,我学到最重要的技巧之一就是伪造,如果你要做的事情执行速度实在太慢,你可以向用户展示一个旋转图标,告知用户你正在努力完成剩下的工作,提示用户完成进度。或者用动画效果表达。

使用动画效果掩饰延迟是非常聪明的办法,让 iPhone 看起来运行速度非常快,反应迅速。尽管硬件性能相对有限,且竞争对手推出了更高性能的硬件,更好的硬件加速处理,动画效果仍让 iPhone 保持了领先地位。

例如,打开 PDF 文件渲染第一页(或头两页)会花一些时间,然而,借助动画效果将文件从缩略图大小切换成全屏的这个过程,用户的注意力被动画效果所吸引,同时系统忙于处理打开 PDF 的工作。动画可以由 GPU 处理,所以不会占据 CPU 的资源。

图像的缩放和剪切

在上世纪 90 年代,我开发了多款 NeXT 软件,以输出设备的驱动为主,从 NeXT 彩色打印机到成本高达数万美元的高端彩色激光复印机,都可以使用我开发的软件。其中一个软件是 eXTRASLIDE(见图 16.1),可驱动宝丽来 CI-5000S 数字调色板录像机。

image.png

从工程师的角度来说,程序的核心部分是底层驱动,使用 SCSI 端口将由 Display PostScript 渲染的高清分辨率位图传送到设备上,设备往往使用自定义且缺乏文档说明的协议。

良好的性能对上面这些软件来说至关重要。就拿宝丽来录像机来说,有 4000 行分辨率,图像大小 48 M,对传送图像有实时性要求。1 GB 内存的手机看起来貌似有点不够用,但是当时我们最高端的盒子是佳能 object.station 41,32 MB 的 DRAM,100 MHz 的 486 处理器。频率钟显示此设备的 CPU 和现代 CPU 之间的差异是 10 到 20 倍,基准测试显示是 100 倍,简而言之,该设备比当今中等配置的手机要慢得多。那时候,我们还认为它“超级快”呢。

就 eXTRASLIDE 而言,还有另外一个问题:需要前端界面对原材料进行定位、缩放、剪切(见图 16.1)。小菜一碟,除了需要硬件需要花费大量的时间重绘原素材,实时重绘是无法实现的。于是,标准做法是绘制矩形轮廓辅助定位,定位后重新一次性绘制整张图片。

这时候 NSImage 就派上用场了。不管你的原素材是什么,它都能为不同的屏幕分辨率自动创建并缓存预览界面以及渲染。这样会导致一些问题,尽管类名称已经暗示和图片有关(NSBitmapImageRep 是处理位图的类),有时候人们依旧会忘记正在处理一个包装器,而不是一张图片,但在这种情况下正是我们所需要的。eXTRASLIDE 的预览图片足够小一遍满足界面交互的所需性能。不管原始图片有多大,从截图中也可能看出,预览图片非常小。

最后一个问题就是 NSImage 只会考虑屏幕的有效缓存,如果分辨率和屏幕正好匹配的话。否则,就会从原始的展示中生成缓存。哎,在缩放的时候的确会发生这样的事情,每次缩放都会导致分辨率不再匹配屏幕,因而触发重绘机制。这本是 “正确” 行为,然而反复采样缩略图会引发严重的质量问题,也意味着不可能实时缩放。

关键的技巧如图 16.2 所示,只用最初的 NSImage 缓存创建新的 NSImage 实例,在这里,NSImage 没得选,只能缩放低分辨率位图,因此可以实现实时缩放。缩放低质量缩略图没什么问题,图片在移动的时候,人眼无法分辨细节,一旦实施缩放结束(按钮松开),就切回原来的 NSImage,重新缓存新的屏幕位图。

image.png

我得到的经验教训就是,凡是交互形式的程序,只要不被抓住,就可以作弊。在这里的例子中,只要能缩放就行,哪怕是只能缩放低分辨率的位图。虽然不如缩放源文件效果好,图片在运动过程中,这两者之间的区别不是很大,也不会引人注意,客户还是很喜欢这个特性的。

缩略图绘制

在开发获奖软件 Livescribe Desktop 时,图片,特别是缩略图,再次使用了作弊手段未被发现,结果证明这样的处理是合理的。Livescribe Smartpen 使用红外摄像机精准地捕捉位置,正如你在纸上书写不规则的点图案一样。桌面应用展示并整理这些捕捉到的手写或绘制页面。

每个笔记的预览模式,应该展示缩略图,展示捕捉到的纸张背景的向量笔触,很明显这不是笔捕捉的图像。这些背景有两个问题,意识高分辨率的 PNG 图像渲染速度非常慢,包含笔触的文件格式在读取时需要执行很多初始化的工作。

如何不绘制缩略图

绘制缩略图的第一个方案,如图 16.3 所示,以 “缩略图” 为主。一张缩略图实际上就是一张从源文件中生成的小图片,我们的 Windows 客户端直接为每个页面创建了缩略图图片,保存在硬盘里。

Mac 团队认为他们应该也这样做,不过效果更好,因为 OS X 支持高质量的 PDF,苹果也引入了 CGImageSource CreateThumbnailAtIndex() 方法,专门用于从硬盘中加载缩略图。那么最可能出什么纰漏呢?

image.png

额怎么说呢,使用该方法后所有东西都出错了:因为 PDF 是一种与分辨率无关的格式,每个 PDF “缩略图” 包含了全分辨率的 PNG,绘制到 PDF 需要解压 PNG,生成 PDF 后再重新压缩。这些 PDF 由于包含了太多细节信息,因此 “缩略图” 渲染速度也很慢,为每个 PDF 生成新的线程解决进程自身执行缓慢的问题。

要尝试这个办法,需要重启机器,因为 200 个线程对 CPU 和内存的消耗会产生大量持续的交换。

如何真的不画缩略图

把 PDF 写到硬盘后再去渲染明显就是一个典型的 烂注意 TM,我们仍然坚持认为一张缩略图就是一个特定的图像,我们刚刚接受了位图在低分辨率下够用的想法。苹果公司的 ImageKit (新出的)和 IKImageBrowser View 提供了答案:快速(OpenGL 加速!)、可用的图片视图。很完美吧?

应用程序接口也非常简单,我们需要做的就是提供数据源,然后 API 返回给我们任何图片格式的图片。

哎!可惜结果和理想效果差距甚远,如图 16.4 所示:缩略图生成后,速度就会变的超级快。但是初始的加载却非常缓慢,一张一张渲染缩略图的过程简直辣眼睛。更糟糕的是,打开过程中发生的延迟导致我们无法绘制任何东西。

image.png

这也导致我们收到了亚马逊客户无爱的评论:

如果笔记有数百页之多,每次打开软件的时候都要重新加载缩略图,这也太奇怪了。如果你愿意等那么长的时间,只要软件仍处于打开状态,这些缩略图总会存下来的。但是等的时间也太长了吧!如果这时候你关闭软件,然后重新打开,猜猜会发生什么?你又要重新等待缩略图加载!我不知道这个问题是否出现在 Windows 版本的软件中,不过在 Mac 版里确实出现了。

基本上和之前遇到的问题一样:无法获得 IKImageBrowserView 想要展示的缩略图。我们使用的是大尺寸、共享且渲染缓慢的背景图和每个数据源里的向量数据,必须要将这两者用 IKImageBrowserView 结合在一起,这自然拖慢了进程速度。

如何绘制非缩略图

庆幸的是,导致问题发生的架构给出了解决方案。不再作为原子单元和实际图片传递每张缩略图,而是使用古老的 Quartz/AppKit 绘制所有的屏幕元素,使用 ThumbView 类里的 -drawRect: 方法。

与 eXTRASLIDE 图片缩放相似,NSImage 可用于缓存已优化的、屏幕大小分辨率的 PNG 格式背景图片,每台笔记本仅需缓存一次,之后为每张缩略图绘制 NSImage 背景图。这使得绘制背景操作瞬间完成成为可能。

然而,我们还遗留一个问题需要解决:从文件格式中提取笔触数据略微有些耗时。解决方案还是有点取巧嫌疑:不再像过去那样等到所有的笔触数据都可用时才操作,而是在渲染完背景图后立即绘制所有的缩略图,就可以看见需要绘制的一些东西,即便不是最终的图片。

结果如图 16.5 所示,首先绘制所有背景图,同时检索笔触数据。在下一步中,所有的笔触数据都会在同一时间出现。

在屏幕的效果非常动态:缩略图似乎是立即出现了,感觉像是活的可以触摸的对象。原因就是缩略图立即出现然后缓慢的改变,比一个接连一个出现最终完整的缩略图的效果要好得多。该解决方案给用户一种立即响应的感觉,感觉像是正在直接操作屏幕上的项目,而不是等待计算机响应操作。

这种技术类似于苹果要求 iOS 应用程序的启动页快速响应,看似响应及时,“实则因为它出现在界面后,立即被第一个屏幕置换掉了“。

image.png

想要达到这种效果的关键在于不要将缩略图当做一个独立的单位,活用缩略图(背景图+笔触数据)的结构优势:首先,考虑到背景图绘制缓慢,所以我们一次只绘制一页,不要每个页面都绘制,然后,在等待解码笔触数据的时候可以显示这些背景图。效果对比如图 16.6 所示。

实际上我们不需要更快的图形程序或借助 OpenGL 图形程序接口。事实证明,使用 OpenGL 内部的方法(IKImageBrowserView)实现该需求时速度不增反降,不如使用 Quartz 和 AppKit 进行绘制。通常情况下,数据结构优势远远超过了 API 的消耗。

iPhone 上绘制直线

本章 “一切都是假象”一节中讨论的 Livescribe 软件还使用了另外一个方案:称之为“Paper Replay”:可以用笔记录音频和笔画,播放这段音频时,笔画的动画效果会和音频同步浮现。更具体地说,所谓的未来墨迹 (即尚未书写的笔记) 呈现灰色,随着音频播放书写过的墨迹显示绿色。该效果可以从手写文本(或图片)处找到,看起来就好像是第一次书写时那样。

对于 Mac 客户端来说,这真的是小菜一碟。直接使用 Quartz 代码绘制笔触,调用 PageView’s drawRect: 方法。对于 Paper Replay 功能,只需添加 time-fromtime-to 参数(每一笔都有自己的时间戳),然后绘制笔触两遍:设置笔触颜色为灰色,设置 time-to 为录音中的当前时间后,绘制一遍;设置笔触颜色为绿色,设置 time-from 为录音中的当前时间后,再绘制一遍。

image.png

当我们把这段代码移植到 iPhone 上后,我们决定使用 Core Animation,也是苹果推荐使用的高效应用程序接口,毕竟我们用了动画效果。具体来说,用 CATiledLayer 支持缩放功能,CATiledLayer 内部甚至支持多线程,所以速度应该会更快。只可惜凡事都有意外:在复杂的图形上,Instruments 显示每秒只有 3-4 帧,CPU 使用率已达 100%。在动画工程中还要实现重绘,重绘命令也会占用 CPU,使得应用程序彻底瘫痪。Paper Replay 功能没法用了,更别提还会有更复杂的页面内容。

到底做错了什么?我们并非忽略了性能问题,实际上,我们所做的一切都是为了提升性能。为了提高速度,我们甚至研究了笔触的最佳长度,可是不管长度如何,性能上只提升了 10% - 20% ,实在是微不足道。于是我们查看 OpenGL,问题依然萦绕我的思绪,这个问题本不应该这么难解决的。

image.png

当然了,问题的确不难解决,我们只是被技术细节蒙蔽了双眼,却没有真正地思考问题。图 16.7 显示了 Paper Replay 的两帧,学习飞行课程的笔记——两种不同的方式输入空白区。该区域实际上需要重绘,才能让第一张图的矩形所示区域变成第二张图的样子。

笔是由手控制的实体物品,在一定的时间内移动速度是有限的,六十分之一秒内,一帧所移动的距离不可能很远。所以只有屏幕的一小部分需要在两帧之间做出改变,也只有这一小部分需要重绘。

之前一味地关注 Core Animation,从而忽视了这点。Core Animation 只允许一次替换整个 layer 的位图。AppKit 和 UIkit 的视图机制,换句话说,都允许使用 drawRect: 方法只绘制一部分区域(或多个矩形子区域),用 setNeedsDisplayInRect: 让视图中一部分矩形无效。在 iOS 中,这些矩形最终绘制写入作为整个 layer 的位图。

在了解到应该使用 UIKit 而不是 Core Animation 后,我们立即修改了代码。添加了 PageView,放到绘制代码这里,在设置时间的代码里,添加了一些程序,从一组时间中获取更改后的矩形,接着针对特定的几帧让一部分矩形无效。效果非常明显:之前只能每秒处理 3-4 帧,就到达了 CPU 的极限,现在每秒可以处理 60 帧,而 CPU 的使用率只有 2% - 3%,很大程度上独立于页面内容的复杂度。更棒的是,UI 的动画直接从 UI 界面程序,没有了被音频推出来的感觉,所以永远都不会出现音频和动画不同步的情况了。

总结

在本章,我们了解了图形性能和响应性。尽管底层绘制性能更容易测量,一直是开发者热衷讨论的话题,我觉得架构模式和特定领域的优化有更深远的影响。实际上,阻止高级优化的底层的技术限制,常常比单纯的低级优化有更深远的影响,在 model-view-controller 通信机制中更为明显,也和我们网络连接的设备更息息相关,因为我们可以快速更改 model 无需用户输入。我们会在下一章中寻找一种更容易理解的解决方案。