前言
App 里面图像的加载无处不在,通常我们知道,图像加载管理不当会造成性能和内存问题,体现为 App 卡顿、运行缓慢和奔溃这些问题。
一张手机拍摄的照片(图 1),大小约 3.7 MB,在 iOS 应用中展示他理论上需要多少内存?
答案肯定不是 3.7 MB,而是接近 48 MB。
为什么会出现这样的情况?
iOS 实际上是从一幅图像的尺寸计算它占用的内存 – 实际的文件大小会比这小很多。这张照片的尺寸宽 3024 像素,高 4032 像素。假设每个像素会消耗我们 4 个 byte。
3024 * 4032 * 4 / 1000000 = 48.77 MB 占用
复制代码
我们使用Demo工程运行模拟器来对比下使用UIImageView
加载这张图片的前后内存变化(图 2、图 3)
对比差值这里使用模拟器,如果使用真机,因为 Apple 对图像加载到内存有优化,实际的内存差值要比模拟器要小
加载图像之前是App内存使用 206 MB ,加载图片之后是 253 MB,差值47 MB,和我们之前一算的基本一致
如果这样的一张图片加载到 App 中,不管你用来展示的 UIImageView
控件尺寸多小,哪怕是仅仅用来展示头像的 60x60pt 尺寸,也会需要占用到这么多的内存。
如果同时存在很多这样的情况,比如列表,这就会出现我们文章开头所提到现象,卡顿、内存不足奔溃。
要搞清楚这个问题,我们先了解下图像的渲染流程
渲染流程
UIImage 和 UIImageView
UIImage 是用来处理图像数据的高级类,UIImageView 是 UIKit 提供的用于显示 UIImage 的类。若采用 MVC
模型进行类比,UIImage 可以看作模型对象(Model),UIImageView 是一个视图(View)。它们都肩负着各自的职责:
UIImage 负责加载图片内容, UIImageView 负责显示和渲染它。
这看似是一个简单的单向过程,但实际情况却复杂的多,因为渲染是一个连续的过程,而不是一次性事件。这里还有一个非常关键的隐藏阶段,对衡量 app 性能至关重要,这个阶段被称为解码(Decode)。
图片加载流程: 加载 -> 解码 -> 渲染
缓冲区 Buffers
在讨论解码之前,先了解下缓冲区的概念。
缓冲区(Buffers):是一块连续的内存区域,用来表示一系列元素组成的内存,这些元素具有相同的尺寸,并通常具有相同的内部结构。
图像缓冲区(Image Buffer):它是一种特定缓冲区,它保存了某些图像在内存中的表示。此缓冲区的每个元素,描述了图像中每个像素的颜色和透明度。因此这个缓冲区在内存中的大小与它包含的图像大小成正比。
帧缓冲区(Frame Buffer):它保存了 app 中实际渲染后的输出。因此,当 app 更新其视图层次结构时,UIKit 将重新渲染 app 的窗口及其所有视图到帧缓冲区中。帧缓冲区中提供了每个像素的颜色信息,显示硬件将读取这些信息用来点亮显示器上对应的像素。
如果 app 中没有任何改变,则显示硬件会从帧缓冲区中取出上次看到的相同数据。但是如果改变了视图内容,UIKit会重新渲染内容,并将其放入帧缓冲区,下一次显示硬件从帧缓冲区读取内容时,就会获取到新的内容。
数据缓冲区(Data Buffer):包含图像文件的数据缓冲区,通常以某些元数据(metadata)开头,这些元数据描述了存储在数据缓冲区中的图像大小和图像数据本身。
数据缓冲区 (Data Buffers)
- 在内存中存储图像文件的内容
- 描述图像尺寸的元数据(metadata)
- 图像本身被编码为JPEG、PNG或其他(通常是压缩的)形式。
- bytes 并不直接描述像素
图像渲染到帧缓冲区的详细过程
这块区域将由 UIImageView 进行渲染填充。我们已经为 UIImageView 分配一个 UIImage,它有一个表示图像文件内容的数据缓冲区。我们需要用每个像素的数据来填充帧缓冲区,为了做到这一点,UIImage 将分配一个图像缓冲区,其大小等于包含在数据缓冲区中的图像大小,并执行称为解码的操作,这就是将 JPEG 或 PNG 或其它编码的图像数据转换为每个像素的图像信息。然后取决于我们图像视图的内容模式,当 UIKit 要求图像视图进行渲染时,它会将数据复制到帧缓冲区的过程中对来自图像缓冲区的数据进行复制和缩放。
解码 Decode
解码阶段是 CPU 密集型的,特别是对于大型图像。因此,不是每次 UIKit 要求图像视图渲染时都执行一次这个过程。缓冲区实际上会保存到 UIImage 中。由于渲染不是一次性的操作,UIImage 会执行一次解码操作,然后一直保留图像缓冲区。因此,在你的 App 中,对于每个被解码的图像,都可能会持续存在大量的内存分配,这种内存分配与输入的图像大小成正比,而与帧缓冲区中实际渲染的图像视图大小没有必然联系,这会对内存产生相当不利的后果。
解码注意事项
- CPU密集型操作
- 保留用于重复渲染
- 持续性的大内存分配
- 与原始图像尺寸成比例,而不是与视图尺寸成比例
过度使用内存的后果
- 碎片增多
- 引用的位置性差
- 系统开始压缩内存
- 进程终止
结论
图像尺寸对内存影响很大
回到开始之前问题,如果我们将图片尺寸降低到 600*800 pt,理论上只需要占用大约 1.92 MB 内存
600 * 800 * 4 / 1000000 = 1.92 MB 占用
复制代码
优化
什么时候需要优化?就是当图片明显大于 UIImageView 显示尺寸的时候
通常我们会想到直接缩小尺寸,比如采用 UIGraphicsImageRenderer
(旧的 UIGraphicsBeginImageContext
) 来进行绘制draw
出更小尺寸的图片
UIGraphicsBeginImageContext
旧 API官方建议不要再使用
还有一种方式就是通过下采样的操作获取缩略图来展示。
下采样 Downsamping
我们先简单了解下下采样技术
目的:
- 缩小原图,即生成对应图像的缩略图。
- 使图像符合对应的显示区域。
- 降低特征的维度并保留有效信息,一定程度上避免过拟合,保持旋转、平移、伸缩不变形。
原理: 把一个位于原始图像上的 s*s 的窗口变成一个像素:
$$ P_k = \sum_{i \in win(k)} \frac{I_i} {s^2} $$
复制代码
原图若为 x*y,则下采样之后原图的尺寸为 (x/s)(y/s)。这说明 s 最好是 x 和 y 的公约数。
实现: 池化(pooling)。池化操作是在卷积神经网络中经常采用过的一个基本操作,一般在卷积层后面都会接一个池化操作,使用的比较多的也是max-pooling即最大池化,因为max-pooling更像是做了特征选择,选出了分类辨识度更好的特征,提供了非线性。
使用 ImageIO
进行图片下采样操作
我们可以通过这种下采样技术来节省一些内存。本质上,我们要做的就是捕捉该缩小操作,并将其放入缩略图的对象中,最终达到降低内存的目的,因为我们将有一个较小的解码图像缓冲区。
这样,我们设置了一个图像源,创建了一个缩略图,然后将解码缓冲区捕获到 UIImage 中,并将该 UIImage 分配给我们的图像视图。接下来我们就可以丢弃包含图片数据的数据缓冲区,最终结果就是我们的 app 中将具有一个更小的长期内存占用足迹。
下面看下如何使用代码来实现这一过程:
1. 首先,创建一个 CGImageSource 对象
let imageURL = URL(fileURLWithPath: imageFilePath)
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)
复制代码
KCGImageSourceShouldCache
参数为 false
,用来告诉 Core Graphic
框架我们只是在创建一个对象,来表示存储在该 URL
的文件中的信息,不要立即解码这个图像,只需要创建一个表示它的对象,我们需要来自此 URL
的文件信息。
2. 然后在水平和垂直轴上进行计算,该计算基于期望的图片大小以及我们要渲染的像素和点大小:
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
复制代码
这里也创建了一个缩略图选项的字典,最重要的是 CacheImmediately
这个选项,通过这个选项,告诉 Core Graphics
,当我们要求你创建缩略图时,这就是你应该为我创建解码缓冲区的确切时刻。因此,我们可以确切的控制何时调用 CPU 来进行解码。
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDim
复制代码
3. 最后,我们创建缩略图,即拿到返回的 CGImage 。
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
复制代码
其完整代码如下:
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
return UIImage(cgImage: downsampledImage)
}
复制代码
缩小图片 VS 下采样
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
guard let image = UIImage(contentsOfFile: url.path) else {
return nil
}
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
复制代码
根据 Apple 工程师 kyle Howarth 的说法,由于内部坐标转换的原因,缩小图片的优化效果并不太好。
UIImage
的尺寸调整开销很大
- 将原始图像解压到内存中
- 内部坐标空间变换是昂贵的
ImageIO
可以在不污染内存的情况下读取图像尺寸和元数据信息
ImageIO
可以调整图像的大小,但只以调整后的图像为代价
下采样相比于缩小图片是更适合的优化方式
参考文献
WWDC 18 219: Images and Graphics Best Practices
WWDC 18 416: iOS Memory Deep Dive
南京三百云信息科技有限公司(车300)成立于2014年3月27日,是一家扎根于南京的移动互联网企业,目前坐落于南京、北京。经过7年积累,累计估值次数已达52亿次,获得了国内外多家优质投资机构青睐如红杉资本、上汽产业基金等。
三百云是国内优秀的以人工智能为依托、以汽车交易定价和汽车金融风控的标准化为核心产品的独立第三方的汽车交易与金融SaaS服务提供商。
欢迎加入三百云,一起见证汽车行业蓬勃发展,期待与您携手同行!
Java开发、Java实习、PHP实习、测试、测开、产品经理、大数据、算法实习
,热招中…
官网:www.sanbaiyun.com/
投递简历:hr@che300.com,请注明来自掘金?