图像优化

本文翻译自:Optimizing Images
参考翻译:翻译 - 图像优化

有句话说:最好的相机就是你随身携带的。如果这句话是对的,毫无疑问 - iPhone 是目前这个星球上主要的相机。我们的产业也证明了这点。

在度假?如果没有在你的 Instagram 上放几张照片,那就等于没有发生过。

爆炸新闻?通过 Twitter 上的照片,了解事件的实时进展。

等等。

平台上无处不在展示着图像,在低性能和内存紧张的情况下展示他们,很容易导致失控。如果了解一些 UIKit 的原理以及它是如何处理图像的,那么我们就可以节省大量资源,并且逃脱系统的清除制裁。

理论上

突击测验:我女儿的这张 266 k 的照片(还很时尚),在一个 iOS APP 上展示会消耗多少内存?

image

剧透下:不是 266 k,也不是 2.66 M,大约需要 14 M。

为什么?

iOS 的内存消耗本质上取决于图像的尺寸,而与它文件的大小关系不大。这张图片的尺寸是 1718x2048 px 。假设 1 px 会消耗 4 个字节:

1718 * 2048 * 4 / 1024 / 1024 = 13.42 M 大约

想象一下,你有一个用户列表,其中每一行的左侧都展示一个常见的圆角头像。如果你认为每张图片都可以通过 ImageOptim 或者其他的类似选项,被压缩的又好又彻底,那就错了。每张图片保守估计都是 256x256 的尺寸,这在内存方面的消耗依然严重。

渲染过程

也就是说:探究下这中间发生了什么很有必要。当你加载一张图片的时候,会经过以下三步:

  1. 加载:iOS 把压缩的图片加载到内存中,(在我们的例子中)此时消耗 266 k 。现在还不用担心。
  2. 解码:现在 iOS 把图片转化为 GPU 可以处理的数据。这是解压缩,这里就产生了上面提到的 14 M 内存开销。
  3. 渲染:就像名称一样,现在图像数据准备好了以任何方式进行渲染。即使是放在大小为 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 展示给我们图片的真实尺寸如下:

<UIImage: 0x600003d41a40>, {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<NSIndexPath *> *)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:

结语

你无法察觉那些你不知道的事。对于编程来说,你基本上等于报名了一个每小时跑 10,000 英里,只为跟上创新和改变的职业生涯。这意味着,有堆积如山的 API, 框架,设计模式和优化方案你并不知道。

对于图像领域来说也是如此。大多数时候,你初始化一个 UIImageView 然后放入一些好看的像素。我懂了,摩尔定律什么的。现在这些手机运行速度快,而且有着数 G 内存,并且我们把人类运送到月球上,都只用了一台不到 100 k 内存的电脑。

但是与恶魔共舞的时间长了,他一定会扬起他的犄角。 不要让系统因为一张 1 G 内存的自拍,强制杀死你的应用。希望,这些知识和技巧能节省你在崩溃日志上的时间。

下次见 ✌️。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容