第 15 章 图像和 UI:测量和工具

节选自《iOS和macOS性能优化》

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

正如其他领域的性能一样,如果不清楚导致缓慢的原因,优化图形通常是毫无意义的,更为重要的是你不知道刚才所做的优化对性能是有益还是有害。

在各方面而言,测量图形性能和响应性会比测量其他类型的性能更加困难。一般来说,相关的操作会涉及到一些完全封闭的系统库,未必有权限接触的进程,甚至可能是完全无法感知的硬件。

更重要的是,涉及到图像性能,你需要真正的关心单个事件的时间,而这个时间的数量级会降低到几十毫秒的水平上。在之前的章节中,我们通过测量大量的单个事件,然后进行分割以获得单个事件的的“时长”。这其实并不完全正确,因为获取的实际上是 平均 时长。

正如在第 14 章中所见的,平均数在测量图像时长中的作用非常有限:例如现在有两组数据,第一组数据中,在 1 秒的时间内,每一帧都能在保证在 16.6 ms 后刷新,第二组数据中,同样是在 1 秒的时间内,前 50 帧在 1ms 内完成刷新,而后 10 帧以每帧 90ms 的速度刷新,虽然第二组数据的平均数比第一组数据要好,但是在视觉上很难接受这样的效果,还是第一种方法在视觉上更平滑一些。

幸运的是,我们还是有解决办法的:系统和相应的工具给出了更好的解决方案,可以让开发者直观地观察到是否高效达成了目标。例如,如果你正用同样的值重绘一个像素,这个无效的操作指令就会被标注。

本章将会介绍这些特定的工具以及它们报告出来的信息在整个图形管线(graphics pipeline)中的意义,我们还会说说如何将这些工具和之前介绍的通用工具结合起来使用。

CPU 分析仪

在之前的章节中,主要介绍了基于 CPU 绘制图像的 Quartz 框架和基于硬件绘制图像 OpenGL 框架,以及这两个框架在性能上的区别。图 15.1 展示了时间分析仪(time profile)在 Quartz 示例下表现。

时间分析仪里的前 11 个条目包含了开发者自己编写的代码,但可以看到,这些代码只占用了总时间的 3.3%,最后一个开发者编写的代码条目是个闭包,这个闭包在 -[GLBenchView drawOn:inRect:] 中定义,被 -[MPWAbstractContext ingsave:] 方法调用。剩下 96.7% 的运行时间都花在了 Quartz 的函数 CGContextDrawPath() 上。这个看起来很有意思,其中 50% 都花费在了 CGSColorMaskSover ARGB8888() 里,而且很显然支持 SSE 的函数 CGSColor MaskSoverARGB8888_sse() 在这里并没起到什么作用。

事实上,你花了大量的时间绘制路径,也许算是有用的信息,但是它并没有告诉你为什么会这样。是路径太复杂了吗?还是进行了多余的绘制?或者你正在尝试的操作对系统来说太复杂了?如何是后者的话,那我们可以肯定这是不应该发生的,因为即便是 Quartz 也能在理论上以动画帧速率(animation frame rate)填充屏幕上的每个像素点。

当使用硬件加速时,问题更严重,因为现在 CPU 几乎处于闲置状态,只是在等 GPU 的结果,图 15.2 显示了基准程序在同样时间下的数据图,不过这次用的是 OpenGL 代码,用 CPU 进行绘制。

鉴于 Quartz 示例下,CPU 花费了 2176 ms,而在 OpenGL 示例下,CPU 仅仅花费了6 ms,而这6 ms 中真正用于绘图的代码只占用了 16% 的时间。

当使用硬件协助绘图时,CPU 的分析结果不会告诉你的应用程序瓶颈在哪里。

Quartz 调试

在 Mac OS X 上,有个专门用来调试图形性能的工具,叫做 Quartz Debug。图 15.3 显示了其主要菜单选项和帧率表盘。Quartz Debug 可以调试 Mac OS X 图形堆栈中的全局元素。它不仅会检测你的程序,还会检测所有正在运行的程序,包括 Quartz Debug 本身,最好在测试之前关闭或者隐藏其他正在运行的程序。

我个人觉得最有用的选项是 Flash identical screen updates,差不多在菜单的中间位置,开启该选项后,Quartz 会用红色矩形块标注屏幕中重复刷新相同内容的区域,这表示红色区块的绘制操作是多余的,应该被删除。很显然我们没有必要绘制同样的内容。

下一步是 Flash screen updates 设置,这个设置和前面说的选项的很像,不过它会在有更新的界面上闪动一个黄色矩形框。该选项能让你区分出那些刷新次数过于频繁的地方。打开该选项可能会产生一些干扰数据。

Autoflush drewing 选项会关闭合并内存的访问模式(coalescing),所以每个绘制操作都会直接展示在屏幕上(如果开启前面所说的两个选项,可能会造成闪烁),这会产生更多干扰信息,但是这样会将绘制过程划分的更细,展示的矩形越多,就越能让开发者了解是系统是如何绘制的、以及绘制过程发生了哪些改变。

最后,应该注意随着 Quartz Debug 的运行,应用程序与平常的运行状态有一点区别,绘制这些额外的矩形会造成不小的开销,你甚至可以感觉到屏幕刷新的过程中有一些延迟。开启一个 Flash 选项,然后尝试拖拽窗口,这时不仅能看到有很多的闪烁效果,拖拽的过程也会变得迟钝。关闭延迟能让性能恢复正常,但闪烁会导致肉眼难以识别屏幕上的情况。

Core Animation 工具

iOS 有一个更高级的调试工具,这也许是因为 iOS 上的图像架构更复杂,但手机的硬件性能不够强大,所以这就要求调试更加严格和精准。总之,在 iOS 上你需要这些高级调试工具!

最主要的调试工具是 Core Animation,它属于 Instruments 一部分,并非像 Quartz Debug 那样的独立工具。它和 Instruments 集成在一起非常有用,你可以将多种工具结合起来调试,并由此寻找问题的根源。

图 15.4 显示了在开发 Wunderlist 3 时,我们遇到的一个动画性能问题。

测试针对的 iPhone 5s,但在 iPhone 4s 上动画性能下降地更为明显。通过关注性能下降区域,然后切换到 CUP 调试工具上,我们可以弄清楚发生了什么 —— 一段程序反复调用 valueForKeyPath::进行计算。回顾下第 3 章提到的,使用键值访问比直接访问或发送信息要慢得多。

简单的解决方案是日常工作不再使用 valueForKeyPath:方法,而使用循环和发送消息,这样计算速度更快。如果此路不通,还有一个办法,就是延迟计算操作,稍等片刻再计算,在后台线程上执行,而不是主线程上,或者逐步计算。

当 CPU 不再是问题

在之前的几个例子中,我们足够幸运,发现问题都出现在 CPU 上,当然这是使用了第 2 章所说的分析工具确定了问题所在。不过要是问题不在 CPU 上怎么办?iOS 的 Core Animation instrument 有一组和 Quartz Debug 工具类似的选项,除了应用广泛,也适用于一些特殊的 iPhone / iPad 环境。

正如在第 14 章中解释的那样,iOS 将最为笨重且低效的位图作为标准,虽然这样通过充分利用 GPU 来抵消性能上的文档,但这也意味着,在遇到大量数据时,数据读取效率低的缺点会格外明显。图 15.5 Core Animation 的选项表里列举出了一些虽然看起来不显眼,但有可能造成性能问题的情况,这些选项的显示结果和 Quartz Debug 的显示结果相似。

具体来说,这个调试工具支持以下功能:

  • Color Blended Layers——将目标色和来源色混合意味着需要同时读取目标色值和源色值,不混合就意味着能少读取一次数据,工具会将进行混合的图层层用红色标注,不需要混合的图层用绿色标注。
  • Color Hits Green and Misses Red——这里指的是支持 shouldRasterize 标识的系统缓存。通常状况下 Core Animation 会在每次更新后复制/混合整个视图层级树,当视图设置了 shouldRasterize 标识时,这就意味着它告诉 Core Animation 去缓存该整个视图的栅格图像。然而,系统并不会保留所有视图的栅格图,它是一个全局缓存,在有限的时间内缓存有限数量的位图。
  • Color Copied Images——用于标注图像是否能直接被 GPU 使用,或者图像是否需要通过 CPU进行一次 复制/转换(开启选项后,系统会为需要转换的情况标注颜色)。
  • Color Misaligned Image——该标记用于优化内存的访问模式。当图像边界以字(word)的方式对齐时,所有的内存只需要访问一个完整的字。当图像没有对齐时,需要读取多个字里的内容进行计算,之后 GPU 会根据计算结果对原始数据进行一些必要的处理。根据 GPU 的智能程度,额外的内存访问可能发生在每个字或者图像的边缘上。不管哪种方式,最好避免不对齐的图像,开启这个选项会标注出没有对齐的图像。
  • Flash Updated Regions——这个功能与名为 Quartz Debug 的功能相似:当屏幕上的某个区域改变后,该区域会以独特的颜色进行闪烁。目前我还没有在 iOS 的调试工具中找到与 Quartz Debug 里 “flash identical regions” 功能相似的选项。
  • Color OpenGL Fast Path Blue——开启改功能后,调试工具会标注出屏幕上哪些区域需要避免合成,应该直接使用 OpenGL 进行渲染。
  • Color Offscreen Rendered Yellow——开启该功能后,调试工具会显示哪些区域是先进行离屏渲染,再将渲染内容复制到屏幕上的,很明显额外的复制操作会存在潜在的性能问题。

和 Quartz Debug 需要注意的一样,这些选项会全局影响设备的渲染,而不是仅仅影响正在使用 Instruments 的应用。

为了更形象的描述这些选项,让我们看一下 iPad 的 PostScript/PDF 预览界面的缩略图。图 15.6 显示了看起来正常的缩略图,没有开启颜色标识。滚动的时候会有一点延迟的感觉,所以在这里我们使用刚才所说 debug 选项来找找里面的问题。

图 15.7 显示了开启了 Color Blended Layers 选项后的缩略图。每个单独的缩略图都被 Instruments 工具标红了(受纸质书籍的打印问题,这些红色区域在书中会显示为深灰色),意味着这里使用了混合功能。由于这里完全不需要显示缩略图后的背景,所以这里不应该有 blending 才对。

首当其冲的想法就是 UIImageView 没有被设置成不透明色,所以才有了阴影,阴影需要混合。然而,将视图设置成不透明色、并移除阴影后,结果还是没什么变化,由于使用了混合,所有的缩略图仍然是红色的。

经过检查,问题的原因是缩略图本身包含一个 alpha 通道。 从视觉角度上来说,这个 alpha 通道是不需要的,因为所有的像素都是不透明的,但这并不是 GPU 或者 API 可以理解的,所以它们还是会进行混合(blending)操作,这样就会重新计算目标对象的像素。

这些缩略图是参考 PDF 文件并通过 Core Graphics 位图上下文创建的,所以解决方案是在 CGBitmapContextCreate() 方法中,使用 kCGImageAlphaNoneSkipLast,而不是 kCGImageAlphaPremultiplied Last。使用 kCGImageAlphaNone 似乎是一个显而易见的去掉透明图层的办法,但是它并会不起任何作用,如果这样设置的话,函数在运行时返回了一个错误信息。

图 15.8 显示了解决方案的结果:现在屏幕上所有的东西都成了绿色(这些颜色在纸质书中显示为浅灰色),现在没有图层混合的问题了。但是你会惊奇的发现,性能几乎没有什么提升,不过这就是争取到的最好结果了,毕竟你也没费多少精力,但是优化仍然是值得的。

最后,我还查看了没有对齐的图像(如图 15.9),在图 15.9 显示 app 中没有对齐的缩略图。由于这些图片应该居中且宽度没法控制,所以不太好解决图像对齐的问题,所以就目前来讲,该问题仍未得到优化解决。

如果图像未对齐真的会造成明显的性能问题,那么我们可以修改缩略图的生成过程,让图片的宽度取整,让其接近一个几乎 “对齐安全” 的数值,接着居中绘制实际文档的大号缩略图。然而,这需要让图片的边缘透明才能保证精准还原图片,但这又会导致图层混合问题,从而增大计算开销。当然另外一种选择是在图片还原度上妥协,用小的白色边框绘制缩略图或将其缩放到略微不同的尺寸。

我在测量什么?

我们在考虑是使用静态的图像资源,还是使用代码绘制图像。其中一个考量因素就是不同技术的性能表现如何。示例 15.10 是绘制的渐变色效果。

在示例 15.1 的代码中,想绘制渐变效果,要么用 CoreGraphics,要么加载一张预渲染渐变效果的 PNG 或 JPEG 图片文件,并测量一下每种方法的时间。

示例 15.1 尝试记录加载图像的时间和生成图像的时间

-(void)timeImageDrawingAndLoading {
    CGFloat drawnTime, PNGTime, JPGTime;
    CFTimeInterval startTime, endTime;
    startTime = CACurrentMediaTime();
    self.drawnImageView.image = [self drawnImage];
    endTime = CACurrentMediaTime();
    drawnTime += 1000*(endTime - startTime);

    //What Am I Measuring? 321
    startTime = CACurrentMediaTime();
    self.PNGImageView.image = [UIImage imageNamed:@"Image.png"];
    endTime = CACurrentMediaTime();

    PNGTime += 1000*(endTime - startTime);
    SEL flusher = NSSelectorFromString(@"_flushSharedImageCache");
    [[UIImage class] performSelector:flusher];

    startTime = CACurrentMediaTime();
    self.JPGImageView.image = [UIImage imageNamed:@"Image.jpg"];
    endTime = CACurrentMediaTime();

    JPGTime += 1000*(endTime - startTime);
    [[UIImage class] performSelector:flusher];
    NSLog(@"Drawing %f, PNG %f, JPG %f", drawnTime, PNGTime, JPGTime);
}

测量出来的时间是:绘制用了 11.1 毫秒,PNG 3.89 毫秒,JPG 0.51 毫秒,所以这是一个能够说明问题的例子,不是么?我们可以粗略地解释这些数字:正如在第 14 章中见到的,Core Animation 基于位图,所以绘制是一个额外步骤,然而图片的加载或存储可以简单的理解为是直接从后备存储器(backing storage)中获取的。

先别急着下结论!

因为这些时长是模拟器的测量结果(数值可疑),另外,JPG 的读取时间比 PNG 快了 8 倍?这有点奇怪啊。

首先,打开 Instruments,了解一下大体发生了什么,timeImageDrawing AndLoading 方法中没有图片解码,相反稍后在实际绘制这些视图时,会看到一些 PNG 的解码操作。我没有看到 JPEG 的解码过程,整个过程发生的太快了。这验证了我们对 iOS 中图片加载和解码的理解:它是一种懒加载的模式,只在万不得已才会加载/解码,比如绘图时。

一些书籍声称,当为 UIImageViewimage 属性赋值或者 CALayerimage 属性赋值时,解码是会被强制执行的,不过我在实践中并没有发现这点。

在真机上运行代码得到以下结果:绘制 3.26 毫秒,PMG 67.2 毫秒,JPG 49.1 毫秒。这次,结果反转了,绘制比 PNG 解码快 20 倍,比 JPG 解码快 15 倍。然而,对该结果也令人困惑——因为在这里 iOS 实际上并没有解码图片(通过 Instrument 工具可以确认这点),那么,到底发生了什么?

如果仔细观察一下 Instruments,可以在当前的例子中,JPG、PNG 的“解码”时长是包含了各自解码器的初始时间成本,所以“加载”图片的副本可能会快很多倍:绘制 3.50 毫秒,PNG 2.54 毫秒,JPG 1.91 毫秒。但是这些并不是解码图片的真正时长,现在得到的时长只是从硬盘中读取图片元数据并用于将来解码用。

为了将在视图中绘制图像与解码过程区分出来(当然这很难区分),我们需要在位图上下文中“绘制”图像,使用此方法获得以下时长:绘制 3.41 毫秒,PNG 7.91 毫秒,JPG 8.39 毫秒。这是目前最接近的真实时间,尽管很有点小误差,我们不得不把解码和绘制时间合在了一起。尽管苹果公司声称该操作与纯解码基本上是一样的,但我们还是不能百分之百确定。

另一个异常现象是,尽管现在可以清楚地在 CPU 调试工具中追踪到 PNG 的解码时间,但还是没法追踪 JPEG 的解码时间。运行 CPU Profile Instrument 工具,打开 “Record Waiting Threads” 选项,我们可以看到 JPEG 解码位于 mach_msg_trap() 中,这意味着它在等待一个不会被 CPU 调试工具显示任何信息的进程完成任务。答案很显然是 iPhone 有一个 JPEG 的硬件解码器,使用该解码器时,不会在 CPU 上显示任何信息。

硬件解码器在处理大图片时非常快,但对于小图片而言,这个开销就比较大了,即便针对 PNG 使用相对缓慢的 zlib/flate 解压器,总体速度依然很快,而且类似 TurboJPEG 这样专门的 JPEG 库甚至可以快好几倍。

总结

本章展示了在衡量图形性能过程中会遇到各种错综复杂的情况,除了要考虑其他子系统的影响外,实际操作中的延迟也会影响测量结果。获取这些操作的平均值是无法得到有意义的结果。

然而,还是有一些测量的途径的——比如,Mark 1 eyeball 是个非常好的测量装置,特别是当它配备一个性能良好的电子秒表(带有机械按钮的那种大秒表,不是 iPhone 上的那种)时,效果更佳。当我们使用手动测量结果作为性能评估的关键指标是,我们只花费了十分之一秒就诊断出问题所在。

在下一章中,我们将关注如何解决发现的问题。