在第13章“高效绘图”中,我们研究了和Core Graphics
绘图相关的性能问题,以及如何修复。和绘图性能相关紧密相关的是图像性能。在这一章中,我们将研究如何 优化从闪存驱动器或者网络中加载和显示图片。
加载和潜伏
绘图实际消耗的时间通常并不是影响性能的因素。图片消耗很大一部分内存,而且不太可能把需要显示的图片都保留在内存中,所以需要在应用运行的时候周期性地加载和卸载图片。
图片文件加载的速度被CPU
和IO(输入/输出)
同时影响。iOS
设备中的闪存已经 比传统硬盘快很多了,但仍然比RAM
慢将近200
倍左右,这就需要很小心地管理加载,来避免延迟。
只要有可能,试着在程序生命周期不易察觉的时候来加载图片,例如启动,或者在屏幕切换的过程中。按下按钮和按钮响应事件之间最大的延迟大概是200ms
,这 比动画每一帧切换的16ms
小得多。你可以在程序首次启动的时候加载图片,但是如果20
秒内无法启动程序的话,iOS
检测计时器就会终止你的应用(而且如果启动 大于2,3秒
的话用户就会抱怨了)。
有些时候,提前加载所有的东西并不明智。比如说包含上千张图片的图片传送带:用户希望能够能够平滑快速翻动图片,所以就不可能提前预加载所有图片;那样会消耗太多的时间和内存。
有时候图片也需要从远程网络连接中下载,这将会比从磁盘加载要消耗更多的时间,甚至可能由于连接问题而加载失败(在几秒钟尝试之后)。你不能够在主线程中加载网络造成等待,所以需要后台程。
线程加载
在第12章“性能调优”
我们的联系人列表例子中,图片都非常小,所以可以在主线 程同步加载。但是对于大图来说,这样做就不太合适了,因为加载会消耗很长时间,造成滑动的不流畅。滑动动画会在主线程的run loop
中更新,所以会有更多运 行在渲染服务进程中CPU
相关的性能问题。
GCD和 NSOperationQueue
GCD(Grand Central Dispatch)
和NSOperationQueue
很类似,都给我们提供 了队列闭包块来在线程中按一定顺序来执行。 NSOperationQueue
有一个 Objecive-C
接口(而不是使用GCD
的全局C
函数),同样在操作优先级和依赖关系 上提供了很好的粒度控制,但是需要更多地设置代码。
延迟解压
一旦图片文件被加载就必须要进行解码,解码过程是一个相当复杂的任务,需要消耗非常长的时间。解码后的图片将同样使用相当大的内存。
用于加载的CPU
时间相对于解码来说根据图片格式而不同。对于PNG
图片来说, 加载会比JPEG
更长,因为文件可能更大,但是解码会相对较快,而且Xcode
会把 PNG
图片进行解码优化之后引入工程。JPEG
图片更小,加载更快,但是解压的步 骤要消耗更长的时间,因为JPEG
解压算法比基于zip
的PNG
算法更加复杂。
当加载图片的时候,iOS
通常会延迟解压图片的时间,直到加载到内存之后。这就会在准备绘制图片的时候影响性能,因为需要在绘制之前进行解压(通常是消耗 时间的问题所在)。
最简单的方法就是使用UIImage
的+ imageNamed:
方法避免延时加载。不像+ +imageWithContentsOfFile:
(和其他别的 UIImage
加载方法),这个方法会在加载图片之后立刻进行解压(就和本章之前我们谈到的好处一样)。问题在于 +imageNamed:
只对从应用资源束中的图片有效,所以对用户生成的图片内容 或者是下载的图片就没法使用了。
另一种立刻加载图片的方法就是把它设置成图层内容,或者是UIImageView
的image
属性。不幸的是,这又需要在主线程执行,所以不会 对性能有所提升。
第三种方式就是绕过 UIKit
,像下面这样使用ImageIO
框架:
这样就可以使用 kCGImageSourceShouldCache
来创建图片,强制图片立刻解 压,然后在图片的生命周期保留解压后的版本。
最后一种方式就是使用UIKit
加载图片,但是立刻会知道 CGContext
中去。图片必须要在绘制之前解压,所以就强制了解压的及时性。这样的好处在于绘制图片可以在后台线程(例如加载本身)执行,而不会阻塞UI
。
有两种方式可以为强制解压提前渲染图片:
- 将图片的一个像素绘制成一个像素大小的
CGContext
。这样仍然会解压整张图片,但是绘制本身并没有消耗任何时间。这样的好处在于加载的图片并不会 在特定的设备上为绘制做优化,所以可以在任何时间点绘制出来。同样iOS
也 就可以丢弃解压后的图片来节省内存了。 - 将整张图片绘制到
CGContext
中,丢弃原始的图片,并且用一个从上下文内 容中新的图片来代替。这样比绘制单一像素那样需要更加复杂的计算,但是因 此产生的图片将会为绘制做优化,而且由于原始压缩图片被抛弃了,iOS
就不能够随时丢弃任何解压后的图片来节省内存了。
需要注意的是苹果特别推荐了不要使用这些诡计来绕过标准图片解压逻辑(所以也是他们选择用默认处理方式的原因),但是如果你使用很多大图来构建应用,那如果想提升性能,就只能和系统博弈了。
如果不使用+imageNamed:
,那么把整张图片绘制到 CGContext
可能是最佳的方式了。尽管你可能认为多余的绘制相较别的解压技术而言性能不是很高,但是 新创建的图片(在特定的设备上做过优化)可能比原始图片绘制的更快。
同样,如果想显示图片到比原始尺寸小的容器中,那么一次性在后台线程重新绘制到正确的尺寸会比每次显示的时候都做缩放会更有效(尽管在这个例子中我们加
载的图片呈现正确的尺寸,所以不需要多余的优化)。
CATiledLayer
如第6章“专用图层”
中的例子所示,CATiledLayer
可以用来异步加载和显示大型图片,而不阻塞用户输入。但是我们同样可以使用 CATiledLayer
在 UICollectionView
中为每个表格创建分离
的CATiledLayer
实例加载传动器图片,每个表格仅使用一个图层。
这样使用 CATiledLayer
有几个潜在的弊端:
-
CATiledLayer
的队列和缓存算法没有暴露出来,所以我们只能祈祷它能匹配我们的需求 -
CATiledLayer
需要我们每次重绘图片到CGContext
中,即使它已经解压缩,而且和我们单元格尺寸一样(因此可以直接用作图层内容,而不需要重 绘)。
我们来看看这些弊端有没有造成不同。
需要解释几点:
-
CATiledLayer
的tileSize
属性单位是像素,而不是点,所以为了保证瓦片和表格尺寸一致,需要乘以屏幕比例因子。 -
在
- drawLayer: inContext:
方法中,我们需要知道图层属于哪一个indexPath
以加载正确的图片。这里我们利用了CALayer
的KVC
来存储和检索任意的值,将图层和索引打标签。
结果 CATiledLayer
工作的很好,性能问题解决了,而且和用GCD
实现的代码 量差不多。仅有一个问题在于图片加载到屏幕上后有一个明显的淡入。
我们可以调整CATiledLayer
的fadeDuration
属性来调整淡入的速度,或者直接将整个渐变移除,但是这并没有根本性地去除问题:在图片加载到准备绘制的 时候总会有一个延迟,这将会导致滑动时候新图片的跳入。这并不是 CATiledLayer
的问题,使用GCD
的版本也有这个问题。
即使使用上述我们讨论的所有加载图片和缓存的技术,有时候仍然会发现实时加载大图还是有问题。就和13章
中提到的那样,iPad
上一整个视网膜屏图片分辨率达 到了2048x1536
,而且会消耗12MB
的RAM
(未压缩)。第三代iPad
的硬件并不能 支持1/60
秒的帧率加载,解压和显示这种图片。即使用后台线程加载来避免动画卡顿,仍然解决不了问题。
我们可以在加载的同时显示一个占位图片,但这并没有根本解决问题,我们可以做到更好。
分辨率交换
视网膜分辨率(根据苹果市场定义)代表了人的肉眼在正常视角距离能够分辨的最小像素尺寸。但是这只能应用于静态像素。当观察一个移动图片时,你的眼睛就会对细节不敏感,于是一个低分辨率的图片和视网膜质量的图片没什么区别了。
如果需要快速加载和显示移动大图,简单的办法就是欺骗人眼,在移动传送器的 时候显示一个小图(或者低分辨率),然后当停止的时候再换成大图。这意味着我 们需要对每张图片存储两份不同分辨率的副本,但是幸运的是,由于需要同时支持 Retina
和非Retina
设备,本来这就是普遍要做到的。
如果从远程源或者用户的相册加载没有可用的低分辨率版本图片,那就可以动态 将大图绘制到较小的 CGContext
,然后存储到某处以备复用。
为了做到图片交换,我们需要利用 UIScrollView
的一些实
现UIScrollViewDelegate
协议的委托方法(和其他类似于UITableView
和UICollectionView
基于滚动视图的控件一样):
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset NS_AVAILABLE_IOS(5_0);
你可以使用这几个方法来检测传送器是否停止滚动,然后加载高分辨率的图片。只要高分辨率图片和低分辨率图片尺寸颜色保持一致,你会很难察觉到替换的过程(确保在同一台机器使用相同的图像程序或者脚本生成这些图片)。
缓存
如果有很多张图片要显示,最好不要提前把所有都加载进来,而是应该当移出屏幕之后立刻销毁。通过选择性的缓存,你就可以避免来回滚动时图片重复性的加载了。
缓存其实很简单:就是存储昂贵计算后的结果(或者是从闪存或者网络加载的文 件)在内存中,以便后续使用,这样访问起来很快。问题在于缓存本质上是一个权 衡过程 - 为了提升性能而消耗了内存,但是由于内存是一个非常宝贵的资源,所以 不能把所有东西都做缓存。
何时将何物做缓存(做多久)并不总是很明显。幸运的是,大多情况下,iOS都 为我们做好了图片的缓存。
+imageNamed: 方法
之前我们提到使用[UIImage imageName:]
加载图片有个好处在于可以立刻解 压图片而不用等到绘制的时候。但是[UIImage imageName:]
方法有另一个非常 显著的好处:它在内存中自动缓存了解压后的图片,即使你自己没有保留对它的任何引用。
对于iOS
应用那些主要的图片(例如图标,按钮和背景图片),使用[UIImage imageNamed:]
加载图片是最简单最有效的方式。在nib
文件中引用的图片同样也是这个机制,所以你很多时候都在隐式的使用它。
但是[UIImage imageNamed:]
并不适用任何情况。它为用户界面做了优化,但是并不是对应用程序需要显示的所有类型的图片都适用。有些时候你还是要实现自 己的缓存机制,原因如下:
-
[UIImage imageNamed:]
方法仅仅适用于在应用程序资源束目录下的图片, 但是大多数应用的许多图片都要从网络或者是用户的相机中获取,所以[UIImage imageNamed:]
就没法用了。 -
[UIImage imageNamed:]
缓存用来存储应用界面的图片(按钮,背景等 等)。如果对照片这种大图也用这种缓存,那么iOS
系统就很可能会移除这些图片来节省内存。那么在切换页面时性能就会下降,因为这些图片都需要重新加载。对传送器的图片使用一个单独的缓存机制就可以把它和应用图片的生命周期解耦。 -
[UIImage imageNamed:]
缓存机制并不是公开的,所以你不能很好地控制 它。例如,你没法做到检测图片是否在加载之前就做了缓存,不能够设置缓存 大小,当图片没用的时候也不能把它从缓存中移除。
自定义缓存
构建一个所谓的缓存系统非常困难。菲尔 卡尔顿曾经说过:“在计算机科学中只 有两件难事:缓存和命名”。
如果要写自己的图片缓存的话,那该如何实现呢?让我们来看看要涉及哪些方面:
-
选择一个合适的缓存键 - 缓存键用来做图片的唯一标识。如果实时创建图片, 通常不太好生成一个字符串来区分别的图片。在我们的图片传送带例子中就很 简单,我们可以用图片的文件名或者表格索引。
-
提前缓存 - 如果生成和加载数据的代价很大,你可能想当第一次需要用到的时 候再去加载和缓存。提前加载的逻辑是应用内在就有的,但是在我们的例子 中,这也非常好实现,因为对于一个给定的位置和滚动方向,我们就可以精确 地判断出哪一张图片将会出现。
-
缓存失效 - 如果图片文件发生了变化,怎样才能通知到缓存更新呢?这是个非 常困难的问题(就像菲尔 卡尔顿提到的),但是幸运的是当从程序资源加载静 态图片的时候并不需要考虑这些。对用户提供的图片来说(可能会被修改或者 覆盖),一个比较好的方式就是当图片缓存的时候打上一个时间戳以便当文件 更新的时候作比较。
-
缓存回收 - 当内存不够的时候,如何判断哪些缓存需要清空呢?这就需要到你 写一个合适的算法了。幸运的是,对缓存回收的问题,苹果提供了一个叫做
NSCache
通用的解决方案
NSCache
NSCache
和NSDictionary
类似。你可以通过- setObject:forKey:
和- object:forKey:
方法分别来插入分别来插入,检索。和字典不同的是,NSCache
在系统低内存的时候自动丢弃存储的对象。
NSCache
用来判断何时丢弃对象的算法并没有在文档中给出,但是你可以使用- setCountLimit :
方法设置缓存大小,以及 -setObject:forKey:cost:
来对每个存储的对象指定消耗的值来提供一些暗示。
指定消耗数值可以用来指定相对的重建成本。如果对大图指定一个大的消耗值, 那么缓存就知道这些物体的存储更加昂贵,于是当有大的性能问题的时候才会丢弃 这些物体。你也可以用- setTotalCostLimit:
方法来指定全体缓存的尺寸。
NSCache
是一个普遍的缓存解决方案,我们创建一个比传送器案例更好的自定 义的缓存类。(例如,我们可以基于不同的缓存图片索引和当前中间索引来判断哪些图片需要首先被释放)。但是 NSCache
对我们当前的缓存需求来说已经足够 了;没必要过早做优化。
文件格式
图片加载性能取决于加载大图的时间和解压小图时间的权衡。很多苹果的文档都 说PNG
是iOS
所有图片加载的最好格式。但这是极度误导的过时信息了。
PNG
图片使用的无损压缩算法可以比使用JPEG
的图片做到更快地解压,但是由 于闪存访问的原因,这些加载的时间并没有什么区别。
相对于不友好的PNG
图片,相同像素的JPEG
图片总是比PNG
加载更快,除非一些非常小的图片、但对于友好的PNG
图片,一些中大尺寸的图效果还 是很好的。
所以对于之前的图片传送器程序来说,JPEG
会是个不错的选择。如果用JPEG
的话,一些多线程和缓存策略都没必要了。
但JPEG
图片并不是所有情况都适用。如果图片需要一些透明效果,或者压缩之 后细节损耗很多,那就该考虑用别的格式了。苹果在iOS
系统中对PNG
和JPEG
都 做了一些优化,所以普通情况下都应该用这种格式。也就是说在一些特殊的情况下 才应该使用别的格式。
混合图片
对于包含透明的图片来说,最好是使用压缩透明通道的PNG
图片和压缩RGB
部分的JPEG
图片混合起来加载。这就对任何格式都适用了,而且无论从质量还是文 件尺寸还是加载性能来说都和PNG
和JPEG
的图片相近。
JPEG 2000
除了JPEG
和PNG
之外iOS
还支持别的一些格式,例如TIFF
和GIF
,但是由于他们 质量压缩得更厉害,性能比JPEG
和PNG
糟糕的多,所以大多数情况并不用考虑。
但是iOS
之后,苹果低调添加了对JPEG 2000
图片格式的支持,所以大多数人并 不知道。它甚至并不被Xcode
很好的支持 - JPEG 2000
图片都没在Interface Builder
中显示。
但是JPEG 2000
图片在(设备和模拟器)运行时会有效,而且比JPEG
质量更好,同样也对透明通道有很好的支持。但是JPEG 2000
图片在加载和显示图片方面明显要比PNG
和JPEG
慢得多,所以对图片大小比运行效率更敏感的时候,使用它是一个不错的选择。
但仍然要对JPEG 2000
保持关注,因为在后续iOS
版本说不定就对它的性能做提升,但是在现阶段,混合图片对更小尺寸和质量的文件性能会更好。
PVRTC
当前市场的每个iOS
设备都使用了Imagination Technologies PowerVR
图像芯片 作为GPU
。PowerVR
芯片支持一种叫做PVRTC(PowerVR Texture Compression
)的标准图片压缩。
和iOS
上可用的大多数图片格式不同,PVRTC
不用提前解压就可以被直接绘制到 屏幕上。这意味着在加载图片之后不需要有解压操作,所以内存中的图片比其他图片格式大大减少了(这取决于压缩设置,大概只有1/60
那么大)。
但是PVRTC仍然有一些弊端:
-
尽管加载的时候消耗了更少的
RAM
,PVRTC
文件比JPEG
要大,有时候甚至比PNG
还要大(这取决于具体内容),因为压缩算法是针对于性能,而不是文件尺寸。 -
PVRTC
必须要是二维正方形,如果源图片不满足这些要求,那必须要在转换成PVRTC
的时候强制拉伸或者填充空白空间。 -
质量并不是很好,尤其是透明图片。通常看起来更像严重压缩的
JPEG
文件。
-
PVRTC
不能用Core Graphics
绘制,也不能在普通的UIImageView
显示,也 不能直接用作图层的内容。你必须要用作OpenGL
纹理加载PVRTC
图片,然后 映射到一对三角板来在CAEAGLLayer
或者GLKView
中显示。 - 创建一个
OpenGL
纹理来绘制PVRTC
图片的开销相当昂贵。除非你想把所有图 片绘制到一个相同的上下文,不然这完全不能发挥PVRTC
的优势。 -
PVRTC
使用了一个不对称的压缩算法。尽管它几乎立即解压,但是压缩过程相 当漫长。在一个现代快速的桌面Mac
电脑上,它甚至要消耗一分钟甚至更多来 生成一个PVRTC
大图。因此在iOS
设备上最好不要实时生成。
如果你愿意使用OpehGL
,而且即使提前生成图片也能忍受得了,那么PVRTC
将 会提供相对于别的可用格式来说非常高效的加载性能。比如,可以在主线程1/60
秒 之内加载并显示一张2048×2048
的PVRTC
图片(这已经足够大来填充一个视网膜 屏幕的iPad
了),这就避免了很多使用线程或者缓存等等复杂的技术难度。
总结
在这章中,我们研究了和图片加载解压相关的性能问题,并延展了一系列解决方案。