7

探秘越来越复杂的ImageIO框架

 1 year ago
source link: https://dreampiggy.com/2022/11/07/%E6%8E%A2%E7%A7%98%E8%B6%8A%E6%9D%A5%E8%B6%8A%E5%A4%8D%E6%9D%82%E7%9A%84ImageIO%E6%A1%86%E6%9E%B6/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

探秘越来越复杂的ImageIO框架

2022-11-072022-11-09iOS

ImageIO是Apple提供的上层框架,用于处理常见图像格式的编解码支持。这篇文章主要讲述了三个子话题:WebP/AVIF的支持进展,IOSurafce和硬件解码优化50%内存开销,以及CGImageSource机制变化导致的线程安全问题

ImageIO的定位是上层的支持框架,其封装了诸多的苹果的底层解码器,开源编解码器,硬件HEVC/ProRes加速器等等底层细节,致力于提供和上层UI框架(如UIKit/CoreGraphics)的可交互性。

在早些年的时候,我写过一系列文章,介绍了其API使用的基本流程(参考:《iOS平台图片编解码入门教程(Image/IO篇)》),以及有关其惰性解码的机制(参考:《主流图片加载库所使用的预解码究竟干了什么》)。

实话说,自从重心从iOS开发,转移到做LLVM工具链相关工作之后,我本以为不会再写这些上层iOS框架的文章了,但是SDWebImage这个开源库依旧没有合我预期的新Maintainer,来作为交接,因此现在还是忍不住先写这一篇吐槽和说明文章。

这篇文章会介绍,自iOS 13时代之后,苹果在ImageIO上做的一系列优化(“机制变化”),以及对开发者生态带来的影响。

WebP/AVIF新兴图像格式支持

自从HEVC/HEIF在苹果高调提供支持之后,由于硬件解码器的加持,本以为苹果会对其他竞争的媒体格式不再抱有兴趣,但实际上并非如此

WebP作为Google主导的无专利费的图像格式,其诞生后就一直跟随Chrome推广到各大Web站点,如今已经占据了互联网的一大部分(虽然其兄弟的WebM视频编码并没有这么热门)。

早在iOS 11时代,我就呼吁并提Radar希望Apple的ImageIO能够支持原生的WebP,而最终,时隔3年,在iOS 14上,ImageIO终于迎来了其内置的WebP支持,并且能够在Mac,iPhone上的各种原生系统应用中,预览WebP图像了。

那么,ImageIO对WebP的支持到底如何呢?答案其实很简单,ImageIO直接内置了开源的libwebp的一份源码和VP8的支持,并且去掉了编码的能力支持,所以能够以软件解码的形式支持WebP,不支持硬件解码。

screenshot-20221107-210603

换言之,使用这个ImageIO的系统解码器解码WebP,和使用我写的SDWebImageWebPCoder没有本质上的巨大差异(最多是一些编译器优化导致的差异),而后者还支持WebP编码(虽然耗时很慢)

AVIF是基于AV1视频编码的新兴图像格式,作为HEVC的无专利费的竞争对手。AVIF与AV1,HEIF和HEVC,这两大阵营的关系一直是在相互竞争中不断发展的。而各大视频站如YouTube,Netflix,以及国内的Bilibli都在积极的推广这一视频格式,减少CDN带宽和专利费的成本。

而随着Apple在2018年加入AOM-Alliance for Open Media之后,我就预测有朝一日能够看到苹果拥抱这一开源标准。在2021年WebKit的开源部分曾经接受了PR并支持AVIF软件解码。而在2022的今年,iOS 16/macOS 13搭载的Safari 16,已经正式宣布支持了AVIF

虽然目前没有在其他系统应用中可以直接预览AVIF,但是我们已经看到这一趋势。在ImageIO的反编译结果中也看到了对.avif的处理和UTI的识别,虽然目前其本身只是会fallback到AVCI(AVC编码的HEIF,并不是AV1),但是我相信,后续OS版本一定会带来其对应的原生SDK和应用层的整体支持,甚至未来可以看到新iPhone搭载AV1的硬件解码器。

screenshot-20221107-211812

PS:广告时间,我之前也尝试过一些利用开源AV1解码器实现的AVIF解码库,以及macOS专用的Finder QuickLook插件,在未来到来之前,依旧可以发挥其最后的功用:)

brew install avifquicklook

IOSurface和硬件解码优化

IOSurface,作为iOS平台上古老的一套在多进程,CPU与GPU之间共享内存的方案,在早期iOS 4时代就已经诞生,但是一直仅仅作为系统私有的底层XPC通信用的数据格式

而从iOS 13之后,苹果对硬件解码的支持的图像格式的上屏渲染,大量使用了IOSurface,抛弃了原有的“主线程触发CGImage的惰性解码”的模式。

也就是说,《主流图片加载库所使用的预解码究竟干了什么》这篇文章关于ImageIO的部分已经彻底过时了,至少对于JPEG/HEIF而言是这样。

如何验证这一点呢?可以从一个简单的Demo,我们这里有一个4912*7360分辨率的JPEG和HEIC图(链接),使用UIImageView渲染上屏,开启Instruments,对比内存占用

IOSurface:

// JPEG/HEIF格式限定,iOS 13,arm64真机限定
let image = UIImage(contentsOfFile: largeJpegUrl.path)
self.imageView.image = image

CGImage:

// JPEG/HEIF格式限定,iOS 13,arm64真机限定
let source = CGImageSourceCreateWithURL(data as CFURL, nil)!
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)!
let image = UIImage(cgImage: cgImage)
self.imageView.image = image

数据较多,直接看IOSurface的结果,可以发现,除了峰值上HEIC出现了翻倍,最终稳定占用都为51.72MB

而直接用CGImage(或者你换用模拟器而不是真机),则结果为137.9MB(RGBA8888)

  • JPEG(IOSurface)
screenshot-20221107-220103.png
  • HEIC(IOSurface)
screenshot-20221107-220104.png
  1. 在iOS 15+之后,这部分的Responsible Library会变成CMPhoto,iOS 15+新增的UIImage.preparingForDisplay()也利用了它的能力
  2. 使用UIImage(contentsOfFile:)和这里的UIImage(data:),在iOS 15上并无明显差异,但是在低版本如iOS 13/14上,可能出现UIImage(data:)对于HEIC格式,无法利用IOSurface的Bug,因此更推荐使用文件路径的接口

50%内存开销的奥秘

反编译可以发现,苹果系统库的内部流程,已经废弃了CGImage来传递这种硬件解码器的数据Buffer,而直接使用IOSurface,以换取更小的内存开销,达到同分辨率下RGBA8888的内存占用的37.5%(即3/8),同分辨率下RGB888的内存占用的50%(即1/2)

你可能会表示很震惊,因为数学公式告诉我们,一个Bitmap Buffer的内存占用为:

Bytes = BytesPerPixel * Width * Height

而要实现这个无Alpha通道的50%内存占用,简单计算就知道,意味着BytesPerPixel只有1.5,也就是说12个Bit,存储了3个256(2^8)色彩信息,换句话说0-255的数字用4个Bit表示!

你觉得数学上可能吗?答案是否定的,因为实际上是用了色度采样,并不是完整的0-255的数字,学过数字图像处理的同学都应该有所了解。

打开调试器,给IOSurface的initWithProperties:下断点,发现这个创建的IOSurface很有意思,PixelFormat = 875704438('420v'),即kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,看来使用了YUV 4:2:0的采样方式

screenshot-20221107-215148

因此,这里应该对应有两个Plane,分别对应了Y和U两个采样的平面,最终由GPU渲染时进行处理。这里不采取YUV 4:4:4的原因是,大多数JPEG/HEIF的无透明度的图像,在肉眼来看,采样损失的色度人眼差异不大,这一优化能节省50%内存占用,无疑是值得的。

值得注意的是,这里苹果处理具体采样的逻辑也是和原图像编码有关的,如果YUV 4:4:4编码的,则最终CMPhoto可能依旧会采取YUV 4:4:4进行解码并直接上屏,苹果专门的策略类来进行处理。

IOSurface和跨进程Buffer

不过,除了这一点,为什么只有真机能支持色度采样呢?答案和Core Animation的跨进程上屏有关。

之前有文章分享过之前,iOS的UI渲染是依赖于SpringBoard进程的中的CARenderServer子线程来处理的,因此这就有一个问题,我们如何才能将在App进程的Bitmap Buffer传给另一个进程的CARenderServer呢?

在iOS 13之前我们的方案,就是利用mmap,直接分配内存。但是mmap的问题在于,在最终Metal渲染管线传输时,我们依旧要经过一次额外的把Bitmap Buffer转为Texture并拷贝到显存的流程,因此这一套历史工作的横竖还有一些局限性。

在A12+真机的设备上,这一步借助IOSurface来实现跨CPU内存和GPU显存的高效沟通。

参考苹果的文档以及一些相关资料

IOSurface的资源管理本质上是Kernel-level而不是User-level的mmap的buffer,Kernel已经实现了一套高效的传输模型,借助Lock/Unlock来避免多个进程或者CPU/GPU之间发生资源冲突,因此这是上述优化的一个必要条件。

let surface: IOSurface
surface.lock(options: .readOnly, seed: nil)
defer { surface.unlock(options: .readOnly, seed: nil) }

// Use surface.baseAddress to read the pixel data
// Make sure to step by bytesPerRow for each row

开发者的痛,我的Public API呢?

现在揭秘了苹果优化JPEG和HEIF硬件解码内存开销之后,下一个问题是:

作为开发者,我如果加载一个JPEG/HEIF网络图,有办法也利用这个优化吗?

答:可以,但是使用时需要遵守以下几个原则:

  1. 对JPEG/HEIF网络图,如果仅有内存中的数据,则优先考虑使用UIImage(data:)
  2. 如果能够将数据下载到本地存储产生文件路径,则优先考虑使用UIImage(contentsOfFile:)加载
  3. 如果直接使用ImageIO接口,需要注意,调用CGImageSourceCreateImageAtIndex返回的是惰性解码的占位CGImage,而CGImageSourceCreateThumbnailAtIndex返回的是解码后的CGImage(也就根源上无法利用IOSurface优化)
  4. 如果要进行预解码,在iOS 15之后,请不要使用老文章写的,使用CGContext提取Bitmap Buffer的方案,优先调用UIImage.preparingForDisplay(),甚至是如果仅有CGImage的情况下,也推荐创建一个临时UIImage再来调用。其原理是,对于上文提到的,惰性解码的占位CGImage,CMPhoto能间接进行IOSurface的创建(利用后文讲到的CGImageGetImageSourcce),达到偷梁换柱的作用,而手动创建CGContext并没有这样的能力(可以参考#3368

如果遵守以上几点,那么我们依旧可以利用到这个优化,节省内存占用。否则会退化到传统的RGBA8888的内存开销上。尤其是关于第4点,苹果这个设计本想让开发者淡化IOSurface和CGImage的差异,但是我感觉反而增加了理解成本和性能优化成本。

另外,ImageIO和UIKit并没有提供更详细的IOSurface的公开API,只有其内部流程,本质间接使用了以下私有接口:

  • -[UIImage initWithIOSurface:]
  • CGImageSourceCreateIOSurfaceAtIndex

诚然,我们都知道能够直接调用任意的Objective-C/C API的姿势,这里也不再展开,只是需要注意,上文提到的这些优化,都存在特定iPhone硬件(A12+)和格式(JPEG/HEIF)的限定,需要注意检查可用性。

此外,从实践来看,苹果UIKit和ImageIO的上层接口,都更推荐文件路径的形式(因为可以优化为mmap读取,文件扩展名的Hint等逻辑),如果我还继续维护SDWebImage下去的话,未来也许会提供基于URLSessionDownloadTask以及文件路径模式的解码方案,或许就能更好地支持这一点。

不再安全的ImageIO

曾经,在我的最佳设计模式观念里,一个Producer,产出的Product,永远不应该反向持有Producer本身。但是这个想法被ImageIO团队打破了

从一个崩溃说起

在iOS 15放出后的很长一段时间里,SDWebImage遇到一个奇怪的崩溃问题#3273,从堆栈来看是典型的多线程同时访问了CFData(CFDataGetBytes)导致的野指针。起初我对此并没有在意,以为又是小概率问题,并且@kinarobin提了一个可能的CGImageSource过度释放的修复后,我就关闭了这个问题。但是随后越来越多用户依旧反馈这个崩溃,因此重新打开仔细看了一下,发现了其背后的玄机。

玄机在于,iOS 15之后,Core Animation在主线程渲染CGImage时,会调用一个新增的奇怪的接口CGImageGetImageSource。如果带着疑问进一步追踪调用堆栈,发现在调用CGImageSourceCreateImageAtIndex时,ImageIO会通过CGImageSetImageSource绑定一个CGImageSource实例,到CGImage本身的成员变量(实际来说,是绑定到了其结构体指针存储的CGImageProperty字典)。随后,Core Animation会通过获取到这个CGImageSource,后续在渲染时间接调用CGImageSource的相关接口。持有链条为 UIImage -> UIImageCGContent -> CGImageSource

screenshot-20221107-185536

崩溃的背后

这一机制改变,同时带来了一个隐患是:ImageIO它不再线程安全了。而且开发者不能修改Core Animation代码来强制加锁。

主要原因是,CGImageSource支持渐进式解码,而第三方自定义UIImage的子类时,有可能自己创建并持有这个渐进式解码的CGImageSource,并不断更新数据。在SDWebImage本身的设计中,我们通过加锁来保证,所有的对渐进式解码的调用,以及更新数据的方法,均能被同一把锁保护。

而当我们产出的CGImage,传递给了Core Animation,它无法访问这一把锁,而直接获取CGImageSource,并调用其相关的解码调用,就会出现多线程不安全的崩溃问题。

总而言之,这一设计模式的打破,即把Product和本不应该关心的Producer一起交给了外部用户,但是外部用户无法保障Producer的生命周期和调用,最终导致了这样的问题。

Workaround方案

最终,针对这个问题,SDWebImage提供了两套解决思路,第一个思路是直接通过CGContext提取得到自己的Bitmap Buffer,得到一个新的CGImage,切断整个持有链,最简单粗暴的修复,代价是全量关闭惰性解码无法用户控制,可能带来更高的内存占用(#3387,修复在5.13.4版本上)

第二个思路是,通过抹除掉CGImage持有的这些额外信息,采取通过CGImageCreate重新创建一个复制的CGImage,但是依旧保留了惰性解码的可选能力(#3425,方案在5.14.0版本上)。顺便提一句,通常动图(GIF/AWebP)都不支持硬件解码且切换帧频率较高,关闭惰性解码依旧是小动图的最佳实践。

PS:对感兴趣的小伙伴详细解释一下,第二个解决思路利用了CGImageProperty(类似于CGImage上存储的一个字典,按Key-Value形式存取)的时机特性,使用CGImageCreate重建CGImage时会完全丢失所有CGImageProperty(只有CGImageCreateCopy能够保留)。

而上文提到的CGImageGetImageSource/CGImageSetImageSource这些私有接口,本质上是操作这个com.apple.ImageIO.imageSourceReadRef的Key(全局变量kImageIO_imageSourceReadRef),Value存储了ImageIO的C++对象,并可以还原回一个CGImageSourceRef指针。一旦我们把CGImageProperty丢失掉,那么就能打断这个持有链条。

screenshot-20221107-202706

总结起来,ImageIO Team做出如此重大的设计模式改变,并没有在任何公开渠道同步过开发者,也没有提供公开接口能够控制这个行为,或者至少,没有暴露对应的CGImageSetImageSource接口,导致第三方开发者不得不采取曲线救国的解决方案去Workaround,这一点很值得让人吐槽。

这篇文章看似讲了三个话题,其实背后有着一贯的缘由背景:

早期的ImageIO和各种上层框架的设计,是针对iPhone的低内存的机型做了深入优化,希望能尽量利用惰性解码,mmap缓存,换取较低内存开消,并且对各种无硬件解码的开源格式完全不感兴趣。

而最近几年,随着苹果芯片团队的努力,高内存,M1的统一内存,以及高性能芯片的诞生,苹果已经有充足的能力能够通过软件解码,共享内存,越来越多硬件解码器技术来满足主流的多媒体图像支持,本身这是一件好事。

不过问题在于历史遗下来的API,依旧保持了之前的设计缺陷,Apple团队却一直在,通过越来越Trick和Hack的方式解决问题,并没有给开发者可感知的新机制和手段来跟进优化(除开这一点吐槽,AppKit上的NSImage的NSImageRep这种代理对象设计,比UIImage的私有类UIImageContent设计要适宜的多,也灵活的多)

个人看法:软硬件一体加之闭源,会导致开源社区的实现,永远无法及时跟上其一体的私有集成,最终会捆绑到开发者和用户(开发者越强依赖苹果API和SDK,就会越强迫用户更新OS版本,进而捆绑硬件换代销售),这并不是一个好的现象🙃

SDWebImage开源项目如今缺少长久维护的Maintainer,如果你对iOS/macOS框架开发感兴趣,对图像渲染和Apple平台有所涉猎,对Swift/Objective-C大型开源项目贡献有所期待,可以在我的GitHub上,以Email,Twitter私信等方式联系我。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK