图像优化
翻译自:https://www.swiftjectivec.com/optimizing-images/
Written by Jordan Morgan • Dec 11th, 2018
他们说最好的相机就是你随身携带的手机。假如这句话相当有分量的话,那么毫无疑问:iPhone就是地球上最重要的相机。我们的行业也证实了这一点。
你正在度假?如果你的 Instagram 故事集中没有几张公开的照片做记录,那就跟没发生过一样。
有突发性新闻?查看 Twitter,通过查看正在发布的活动照片,就可以看到有哪些媒体正在进行报到。
诸如此类。
但是,因为他们在平台上无处不在,并以高性能和存储保守的方式来展示他们的行为,很容易会变成失控的尝试。稍微对 UIKit 中发生的事情有所了解,并且了解为什么它要处理图像,你就可以从中节省很多内存消耗并放弃对低内存处理机制 Jetsam 无情的愤怒。
理论
流行测试:这张 266 千字节(并且相当漂亮)的照片,在 iOS 应用程序中需要多少内存?
剧透警报 - 不是 266 千字节,也不是 2.66 兆字节。而是大约 14 兆字节。
为什么?
iOS 本质上是从图像的尺寸获得内存命中 - 而实际的文件大小与此无关。这张照片的尺寸为 1718 像素宽,2048 像素高。假设每个像素点将耗费 4 个字节:
1718 * 2048 * 4 / 1024 / 1024 = 13.42 megabytes give or take
想象一下,如果你有一个带有用户列表的表视图,在它每一行的左边都显示了用户的圆形头像。如果你认为每件事情都是合法的,因为每件事情都已经从图像优化或类似的东西封装得很好,那可能不是这样的。如果每一个都是保守的 256 x 256 ,你依然会对内存产生相当大的影响。
渲染管道
那么所有这些事情都在说明 - 很值得去了解其背后发生的事情。当加载图像时,它将分三步去处理:
加载 - iOS 将压缩后的图像加载(在我们的示例中)266 千字节到内存中。这个真的不用担心。
解码 - 现在,iOS 将图像转换成 GPU 能读取和理解的方式。它现在是未解压缩的,并且我们在这里列出了 14 兆字节大小。
渲染 - 就像听起来的一样,图像数据已经准备好并且接受被任意方式进行渲染。尽管只是 60 x 60 的图像视图。
解码阶段非常重要。在这里,iOS 创建了一个缓冲区 - 特别是一个图像缓冲区,它有一个描述图像的内存区域。因此,这是有原因的,这个本质上是跟图像本身的比例有关,而不是文件大小。这就清楚地说明了在处理图像时尺寸对于内存消耗为什么如此重要。
尤其对于 UIImage,当我们把从网络接收到或其他来源接收到的图像数据提供给它时,它会将该数据解码到缓冲区以任何压缩形式表示(例如 PNG 或 JPEG)。然而,它实际上也会紧紧挂载在其上面。由于渲染不是一次性操作, UIImage 将保存着图像缓冲区,因此它只能解码一次。
在这个想法上扩展开去 - 任何一个 iOS 应用程序的一个完成整缓冲区就是它的帧缓冲区。这就是实际上显示在屏幕上的原因,因为它保存了它的内容的渲染输出。任何 iOS 设备上的显示屏幕都使用每个像素信息来点亮物理设备上的每个像素。
时机的把握在这里很重要。想要得到流畅光滑的每秒 60 帧的滑动,帧缓冲区将需要 UIKit 渲染应用程序的窗口,并且当它们的信息发生改变时(即将图像分配给图像视图时),它也需要渲染随后而来的子视图。如果你还这么慢,你就掉一帧了。
你认为 1 / 60 秒的时间不够吗?更专业的移动设备提高要求到 1 / 120 秒。
大小很重要
我们很容易可以看到这个过程和内存被消耗。我使用我女儿的一张照片创建了一个简单的应用程序,它用来显示一个图像视图,其中包含一个精确的图像。
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
注意能量是如何在生产中展开的。这里我们使用一个简单的示例场景。
这呈现给我们:
快速访问一下 LLDB, 就会向我们展示正在使用的图像的尺寸,尽管我们使用一个更小的图像来展示它。
, {1718, 2048}
记住 - 这是重点。所以,如果我使用的是 3x 或 2x 设备,那么你可能需要把这个数字乘以更多。让我们快速浏览一下 vmmap ,看看是否可以证实这一张图片使用了大约 14 兆字节:
vmmap --summary baylor.memgraph
一些东西比较突出(为了简介而删减):
Physical footprint: 69.5M
Physical footprint (peak): 69.7M
我们看到它占用了接近 70 兆字节,这给我们一个很好的基线来确认任何重构。如果我们深入了解图像输入输出,我们也可能看到图像的一些成本:
vmmap --summary baylor.memgraph | grep "Image IO"
Image IO 13.4M 13.4M 13.4M 0K 0K 0K 0K 2
啊 - 那里有大约 14 兆字节的脏内存。这就是我们粗略的数学假设图像会花费的成本。对于上下文,这里有个命令行终端的快照,它清晰地说明了每一列的含义,因为他们从我们的 greppin' 中被省略了:
所以很明显,我们在 300 x 400 的图像视图上使用了全部花费。图像的大小是关键,但也不是唯一重要的事情。
色彩空间
你需要的部分内存取决于另一个重要因素 - 色彩空间。在上面的例子中,我们假设大多数 iPhone 可能不是这种情况 - 图像使用的是 sRGB 格式。每像素 4 个字节是通过给出的红、蓝、绿和 alpha 分量来构成的。
如果你使用的设备支持宽幅彩色格式(例如:iPhone 8+ 或 iPhone X)进行拍照,那么你很可能会将点击率提高一倍。当然,反过来也一样。金属可以使用 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)
}
缩小与缩小采样
通过简单地方案 - 图像的许多问题和对内存的影响来源于我们我们与实际摄影艺术联系起来的独特照片。想想肖像画,风景画等等。
有理由相信,一些工程师会假设(和逻辑上也如此)通过 UIImage 简单地进行压缩它们就足够了。但是通常不是因为上面的原因,并且根据 APPLE 工程师 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() 方法而不是仅仅将香草图放到图像视图中。真相的源头将会再次出现在 vmmap 中,以确定我们的优化是否有效(同样地,为了简洁而截取):
vmmap -summary baylorOptimized.memgraph
Physical footprint: 56.3M
Physical footprint (peak): 56.7M
那么接下来的节约就会越来越多。如果我们将之前的 69.5 兆字节对比现在的 56.3 兆字节 ,我们可以节省 13.2 兆字节。这是一个很大的优化,几乎是图像的整个成本。
另外,你可以使用许多可选的选项来打磨你所使用的样例。在 WWDC 2018 的 219 节“图像与图形的最佳实践”中,在编码时通过使用 kCGImageSourceShouldCacheImmediately 标志 是苹果公司的工程师 Kyle Sluder 展示的一种有趣的控制方法:
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 并不会像我们在两个示例中所做的那样,因为如果你不这样做 - 你将只会得到一个和图像一样大小的缩略图。从文档:
"......如果你不指定最大像素大小,那么缩略图的大小将会跟图像大小相同,这很可能并不是你想看见的。"
那么发生了什么呢?简而言之,我们通过把压缩的方程式放入缩略图来创建比以前小得多的解码图像缓冲区。回顾一下我们的渲染管道,对于第一部分(加载),我们传递了一个图像缓冲区,它仅仅代表了我们展示的图片大小,而不是去反映要解码的整个图片的尺寸。
想要一个太长不愿看的文章?寻找机会来减少图片而不是使用 UIImage 来缩小图像。
奖励积分
我个人将它与 iOS11 引入的预加载 API 结合使用。我们本身就会引入 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 2018 中获取这些特别丰富的内容:
- iOS Memory Deep Dive
- Images and Graphics Best Practices
总结
你不知道你不知道什么。在编程领域,你基本上是在报名参加一个一直以时速 10,000 英里的事业生涯,以求跟上创新和变化的步伐。这就意味着......你将会看到大量的你根本不知道的 API,框架,模式和优化。
这在图像领域肯定是正确的。大多数情况下,你会使用一些漂亮的像素来初始化 UIImageView ,并继续初始化其他。我清楚,摩尔定律等等。这些手机运行很快和存储容量很大,嘿,我们使用了一台存储容量不到 100 千字节的计算机把人类放在月球上。
但与魔鬼共舞的时间足够长之后,他一定会扬起他的犄角。不要因为自拍占用了 1GB 内存而被低内存处理机制 Jetsam 给释放掉。希望,这些知识和技术能帮助你避免一些崩溃。
直到下次✌️。