2 minute read

前段时间和一位做 iOS 开发的朋友聊系统 API 的演进,他提到一个很有共鸣的观点:我们平时用得顺手的 API,即便曾经看起来稳定、安全,官方也可能因为底层优化或实现调整,悄悄改变它的运行语义。ImageIO 就是一个非常典型的例子。

这句话一下子勾起了我的兴趣。再结合最近项目里踩过的几个坑,我索性系统梳理了一遍 ImageIO 这些年的变化和当前的使用边界,分享给大家,尽量帮同行少走一些弯路。

一、核心疑问:ImageIO 曾经是线程安全的吗?

先说结论:严格意义上,Apple 从未明确承诺过 ImageIO“整体线程安全”。过去我们之所以觉得它“看起来安全”,更多是因为很多只读场景长期没有出问题;但这种“经验上的安全”,并不等于官方保证。从 iOS 15 开始,这种基于经验的判断也基本失效了。

1.1 Apple 关于线程安全的原则

Apple 在通用线程安全原则里长期强调:不可变对象通常可以跨线程安全使用。落实到日常图像开发里,大致可以这样理解:

  • UIImage:官方长期将其视为不可变对象,一般可以放心在不同线程中创建、传递和访问;
  • CGImage:作为 Core Graphics 体系中的不可变对象,从现代文档和设计意图来看,也适合跨线程使用;
  • CGImageSourceCGImageDestination:这里最值得警惕。公开文档并没有给出明确的线程安全承诺,更没有说“整个 ImageIO 框架都可以安全并发访问”。

也就是说,我们过去踩坑的根源之一,就是把“部分结果对象可跨线程使用”误解成了“整个 ImageIO 解码链路天然线程安全”。

1.2 为什么大家会误以为 ImageIO 以前是线程安全的?

原因其实很现实:经验主义

iOS 14 及更早的系统里,最常见的 ImageIO 用法通常是:

  1. 在某个线程创建 CGImageSource
  2. 通过 CGImageSourceCreateImageAtIndex 生成 CGImageRef
  3. 再把这个结果对象传给其他线程或 UI 层继续使用。

在大多数项目里,这条路径长期“跑得挺稳”,于是大家逐渐形成了一种集体印象:至少在只读解码场景下,ImageIO 大体上是线程安全的。

但需要明确一点:这只是实践层面的“暂时没出错”,不是 Apple 的正式保证。只要底层实现发生变化,这种经验结论就可能瞬间失效。

1.3 为什么 iOS 15 之后,旧经验开始失灵?

这一点,SDWebImage 的维护者在适配 iOS 15+ 时已经明确踩到过坑:CGImageSourceCreateImageAtIndex 生成的 CGImageRef,会隐式 retain 原始的 CGImageSourceRef

这意味着,我们拿到的并不是一个完全独立的图像对象,而是一个与原始 image source 存在内部关联的结果对象。

在动图播放、多线程 frame 缓存、后台解码、前台渲染这类高频场景里,这种耦合会非常致命:以前看起来没问题的代码,在 iOS 15 之后可能突然崩溃。归根到底,不是业务代码“突然写错了”,而是旧系统把这种耦合关系隐藏得更深,而 iOS 15 之后,这层实现细节开始直接影响上层行为。

1.4 别再说 ImageIO“线程安全”

一句话总结:ImageIO 从来都不应该被笼统地归类为“线程安全框架”

过去,一些只读解码路径在实践中“通常可用”,但始终缺乏官方背书;到了 iOS 15 之后,连这层经验上的安全也不再可靠。

这里还要特别强调一个容易混淆的点:

  • CGImageUIImage 这类不可变结果对象,很多情况下确实可以跨线程使用;
  • 但这并不等于 ImageIO 这个解码框架本身具备完整而稳定的线程安全保证。

不要把“结果对象可跨线程使用”和“解码过程本身线程安全”混为一谈。

二、站在 2026 年回看:ImageIO 的“不安全”到底指什么?

先澄清一个误区:这里说 ImageIO“不安全”,并不是指它存在安全漏洞,也不是说它不能用了,而是说——它已经不再适合作为一个“给我 NSData,还我 UIImage,中间无需关心细节”的黑盒工具。

现在的 ImageIO,运行语义受系统版本、解码路径、对象生命周期、图像格式等因素影响越来越大。很多行为已经不能仅凭经验判断。如果直接把它裸接到业务级图像框架中,迟早会被底层细节反噬。

2.1 ImageIO 的变化趋势,需要看清楚

过去我们使用 ImageIO,思路很直接:调用 CGImageSourceCreateWithDataCGImageSourceCreateImageAtIndex 拿到 CGImageRef,再包装成 UIImage 即可。

但从 iOS 15、16 到 17,这套思路越来越不够用了。核心变化在于:框架内部策略,开始持续外溢到业务层。典型表现包括:

  • lazy decode 与 non-lazy decode 的行为差异越来越明显,用错路径要么多占内存,要么引入崩溃风险;
  • 解码结果与 source 的生命周期关系变复杂,稍不注意就会出现泄漏、悬空引用或非预期持有;
  • 系统为了性能优化,可能调整内部实现,进而改变线程相关语义,导致“代码没变,行为变了”;
  • 某些格式在不同系统版本上会暴露系统级问题,例如 PDF、PNG 等,排查成本很高;
  • force decode、缩略图解码、PDF 解码、HDR 解码等路径差异巨大,不能简单视作同一类操作。

说得直接一点:ImageIO 已经从一个“默认可直接拿来用的系统工具”,变成了一个需要做防御性封装、持续回归验证的系统依赖。

2.2 iOS 15+ 的核心坑,以及 SDWebImage 的应对方式

iOS 15+ 最典型的问题,出现在动图播放链路里:CGImageRef 隐式 retain CGImageSourceRef,导致单帧图像与源数据发生耦合,多线程环境下非常容易出现崩溃。这也是 SDWebImage issue #3273 背后的核心问题。

围绕这个问题,SDWebImage 大致给出过两代解决方案,非常值得业务项目参考。

2.2.1 第一代:先止血(对应 PR #3387)

思路很直接:在 iOS 15+ 的动图帧解码路径中,尽量不再依赖 ImageIO 的缓存语义,而是主动触发 force decode,把危险的 lazy CGImage 尽早转换为已经展开的位图。

这套方案的特点很明确:

  • 优点:优先保稳定,能有效降低 crash 风险;
  • 代价:CPU 和内存消耗会上升,本质上是用性能换稳定。

因此,它更像是一种应急修复方案。

2.2.2 第二代:工程化落地(对应 PR #3425)

到了 SDWebImage 5.14.0,框架引入了 SDImageCoderDecodeUseLazyDecoding 配置,通过更细粒度的分层策略,在稳定性和性能之间做平衡。这种思路在业务项目里很值得借鉴:

  • 策略分流:静态图 coder 默认开启 lazy decode,以节省内存;动图 coder 默认关闭 lazy decode,以优先保证稳定;
  • 按场景配置:如果业务确实有特殊需求,可以通过参数手动调整不同类型图像的策略;
  • 剥离 source 关联:如果动图场景必须保留 lazy decode,就不要直接使用 ImageIO 返回的原始 CGImageRef,而是通过自定义 copy 方法(如 SDCGImageCreateCopy)创建新的 CGImageRef,把它和 CGImageSourceRef 的隐式持有关系拆开。

这里还要注意一点:直接调用系统的 CGImageCreateCopy,并不能彻底去掉所有不希望保留的内部 CGImage property。SDWebImage 之所以自己实现一层 copy,本质上是在重建对象语义,确保上层拿到的是可控的结果对象。

这也是这套方案最有价值的地方:既尽量保留 lazy decode 的收益,又避免生命周期耦合带来的崩溃风险。

2.3 iOS 16+ 之后的新坑,也别忽视

ImageIO 的问题并不止 iOS 15 一个阶段。到了 iOS 16、17,新的系统级问题又陆续出现。SDWebImage 的处理方式,本身就是非常好的经验样本:

  • iOS 16:PDF 解码问题
    部分 PDF 解码路径表现异常,SDWebImage 直接绕开 ImageIO,不再依赖它完成 PDF 解码,而是改用 Core Graphics 自己做 PDF 位图化。如果你的业务里有 PDF 首帧渲染或缩略图生成需求,这条思路很值得参考。

  • iOS 17:indexed PNG 系统级 bug
    某些 indexed PNG 在系统解码后,返回的 CGImageAlphaInfo 与真实 bitmap 数据不一致。SDWebImage 的做法是运行时检测当前系统的 PNG decoder 是否存在该问题,再动态修正 bitmap info,避免显示异常。

  • HDR 支持引入新的复杂度
    近两年 HDR 解码/编码逐渐进入通用图像链路,但 HDR 也让问题变得更复杂:系统版本、设备硬件、色域和 force decode 语义都会影响最终行为。因此,SDWebImage 在部分 HDR 图像路径上会明确跳过 force decode。业务如果要接 HDR,必须提前做好兼容策略,而不是简单沿用 SDR 时代的经验。

三、2026 年业务开发中的实战建议

对业务开发者来说,不一定要深入研究 ImageIO 的全部底层实现,但一定要建立正确的使用边界和排障思路。我们的目标不是研究系统源码,而是用尽量低的成本,把图像链路做稳。

3.1 核心使用原则

1)不要自己维护基于 ImageIO 的动图播放器

业务团队通常承担不起长期的系统兼容成本。sourceframe 的隐式关联、不同系统版本的差异、格式级 bug、解码策略分流……这些问题叠加起来,维护成本远高于想象。能直接复用成熟库,就不要重复造轮子。

2)静态图默认优先 lazy decode

在大多数静态图场景下,lazy decode 更省内存,也更容易控制解码时机。只要主线程使用路径可控,这通常是一个更均衡的默认策略。

3)动图默认优先 non-lazy decode

动图链路涉及播放线程、帧缓存、回收、预取、渲染同步等多重因素。保留与 source 关联的图像对象,风险很高。对动图来说,稳定性优先级通常高于理论上的内存收益。

4)重新理解 force decode

不要再把 force decode 仅仅看作一种性能优化技巧。很多时候,它更重要的价值在于:

  • 提前消除 lazy wrapper;
  • 规避某些渲染路径上的异常;
  • 显式确定图像进入渲染阶段前的状态。

换句话说,它首先是稳定性工具,其次才是性能调优手段。

5)把 ImageIO 问题视为系统兼容问题

同一份代码,在 iOS 15、16、17 上的表现可能完全不同。涉及 ImageIO 的链路,不能只做一次验证就放心上线,而应该按系统版本、图像格式、解码路径做最小必要的回归测试。

3.2 如果你在用 SDWebImage,建议这样配

如果项目本身已经接入了 SDWebImage,我比较建议沿用它目前较成熟的默认策略,而不是贸然自定义底层行为:

  • 升级到较新的版本,优先吃掉框架已经处理过的系统兼容修复;
  • 静态图沿用默认的 lazy decode;
  • 动图沿用默认的 non-lazy decode;
  • force decode 优先使用 automatic,交给框架根据场景判断;
  • HDR 不要默认全量开启,只在业务明确需要时启用。

这套配置的核心思路不是“榨干所有性能”,而是先把稳定性和长期可维护性守住。

3.3 常见问题的排查方向

场景一:列表滚动掉帧

优先检查这几个点:

  • 主线程是否发生了 lazy decode;
  • 是否出现额外的 CA::copy_image
  • 图片像素是否过大,但没有做缩略图解码。

这三类问题,是滚动掉帧里最常见、也最容易被忽略的原因。

场景二:动图偶现 crash

优先排查:

  • 是否跨线程持有并复用了 frame image;
  • 是否直接使用了 ImageIO 返回的原始 CGImageRef
  • 是否错误延长了 sourceframe 之间的生命周期关系。

这类问题通常不容易稳定复现,但一旦命中,往往就和对象语义没有处理干净有关。

场景三:特定系统版本图片显示异常

建议优先判断:

  • 是否命中了系统格式级 bug;
  • 是否涉及 PDF、indexed PNG、HDR 等敏感格式;
  • 是否需要切换底层解码路径,或直接升级 SDWebImage 版本。

很多“看起来像业务层 bug”的问题,最后归根结底都在系统上。

四、总结:别再把 ImageIO 当成一个稳定黑盒

ImageIO 依然是 iOS 图像链路里不可绕开的基础框架,但它已经不再是那个可以“放心裸用”的系统黑盒。今天再看,它更像是一个需要被验证、隔离、包装、监控,并且持续回归测试的系统依赖。

到了 2026 年,如果还在业务层直接裸用 ImageIO 去承接复杂图像链路,本质上就是把 Apple 图像栈的系统兼容债务,主动背到自己团队身上。投入很高,收益却未必成正比。

最后也给自己和同行提个醒:

  • 不要迷信系统框架天然稳定;
  • 不要因为 API 形式没变,就默认运行语义也没变;
  • 不要把图像链路问题简单理解为“只是性能问题”;
  • 也不要低估像 SDWebImage 这样的成熟图片库,在系统兼容层面替我们承担的成本。

真正难的,从来不是“把图片显示出来”,而是让它在不同系统、不同格式、不同并发场景下,长期稳定地显示出来。

参考资料

附:如果想继续深挖,建议优先看这些源码点

如果想更深入地理解 SDWebImage 是如何处理 ImageIO 兼容问题的,建议优先从以下文件和逻辑入手:

  • 源码文件:SDImageIOAnimatedCoder.mSDImageIOCoder.mSDImageCoderHelper.mSDImageCoderHelper.h
  • 重点逻辑:decodeUseLazyDecoding 的实现、animated image 的 frame 创建路径、force decode 的策略判断、UIImageimageByPreparingForDisplay 方法,以及 PDF / PNG / HDR 的特判分支