李峰峰博客

图片加载原理及优化

2021-02-18

一、图片加载(解压缩)原理

1、图片加载的工作流

概括来说,从磁盘中加载一张图片,并将它显示到屏幕上,中间的主要工作流如下:

  • (1) 将 UIImage 赋值给屏幕上 UIImageView;
  • (2) Core Animation 渲染流水线被触发;
  • (3) Core Animation 渲染流水线第一阶段的 Commit Transaction 部分有四个小阶段:布局、显示、准备、提交,在其中的“准备”阶段,会对图片进行解码,这里可能还会涉及到图片文件 I/O 操作。
    • 解码即:将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作;
  • (4) 在图片解码完成后,后续被提交到 Render Server 进程,Render Server 处理完成后提交给 GPU 渲染。
  • (5) GPU 渲染完成后,将渲染后的位图放到帧缓冲区,后续将被视频控制器读取显示到屏幕上。

在上面的步骤中,图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。

2、图片解压缩原理

图片显示到屏幕上,必须要经过图片的解压缩这一步。不管是 JPEG 还是 PNG 图片,都是一种压缩的位图图形格式,位图就是一个像素数组,数组中的每个像素就代表着图片中的一个点。只不过 PNG 图片是无损压缩,并且支持 alpha 通道,而 JPEG 图片则是有损压缩,可以指定 0-100% 的压缩比。值得一提的是,在苹果的 SDK 中专门提供了两个函数用来生成 PNG 和 JPEG 图片:

1
2
3
4
5
// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);

// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)
UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);

解压缩后的图片大小与原始文件大小之间没有任何关系,而只与图片的像素有关,每个像素由 RGBA 四个字节表示颜色。例如,对于一个大小为 600 B, 像素 30 x 30 的图片:

1
解压缩后的图片大小 = 图片的像素宽 30 * 图片的像素高 30 * 每个像素所占的字节数 4

所以其解压缩后的图片大小,即图片原始像素数据,大小为 3600 B。

当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而如果图片已经解压缩了,系统就不会再对图片进行解压缩。因此,对于主线程解压缩影响性能的问题,也就有了业内的解决方案,在子线程提前对图片进行强制解压缩。

而强制解压缩的原理就是对图片进行重新绘制,得到一张新的解压缩后的位图。其中,用到的最核心的函数是 CGBitmapContextCreate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Create a bitmap context. The context draws into a bitmap which is `width'
pixels wide and `height' pixels high. The number of components for each
pixel is specified by `space', which may also specify a destination color
profile. The number of bits for each component of a pixel is specified by
`bitsPerComponent'. The number of bytes per pixel is equal to
`(bitsPerComponent * number of components + 7)/8'. Each row of the bitmap
consists of `bytesPerRow' bytes, which must be at least `width * bytes
per pixel' bytes; in addition, `bytesPerRow' must be an integer multiple
of the number of bytes per pixel. `data', if non-NULL, points to a block
of memory at least `bytesPerRow * height' bytes. If `data' is NULL, the
data for context is allocated automatically and freed when the context is
deallocated. `bitmapInfo' specifies whether the bitmap should contain an
alpha channel and how it's to be generated, along with whether the
components are floating-point or integer. */

CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(void * __nullable data,
size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow,
CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo)
CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0);

这个函数用于创建一个位图上下文,用来绘制一张宽 width 像素,高 height 像素的位图。

CGBitmapContextCreate 函数中每个参数所代表的具体含义:

  • data :如果不为 NULL,那么它应该指向一块大小至少为 bytesPerRow * height 字节的内存;如果 为 NULL,那么系统就会为我们自动分配和释放所需的内存,所以一般指定 NULL 即可;
  • widthheight :位图的宽度和高度,分别赋值为图片的像素宽度和像素高度即可;
  • bitsPerComponent :表示每一个颜色分量由多少位组成,Component 就是指颜色分量,例如 RGB 中,指定 R/G/B 这些颜色分量由多少位来表示,在 RGB 颜色空间下指定 8 即可;
  • bytesPerRow :位图的每一行使用的字节数,大小至少为 width * bytes per pixel 字节。有意思的是,当我们指定 0 时,系统不仅会为我们自动计算,而且还会进行 cache line alignment 的优化。
  • space :颜色空间,一般使用 RGB 即可;
  • bitmapInfo :位图的布局信息。

注释中提到:

1
每个像素占用的字节数 = (bitsPerComponent * number of components + 7)/8

对于 RGBA(R、G、B、A 共 4 个分量) 图片:

1
每个像素占用的字节数 = (8* 4 + 7)/8 = 4B

CGBitmapInfo 由两部分取或运算组成:CGImageAlphaInfoCGImageByteOrderInfo,例如:kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host

(1) CGImageAlphaInfo 是 Alpha 的信息由枚举值

1
2
3
4
5
6
7
8
9
10
typedef CF_ENUM(uint32_t, CGImageAlphaInfo) {
kCGImageAlphaNone, /* For example, RGB. */
kCGImageAlphaPremultipliedLast, /* For example, premultiplied RGBA */
kCGImageAlphaPremultipliedFirst, /* For example, premultiplied ARGB */
kCGImageAlphaLast, /* For example, non-premultiplied RGBA */
kCGImageAlphaFirst, /* For example, non-premultiplied ARGB */
kCGImageAlphaNoneSkipLast, /* For example, RBGX. */
kCGImageAlphaNoneSkipFirst, /* For example, XRGB. */
kCGImageAlphaOnly /* No color data, alpha data only */
};

它提供了三个方面的 alpha 信息:

  • 是否包含 alpha
  • 如果包含 alpha,那么 alpha 信息所处的位置,在像素的最低有效位,比如 RGBA ,还是最高有效位,比如 ARGB;
  • 如果包含 alpha,那么每个颜色分量是否已经乘以 alpha 的值,这种做法可以加速图片的渲染时间,因为它避免了渲染时的额外乘法运算。比如,对于 RGB 颜色空间,用已经乘以 alpha 的数据来渲染图片,每个像素都可以避免 3 次乘法运算,红色乘以 alpha,绿色乘以 alpha 和蓝色乘以 alpha

那么我们在解压缩图片的时候应该使用哪个值呢?根据 Which CGImageAlphaInfo should we use 和官方文档中对 UIGraphicsBeginImageContextWithOptions 函数的讨论:

You use this function to configure the drawing environment for rendering into a bitmap. The format for the bitmap is a ARGB 32-bit integer pixel format using host-byte order. If the opaque parameter is YES, the alpha channel is ignored and the bitmap is treated as fully opaque (kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Host). Otherwise, each pixel uses a premultipled ARGB format (kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host).

我们可以知道,当图片不包含 alpha 的时候使用 kCGImageAlphaNoneSkipFirst ,否则使用 kCGImageAlphaPremultipliedFirst 。此外,上面提到了字节顺序应该使用 32 位的主机字节顺序 kCGBitmapByteOrder32Host

(2) CGImageByteOrderInfo 是像素格式的字节顺序

1
2
3
4
5
6
7
typedef CF_ENUM(uint32_t, CGImageByteOrderInfo) {
kCGImageByteOrderMask = 0x7000,
kCGImageByteOrder16Little = (1 << 12),
kCGImageByteOrder32Little = (2 << 12),
kCGImageByteOrder16Big = (3 << 12),
kCGImageByteOrder32Big = (4 << 12)
} CG_AVAILABLE_STARTING(__MAC_10_12, __IPHONE_10_0);

它主要提供了两个方面的字节顺序信息:

  • 小端模式还是大端模式;
  • 数据以 16 位还是 32 位为单位。

iOS 设备的处理器是基于 ARM 架构的,默认是采用小端模式读取数据的,而网络和蓝牙传输数据通常是用的大端模式。例如:想传输 ABCD,按大端模式给到 iOS 客户端是 DCBA,那么 iOS 默认读取出来的也是 DCBA,而非 ABCD。所以就需要进行大小端的转换,要不然没办法得到想要的数据。苹果为我们提供了丰富的 API,而不需要让我们对逐个字节进行转换,详见官方文档

通常 iOS 比较常用的就是 CFSwapInt16BigToHostCFSwapInt32BigToHost,把大端转换为本机支持的模式,如果本机是大端了则不做任何改变。

由于 iPhone 采用的是小端模式,但是为了保证应用的向后兼容性,我们可以使用系统提供的宏来做大小端转换,来避免 Hardcoding :

1
2
3
4
5
6
7
#ifdef __BIG_ENDIAN__
#define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Big
#define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Big
#else /* Little endian. */
#define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Little
#define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Little
#endif

根据前面的讨论,我们知道字节顺序的值应该使用的是 32 位的主机字节顺序 kCGBitmapByteOrder32Host,这样的话不管当前设备采用的是小端模式还是大端模式,字节顺序始终与其保持一致。

下面,我们来看一张图,它非常形象地展示了在使用 16 或 32 位像素格式的 CMYK 和 RGB 颜色空间下,一个像素是如何被表示的:

我们从图中可以看出,在 32 位像素格式下,每个颜色分量使用 8 位;而在 16 位像素格式下,每个颜色分量则使用 5 位。

图片解压缩的实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CGImageRef imageRef = image.CGImage;

// 调用 CGBitmapContextCreate() 方法,生成图片绘制上下文
CGContextRef context = CGBitmapContextCreate(.....);

// 调用 CGContextDrawImage() 方法,将未解码的 imageRef 指针内容,写入到我们创建的上下文中,这个步骤,完成了隐式的解码工作
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);

// 从 context 上下文中创建一个新的 imageRef,这是解码后的图片了
CGImageRef newImageRef = CGBitmapContextCreateImage(context);

// 从 imageRef 生成供 UI 层使用的 UIImage 对象,同时指定图片的 scale 和 orientation 两个参数。
UIImage *newImage = [UIImage imageWithCGImage:newImageRef
scale:image.scale
orientation:image.imageOrientation];

CGContextRelease(context);
CGImageRelease(newImageRef);

它接受一个原始的位图参数 imageRef,最终返回一个新的解压缩后的位图 newImage ,中间主要经过了以下三个步骤:

  • 使用 CGBitmapContextCreate 函数创建一个位图上下文;
  • 使用 CGContextDrawImage 函数将原始位图绘制到上下文中;
  • 使用 CGBitmapContextCreateImage 函数创建一张新的解压缩后的位图。

上面是图片强制解压缩的简单实现逻辑,提前在子线程对图片进行解压缩,后续在 Core Animation 渲染流水线的流程中就不会再次解压缩了,可以避免因为图片解压缩耗时过长引起的卡顿问题,目前 SDWebImage、YYImage 都是这样的实现逻辑。

3、第三方开源库解压缩图片的实现

我们来看看 YYKit 中的相关代码,用于解压缩图片的函数 YYCGImageCreateDecodedCopy 存在于 YYImageCoder 类中,核心代码如下:

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
28
29
30
31
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 {
...
}
}

事实上,SDWebImage 和 FLAnimatedImage 中对图片的解压缩过程与上述完全一致,只是传递给 CGBitmapContextCreate 函数的部分参数存在细微的差别:

在上表中,用浅绿色背景标记的参数即为我们在前面的分析中所推荐的参数,用这些参数解压缩后的图片渲染的速度会更快。因此,从理论上说 YYKit 中的解压缩算法是三者之中最优的。

二、存在问题

我们调用 UIImage的-imageNamed: 方法或者 -imageWithContentsOfFile: 方法显示一张图片时,显示在屏幕上的图片最终都会被转化为 OpenGL 纹理,同时 OpenGL 有一个最大的纹理尺寸(通常是 20482048,或 40964096,这个取决于设备型号)。如果你想在单个纹理中显示一个比这大的图,即便图片已经存在于内存中了,你仍然会遇到很大的性能问题,因为 Core Animation 强制用 CPU 处理图片而不是更快的 GPU。

在一个简单的图片加载 demo 中测试,使用 [UIImage imageNamed:@"xxx"] 和 第三方开源库加载一个原图约 8 MB 大小、7000 × 10000 像素的图片时,表现如下:

  • [UIImage imageNamed:@"xxx"] 方式加载该图片时,内存变化约为:42 MB —> 298.5MB。
  • SDWebImage 或者 YYWebImage 方式载该图片时,两者差别不是非常大,有几 MB 的差别,内存变化约为 42 MB -> 235 MB -> 44 Mb。

由此可见,对于大图加载,以上方式都存在卡顿问题,甚至有因内存爆增而导致的 Crash 的风险!

三、图片加载优化

1、对不常用的大图片,使用 imageWithContentsOfFile: 代替 imageNamed: 方法
两个方法区别如下:

  • imgeNamed:
    用这个方法加载图片分为两种情况:

    • 系统缓存有这个图片,直接从缓存中取得
    • 系统缓存没有这个图片,则会加载图片并缓存到内存
  • imageWithContentsOfFile:
    用这个方法只有一种情况,那就是仅仅加载图片, 图像数据不会被缓存。因此在加载较大图片的时候,以及图片使用情况很少的时候可以使用这个方法,降低内存消耗。

2、降低采样
在视图比较小,图片比较大的场景下,直接展示原图片会造成不必要的内存和 CPU 消耗,这里就可以使用 ImageIO 的接口,DownSampling,也就是生成缩略图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// DownSampling(降低采样)
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage
{
let sourceOpt = [kCGImageSourceShouldCache : false] as CFDictionary

// 其他场景可以用 createwithdata (data并未decode,所占内存没那么大),
let source = CGImageSourceCreateWithURL(imageURL as CFURL, sourceOpt)!

let maxDimension = max(pointSize.width, pointSize.height) * scale
let downsampleOpt = [kCGImageSourceCreateThumbnailFromImageAlways : true,
kCGImageSourceShouldCacheImmediately : true ,
kCGImageSourceCreateThumbnailWithTransform : true,
kCGImageSourceThumbnailMaxPixelSize : maxDimension] as CFDictionary
let downsampleImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOpt)!
return UIImage(cgImage: downsampleImage)
}

注意事项:

  • 设置 kCGImageSourceShouldCachefalse,避免缓存解码后的数据,64 位设置上默认是开启缓存的(很好理解,因为下次使用该图片的时候,可能场景不同,需要生成的缩略图大小是不同的,显然不能做缓存处理)

  • 设置 kCGImageSourceShouldCacheImmediatelytrue,避免在需要渲染的时候才做解码,默认选项是 false

  • 上面 CGImageSourceCreateWithURL 入参数是 URL,也可以传本地图片 URL

    • 例如:let url = Bundle.main.url(forResource:"test", withExtension: "jpg")

对于确实需要加载高分辨率图片的场景,也可以图片加载前获取图片的尺寸,如果尺寸达到预设的阀值(例如设置最大尺寸 2000 x 2000),则根据阀值按比例降低采样(生成缩略图)。

如何获取图片的尺寸呢?下面这种方式是有问题的:

1
2
UIImage *image = [UIImage imageWithContentsOfFile:...];
CGSize imageSize = image.size;

这样获取图片尺寸会先把图片加载到内存里,对于超大图,这样已经出现内存爆增的问题了。

最佳获取图片尺寸的方式仍然是使用 ImageIO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NSURL *imageFileURL = [NSURL URLWithString:@"xxxxxxxxxxxxxxx"];
CGImageSourceRef imageSource = CGImageSourceCreateWithURL((CFURLRef)imageFileURL, NULL);

NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:NO], (NSString *)kCGImageSourceShouldCache,
nil];
CFDictionaryRef imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, (CFDictionaryRef)options);
if (imageProperties) {
NSNumber *width = (NSNumber *)CFDictionaryGetValue(imageProperties, kCGImagePropertyPixelWidth);
NSNumber *height = (NSNumber *)CFDictionaryGetValue(imageProperties, kCGImagePropertyPixelHeight);

// 获取图片的 size
NSLog(@"Image dimensions: %@ x %@ px", width, height);

CFRelease(imageProperties);
}
CFRelease(imageSource);

这种方式获取 size 不需要提前将图片加载进内存,避免了内存爆增的情况。CGImageSourceCopyPropertiesAtIndex() 返回的字典包含许多信息,除了图片的 size 还包含拍摄的日期、相机的模式和 GPS 信息等。

3、分片加载
本方案图片显示载体为 CATiledLayerCATiledLayer 作为 UIScrollView 的子 View 实现图片大小缩放,CATiledLayer 设置一个缩放区域的集合和重绘阈值,让 UIScrollView 在缩放时,绘制层根据这些区域和缩放阈值去重新绘制当前显示的区域。

Apple 官方大图加载完整 demo 地址

4、第三方库用参数设置
对于 SDWebImage,decodeImageWithImage 这个方法用于对图片进行解压缩并且缓存起来,以保证 tableviews/collectionviews 交互更加流畅,但是如果是加载高分辨率图片的话,会适得其反,有可能造成上G的内存消耗。对于高分辨率的图片,应该在图片解压缩后,禁止缓存解压缩后的数据,相关的代码处理为:

1
2
[[SDImageCache sharedImageCache] setShouldDecompressImages:NO];
[[SDWebImageDownloader sharedDownloader] setShouldDecompressImages:NO];

除此之外,也可以设置 SDWebImage 的其他参数,比如是否缓存到内存以及内存缓存最高限制等,来保证内存安全:

  • shouldCacheImagesInMemory:是否缓存到内存
  • maxMemoryCost:内存缓存最高限制

但是该方式只能避免加载大量不是特别大的高清图时因缓存图片数量过多导致内存爆增引起的 crash,无法解决单个超大图引起的 crash。由于是全局设置,需要在 dealloc 方法中设置回去。

Tags: 优化