[转载] 探秘越来越复杂的 ImageIO 框架

原文地址 : 探秘越来越复杂的 ImageIO 框架

探秘越来越复杂的 ImageIO 框架

前言

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

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,不支持硬件解码。

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

AVIF

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 的硬件解码器。

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

1
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:

1
2
3
4
// JPEG/HEIF格式限定,iOS 13,arm64真机限定
let data = try! Data(contentsOf: largeJpegUrl)
let image = UIImage(data: data)!
self.imageView.image = image

CGImage:

1
2
3
4
5
6
// JPEG/HEIF格式限定,iOS 13,arm64真机限定
let data = try! Data(contentsOf: largeJpegUrl)
let source = CGImageSourceCreateWithData(data as CFData, 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)

  • HEIC(IOSurface)

50%内存开销的奥秘

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

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

1
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 的采样方式

因此,这里应该对应有两个 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 之间发生资源冲突,因此这是上述优化的一个必要条件。

1
2
3
4
5
6
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 网络图,有办法也利用这个优化吗?

答:可以也不可以。
可以是由于,如果 JPEG/HEIF 网络图下载到本地,并通过UIImage imageWithContentsOfFile:加载,那么我们依旧可以利用到这个优化,节省内存占用。

不可以的原因是,假如像 SDWebImage 这种图片库,老的接口仅仅把 Data 存在内存中的话,就会有问题。原因是 ImageIO 和 UIKit 并没有提供公开 API 来加载内存中的 Data,只有其内部使用了以下私有接口:

* -[UIImage initWithIOSurface:]
* CGImageSourceCreateIOSurfaceAtIndex

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

对于 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

崩溃的背后

这一机制改变,同时带来了一个隐患是: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 丢失掉,那么就能打断这个持有链条。

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

总结

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

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

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

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

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

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道