本文翻译自:Optimizing Images
参考翻译:翻译 - 图像优化
有句话说:最好的相机就是你随身携带的。如果这句话是对的,毫无疑问 - iPhone 是目前这个星球上主要的相机。我们的产业也证明了这点。
在度假?如果没有在你的 Instagram 上放几张照片,那就等于没有发生过。
爆炸新闻?通过 Twitter 上的照片,了解事件的实时进展。
等等。
平台上无处不在展示着图像,在低性能和内存紧张的情况下展示他们,很容易导致失控。如果了解一些 UIKit 的原理以及它是如何处理图像的,那么我们就可以节省大量资源,并且逃脱系统的清除制裁。
理论上
突击测验:我女儿的这张 266 k 的照片(还很时尚),在一个 iOS APP 上展示会消耗多少内存?
剧透下:不是 266 k,也不是 2.66 M,大约需要 14 M。
为什么?
iOS 的内存消耗本质上取决于图像的尺寸,而与它文件的大小关系不大。这张图片的尺寸是 1718x2048 px 。假设 1 px 会消耗 4 个字节:
1718 * 2048 * 4 / 1024 / 1024 = 13.42 M 大约
想象一下,你有一个用户列表,其中每一行的左侧都展示一个常见的圆角头像。如果你认为每张图片都可以通过 ImageOptim 或者其他的类似选项,被压缩的又好又彻底,那就错了。每张图片保守估计都是 256x256 的尺寸,这在内存方面的消耗依然严重。
渲染过程
也就是说:探究下这中间发生了什么很有必要。当你加载一张图片的时候,会经过以下三步:
- 加载:iOS 把压缩的图片加载到内存中,(在我们的例子中)此时消耗 266 k 。现在还不用担心。
- 解码:现在 iOS 把图片转化为 GPU 可以处理的数据。这是解压缩,这里就产生了上面提到的 14 M 内存开销。
- 渲染:就像名称一样,现在图像数据准备好了以任何方式进行渲染。即使是放在大小为 60x60 pt 的 image view 上。s
解码阶段是重头。此时,系统创建了一个缓冲区,准确的说是图像缓冲区,这里会将图像放在内存当中。这就说明了为什么内存开销和图像尺寸有关而不是它的文件大小。也清晰的解释了,处理图像时为什么尺寸对于内存消耗如此重要。
对于 UIImage
来说,当我们把从网络或者其他途径获取到的图像数据给它时,它会将缓冲区的数据解码为数据声称的编码格式(例如:PNG 或者 JPEG)。然而,它到这里就停止了。由于渲染不是一次性操作,UIImage
会保留这个缓冲区,这样就只需要解码一次。
扩展一下:一个 iOS APP 完整的缓冲区称之为帧缓冲区。这是 iOS APP 展示在屏幕上时,负责持有输出内容的东西。任何 iOS 设备显示硬件,都会使用其中的像素信息来点亮对应物理像素。
这里时间很重要。为了获得每秒 60 帧的滑动效果,在 APP 的 window 及其 subview 改变的时候(例如:给 image view 添加 image),帧缓冲区需要 UIKit 来渲染。如果渲染太慢,就丢帧了。
觉得 1/60 s 的处理时间很短?Pro Motion 的设备只给 1/120 s 的时间。
尺寸确实重要
我们其实可以很简单的直观的看到这步处理时内存消耗的过程。我创建了一个实验 APP 通过一个 image view 来展示我女儿的照片:
let filePath = Bundle.main.path(forResource:"baylor", ofType: "jpg")!
let url = NSURL(fileURLWithPath: filePath)
let fileImage = UIImage(contentsOfFile: filePath)
// Image view
let imageView = UIImageView(image: fileImage)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 400).isActive = true
view.addSubview(imageView)
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
生产环境谨慎使用强制解包。我们这里是实验场景。
代码运行之后看起来是这样的:
[图片上传失败...(image-ded4c5-1564573290337)]
虽然我们在一个小的多的 image view 上去展示图片,但 LLDB 展示给我们图片的真实尺寸如下:
, {1718, 2048}
注意:这里是点表示。如果是在一个 2x 或者 3x 设备上,那么这个数需要更大。接下去我们用 vmmap
来确认下这张图片是否真的消耗了将近 14 M 的内存。
vmmap --summary baylor.memgraph
有几点比较突出(为了简洁,只截取部分):
Physical footprint: 69.5M
Physical footprint (peak): 69.7M
70 M 左右给了我们一个很好的基准 ,来衡量之后的修改是否有效。如果我们使用 grep 命令来查看 Image IO 的相关信息,可以看到我们图片的开销:
vmmap --summary baylor.memgraph | grep "Image IO"
Image IO 13.4M 13.4M 13.4M 0K 0K 0K 0K 2
啊哈,这里确实有接近 14 M 的脏内存,就如同我们之前在纸巾上计算出来的图片开销。背景补充下,下面这个终端的截图说明了被 grep 命令省略掉的每一列的具体含义:
[图片上传失败...(image-ca1ac0-1564573290337)]
很明显:此时我们消耗了整张图片的开销在 300x400 的 image view 上。图像的尺寸是关键,但也不是唯一的重要因素。
色域
你所请求的内存部分消耗取决于另一重要因素 - 色域。在上面的例子中,我们做了个假设,但这个假设并不适合大多数 iPhone - 也就是图像采用的是 sRGB 格式。即每个像素 4 个字节,分别表示红,蓝,绿以及透明度。
如果你使用的是更广色域的设备(像:iPhone 8+ 或者 iPhoneX)拍摄的照片,那么需要的数字可能翻倍。反之亦然,Metal 可以使用 Alpha 8 格式,就像它的名字那样只有单一通道。
这里有很多值得思考的。这也是你为什么该使用 UIGraphicsImageRenderer 而不是 UIGraphicsBeginImageContextWithOptions
的原因之一。后者总是会使用 sRGB 格式,这意味着你会丢失更广色域的格式,如果你想要的话。或者错过节省内存开销的机会,如果你将使用更小的色域格式。在 iOS 12 上 UIGraphicsImageRenderer
会自动为你选择合适的格式。
最后别忘记,很多图片并不是拍摄出来的,而是绘制。不是刻意重复之前写过的东西,而是怕你错过:
let circleSize = CGSize(width: 60, height: 60)
UIGraphicsBeginImageContextWithOptions(circleSize, true, 0)
// Draw a circle
let ctx = UIGraphicsGetCurrentContext()!
UIColor.red.setFill()
ctx.setFillColor(UIColor.red.cgColor)
ctx.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
ctx.drawPath(using: .fill)
let circleImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
这个圆形图片使用的是每像素 4 字节的格式。但是,如果你使用 UIGraphicsImageRenderer
通过渲染器自动选择合适的格式,每像素 1 字节,就能节约 75% 的资源。
let circleSize = CGSize(width: 60, height: 60)let renderer = UIGraphicsImageRenderer(bounds: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
let circleImage = renderer.image{ ctx in
UIColor.red.setFill()
ctx.cgContext.setFillColor(UIColor.red.cgColor)
ctx.cgContext.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
ctx.cgContext.drawPath(using: .fill)}
缩小 vs 降采样
跳过简单绘制的场景 - 许多与图像相关的内存问题,源自真实拍摄的照片。像肖像,风景照等。
对于一些工程师,他们有理由认为(逻辑上也说得过去)通过 UIImage
缩小图像的尺寸就够了。由于上面提及的原因,这样并不够。而且根据苹果公司的 Kyle Howarth 的说法,内部空间坐标转换,效率也不高。
如我们在渲染过程讨论到的,UIImage
的主要问题是在于它会将原始图像解压,并加载到内存中。在理想状态下,我们应该减小图像缓冲区的尺寸。
幸运的是,我们可以通过调整图像尺寸来优化内存占用。很多人认为系统默认做了这个优化,实际上没有,这需要你自己去调整。
接下来我们用更底层的 API 来降采样:
let imageSource = CGImageSourceCreateWithURL(url, nil)!
let options: [NSString:Any] = [kCGImageSourceThumbnailMaxPixelSize:400,
kCGImageSourceCreateThumbnailFromImageAlways:true]
if let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) {
let imageView = UIImageView(image: UIImage(cgImage: scaledImage))
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 400).isActive = true
view.addSubview(imageView)
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
}
展示良好,我们得到和之前一样的效果。但是这里,我们使用了 CGImageSourceCreateThumbnailAtIndex()
而不是和之前一样将整个图像放入 image view 当中。该真相大白了,让我们再次使用 vmmap 来确认我们的优化是否奏效(同样,为了简洁,截取部分):
vmmap -summary baylorOptimized.memgraph
Physical footprint: 56.3M
Physical footprint (peak): 56.7M
所有的内存开销都在这里。和之前的 69.5 M 相比,现在是 56.3 M ,节省了 13.2 M 。这是很大的节省,几乎是整个图片的内存开销。
更进一步,你可以针对你的情况,通过各种选项来打磨。在 WWDC 18 session 219 “Images and Graphics Best Practices” 中,苹果工程师 Kyle Sluder 展示了个有趣的方法,通过 kCGImageSourceShouldCacheImmediately
这个 flag 来控制解码触发时机:
func downsampleImage(at URL:NSURL, maxSize:Float) -> UIImage
{
let sourceOptions = [kCGImageSourceShouldCache:false] as CFDictionary
let source = CGImageSourceCreateWithURL(URL as CFURL, sourceOptions)!
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways:true,
kCGImageSourceThumbnailMaxPixelSize:maxSize
kCGImageSourceShouldCacheImmediately:true,
kCGImageSourceCreateThumbnailWithTransform:true,
] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions)!
return UIImage(cgImage: downsampledImage)
}
这里 Core Graphics 不会触发解码,直到你需要展示缩略图。并且,注意传入 kCGImageSourceCreateThumbnailMaxPixelSize
就像我们在两个例子中那样。因为,如果不这么做的话,你得到的缩略图的尺寸和你的原始图片是一样的。根据文档描述:
“... 如果没有指定最大尺寸,那么缩略图就会是完整图片的尺寸,而这可能不是你想要的。”
所以到底发生了什么?简而言之,我们创造了一个比之前更小的图像缓冲区,根据缩略图的缩小方程。回顾下我们的渲染过程,第一步(加载)我们创造了一个 image view 显示大小的缓冲区,而不是 UIImage
解码需要的整个图像尺寸的缓冲区。
整片文章的主旨是什么?找机会降采样你的图像,而不是通过 UIImage
缩小他们。
附加内容
我通常会将这部分内容和 iOS 11 中介绍的 prefetch API 一起使用。记住,即使我们在 table view 或者 collection view 需要 cell 的时候,才去解码图片,这依然会带来一个 CPU 的使用峰值。
尽管 iOS 在处理连续性能消耗时很高效,但在我们的例子中,这种需求是时不时产生的,最好是放在你自己的队列中处理。同时也就把解码放到了后台处理,这是另一个好处。
快遮住你的眼睛,我业余项目的 Objective-C 代码要出来了:
// Use your own queue instead of a global async one to avoid potential thread explosion
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray *)indexPaths
{
if (self.downsampledImage != nil ||
self.listItem.mediaAssetData == nil) return;
NSIndexPath *mediaIndexPath = [NSIndexPath indexPathForRow:0
inSection:SECTION_MEDIA];
if ([indexPaths containsObject:mediaIndexPath])
{
CGFloat scale = tableView.traitCollection.displayScale;
CGFloat maxPixelSize = (tableView.width - SSSpacingJumboMargin) * scale;
dispatch_async(self.downsampleQueue, ^{
// Downsample
self.downsampledImage = [UIImage downsampledImageFromData:self.listItem.mediaAssetData
scale:scale
maxPixelSize:maxPixelSize];
dispatch_async(dispatch_get_main_queue(), ^ {
self.listItem.downsampledMediaImage = self.downsampledImage;
});
});
}
}
注意在你有大量原始图像时使用资源管理,原因是它会为你管理缓存区大小(还有很多其他好处)。
更多关于内存管理和图像的信息,可以关注这些信息巨大的 WWDC 18 的 session:
- iOS Memory Deep Dive
- Images and Graphics Best Practices
结语
你无法察觉那些你不知道的事。对于编程来说,你基本上等于报名了一个每小时跑 10,000 英里,只为跟上创新和改变的职业生涯。这意味着,有堆积如山的 API, 框架,设计模式和优化方案你并不知道。
对于图像领域来说也是如此。大多数时候,你初始化一个 UIImageView
然后放入一些好看的像素。我懂了,摩尔定律什么的。现在这些手机运行速度快,而且有着数 G 内存,并且我们把人类运送到月球上,都只用了一台不到 100 k 内存的电脑。
但是与恶魔共舞的时间长了,他一定会扬起他的犄角。 不要让系统因为一张 1 G 内存的自拍,强制杀死你的应用。希望,这些知识和技巧能节省你在崩溃日志上的时间。
下次见 ✌️。