介绍下 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:适合“大”且“低频使用”的图片。
怎么能避免缓存呢?
- 手动调用 CGImageSourceCreateWithData() 来创建图片,并把 ShouldCache 和 ShouldCacheImmediately 关掉。这么做会导致每次图片显示到屏幕时,解码方法都会被调用,造成很大的 CPU 占用。
- 把图片用 CGContextDrawImage() 绘制到画布上,然后把画布的数据取出来当作图片。这也是常见的网络图片库的做法。
最佳实践
大图使用缩略图
值得注意的是,可能业务中需要载入一张很大的图片。这时,若还使用常规的方式加载会占用过多的内存;况且,若图片的像素过大(目前主流 iOS 设备最高支持 4096 x 4096 纹理尺寸),在显示的时候 CPU 和 GPU 都会消耗额外的资源来处理图片。可以采用imageIO api来生成缩略图
具体代码如下,指定显示区域大小
1 | func downsample(imageAt imageURL:URL,to pointSize:CGSize,scale:CGFloat)->UIImage{ |
其他缩略方法可以看iOS图片-图片缩略方法
大图使用CATiledLayer
有些时候你可能需要绘制一个很大的图片,常见的例子就是一个高像素的照片或者是地球表面的详细地图。iOS应用通畅运行在内存受限的设备上,所以读取整个图片到内存中是不明智的。载入大图可能会相当地慢,那些对你看上去比较方便的做法(在主线程调用UIImage的-imageNamed:方法或者-imageWithContentsOfFile:方法)将会阻塞你的用户界面,至少会引起动画卡顿现象。
能高效绘制在iOS上的图片也有一个大小限制。所有显示在屏幕上的图片最终都会被转化为OpenGL纹理,同时OpenGL有一个最大的纹理尺寸(通常是20482048,或40964096,这个取决于设备型号)。如果你想在单个纹理中显示一个比这大的图,即便图片已经存在于内存中了,你仍然会遇到很大的性能问题,因为Core Animation强制用CPU处理图片而不是更快的GPU(见第12章『速度的曲调』,和第13章『高效绘图』,它更加详细地解释了软件绘制和硬件绘制)。
CATiledLayer为载入大图造成的性能问题提供了一个解决方案:将大图分解成小片然后将他们单独按需载入。
1 |
|
异步加载解压
对于加载过程,若文件过大或加载频繁影响了帧率(比如列表展示大图),可以使用异步方式加载图片,减少主线程的压力,解压是耗时的,而系统默认是在主线程执行,所以业界通常有一种做法是,异步强制解压,也就是在异步线程主动将二进制图片数据解压成位图数据,使用CGBitmapContextCreate(…)系列方法就能实现。代码大致如下:
1 | let serialQueue = DispatchQueue(label: "Decode queue") |
合理使用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绘制方案的问题
- Backing Store的创建造成了不必要的内存开销
- UIImage先绘制到Backing Store,再渲染到frameBuffer,中间多了一层内存拷贝
- 背景颜色不需要绘制到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 | let sharedContext = CIContext(options: [.useSoftwareRenderer : false]) |
Drawing Off-Screen
对于需要离屏渲染的场景推荐使用UIGraphicsImageRenderer替代UIGraphicsBeginImageContext,性能更好,并且支持广色域。
1 | func uikit_resize(oriImg:UIImage?,size:CGSize) -> UIImage?{ |