[SRC] 源码行号可证 · xnu-7195.141.2(iOS 14.8) [ARCH] 架构层知识(Apple/华为/Google 未全部开源)

iOS / 鸿蒙 / 安卓 UI 渲染管线深度对比
—— 以 XNU 内核源码为锚点

核心问题:用户印象中,鸿蒙会在应用内部生成 UI 组件树,发送给 RenderService 渲染进程, 由渲染进程构建渲染树并完成最终渲染——iOS 是这样的吗? 本文以 XNU(Darwin 内核)源码中可验证的机制为证据基础,逐层拆解 iOS 的渲染管线, 并与鸿蒙 ArkUI、安卓(Android)的渲染架构做正面对比。 §7 为追问专题:深导航场景下不可见详情页由谁管理(UIKit / Core Animation / WebKit / 内核的分工)。

🔬 内核源码:apple-oss-distributions/xnu @ xnu-7195.141.2 📐 证据分层:[SRC] 行号锚点 / [ARCH] 架构知识 🗓 分析日期:2026-08-31

§0速览结论(TL;DR)

先回答标题问题:iOS 与鸿蒙"形似而神不同"。

相似之处——两者都不让应用进程直接触碰屏幕合成,都存在一个独立于应用的渲染服务进程, 且渲染数据(命令 + 像素)都要跨进程传输

  • iOS:应用进程内构建 CALayer 树,Core Animation 把树变更编码为事务(transaction), 经 Mach IPC 提交给 render server(iOS 上由 backboardd 进程承担, macOS 上对应 WindowServerARCH; 像素内容则通过 IOSurface 共享内存共享,不随 IPC 拷贝SRC
  • 鸿蒙:应用进程内生成 ArkUI 组件树,通过 IPC/共享内存发送给 RenderService(RS)渲染进程,由 RS 构建 RSRenderNode 渲染树并完成绘制合成ARCH

关键差异——"渲染树由谁构建、动画由谁驱动"完全不同:

  • iOS:layer 树的构建发生在应用进程内;render server 侧维护的是应用提交的 layer 树镜像, 其职责是合成调度与动画插值(动画提交后由 server 端逐帧插值,应用进程可以完全休眠)。
  • 鸿蒙:渲染树(RSRenderNode)由 RS 渲染进程从组件数据构建并持有, 应用侧只提交组件/属性变更,构建渲染树的主权在渲染进程——这正是用户印象中的模型。
  • 安卓:与两者都不同。渲染树(RenderNode/DisplayList)在应用进程内的 RenderThread(线程而非进程)构建完成,跨进程传的是已渲染好的 GraphicBuffer; 系统侧 SurfaceFlinger 只做窗口合成,不构建任何渲染树,应用动画也在应用进程内驱动。

一句话概括:"树建在哪个进程、动画由谁驱动、谁有权触达 GPU"——这三个维度把三家彻底分开。

§1iOS 渲染管线:从 UIKit 到屏幕

1.1 全链路总览

App 进程
UIKit / SwiftUI
UIView 层级 / 声明式 diff,驱动 layer 属性变更
App 进程
Core Animation
进程内 CALayer 树,变更编码进 CA::Transaction
XNU 内核
Mach IPC
mach_msg trap 提交编码后的事务到 server 端口
backboardd
Render Server
layer 树镜像、动画插值、渲染合成调度
GPU
Metal / GPU 驱动
IOKit UserClient 提交渲染命令,产生合成帧
显示
屏幕
合成结果经显示管线上屏

注:render server 由 backboardd 承担为架构层结论(Apple 未开源该守护进程),内核侧通路(IPC / 共享内存 / IOKit)全部有 XNU 源码锚点,见 §2。

1.2 应用进程内:视图与 layer 树的构建 ARCH

iOS 上所有视图建模都发生在应用进程内。UIKit 的 UIView 只是 CALayer 的宿主与手势/布局容器——每个 UIView 创建时都会随之创建一个 CALayer; SwiftUI 则在声明式 diff 之后把结果落到 UIKit/CA 层级上(TableViewCell 场景甚至直接复用 layer)。 也就是说,iOS 应用的"组件树"(视图树 + layer 树)完整地活在应用进程的地址空间里, 这一点与用户印象中鸿蒙"把组件发给渲染进程"有本质区别。

Core Animation 采用 client/server 架构:应用进程是 client,负责构建 layer 树、 修改属性、把变更打包进 CA::Transaction;Runloop 的 commit 阶段(或显式 CATransaction.commit())把事务序列化为紧凑的二进制命令流,通过 Mach IPC 发往 server。 由于 Mach 消息是定长缓冲的 IPC(见 §2.2 源码),树数据必须编码压缩,而不是传对象图。

1.3 跨进程提交:Mach IPC 通道 SRC

应用提交渲染事务走的是 XNU 的 Mach 消息陷阱。内核侧入口是 mach_msg_overwrite_traposfmk/ipc/mach_msg.c L513), 消息体里既可以是内联数据,也可以携带 OOL(out-of-line)内存描述符—— 这是 iOS 渲染管线跨进程传大块数据(纹理、layer backing、参数缓冲)的内核级机制。

1.4 Render Server:backboardd 的职责 ARCH

iOS 上 Core Animation 的 server 端运行在 backboardd(macOS 上是 WindowServer)。 它为每个应用的 layer 树维护一份服务端镜像(WebKit 开源代码中的 RemoteLayerTree 协议即为此通路的公开实现, WebContent 进程 → UI 进程的 layer 树同步用的同一套机制)。它的核心职责:

1.5 像素不拷贝:IOSurface 共享内存 SRC

iOS 渲染架构最精妙的一点:跨进程传输的是"命令",而像素内容从不随 IPC 拷贝。 每个 layer 的 backing store 是一个 IOSurface——本质是一块由内核管理的命名内存, 应用进程与 render server(以及 GPU 驱动)通过同一份物理内存的不同虚拟映射访问它。 内核机制上有两条通路(§2.4 详述):

这些像素内存同时挂接在内核的 purgeable(可清除)内存状态机上(osfmk/vm/vm_purgeable.c 的 token 机制):应用退到后台、内存吃紧时,内核可以直接丢弃这些页(内容可由应用重新渲染恢复), 这是 iOS "后台应用内存自动回收"在渲染侧的底层支撑。

1.6 GPU 触达:Metal 与 IOKit UserClient SRC

iOS 上有两种渲染来源,最终都汇入 render server 的合成:

无论哪种,GPU 命令的用户态提交都经由 IOKit 的 UserClient 机制进入内核 GPU 驱动 (Apple GPU 驱动 + AGX 用户态编译器服务),映射关系同样落在 mapClientMemory64createMappingInTask 这条内核通路上(§2.4)。

1.7 安全边界:为什么必须走 IPC 而不能直接读对方内存 SRC

XNU 对跨进程内存访问有硬性门控:ipc_tt.ctask_conversion_eval(L2154-2194)规定, 在非 macOS 平台上,若目标 task 是平台二进制(backboardd、SpringBoard 等)而调用方是第三方应用, 所有跨进程 VM 原语(mach_vm_readmach_vm_region_recurse 等)一律返回 KERN_INVALID_SECURITY结论:第三方应用无法旁路渲染服务进程直接窥探或操纵合成层, 所有渲染数据必须经由内核裁剪过的公开通道(Mach IPC + 受控共享内存)流动。

§2XNU 源码证据链 SRC

以下锚点全部来自 apple-oss-distributions/xnu @ xnu-7195.141.2(iOS 14.8), 本机已下载原始文件,行号可复核。

2.1 应用提交事务的内核入口:mach_msg trap

osfmk/ipc/mach_msg.cL512-566(节选) · mach_msg_overwrite_trap
mach_msg_return_t
mach_msg_overwrite_trap(
	struct mach_msg_overwrite_trap_args *args)
{
	mach_vm_address_t       msg_addr = args->msg;        // 应用态消息缓冲
	...
	if (option & MACH_SEND_MSG) {
		ipc_space_t space = current_space();
		ipc_kmsg_t kmsg;

		mr = ipc_kmsg_get(msg_addr, send_size, &kmsg);          // ① 拷入内核 kmsg
		...
		mr = ipc_kmsg_copyin(kmsg, space, map, priority, &option); // ② 解析端口/OOL 描述符
		...
		mr = ipc_kmsg_send(kmsg, option, msg_timeout);            // ③ 入队目标端口 → 唤醒接收方
	}

渲染事务(编码后的 layer 命令流)就是从这里离开应用进程的:①把消息体拷进内核缓冲, ②解析其中的端口权限与 OOL 内存描述符,③挂到目标端口(render server 的接收端口)的队列上并唤醒接收线程。 整个过程在内核里完成原子转换,应用与 server 之间没有任何共享队列的竞态窗口。

2.2 大块数据跨进程:OOL 描述符的 copy-in / copy-out

osfmk/ipc/ipc_kmsg.cL3380-3478(节选) · ipc_kmsg_copyin_ool_descriptor
static mach_msg_descriptor_t *
ipc_kmsg_copyin_ool_descriptor(...)
{
	...
	} else {
		/*
		 * Make a vm_map_copy_t of the of the data.  If the
		 * data is small, this will do an optimized physical
		 * copy.  Otherwise, it will do a virtual copy.       ← 虚拟拷贝:仅建映射不搬字节
		 */
		kern_return_t kr = vm_map_copyin(map, addr,
		    (vm_map_size_t)length, dealloc, copy);
		...
	}
osfmk/ipc/ipc_kmsg.cL5020-5101(节选) · ipc_kmsg_copyout_ool_descriptor
// 接收端(render server)取出 OOL 内存:
kr = vm_map_copyout_size(map, &rcv_addr, copy, size);  // 映射进接收方地址空间

这就是 iOS 渲染数据"跨进程传输"的内核真相:小数据物理拷贝(一次 memcpy 进内核 copy map), 大数据虚拟拷贝vm_map_copyin 只建立内核持有的映射权,接收方 vm_map_copyout 时才落映射)——没有应用→server 的整块像素搬运。 真正的像素共享走下面 2.4 的 memory entry / IOSurface 机制。

2.3 端口队列与唤醒:ipc_kmsg_send / ipc_port

osfmk/ipc/ipc_kmsg.cL2183 · ipc_kmsg_send(入口)
mach_msg_return_t
ipc_kmsg_send(ipc_kmsg_t kmsg, mach_msg_option_t option, mach_msg_timeout_t send_timeout)
{
	// 将 kmsg 挂入目标端口的消息队列(ipc_port.c 维护 port 状态/turnstiles),
	// 阻塞式发送时等待接收方取走或超时;CA 事务提交用非阻塞形式。
	// 配对文件:osfmk/ipc/ipc_port.c(3281 行,端口权利与队列状态机)

2.4 像素共享的内核机制:memory entry 与 IOKit 映射

osfmk/vm/vm_user.cL2463-2476 · mach_make_memory_entry_64
/*
 * Think of it as a two-stage vm_remap() operation.  First
 * you get a handle.  Second, you get map that handle in
 * somewhere else. Rather than doing it all at once (and
 * without needing access to the other whole map).
 */
kern_return_t
mach_make_memory_entry_64(
	vm_map_t                target_map,
	memory_object_size_t    *size,
	...)

↳ 这段内核注释就是 IOSurface 共享的机制本质:把内存变成port 句柄,句柄发给谁,谁就能二次映射同一份物理内存。

iokit/Kernel/IOMemoryDescriptor.cppL5542-5560 · createMappingInTask
IOMemoryDescriptor::createMappingInTask(
	task_t                  intoTask,      // ← 目标可以是“另一个进程”的 task
	mach_vm_address_t       atAddress,
	IOOptionBits            options, ...)
{
	mapping = new IOMemoryMap;
	...
	result = makeMapping(this, intoTask, (IOVirtualAddress) mapping, options | kIOMap64Bit, 0, 0);
	...
iokit/Kernel/IOUserClient.cppL2016-2041 · IOUserClient::mapClientMemory64
IOUserClient::mapClientMemory64(
	IOOptionBits            type,
	task_t                  task,          // ← 调用方进程
	IOOptionBits            mapFlags,
	mach_vm_address_t       atAddress )
{
	...
	err = clientMemoryForType((UInt32) type, &options, &memory );  // 驱动给出 MD
	if (memory && (kIOReturnSuccess == err)) {
		map = memory->createMappingInTask( task, atAddress, options );   // 映射进该进程
		memory->release();
	}

IOSurface 的内核实现(AppleIOSurface 驱动 + IOSurfaceRootUserClient)正是基于这两个原语: 驱动分配 buffer → mapClientMemory64 把它映射进申请进程 → 应用把 surface 句柄 (一个 Mach port)随渲染事务发给 render server → server 侧同样经 UserClient 把同一块物理内存映射进自己。 应用画一个像素,server 立即可见——零拷贝

2.5 像素内存的生命周期:purgeable token 状态机

osfmk/vm/vm_purgeable.cL60-62(token 定义)· L842+(丢弃路径)
// purgeable token 状态机:每个 volatile 对象持有 token,
// 内存压力下内核按 token 顺序把对象页丢弃(vm_object_purge),页表项撤销、记账清零。
// 应用侧重新访问时触发缺页,发现 VM_PURGABLE_EMPTY 后自行重渲染。
// 深读锚点:token L60-62 / volatile 队列 L84-85 / empty L842-867

渲染像素"可丢弃、可重建"的特性正是靠它:layer backing store 标记为 volatile 后, 系统内存吃紧时内核直接回收物理页(不写压缩器、不换出),需要时由应用重新绘制。 这也解释了 iOS 上后台应用回前台时偶发的"白屏→内容渐显"。

2.6 安全门控:平台二进制保护

osfmk/kern/ipc_tt.cL2154-2194 · task_conversion_eval
// 非 macOS 平台:victim 为平台二进制(backboardd / SpringBoard 等)
// 且 caller 为非平台二进制(第三方应用)→ KERN_INVALID_SECURITY。
// 实测推论:mach_vm_read_overwrite / mach_vm_region_recurse / mach_make_memory_entry_64
// 对第三方应用可用于普通进程,对平台二进制全部被拒 → 渲染数据无法被旁路窥探。

§3鸿蒙 ArkUI 渲染管线 ARCH

鸿蒙(HarmonyOS / OpenHarmony)的图形栈是三家中最接近"集中式渲染服务"模型的, 用户的印象基本准确。整体分三个层次:

3.1 全链路总览

App 进程
ArkUI 组件树
ArkTS 声明式 UI,前端组件 diff 生成最小更新
App 进程
ArkUI 框架层
C++ 侧组件树/属性,序列化为渲染指令流
IPC + 共享内存
传输通道
命令/属性经 IPC 发送,大块数据走共享内存
RS 渲染进程
RenderService
构建 RSRenderNode 渲染树、执行动画
GPU / 合成
绘制合成
2D 渲染引擎绘制,Composer 合成上屏

3.2 应用进程:组件树的生成

ArkUI 采用声明式范式:ArkTS 前端描述 UI 结构,状态驱动 diff,生成/更新组件树。 组件树(Component)在应用进程内完成状态到渲染属性的换算——这一步 iOS 也在应用进程内做(对应 layer 属性计算)。

3.3 RenderService 渲染进程:渲染树的真正归属

与 iOS 最大的不同在这里:渲染树(RSRenderNode)由独立的 RenderService 进程构建并持有。 应用侧把组件/属性变更通过 IPC 发给 RS,RS 在自己的进程内把组件数据物化成渲染节点树, 并负责:

大块像素/纹理数据同样不随 IPC 拷贝,走共享内存(SurfaceBuffer 由 BufferQueue 管理, 应用侧生产、RS 侧消费)。RS 进程作为系统级渲染服务同时服务多个应用, 这与 iOS backboardd 的角色定位类似,但 iOS 的 server 维护的是"应用提交的 layer 树镜像", 鸿蒙 RS 维护的是"由组件数据构建出的渲染树"——树的语义层级不同(镜像 vs 本尊)

§4安卓渲染管线 ARCH

安卓是三家中把最多渲染工作留在应用进程内的:渲染树的构建、动画驱动、GPU 命令录制都在应用进程, 系统合成进程(SurfaceFlinger)只做最后一层合成。

4.1 全链路总览

App 进程
View 树
measure/layout/draw 三部曲,UI 线程执行
App 进程
RenderThread
应用进程内的独立线程,录制 RenderNode/DisplayList
App 进程
GPU 渲染
Skia/Vulkan 直接在应用进程渲染到 GraphicBuffer
SystemServer 侧
SurfaceFlinger
BufferQueue 收 buffer,HWC/GPU 合成多窗口
显示
屏幕
合成帧送显示

4.2 关键特征

§5三平台渲染架构对比总表

维度 iOS(XNU 证据锚定) 鸿蒙 HarmonyOS Android
组件/视图树归属 应用进程(UIView/CALayer + SwiftUI diff)ARCH 应用进程(ArkUI 组件树)ARCH 应用进程(View 树)ARCH
渲染树构建位置 应用进程内构建 layer 树;server 侧是提交后的镜像,不"从组件数据重建" RS 渲染进程内从组件数据构建 RSRenderNode 渲染树(本尊在渲染进程) 应用进程内(RenderThread 录制 RenderNode/DisplayList)
系统渲染服务进程 backboardd 承担 render server(macOS 为 WindowServer)ARCH RenderService(RS)独立渲染进程,集中服务多应用 SurfaceFlinger 只合成,不构建渲染树
跨进程传输内容 编码后的事务命令流(Mach 消息)SRC 组件/属性变更(IPC 指令流) 已渲染的成品帧(GraphicBuffer 句柄)
像素共享机制 IOSurface = memory entry / IOKit MD 映射(mach_make_memory_entry_64 L2471、createMappingInTask L5542)SRC SurfaceBuffer / BufferQueue 共享内存 GraphicBuffer / BufferQueue(BLAST)共享内存
动画驱动者 render server 端插值(应用可休眠) RS 渲染进程驱动(RSRenderAnimation) 应用进程(Choreographer + VSYNC,应用必须存活)
GPU 提交路径 Metal → IOKit UserClient → Apple GPU 驱动(mapClientMemory64 L2016)SRC;server 与应用皆可渲染 RS 进程经 GPU 驱动绘制(应用一般不直接触达 GPU) 应用进程直接经 Vulkan/GLES 提交;SF 走 HWC/RenderEngine
可清除内存机制 内核 purgeable token 状态机(vm_purgeable.c L60+)SRC Buffer 生命周期由 RS/BufferQueue 管理 Buffer 由 BufferQueue 生命周期管理
安全隔离 平台二进制门控:第三方应用对 backboardd 的跨进程 VM 原语全部被拒(task_conversion_eval L2154-2194)SRC RS 与应用间接口受系统权限管控 SF 与应用间只传 buffer 句柄(Binder + 共享内存 fd)

读表要点:三家的"渲染树"根本不在同一抽象层——iOS 传的是已构建好的树的编码事务, 鸿蒙传的是构建树所需的组件数据(树由 RS 建),安卓传的是树渲染完的成品。 因此"谁的渲染进程更强"是伪命题:iOS 与鸿蒙的渲染服务进程承担"树的维护+动画+合成",安卓的系统进程只承担"合成"

§6最终结论:iOS 是不是鸿蒙那样?

✅ 相似的部分

  • 都有独立于应用的渲染服务进程(iOS:backboardd;鸿蒙:RenderService);
  • 渲染命令与像素都要跨进程传输/共享,应用不直接合成屏幕;
  • 动画都在渲染服务侧插值,应用进程可以休眠(安卓做不到);
  • 大块像素都不走 IPC 拷贝,走内核共享内存(iOS:IOSurface/memory entry,源码可证;鸿蒙:SurfaceBuffer)。

❌ 不同的部分

  • 树的构建权:鸿蒙的渲染树(RSRenderNode)由渲染进程从组件数据构建并持有; iOS 的 layer 树在应用进程内构建,server 侧只是提交结果的镜像——iOS 传"树",鸿蒙传"树的原料";
  • 传输语义:iOS 是事务式(编码命令流 + 端口队列),鸿蒙更接近组件状态同步;
  • 内核机制深度:iOS 的 IPC、共享内存、purgeable 回收、安全门控全部可用 XNU 源码逐行验证(本文 §2);
  • 安卓则完全是第三种模型:树在应用内建、GPU 在应用内跑、系统只合成、动画靠应用活着。

给用户问题的直接回答:

"鸿蒙上先在应用内部生成 UI 组件树,发送给 RenderService 渲染进程,由渲染进程构建渲染树最终完成渲染"—— 这个描述对鸿蒙是准确的。iOS 不完全是这样:iOS 同样存在独立渲染服务进程(backboardd 承担 render server), 也同样跨进程提交渲染数据,这与鸿蒙"形似";但 iOS 的渲染树(layer 树)在应用进程内构建完毕, 提交的是编码后的事务命令流,server 侧维护的是这棵树的镜像并负责动画插值与合成—— 渲染树的"构建"不发生在渲染进程,这是与鸿蒙最本质的差异。安卓则站在更远的另一端: 渲染树在应用进程内构建、GPU 渲染也在应用进程完成,系统合成进程(SurfaceFlinger)连树都不建。

§7追问:深导航下不可见详情页由谁管理?

场景:在淘宝里不断 push 进入新的商品详情页,旧详情页对用户不可见。iOS 会管理这些旧页面吗?答案是: 渲染资源层面自动管理(即时),内存层面不自动管理(iOS 6+)——两件事经常被混为一谈。

7.1 Push 之后发生了什么(时序)

App 进程
push 转场
新旧两个 view 同时在层级中做动画
UIKit
viewDidDisappear
旧 view 被移出 window 层级(superview=nil)
Core Animation
事务提交
旧 layer 子树不再进入提交树
backboardd
镜像同步
server 侧摘除,不再消耗合成/GPU 资源
内存
仍保留
VC + view 树 + 位图全部留在应用内存

渲染层面是自动且即时的:view 一旦移出 window,对应 layer 子树就从提交给 render server 的树中消失, 旧页面不再占用任何合成、绘制、GPU 资源。但内存层面,UIKit 自 iOS 6 起不会自动卸载不可见页的 view 树—— viewDidUnload/viewWillUnload 已废弃,自动卸载机制被移除,Apple 把释放责任交给了开发者。 导航栈里的 VC 对象、view 层级、图片解码位图全部保留,这是深导航应用内存线性增长的根源。

7.2 分层责任:谁管什么

管理对象管理者(框架)行为证据
VC 与 view 生命周期 UIKit(UINavigationController) 导航栈持有全部 VC;转场后摘除旧 view 层级;分发 appear/disappear 回调;内存警告分发给所有 VC(含不可见者) ARCH
渲染树与合成资源 Core Animation + render server(backboardd) 事务提交后镜像树摘除不可见 layer;server 侧不再为其保留合成资源 SRC(§2 通路)
不可见 layer 的内容内存 WebKit(WKWebView 场景)/ 各框架自行标记 layer 摘除 → backing store 移入 unparented 集合 → 定时标记 volatile(可丢弃) SRC(§7.3)
物理页回收 XNU 内核(vm_purgeable) volatile 的 backing store 在内存压力下由 token 状态机直接丢页,不写压缩器不换出 SRC
进程级兜底 XNU 内核(jetsam) 应用不释放 → 按 band 优先级 kill SRC

7.3 WebKit 如何管理不可见内容(开源可验证)

淘宝商品详情页大量是 H5(WKWebView)。WebKit 对"从 layer 树摘除的 backing store"有一套显式管理机制, 这是"框架如何主动管理不可见内容"的最佳公开范例(RemoteLayerBackingStoreCollection):

Source/WebKit/Shared/RemoteLayerTree/RemoteLayerBackingStoreCollection.mmL297-312 · backingStoreBecameUnreachable
void
RemoteLayerBackingStoreCollection::backingStoreBecameUnreachable(RemoteLayerBackingStore& backingStore)
{
	...
	m_unparentedBackingStore.add(backingStore);      // ← 移出“活着”集合,进入“已摘除”集合
	m_liveBackingStore.remove(backingStoreIter);
	// This will not succeed in marking all buffers as volatile, because the commit unparenting
	// the layer hasn't made it to the UI process yet. The volatility timer will finish marking
	// the remaining buffers later.                                              ← 定时器稍后补完 volatile 标记
	markBackingStoreVolatileAfterReachabilityChange(backingStore);
}
RemoteLayerBackingStoreCollection.mmL250-286(节选)+ Collection.h L118-119 · 年龄阈值
bool RemoteLayerBackingStoreCollection::markInProcessBackingStoreVolatile(...)
{
	if (markingBehavior.contains(VolatilityMarkingBehavior::ConsiderTimeSinceLastDisplay)) {
		auto timeSinceLastDisplay = now - backingStore.lastDisplayTime();
		if (timeSinceLastDisplay < volatileBackingStoreAgeThreshold) {          // 1s
			if (timeSinceLastDisplay >= volatileSecondaryBackingStoreAgeThreshold)  // 200ms
				backingStore.setBufferVolatile(BufferType::SecondaryBack);          // ← 先丢副后台缓冲
			return false;
		}
	}
	backingStore.setBufferVolatile(BufferType::SecondaryBack);
	backingStore.setBufferVolatile(BufferType::Back);
	if (!m_reachableBackingStoreInLatestFlush.contains(backingStore) || ...)   // ← 不可达(已摘除)才能丢 Front
		backingStore.setBufferVolatile(BufferType::Front);
	...
}
// Collection.h L118-119:
// static constexpr auto volatileBackingStoreAgeThreshold = 1_s;
// static constexpr auto volatileSecondaryBackingStoreAgeThreshold = 200_ms;

机制总结:"从树上摘除"(unreachable/unparented)+ "多久没显示"(200ms/1s 阈值)+ "是否可达"(reachability) 三个条件共同决定一个 backing store 何时标记 volatile。volatile 之后,回收交给内核 purgeable 状态机;页面重新可见时, WebKit 检查发现 FrontBufferIsVolatile(缓冲已被内核丢弃)就触发 setNeedsDisplay 重新绘制。 接收端甚至有对被丢 IOSurface 的显式检测(RemoteLayerBackingStore.mm L542 "Received volatile IOSurface")。

✅ 系统自动管理的部分

  • 渲染树摘除与合成资源释放(UIKit + CA,即时);
  • volatile 标记的 backing store 丢页(内核 purgeable,内存压力时);
  • 内存警告分发、UIKit 内部缓存清理(如 imageNamed 缓存);
  • 整个 app 退后台时,其 layer surface 由系统标记 volatile(与应用内导航的"页面不可见"不同级)。

❌ 系统不管、需要应用自己做的部分

  • 不可见 VC 的 view/layer 树不会自动卸载(iOS 6+ 废弃了该机制);
  • 图片解码位图、数据模型的释放靠 didReceiveMemoryWarning/viewDidDisappear 里的自定义代码;
  • 导航栈深度控制(淘宝这类应用必须自建策略:限制栈深、中间页降级、返回时重建);
  • 内存压力下的最终兜底只有 jetsam——不释放就整个进程被杀。

直接回答:iOS 对不可见详情页的管理是"渲染层全自动、内存层靠自觉"。 框架分工:生命周期与层级摘除归 UIKit(UINavigationController),渲染树与合成资源归 Core Animation + render server,可丢弃内容的标记归 WebKit(H5 场景)等上层框架, 物理页回收归 XNU 内核(purgeable + jetsam)。iOS 6 之后系统不再替应用释放不可见页的视图内存—— "看不见"不等于"不存在",这正是深导航电商应用必须自建内存管理策略的原因。

§8深挖:页面生命周期细节与旧详情页内存去向

8.1 Push 一个新详情页时的完整回调时序 ARCH

UIKit 是闭源的,但生命周期回调顺序是文档化且可实测的。pushViewController:animated: 后, 新旧两个页面的回调交错执行(不是先旧后新):

push 转场时序(可断点实测)UIKit 生命周期交错
// t0: 用户点击商品
newVC.loadView()            // ← view 懒加载:首次访问 .view 才构建
newVC.viewDidLoad()          // ← 一次性:建数据源、注册 cell
oldVC.viewWillDisappear()
newVC.viewWillAppear()
newVC.viewWillLayoutSubviews()
newVC.viewDidLayoutSubviews()    // ← 自动布局解算(新旧 view 同时在 window 上)
// [转场动画 0.35s:两棵 view 树都在层级中,都在渲染]
newVC.viewDidAppear()
oldVC.viewDidDisappear()       // ← 此后 oldVC.view.superview == nil

三个关键细节:

8.2 一张旧详情页的内存账单 ARCH

内存项量级(典型)持有者释放时机
VC 对象 + view/layer 树几十~几百 KB导航栈 → VC → view仅 pop 或应用手动置 nil
drawRect/光栅化 backing store全屏 3x ≈ 11.6 MB/层
(1170×2532×4)
应用(CA 代理分配)应用释放;或标记 volatile 后由内核压力下丢弃
图片解码位图(原生)750×1000 ≈ 3 MB/张CGImage/UIImage引用释放;imageNamed 走系统缓存在内存警告时清
数据模型(JSON→对象)几十 KB ~ MBVC 强引用仅应用手动;不释放则进内核压缩器
H5 页面 DOM/JS/布局每页数 MB ~ 数十 MBWebContent 进程(独立地址空间)WebKit BFCache/MemoryCache 管理(§8.3/8.4,源码可证)
H5 渲染 backing store每 surface ≈ 屏幕尺寸×4应用进程持有 IOSurface标记 volatile → 内核 purgeable 丢页(§7.3)

注意量级对比:对象树是 KB 级噪音,位图和 H5 才是 MB 级主旋律。旧详情页内存问题的本质是 "多份全屏位图 + 多个 H5 页面"的线性累积。

8.3 H5 详情页的前一页:BackForwardCache 的挂起与驱逐 SRC

淘宝详情页大量是 H5。在同一个 WKWebView 内前进导航时,旧 H5 页不是立即销毁——WebKit 有一个 "页面前进缓存"(BFCache):把旧页挂起(停定时器、停脚本、停网络),保留完整 DOM/JS 状态, 返回时瞬间恢复。它的容量与内存压力联动全部有源码:

Source/WebCore/history/BackForwardCache.cppL382-395 · canCache(入缓存门控)
bool BackForwardCache::canCache(Page& page) const
{
	if (!m_maxSize) { ... return false; }              // 容量为 0 = BFCache 关闭
	if (MemoryPressureHandler::singleton().isUnderMemoryPressure()) {
			logBackForwardCacheFailureDiagnosticMessage(&page, ...underMemoryPressureKey());
			return false;                                   // ← 内存压力下:新页直接禁止入缓存(旧页立即销毁)
	}
	return canCachePage(page);
}
BackForwardCache.cppL546-570 · addIfCacheable + L726-737 · prune(驱逐)
// 前进导航 → 挂起旧页放入缓存:
auto cachedPage = trySuspendPage(page, ForceSuspension::No);   // 挂起:CachedPage 构造于 ScriptDisallowedScope
...
m_cachedPageMap.set(identifier, ...);
prune(PruningReason::ReachedMaxSize);                    // 超容量立即裁剪

// 驱逐策略:FIFO,最老的先走
void BackForwardCache::prune(PruningReason pruningReason)
{
	while (pageCount() > maxSize()) {
		auto oldestItem = m_items.takeFirst();          // ← 最老的挂起页被销毁
		...
	}
}

驱逐原因枚举还包括 MemoryPressureProcessSuspended(L465-470 的诊断日志映射): 系统内存压力或 WebContent 进程被挂起时,BFCache 里的旧页会被成批清空。 也就是说 H5 场景下"旧详情页"的内存有完整的容量上限与压力联动——这与 UIKit 导航栈的"无限累积、永不自动清"形成鲜明对比。

8.4 不可见内容的图片内存:MemoryCache 的 live/dead 双池 SRC

旧详情页里的商品图、背景图,在 WebKit 里由 MemoryCache 统一管理。它的核心设计是把缓存容量分成两个池: 有活跃页面引用的资源(live)与无人引用的资源(dead,即页面已销毁但数据还在缓存里):

Source/WebCore/loader/cache/MemoryCache.cppL209-221 · deadCapacity / liveCapacity
unsigned MemoryCache::deadCapacity() const
{
	// Dead resource capacity is whatever space is not occupied by
	// live resources, bounded by an independent minimum and maximum.
	unsigned capacity = m_capacity - std::min(m_liveSize, m_capacity);  // 先减去 live 占用
	capacity = std::max(capacity, m_minDeadCapacity);
	capacity = std::min(capacity, m_maxDeadCapacity);
	return capacity;
}
unsigned MemoryCache::liveCapacity() const
{
	return m_capacity - deadCapacity();                              // live 拿剩余额度
}
MemoryCache.cppL343-415(节选)· pruneDeadResourcesToSize:两阶段降级
// 对“无 client 引用”的资源(旧页销毁后的图片),LRU 从 head(最久未访问)开始:
if (!resource->hasClients() && !resource->isPreloaded() && resource->isLoaded()) {
	resource->destroyDecodedData();      // ← 第一阶段:只丢解码位图(MB级),保留编码数据(KB级)
	if (targetSize && m_deadSize <= targetSize) return;
}
...
if (!resource->hasClients() && !resource->isPreloaded() && !resource->isCacheValidator()) {
	remove(*resource);                   // ← 第二阶段:彻底驱逐(编码数据也删)
	if (targetSize && m_deadSize <= targetSize) return;
}

三个精彩的技术细节:

8.5 内核侧:旧页内存的最终去向分流 SRC

应用层不释放的内存,进入内核后按页的类型分流处理(全部有 xnu-7195.141.2 行号锚点):

内存类型内核机制处理方式
volatile 的 backing store
(IOSurface/图层位图)
purgeable token 状态机
vm_purgeable.c L60-62, L842-867
内存压力下直接丢物理页(不压缩、不换出),回前台缺页发现 EMPTY 由应用重绘
普通堆脏页
(数据模型、对象图)
压缩器
vm_compressor.c WKdm L3917 / LZ4 择优
压缩收纳(典型 0.37 比率),解压缺页 ≈12µs/16KB 页;compressed 仍计入 phys_footprint(task.c L232/L986)
文件映射干净页
(可执行、资源文件)
页回收
vm_pageout.c vm_pageout_scan L150+
直接回收(内容可从文件重读),最廉价
超限的最终兜底 jetsam
kern_memorystatus.c kill 原因表 L100-115、kill 选择 L499-504、hiwat kill L976
按 band 优先级 kill 进程("jettisoned"/"highwater"/"per-process-limit" 等原因都在 L104-115 表中)——应用不释放就整个杀掉

8.6 结论:三条路线的对照

🏛 原生页(UIKit)

  • 生命周期状态机完备(appear/disappear/layout 分明),但只驱动渲染不驱动内存
  • 旧页内存 = VC 强持有,系统永不自动释放(iOS 6 起);
  • 应用唯一抓手:pop、手动置 nil、内存警告回调。

🌐 H5 页(WebKit)

  • 旧页入 BFCache 挂起:有容量上限(prune FIFO),内存压力下拒绝入缓存/成批清空
  • 图片资源走 live/dead 双池 + 两阶段降级(先丢解码位图再丢编码数据);
  • 图层位图走 volatile → 内核丢页。每一级都有自动回收策略

一句话回答"旧详情页内存如何处理":原生页的内存处理权在应用手里,系统只保证"不渲染" (渲染树摘除)并提供内核级的缓冲(压缩器收纳脏页、purgeable 丢位图页),超过限额由 jetsam 一刀切; H5 页则由 WebKit 全自动管理——BFCache 挂起有容量上限,图片缓存有 live/dead 分级回收,位图有 volatile 标记。 同一台手机上,"旧页面内存"的命运取决于它是 UIKit 的孩子还是 WebKit 的孩子。

§9追问:原生页会用 purgeable 管理图片资源吗?

直接回答:会用,但只在三个特定层上,且粒度是"框架持有的缓冲区",从来不是"某个 VC 的图片"。 更关键的是:UIKit 不会因为 viewDidDisappear 就把旧页图片标记 volatile——purgeable 的开关权在 CoreAnimation(应用级)、ImageIO(缓存级)手里,页面可见性根本不在决策输入里。

9.1 原生图片路径上,真正走 purgeable 的三处

缓冲区归谁volatile 时机证据
CA 图层内容
(drawRect 绘制位图、快照、光栅化层)
CoreAnimation
(IOSurface 背书)
应用挂起时(整个 app 粒度)——不是某页消失时。被丢弃后回前台由 drawRect 重绘 SRC(§9.2 头文件)
ImageIO 解码缓存
(JPEG/PNG → RGBA 的解码结果)
ImageIO 框架 内存压力下框架回收,下次绘制重新解码(重新解码花 CPU,不花网络) SRC + EXP(§9.6 实测:解码缓冲在 DefaultPurgeableMallocZone,PURGE=E,ledger_purgeable_volatile 精确 244.14MB)
CoreVideo 像素缓冲
(视频帧、相机帧)
CoreVideo
(IOSurface 背书)
API 一等公民:kCVPixelBufferIOSurfacePurgeableKey,文档明说 volatile→empty 时"OS 直接移除全部内存页" SRC(SDK 文档)

9.2 SDK 头文件证据 SRC

以下全部来自本机 Xcode SDK 头文件原文(MacOSX.sdk),可独立复核:

IOSurface.framework/Headers/IOSurfaceRef.hL433-448 · IOSurfaceSetPurgeable 文档注释
// This acts pretty much exactly like the Mach vm_purgeable object stuff does.   // ← 官方承认:IOSurface 的 purgeable 就是内核 vm_purgeable 的直通
// Note: Higher level OpenGL and/or Metal based purgeability APIs should not be used for
// texture objects backed by IOSurfaces since they will essentially be ignored.
...
// kIOSurfacePurgeableNonVolatile - The IOSurface was not volatile and the contents are still valid
// kIOSurfacePurgeableVolatile    - The IOSurface was volatile, but the contents were not discarded
// kIOSurfacePurgeableEmpty       - The IOSurface was empty and the contents have been discarded.
IOSurfaceSetPurgeable(IOSurfaceRef buffer, uint32_t newState, uint32_t *oldState);
ImageIO.framework/Headers/CGImageSource.hL42-54 · 解码缓存默认值
/* Specifies whether the image should be cached in a decoded form. The
 * value of this key must be a CFBooleanRef.
 * kCFBooleanFalse indicates no caching, kCFBooleanTrue indicates caching.
 * For 64-bit architectures, the default is kCFBooleanTrue, for 32-bit the default is kCFBooleanFalse. */
IMAGEIO_EXTERN const CFStringRef kCGImageSourceShouldCache;   // ← 64 位(所有现代 iPhone)默认开启"解码形式缓存"
CoreVideo.framework/Headers/CVPixelBuffer.hL337-341 · kCVPixelBufferIOSurfacePurgeableKey
// A purgeable IOSurface is capable of being switched between non-volatile, volatile and
// empty states using IOSurfaceSetPurgeable.  When in the volatile state, the OS is
// permitted to instantly change its state to empty and remove all its memory pages.
// Clients should set the IOSurfaces to the non-volatile state while they are in use and
// the volatile state when their need and contents is optional/speculative and OK to
// discard in response to system memory demand.

9.3 应用自己也能用:内核公开的 purgeable 系统调用 SRC

purgeable 不是系统框架的特权——任何应用都可以把自己的缓冲区标记为 volatile。内核公开入口:

osfmk/vm/vm_user.cL2114-2133 · mach_vm_purgable_control
kern_return_t
mach_vm_purgable_control(vm_map_t map, mach_vm_offset_t address,
    vm_purgable_t control, int *state)
{
	if (VM_MAP_NULL == map) return KERN_INVALID_ARGUMENT;
	if (control == VM_PURGABLE_SET_STATE_FROM_KERNEL)
		return KERN_INVALID_ARGUMENT;          // 内核专用入口对用户态封死
	return vm_map_purgable_control(map, ..., control, state);   // → vm_purgeable.c token 状态机
}

应用侧典型用法:mach_vm_allocateVM_FLAGS_PURGABLE 分配缓冲 → 绘制前 VM_PURGABLE_NONVOLATILE(检查是否被丢过)→ 闲置时 VM_PURGABLE_VOLATILE。 这正是上文 §8.2 表中"标记 volatile 后由内核压力下丢弃"一行的用户态入口。图片场景更简单的选择是 NSCache(内存压力自动驱逐,无需自己管理状态机)。

9.4 不会被 purgeable 的部分:VC 持有的图片对象图 ARCH

这是深导航场景的真正痛点。旧详情页里被 VC 强引用的部分,全部是普通脏页,只能进压缩器,等 jetsam:

内存purgeable?实际去向
UIImage 对象 + CGImage 元数据脏页 → 压缩器(KB 级,无关痛痒)
图片编码数据(JPEG/WebP 字节,经 Data Provider 持有)脏页 → 压缩器;VC 活着就一直占
ImageIO 的解码缓存(RGBA 位图)✅ 框架回收压力下可回收,下次绘制重新解码(花 CPU 不花网络)
应用自己预解码的位图(UIGraphicsImageRenderer 快照、字典缓存的 RGBA)最坏情况:完全非托管的脏页,只能靠应用释放
imageNamed: 系统缓存❌(非 VM 层)系统缓存在内存压力下整体清空(类似 NSCache 的框架级驱逐,非 purgeable 机制)

注意第 3 行的微妙之处:即使 VC 还持着 UIImage,解码位图也住在 ImageIO 的缓存里, 压力下可以被回收——对象引用保住的是"编码数据 + 重解码能力",不是"解码结果"。 而第 4 行是电商应用最常见的反模式:为了省解码 CPU 把 RGBA 缓存在自己的字典里, 这些位图既不受 ImageIO 管、也不受 purgeable 管,深导航下线性累积,直达 jetsam。

9.5 三家框架的"不可见内容回收粒度"阶梯

WebKit
页面级 · 秒级
unparented + 200ms/1s 阈值标记 volatile(§7.3)
CoreAnimation
应用级 · 挂起时
app 退后台才标 volatile;页内导航不触发
ImageIO
缓存级 · 压力驱动
系统压力下回收解码缓存,无可见性概念
UIKit
无 · 永不
viewDidDisappear 与内存标记完全无关

这条阶梯正是整个专题的收束:"页面不可见"这个信号,只有 WebKit 在消费;原生侧的 purgeable 决策输入是"应用前后台"(CA)和"系统压力"(ImageIO),页面可见性对它们不存在。 所以原生页的旧图片内存,系统侧的所有自动机制里只有 ImageIO 解码缓存一条能帮上忙—— 其余全部依赖应用在 viewDidDisappear / 内存警告 / pop 时机自行释放。

9.6 实测证据:解码结果的存放、purgeable 状态与生命周期 EXP

论断"对象引用保住的是编码数据 + 重解码能力,不是解码结果"拆成三个可检验命题,本机(macOS, 与 iOS 同源 CoreGraphics/ImageIO)用 8000×8000 JPEG(编码 3.57MB,解码应为 244.14MB)实测:

阶段phys_footprintinternalledger_purgeable_volatile结论
S3 创建 CGImage 对象(默认惰性 options) +4.27 MB 5.36 MB 0 ≈ 编码数据 3.57MB + 元数据。持有对象 ≠ 持有解码结果
S5 绘制该对象到 256×256 目标 +6.95 MB 11.98 MB 4.08 MB 只解码出 1/8 降采样版本(1000²×4≈4.08MB)——解码结果按需选择分辨率
S6 全尺寸解码(缩略图路径 + ShouldCacheImmediately) +7.42 MB 250.77 MB 244.14 MB 解码位图真实产生,精确等于 8000×8000×4,且全部被标为 purgeable VOLATILE
S7 释放解码结果载体(thumb 对象) +7.36 MB 1.28 MB 0 244MB 立即消失;而 img1/source/编码数据继续存活到 S10——解码结果生命周期 ≠ 图片对象生命周期

三个惊人的细节:

配套的开源源码证据(对象存活期间销毁解码数据)

WebKit: Source/WebCore/platform/graphics/BitmapImageSource.cppL117-147(节选)· destroyDecodedFrames / destroyDecodedData
void BitmapImageSource::destroyDecodedFrames(bool destroyAll)
{
	for (index = 0; index < m_frames.size(); ++index) {
		if (!canDestroyDecodedData && (index == primaryFrameIndex || index == currentFrameIndex))
			continue;                                  // ← 当前在显示的帧保护
		decodedSize += m_frames[index].clearImage();  // ← 逐帧销毁解码图像, 返回释放的字节数
	}
	decodedSizeReset(decodedSize);
}
void BitmapImageSource::destroyDecodedData(bool destroyAll)
{
	destroyDecodedFrames(destroyAll);
	// There's no need to throw away the decoder unless we're explicitly asked
	// to destroy all of the frames.
	if (destroyAll && isDecoderWorkQueueIdle())
		resetData();                  // ← 只有 destroyAll 才丢编码数据
	else
		clearFrameBufferCache();      // ← 常规路径: 只清解码帧, 编码数据保留 → 对象保住“重解码能力”
}

WebKit 的 BitmapImage 对象(对 UIImage 的等价物)在存活期间被调用 destroyDecodedData():解码帧被清掉、记账归零,对象继续服务;只有显式 destroyAll 才连编码数据一起丢。 这就是"编码数据与解码数据生命周期分离"的活体实现——绘制时走 ensureFrameIsCached 重新解码。ImageDecoderCG.cpp L82/L104 则展示了与实测对应的两条 ImageIO 选项路径:普通选项(kCGImageSourceShouldCache=true,惰性)与缩略图选项(额外加 ShouldCacheImmediately,立即解码)。

9.7 实践含义(深导航电商场景)

附录XNU 源码锚点清单 SRC

所有 [SRC] 标注均锚定以下文件(xnu-7195.141.2 = iOS 14.8),行号可复核:

osfmk/ipc/mach_msg.cL513-617mach_msg_overwrite_trap:应用 IPC 提交入口;ipc_kmsg_get/copyin/send 三步
osfmk/ipc/ipc_kmsg.cL3380-3481ipc_kmsg_copyin_ool_descriptor:OOL 内存 copy-in,vm_map_copyin 虚拟拷贝(L3469)
osfmk/ipc/ipc_kmsg.cL5020-5101ipc_kmsg_copyout_ool_descriptor:接收端 vm_map_copyout_size 映射(L5101)
osfmk/ipc/ipc_kmsg.cL1941 / L2183 / L4028 / L5568ipc_kmsg_get / ipc_kmsg_send / ipc_kmsg_copyin / ipc_kmsg_copyout
osfmk/ipc/ipc_port.c全文 3281 行端口权利状态机、turnstiles、消息队列管理
osfmk/vm/vm_user.cL2463-2496mach_make_memory_entry_64:"two-stage vm_remap" 注释(L2463-2468)
osfmk/vm/vm_user.cL3437-3452mach_memory_entry_purgable_control:memory entry 的 purgeable 控制
iokit/Kernel/IOMemoryDescriptor.cppL5542createMappingInTask:把内存描述符映射进任意目标 task
iokit/Kernel/IOMemoryDescriptor.cppL451 / L610memoryReferenceCreate / mach_make_memory_entry_internal:共享主干
iokit/Kernel/IOUserClient.cppL2016-2041mapClientMemory64 → createMappingInTask:用户态到驱动的内存映射桥
osfmk/vm/vm_purgeable.cL60-62 / L842-867purgeable token 状态机与对象丢弃路径
osfmk/kern/ipc_tt.cL2154-2194task_conversion_eval:平台二进制门控(跨进程 VM 原语拒绝第三方)
WebKit: RemoteLayerBackingStoreCollection.mmL297-312backingStoreBecameUnreachable:不可见 backing store 移入 unparented 集合并标记 volatile
WebKit: RemoteLayerBackingStoreCollection.mmL250-286 / L380markInProcessBackingStoreVolatile(200ms/1s 年龄阈值)+ volatilityTimerFired 定时器
WebKit: RemoteLayerBackingStoreCollection.hL118-119volatileBackingStoreAgeThreshold = 1_s / volatileSecondaryBackingStoreAgeThreshold = 200_ms
WebKit: RemoteLayerBackingStore.mmL357-358 / L542 / L746FrontBufferIsVolatile 检测 → 重绘;"Received volatile IOSurface"(内核丢页的接收端实证)
WebKit: history/BackForwardCache.cppL382-395 / L546-570 / L726-737canCache(内存压力拒绝入缓存)+ addIfCacheable(trySuspendPage 挂起)+ prune(FIFO 驱逐)
WebKit: loader/cache/MemoryCache.cppL209-221 / L343-415 / L54deadCapacity/liveCapacity 双池 + pruneDeadResourcesToSize 两阶段降级(先丢解码位图再驱逐)+ 0.95 剪裁比率
xnu: kern_memorystatus.cL100-115 / L499-504 / L976jetsam kill 原因表(jettisoned/highwater/per-process-limit)+ kill 选择函数 + 高水位 kill
xnu: vm_compressor.c / task.cL3917 等 / L232, L986WKdm/LZ4 压缩器 + phys_footprint 记账(compressed 页仍计入)
SDK: IOSurfaceRef.hL433-448IOSurfaceSetPurgeable 文档:"acts pretty much exactly like the Mach vm_purgeable object stuff"——CA 图层内容与内核 token 状态机的官方衔接
SDK: CGImageSource.hL42-54kCGImageSourceShouldCache:64 位架构默认以解码形式缓存(ImageIO 解码缓存的存在性证据)
SDK: CVPixelBuffer.hL337-341kCVPixelBufferIOSurfacePurgeableKey:volatile 状态下"OS 可立即置空并移除全部内存页"的官方文档
xnu: vm_user.cL2114-2133mach_vm_purgable_control:应用自行管理 purgeable 缓冲区的公开系统调用(VM_PURGABLE_SET_STATE_FROM_KERNEL 对用户态封死)
[EXP] exp/decode_probe.c(本机实测)S3/S6/S7 阶梯惰性创建 CGImage 仅 +4.27MB;全尺寸解码后 internal=250.77MB、ledger_purgeable_volatile=244.14MB(=8000²×4)而 phys_footprint 不含;释放解码载体后 244MB 立即消失、编码数据存活——"对象保住编码数据+重解码能力"的实测闭环
[EXP] vmmap(S6 时刻)L2213MALLOC_LARGE 244.2M SM=ALI PURGE=E DefaultPurgeableMallocZone —— ImageIO 解码缓冲的专用 purgeable malloc zone 实锤
WebKit: BitmapImageSource.cppL117-147destroyDecodedFrames 逐帧 clearImage()(对象存活期间销毁解码数据)+ destroyDecodedData 注释:"There's no need to throw away the decoder unless we're explicitly asked to destroy all"
WebKit: ImageDecoderCG.cppL79-105 / L687createImageSourceOptions(ShouldCache=true 惰性)vs createImageSourceThumbnailOptions(+ShouldCacheImmediately 立即)两条 ImageIO 解码路径;L687 CGImageSourceCreateImageAtIndex

证据边界声明:本文 iOS 内核机制部分(Mach IPC、OOL 传输、memory entry、IOKit 映射、 purgeable、安全门控)基于 XNU 源码行号锚点;不可见内容管理部分基于 WebKit 开源源码(§7); 涉及闭源组件的部分(UIKit/SwiftUI 细节、backboardd 的 render server 角色、Core Animation 事务编码格式、 鸿蒙 RS 内部实现、安卓 SurfaceFlinger/HWC 细节)标注为 [ARCH] 架构层知识, 基于公开文档与行业共识,Apple/华为/Google 未完整开源,请以此为准确度预期。