星的天空的博客

种一颗树,最好的时间是十年前,其次是现在。

0%

iOS图片显示原理与优化思路

前言

在客户端开发中,图片的下载会占用大量的带宽,图片的加载会消耗大量的性能和内存,正确的使用图片显得尤为重要。本文会对图片显示原理进行介绍,然后提供一些优化思路及第三方图片框架的部分分析。

图像的种类

所有的数字图像都可以归类为光栅或矢量两种类型。我们一般称光栅图为位图,它由像素组成,每个像素都有颜色数据,例如RGB值,透明度等。它常见的格式有PNGJPEGWEBP等,这些只是一个压缩位图格式,不同的压缩格式压缩率和适用的场景不一样,解码性能也不一样,但是它们有一个共同特点是拉升到大于原始宽高后会变得模糊。

矢量图形是用线等基于数学方程的几何图元表示的图像。常见的格式有SVGAI等,它们的特点是放大到任何大小都不会降低其质量。

文本是最常见的矢量图形之一

我们可以把两种类型讲得更通俗一点,位图就是告诉计算机:“这个像素应该是淡黄色的,下一个应该是深紫色的,之后的应该是粉红色的”等等。但是对于矢量图,则是说:”画一个长100,宽100的正方形,给它填充为绿色。”,但是最终它还是会被渲染成位图数据进行展示。不同的是压缩位图根据压缩格式解码得到原始位图数据,矢量图通过计算得到原始位图数据。

位图矢量图的区别对比

位图 矢量图像
原理 像素 锚点,线条
使用场景 照片,复杂的颜色/纹理/阴影等 字体,地图等
缩放 受分辨率限制 在保持质量的同时无限拉升
文件大小 较大,但可以压缩
性能 好,解压缩后根据像素直接渲染 差,需要解析并计算,再生成像素点进行渲染
常见格式 PNG,JPEG,WEBP等 SVG,AI等

表格所示的文件大小并不是绝对的,要分场景。例如一张JPEG风景图,用矢量图来做肯定是非常大且不合适的。

不同位图格式适用场景的对比

日常开发常见的位图格式对比如下:

格式 优点 缺点 适用场景
gif 文件小,支持动画、透明,兼容好 只支持256种颜色 色彩简单的logo、icon、动图
jpeg 色彩丰富,可压缩率高 有损压缩,压缩后图片质量下降 色彩丰富的图片,照片等
png 无损压缩,支持透明,简单图片尺寸小 不支持动画,色彩丰富的图片尺寸大 logo、icon、透明图
webp 文件小,支持有损和无损压缩,支持动画、透明 浏览器兼容性不好,编解码性能差 支持webp格式等app和webView

相同视觉体验下,webp一般比JPEGPNG尺寸更小,但是小icon这类图片,一般PNG尺寸更小。另外webp的解码时间更长,有人测试可以达到png格式的4.4倍,但是一般在后台解码和使用缓存,所以实际运行时webp更长的解码时间并不会造成性能瓶颈。相反,由于webp尺寸更小,下载耗时更少,可以节约带宽和得到更快的显示速度。 具体可以参考:WebP 探寻之路

除了上述格式外,还有HEICAVIF等新格式,有更好的大小表现或编解码性能,但是系统兼容性不好,这里不做说明,有兴趣的可以自行查找相关资料。

图片是如何显示到屏幕上的?

我们知道,屏幕是由一个个像素点组成的,屏幕的每一个像素点显示不同的颜色,构成了我们看到的画面,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等第三方框架帮我们做了大量工作,这一部分经常会被开发者忽略。PNGJPEG等这些压缩格式会在这一步被解码成原始位图,原始位图的大小与压缩格式无关,只与图片的尺寸有关。以一张1024*1024尺寸的JPEG图片为例,只有380KB,但是按照每个像素RGBA四个字节大小,它解压缩后的大小是1024 * 1024 * 4个字节,即4MB,是解码前的10.8倍!。

    iOS中有内存压缩机制,实际显示的内存消耗可能与此数据不符。在第三方图片框架中,将图片缓存到内存时需要计算图片大小,可以参考这部分代码逻辑来计算内存消耗

  • 矢量图的解析:矢量图则是通过矢量图解析库来解析,解析库会创建CALayer,然后按照矢量图格式和绘制信息在CALayer上进行图像绘制,虽然这个绘制很复杂,但是和你自己用CALayer绘制自定义视图没有什么本质区别。需要注意的是这个过程是消耗CPU性能内存的,在直播应用中,使用SVGA来播放复杂动画时,可以明显的看到CPU占用和内存消耗都会增长较多。

3. 渲染图片


这一步大部分都是GPU的工作,通过Render Server及后续流程,将视图进行合成和渲染。日常开发基本接触不到,流程大概如下:

这部分和图片这个主题关系不大,不做过多说明,更详细的内容推荐阅读这篇博文:iOS 渲染原理解析

如何优化图片性能

通过上面的内容,我们对图片的本质和它如何被显示在屏幕上有了一个大概的认知,在这个基础上,我们可以从如下角度进行优化。

图片数据优化

常见的是对图片进行压缩,使用tinypngimageoptim这类工具对项目中的静态资源进行压缩,tinypng是网站,但是有提供API,imageoptim是软件,可以通过编写脚本来简化和标准化这方面的操作。

第二个就是选择合适的图片尺寸与质量,如果图片与视图大小不匹配,会额外消耗性能,并且浪费带宽。现在各种云存储平台都支持输出指定大小图片,我们可以通过后台下发配置,客户端对接口返回的URL根据配置及视图大小等进行处理,以加载合适大小和质量的图片。这部分如果做的比较细致,还可以考虑如下优化点:

  • 屏幕是2x还是3x
  • 大图片降级(iPad或图片实在过大)
  • 低端机降级
  • 降低图片质量

第三个就是选择合适的图片格式,现在比较常见的是把JPEGPNG格式转换为WebP格式,会有很大的图片大小优化,可以查看这里的测试数据

直接删除项目中的冗余图片也是一种优化~,推荐使用FengNiao

图片解码优化

图片解码会大量的消耗CPU性能内存,常见的优化措施有DownSampling后台解码以及缓存

DownSampling(降低采样)

在图片比视图大的情况下,直接展示原图片会额外的消耗CPU性能和内存。想象一下,如果一个浏览照片的应用展示多张照片时,不做任何处理就直接读取照片并展示,那么Decode时,将会极大的消耗CPU和占用内存。而我们展示的图片View,完全不需要这么大的原始图像。

这种情况可以通过Downsampling来解决,它是一种生成缩略图的方式。

上述流程的样例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

//kCGImageSourceShouldCacheImmediately设置为true,创建缩略图时会直接解码
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
return UIImage(cgImage: downsampledImage)
}

后台解码

我分析了三个常见的图片解析库:SDWebImageYYWebImageKingFisher,它们都有后台解码功能,底层实现逻辑大概是:当从网络或硬盘获取到图片后,会根据逻辑在后台队列进行解码,实现细节稍有差异:

图片解析库 解码队列类型 关键API 默认解码
SDWebImage 串行 CGBitmapContextCreate
YYWebImage 串行 CGBitmapContextCreate
KingFisher 并行 UIGraphicsBeginImageContextWithOptions

通过Debug汇编分析,UIGraphicsBeginImageContextWithOptions最终也是调用的CGBitmapContextCreate

另外在分析的过程中发现,同一张JPEG网络图片,基于UIImageView使用不同的框架来加载显示,占用的内存是有差异的,其中SDWebImage的内存占用最高,YYWebImageKingFisher(设置启用后台解码)的内存占用基本一致,都比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
2
3
4
5
6
let thumbnailPixelSizesize = CGSize(width: 100, height: 100)
self.sdImageView.sd_setImage(
with: URL.init(string: urlStr),
placeholderImage: nil,
options: SDWebImageOptions.init(rawValue: 0),
context: [.imageThumbnailPixelSize: thumbnailPixelSizesize])

KingFisher中则通过在options参数中传入相应对processor来进行处理。当然这些第三方框架的功能远不止于此,例如对图片进行高斯模糊,画圆角等都有支持。

缓存

图片下载会大量的占用带宽,解码会大量的消耗CPU性能,所以一般的策略都是将下载的图片存储到硬盘,将解码的图片缓存到内存来优化性能。这些第三方框架都对这些功能进行了良好的封装,具体的实现细节这里就不做展开了。

参考资料