iOS图片-最佳实践

介绍下 wwdc 2018 Image and Graphics Best Practices 中图片处理解压相关知识点
ref: Image and Graphics Best Practices

图片处理过程

一张图片从磁盘中显示到屏幕上过程大致如下:从磁盘加载原始压缩的图片信息、解码二进制图片数据为位图、渲染图片最终绘制到屏幕上。

在实际的渲染过程中,UIImage负责解压Data Buffer内容并申请buffer(Image Buffer)存储解压后的图片信息,然后UIImageView负责将Image Buffer 拷贝至 framebuffer,用于给显示硬件提供颜色信息。

解压过程是一个大量占用CPU资源的工作,因此UIImage 会retain存储解压后信息的Image Buffer以便给重复的渲染工作提供信息,Image Buffer与图片的实际尺寸有关(理论值为height width 4 bytes),与图片文件大小无关。若是在TableView等列表中连续加载多张图片,便会引发连续的大块内存分配,这将对Memory和CPU带来沉重的负担。

Data Buffer / Image Buffer / Frame Buffer

图片处理过程中会产生三种buffer

Data Buffer


Data Buffer存储了图片的元数据,我们常见的图片格式,jpeg,png等都是压缩图片格式。Data Buffer的内存大小就是源图片在磁盘中的大小。

Image Buffer


Image Buffer存储的就是图片解码后的像素数据,也就是我们常说的位图。 Buffer中每一个元素描述的一个像素的颜色信息,buffer的size和图片的size成正相关关系。

Frame Buffer

Frame Buffer 存储了app每帧的实际输出。在应用程序更新图层时,UIKit将window及其subviews渲染至framebuffer,这个framebuffer提供每个像素的信息以供显示硬件定时读取,读取的频率一般为60Hz,但在ipad上可提升至120Hz。

加载和解压

一般使用imageNamed:或者imageWithData:从内存中加载图片生成UIImage的实例,此刻图片并不会解压,当 RunLoop 准备处理图片显示的事务(CATransaction)时,才进行解压,而这个解压过程是在主线程中的,这是导致卡顿的重要因素。

解码后的图片内存占用会比原图大小大很多。ImageBuffer按照每个像素RGBA四个字节大小,一张1080p的图片解码后的位图大小是1920 1080 4 / 1024 / 1024,约7.9mb,而原图假设是jpg,压缩比1比20,大约350kb,可见解码后的内存占用是相当大的

imageNamed: 方法

通过 imageNamed 创建 UIImage 时,系统实际上只是在 Bundle 内查找到文件名,然后把这个文件名放到 UIImage 里返回,并没有进行实际的文件读取和解码。当 UIImage 第一次显示到屏幕上时,其内部的解码方法才会被调用,同时解码结果会保存到一个全局缓存去。

值得注意的是,这些缓存都是全局的,并不会因为当前UIImage实例的释放而清除,在收到内存警告或者 APP 第一次进入后台才有可能会清除,而这个清除的时机和内容是系统决定的,我们无法干涉。

imageWithData: 方法

通过数据创建 UIImage 时,UIImage 底层是调用 ImageIO 的 CGImageSourceCreateWithData() 方法。该方法有个参数叫 ShouldCache,在 64 位的设备上,这个参数是默认开启的。这个图片也是同样在第一次显示到屏幕时才会被解码,随后解码数据被缓存到 CGImage 内部。与 imageNamed 创建的图片不同,如果这个图片被释放掉,其内部的解码数据也会被立刻释放。

两种加载方式的区别 从上面的分析可知,imageNamed:使用时会产生全局的内存占用,但是第二次使用同一张图片时性能很好;imageWithData:不会有全局的内存占用,但对于同一张图片每次加载和解压都会“从头开始”。由此可见,imageNamed:适合“小”且“使用频繁”的图片,imageWithData:适合“大”且“低频使用”的图片。

怎么能避免缓存呢?

  1. 手动调用 CGImageSourceCreateWithData() 来创建图片,并把 ShouldCache 和 ShouldCacheImmediately 关掉。这么做会导致每次图片显示到屏幕时,解码方法都会被调用,造成很大的 CPU 占用。
  2. 把图片用 CGContextDrawImage() 绘制到画布上,然后把画布的数据取出来当作图片。这也是常见的网络图片库的做法。

最佳实践

大图使用缩略图

值得注意的是,可能业务中需要载入一张很大的图片。这时,若还使用常规的方式加载会占用过多的内存;况且,若图片的像素过大(目前主流 iOS 设备最高支持 4096 x 4096 纹理尺寸),在显示的时候 CPU 和 GPU 都会消耗额外的资源来处理图片。可以采用imageIO api来生成缩略图

具体代码如下,指定显示区域大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func downsample(imageAt imageURL:URL,to pointSize:CGSize,scale:CGFloat)->UIImage{
//设置kCGImageSourceShouldCache为false,避免缓存解码后的数据,64位设置上默认是开启缓存的
let imageSourceOptions = [kCGImageSourceShouldCache:false] as CFDictionary
let imageSouce = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!

let maxDimensionInPixels = max(pointSize.width,pointSize.height) * scale
//设置kCGImageSourceShouldCacheImmediately为true,避免在需要渲染的时候才做解码,默认选项是false
let downsampleOptions =
[
kCGImageSourceCreateThumbnailFromImageAlways:true,
kCGImageSourceShouldCacheImmediately:true,
kCGImageSourceCreateThumbnailWithTransform:true,
kCGImageSourceThumbnailMaxPixelSize:maxDimensionInPixels
] as CFDictionary

let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSouce, 0, downsampleOptions)!
return UIImage(cgImage: downsampledImage)
}

其他缩略方法可以看iOS图片-图片缩略方法

大图使用CATiledLayer

有些时候你可能需要绘制一个很大的图片,常见的例子就是一个高像素的照片或者是地球表面的详细地图。iOS应用通畅运行在内存受限的设备上,所以读取整个图片到内存中是不明智的。载入大图可能会相当地慢,那些对你看上去比较方便的做法(在主线程调用UIImage的-imageNamed:方法或者-imageWithContentsOfFile:方法)将会阻塞你的用户界面,至少会引起动画卡顿现象。

能高效绘制在iOS上的图片也有一个大小限制。所有显示在屏幕上的图片最终都会被转化为OpenGL纹理,同时OpenGL有一个最大的纹理尺寸(通常是20482048,或40964096,这个取决于设备型号)。如果你想在单个纹理中显示一个比这大的图,即便图片已经存在于内存中了,你仍然会遇到很大的性能问题,因为Core Animation强制用CPU处理图片而不是更快的GPU(见第12章『速度的曲调』,和第13章『高效绘图』,它更加详细地解释了软件绘制和硬件绘制)。

CATiledLayer为载入大图造成的性能问题提供了一个解决方案:将大图分解成小片然后将他们单独按需载入。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
#import "ViewController.h"
#import

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIScrollView *scrollView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//add the tiled layer
CATiledLayer *tileLayer = [CATiledLayer layer];
tileLayer.frame = CGRectMake(0, 0, 2048, 2048);
tileLayer.delegate = self; [self.scrollView.layer addSublayer:tileLayer];

//configure the scroll view
self.scrollView.contentSize = tileLayer.frame.size;

//draw layer
[tileLayer setNeedsDisplay];
}

- (void)drawLayer:(CATiledLayer *)layer inContext:(CGContextRef)ctx
{
//determine tile coordinate
CGRect bounds = CGContextGetClipBoundingBox(ctx);
NSInteger x = floor(bounds.origin.x / layer.tileSize.width);
NSInteger y = floor(bounds.origin.y / layer.tileSize.height);

//load tile image
NSString *imageName = [NSString stringWithFormat: @"Snowman_%02i_%02i", x, y];
NSString *imagePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"jpg"];
UIImage *tileImage = [UIImage imageWithContentsOfFile:imagePath];

//draw tile
UIGraphicsPushContext(ctx);
[tileImage drawInRect:bounds];
UIGraphicsPopContext();
}
@end

异步加载解压

对于加载过程,若文件过大或加载频繁影响了帧率(比如列表展示大图),可以使用异步方式加载图片,减少主线程的压力,解压是耗时的,而系统默认是在主线程执行,所以业界通常有一种做法是,异步强制解压,也就是在异步线程主动将二进制图片数据解压成位图数据,使用CGBitmapContextCreate(…)系列方法就能实现。代码大致如下:

1
2
3
4
5
6
7
8
9
10
let serialQueue = DispatchQueue(label: "Decode queue")
func collectionView(_ collectionView:UICollectionView,prefetcgItemAt indexPaths:[IndexPath]){
//异步解压 生成缩略图
for indexPath in indexPaths{
serialQueue.async {
let downsampledImage = downsample(images[IndexPath])
DispatchQueue.main.async {self.update(at:IndexPath.with:downsampledImage)}
}
}
}

合理使用draw方法

重载draw方法会导致内存爆涨。原因如下:

一旦你实现了CALayerDelegate协议中的-drawLayer:inContext:方法或者UIView中的-drawRect:方法(其实就是前者的包装方法),CALayer创建了一个合适尺寸的空寄宿图(尺寸由bounds和contentsScale决定)和一个Core Graphics的绘制上下文环境(Backing Store),为绘制寄宿图做准备,它作为ctx参数传入。图层就创建了一个绘制上下文(Backing Store),这个上下文需要的内存可从这个公式得出:图层宽 图层高 4 字节,宽高的单位均为像素。以iphone6为例,750 1134 4 字节 ≈ 3.4 Mb 。

总结下这种使用drawRect绘制方案的问题

  1. Backing Store的创建造成了不必要的内存开销
  2. UIImage先绘制到Backing Store,再渲染到frameBuffer,中间多了一层内存拷贝
  3. 背景颜色不需要绘制到Backing Store,直接使用BackGroundColor绘制到FrameBuffer

所以,正确的实现姿势是将这个大的view拆分成小的subview逐个实现。

推荐使用Image Assets

基于名称和特效优化了查找效率,更快的查找图片
运行时,对内存的管理也有优化
App Slicing,app安装包瘦身。iOS 9 后会从 Image Assets 中保留设备支持的图片 (2x 或者 3x)
iOS 11 后的 Preserve Vector Data。支持矢量图的功能,放大也不会失真

Advanced Image Effects

对于图片的实时处理推荐使用CoreImage框架。
例如将一张图片的灰度值进行调整这样的操作,有滴小伙伴可能使用CoreGraphics获取图像的每个像素点数据,然后改变灰度值,最终生成目标图标,这种做法将大量gpu擅长的工作放在了cpu上处理,合理的做法是: 使用CoreImage的滤镜filter或者metal,OpenGL的shader,让图像处理的工作交给GPU去做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let sharedContext = CIContext(options: [.useSoftwareRenderer : false])
func coreImage_resize(oriImg:UIImage,size:CGSize) -> UIImage? {

guard let cgImage = oriImg.cgImage else { return nil }
let scale = (Double)(size.width) / (Double)(oriImg.size.width)
let image = CIImage(cgImage: cgImage)

let filter = CIFilter(name: "CILanczosScaleTransform")
filter?.setValue(image, forKey: kCIInputImageKey)
filter?.setValue(NSNumber(value:scale), forKey: kCIInputScaleKey)
filter?.setValue(1.0, forKey:kCIInputAspectRatioKey)

guard let outputCIImage = filter?.outputImage,
let outputCGImage = sharedContext.createCGImage(outputCIImage,
from: outputCIImage.extent)
else {
return nil
}

return UIImage(cgImage: outputCGImage)
}

Drawing Off-Screen

对于需要离屏渲染的场景推荐使用UIGraphicsImageRenderer替代UIGraphicsBeginImageContext,性能更好,并且支持广色域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func uikit_resize(oriImg:UIImage?,size:CGSize) -> UIImage?{
let hasAlpha = false
let scale: CGFloat = 0.0 // Automatically use scale factor of main screen

UIGraphicsBeginImageContextWithOptions(size, !hasAlpha, scale)
oriImg!.draw(in: CGRect(origin: .zero, size: size))

let resizedImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return resizedImage!

//in iOS 10 UIGraphicsImageRenderer
// let render = UIGraphicsImageRenderer(size: size)
// return render.image(actions: { (context) in
// oriImg?.draw(in: CGRect(origin: .zero, size: size))
// })
}