前言
在客户端开发中,图片的下载会占用大量的带宽,图片的加载会消耗大量的性能和内存,正确的使用图片显得尤为重要。本文会对图片显示原理进行介绍,然后提供一些优化思路及第三方图片框架的部分分析。
图像的种类
所有的数字图像都可以归类为光栅或矢量两种类型。我们一般称光栅图为位图
,它由像素
组成,每个像素都有颜色数据,例如RGB值,透明度等。它常见的格式有PNG
,JPEG
,WEBP
等,这些只是一个压缩位图格式,不同的压缩格式压缩率和适用的场景不一样,解码性能也不一样,但是它们有一个共同特点是拉升到大于原始宽高后会变得模糊。
矢量图形是用点
,线
等基于数学方程的几何图元表示的图像。常见的格式有SVG
,AI
等,它们的特点是放大到任何大小都不会降低其质量。
文本是最常见的矢量图形之一
我们可以把两种类型讲得更通俗一点,位图
就是告诉计算机:“这个像素应该是淡黄色的,下一个应该是深紫色的,之后的应该是粉红色的”等等。但是对于矢量图
,则是说:”画一个长100,宽100的正方形,给它填充为绿色。”,但是最终它还是会被渲染成位图
数据进行展示。不同的是压缩位图根据压缩格式解码得到原始位图
数据,矢量图
通过计算得到原始位图
数据。
位图
与矢量图
的区别对比
位图 | 矢量图像 | |
---|---|---|
原理 | 像素 | 锚点,线条 |
使用场景 | 照片,复杂的颜色/纹理/阴影等 | 字体,地图等 |
缩放 | 受分辨率限制 | 在保持质量的同时无限拉升 |
文件大小 | 较大,但可以压缩 | 小 |
性能 | 好,解压缩后根据像素直接渲染 | 差,需要解析并计算,再生成像素点进行渲染 |
常见格式 | PNG,JPEG,WEBP等 | SVG,AI等 |
表格所示的文件大小
并不是绝对的,要分场景。例如一张JPEG风景图,用矢量图来做肯定是非常大且不合适的。
不同位图格式适用场景的对比
日常开发常见的位图格式对比如下:
格式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
gif | 文件小,支持动画、透明,兼容好 | 只支持256种颜色 | 色彩简单的logo、icon、动图 |
jpeg | 色彩丰富,可压缩率高 | 有损压缩,压缩后图片质量下降 | 色彩丰富的图片,照片等 |
png | 无损压缩,支持透明,简单图片尺寸小 | 不支持动画,色彩丰富的图片尺寸大 | logo、icon、透明图 |
webp | 文件小,支持有损和无损压缩,支持动画、透明 | 浏览器兼容性不好,编解码性能差 | 支持webp格式等app和webView |
相同视觉体验下,webp
一般比JPEG
和PNG
尺寸更小,但是小icon这类图片,一般PNG
尺寸更小。另外webp
的解码时间更长,有人测试可以达到png
格式的4.4倍,但是一般在后台解码和使用缓存,所以实际运行时webp
更长的解码时间并不会造成性能瓶颈。相反,由于webp
尺寸更小,下载耗时更少,可以节约带宽和得到更快的显示速度。 具体可以参考:WebP 探寻之路
除了上述格式外,还有HEIC
,AVIF
等新格式,有更好的大小表现或编解码性能,但是系统兼容性不好,这里不做说明,有兴趣的可以自行查找相关资料。
图片是如何显示到屏幕上的?
我们知道,屏幕是由一个个像素点组成的,屏幕的每一个像素点显示不同的颜色,构成了我们看到的画面,iOS
中系统以60~120hz
的频率从Frame Buffer
中读取数据来更新屏幕。那么问题来了,Frame Buffer
中的这些数据是如何产生的类?
iOS中视图是基于UIKit
来构建,每个App都有一个根视图:UIWindow
,它上面一层一层的附加了各种不同类型的View组成了整个App的视图:
这些视图最终会被处理为图元
,然后GPU
对这些图元
进行处理,最终合成转换为像素数据,放入Frame Buffer
待屏幕显示时读取。那么其中的图片是怎样被处理并最终显示到屏幕上的类?
在2018的WWDC
,苹果官方给出了关于iOS图像处理的最佳实践:Image and Graphics Best Practices,对这方面内容进行了说明。
图片的加载分为三步,分别是:读取图片数据,解压缩图片,渲染图片。
1. 读取图片数据
这里指从磁盘或者网络获取图片数据(PNG,JPEG等),然后缓存到内存。(注意这些图片数据一般都是压缩的,它们解压缩后才能得到原始位图)
2. 解压缩图片
位图的解压缩:得益于
SDWebImage
等第三方框架帮我们做了大量工作,这一部分经常会被开发者忽略。PNG
,JPEG
等这些压缩格式会在这一步被解码成原始位图,原始位图的大小与压缩格式无关,只与图片的尺寸有关。以一张1024*1024尺寸的JPEG
图片为例,只有380KB
,但是按照每个像素RGBA
四个字节大小,它解压缩后的大小是1024 * 1024 * 4
个字节,即4MB
,是解码前的10.8
倍!。iOS中有内存压缩机制,实际显示的内存消耗可能与此数据不符。在第三方图片框架中,将图片缓存到内存时需要计算图片大小,可以参考这部分代码逻辑来计算内存消耗
矢量图的解析:矢量图则是通过矢量图解析库来解析,解析库会创建
CALayer
,然后按照矢量图格式和绘制信息在CALayer
上进行图像绘制,虽然这个绘制很复杂,但是和你自己用CALayer
绘制自定义视图没有什么本质区别。需要注意的是这个过程是消耗CPU性能
和内存
的,在直播应用中,使用SVGA
来播放复杂动画时,可以明显的看到CPU占用和内存消耗都会增长较多。
3. 渲染图片
这一步大部分都是GPU
的工作,通过Render Server
及后续流程,将视图进行合成和渲染。日常开发基本接触不到,流程大概如下:
这部分和图片这个主题关系不大,不做过多说明,更详细的内容推荐阅读这篇博文:iOS 渲染原理解析
如何优化图片性能
通过上面的内容,我们对图片的本质和它如何被显示在屏幕上有了一个大概的认知,在这个基础上,我们可以从如下角度进行优化。
图片数据优化
常见的是对图片进行压缩,使用tinypng或imageoptim这类工具对项目中的静态资源进行压缩,tinypng
是网站,但是有提供API,imageoptim
是软件,可以通过编写脚本来简化和标准化这方面的操作。
第二个就是选择合适的图片尺寸与质量,如果图片与视图大小不匹配,会额外消耗性能,并且浪费带宽。现在各种云存储平台都支持输出指定大小图片,我们可以通过后台下发配置,客户端对接口返回的URL根据配置及视图大小等进行处理,以加载合适大小和质量的图片。这部分如果做的比较细致,还可以考虑如下优化点:
- 屏幕是2x还是3x
- 大图片降级(iPad或图片实在过大)
- 低端机降级
- 降低图片质量
第三个就是选择合适的图片格式,现在比较常见的是把JPEG
或PNG
格式转换为WebP
格式,会有很大的图片大小优化,可以查看这里的测试数据。
直接删除项目中的冗余图片也是一种优化~,推荐使用FengNiao
图片解码优化
图片解码会大量的消耗CPU性能
和内存
,常见的优化措施有DownSampling
,后台解码
以及缓存
。
DownSampling(降低采样)
在图片比视图大的情况下,直接展示原图片会额外的消耗CPU性能和内存。想象一下,如果一个浏览照片的应用展示多张照片时,不做任何处理就直接读取照片并展示,那么Decode时,将会极大的消耗CPU和占用内存。而我们展示的图片View
,完全不需要这么大的原始图像。
这种情况可以通过Downsampling
来解决,它是一种生成缩略图的方式。
上述流程的样例代码如下:
1 | func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage { |
后台解码
我分析了三个常见的图片解析库:SDWebImage,YYWebImage,KingFisher,它们都有后台解码功能,底层实现逻辑大概是:当从网络或硬盘获取到图片后,会根据逻辑在后台队列进行解码,实现细节稍有差异:
图片解析库 | 解码队列类型 | 关键API | 默认解码 |
---|---|---|---|
SDWebImage | 串行 | CGBitmapContextCreate | 是 |
YYWebImage | 串行 | CGBitmapContextCreate | 是 |
KingFisher | 并行 | UIGraphicsBeginImageContextWithOptions | 否 |
通过Debug
汇编分析,UIGraphicsBeginImageContextWithOptions
最终也是调用的CGBitmapContextCreate
。
另外在分析的过程中发现,同一张JPEG
网络图片,基于UIImageView
使用不同的框架来加载显示,占用的内存是有差异的,其中SDWebImage
的内存占用最高,YYWebImage
与KingFisher
(设置启用后台解码)的内存占用基本一致,都比SDWebImage
占用少,当KingFisher
不设置后台解码时,图片显示后内存占用是最大的。由于时间有限,对这部分没有做更细的分析和测试,这个结论不一定正确,欢迎对这部分感兴趣和熟悉的同学留言交流。下面贴出不同框架的解码代码。
SDWebImage框架解码关键代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// SDImageCoderHelper第228行
+ (CGImageRef)CGImageCreateDecoded:(CGImageRef)cgImage orientation:(CGImagePropertyOrientation)orientation {
// 省略代码...
BOOL hasAlpha = [self CGImageContainsAlpha:cgImage];
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate(NULL, newWidth, newHeight, 8, 0, [self colorSpaceGetDeviceRGB], bitmapInfo);
if (!context) {
return NULL;
}
// Apply transform
CGAffineTransform transform = SDCGContextTransformFromOrientation(orientation, CGSizeMake(newWidth, newHeight));
CGContextConcatCTM(context, transform);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage); // The rect is bounding box of CGImage, don't swap width & height
CGImageRef newImageRef = CGBitmapContextCreateImage(context);
CGContextRelease(context);
return newImageRef;
}YYWebImage
框架解码关键代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// YYImageCoder第868行
CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
// ...省略代码
if (decodeForDisplay) { //decode with redraw (may lose some precision)
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// BGRA8888 (premultiplied) or BGRX8888
// same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo);
if (!context) return NULL;
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode
CGImageRef newImage = CGBitmapContextCreateImage(context);
CFRelease(context);
return newImage;
} else {
// 省略代码....
}
}KingFisher
(4.10.1版本)框架解码关键代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// Image.swift第727行开始
public func decoded(scale: CGFloat) -> Image {
// `KingFisher`对解码部分进行了多个方法的封装,把关键代码抽离如下,细节请查看源代码。
guard let imageRef = self.cgImage
// guard let context = beginContext(size: CGSize(width: imageRef.width, height: imageRef.height), scale: 1.0)
UIGraphicsBeginImageContextWithOptions(size, false, scale)
let context = UIGraphicsGetCurrentContext()
context?.scaleBy(x: 1.0, y: -1.0)
context?.translateBy(x: 0, y: -size.height)
//defer { endContext() }
let rect = CGRect(x: 0, y: 0, width: CGFloat(imageRef.width), height: CGFloat(imageRef.height))
context.draw(imageRef, in: rect)
let decompressedImageRef = context.makeImage()
UIGraphicsEndImageContext()
//return Kingfisher<Image>.image(cgImage: decompressedImageRef!, scale: scale, refImage: base)
return Image(cgImage: decompressedImageRef, scale: scale, orientation: .up)
}
另外,之前提到的DownSampling
这些第三方网络框架都支持。在SDWebImage
框架中,通过context
设置SDWebImageContextImageThumbnailPixelSize
即可调整采样。
1 | let thumbnailPixelSizesize = CGSize(width: 100, height: 100) |
在KingFisher
中则通过在options
参数中传入相应对processor
来进行处理。当然这些第三方框架的功能远不止于此,例如对图片进行高斯模糊,画圆角等都有支持。
缓存
图片下载会大量的占用带宽,解码会大量的消耗CPU性能,所以一般的策略都是将下载的图片存储到硬盘,将解码的图片缓存到内存来优化性能。这些第三方框架都对这些功能进行了良好的封装,具体的实现细节这里就不做展开了。
参考资料
- Image and Graphics Best Practices: 官方视频,推荐观看
- Advanced Graphics and Animation for iOS Apps:官方视频,2014年WWDC发布的,官方已经下架,可以在B站观看
- iOS图像最佳实践总结:对
Image and Graphics Best Practices
视频的整理,非常全面详细。 - iOS 渲染原理解析:写的非常全面和深入,推荐阅读
- 每英寸像素
- 聊一聊几种常用web图片格式:gif、jpg、png、webp
- 将图片库优化到底,性能提高50%!京东京喜App是如何做到的?
- 深入理解 iOS Rendering Process
- WebP 探寻之路
- 主流图片加载库所使用的预解码究竟干了什么
- 移动端图片格式调研