核心问题:用户印象中,鸿蒙会在应用内部生成 UI 组件树,发送给 RenderService 渲染进程, 由渲染进程构建渲染树并完成最终渲染——iOS 是这样的吗? 本文以 XNU(Darwin 内核)源码中可验证的机制为证据基础,逐层拆解 iOS 的渲染管线, 并与鸿蒙 ArkUI、安卓(Android)的渲染架构做正面对比。 §7 为追问专题:深导航场景下不可见详情页由谁管理(UIKit / Core Animation / WebKit / 内核的分工)。
先回答标题问题:iOS 与鸿蒙"形似而神不同"。
相似之处——两者都不让应用进程直接触碰屏幕合成,都存在一个独立于应用的渲染服务进程, 且渲染数据(命令 + 像素)都要跨进程传输:
CALayer 树,Core Animation 把树变更编码为事务(transaction),
经 Mach IPC 提交给 render server(iOS 上由 backboardd 进程承担,
macOS 上对应 WindowServer)ARCH;
像素内容则通过 IOSurface 共享内存共享,不随 IPC 拷贝SRC。关键差异——"渲染树由谁构建、动画由谁驱动"完全不同:
RenderNode/DisplayList)在应用进程内的
RenderThread(线程而非进程)构建完成,跨进程传的是已渲染好的 GraphicBuffer;
系统侧 SurfaceFlinger 只做窗口合成,不构建任何渲染树,应用动画也在应用进程内驱动。一句话概括:"树建在哪个进程、动画由谁驱动、谁有权触达 GPU"——这三个维度把三家彻底分开。
注:render server 由 backboardd 承担为架构层结论(Apple 未开源该守护进程),内核侧通路(IPC / 共享内存 / IOKit)全部有 XNU 源码锚点,见 §2。
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 源码),树数据必须编码压缩,而不是传对象图。
应用提交渲染事务走的是 XNU 的 Mach 消息陷阱。内核侧入口是
mach_msg_overwrite_trap(osfmk/ipc/mach_msg.c L513),
消息体里既可以是内联数据,也可以携带 OOL(out-of-line)内存描述符——
这是 iOS 渲染管线跨进程传大块数据(纹理、layer backing、参数缓冲)的内核级机制。
iOS 上 Core Animation 的 server 端运行在 backboardd(macOS 上是 WindowServer)。
它为每个应用的 layer 树维护一份服务端镜像(WebKit 开源代码中的 RemoteLayerTree 协议即为此通路的公开实现,
WebContent 进程 → UI 进程的 layer 树同步用的同一套机制)。它的核心职责:
CABasicAnimation/CAKeyframeAnimation 提交后,
由 server 端逐帧计算属性值,应用进程可以完全休眠——这是 iOS 后台动画不占应用 CPU 的原因;
iOS 渲染架构最精妙的一点:跨进程传输的是"命令",而像素内容从不随 IPC 拷贝。
每个 layer 的 backing store 是一个 IOSurface——本质是一块由内核管理的命名内存,
应用进程与 render server(以及 GPU 驱动)通过同一份物理内存的不同虚拟映射访问它。
内核机制上有两条通路(§2.4 详述):
mach_make_memory_entry_64 把一块内存变成 port 句柄,
对方进程拿到句柄后映射进自己的地址空间——XNU 源码注释原话:"a two-stage vm_remap()";IOUserClient::mapClientMemory64 →
IOMemoryDescriptor::createMappingInTask 把驱动侧分配的 buffer 映射进任意目标 task。
这些像素内存同时挂接在内核的 purgeable(可清除)内存状态机上(osfmk/vm/vm_purgeable.c
的 token 机制):应用退到后台、内存吃紧时,内核可以直接丢弃这些页(内容可由应用重新渲染恢复),
这是 iOS "后台应用内存自动回收"在渲染侧的底层支撑。
iOS 上有两种渲染来源,最终都汇入 render server 的合成:
CAMetalLayer 场景(游戏、视频、WebView)下,
应用进程自己通过 Metal 渲染到 drawable(本质是一个 IOSurface),server 只做合成。
无论哪种,GPU 命令的用户态提交都经由 IOKit 的 UserClient 机制进入内核 GPU 驱动
(Apple GPU 驱动 + AGX 用户态编译器服务),映射关系同样落在
mapClientMemory64 → createMappingInTask 这条内核通路上(§2.4)。
XNU 对跨进程内存访问有硬性门控:ipc_tt.c 的 task_conversion_eval(L2154-2194)规定,
在非 macOS 平台上,若目标 task 是平台二进制(backboardd、SpringBoard 等)而调用方是第三方应用,
所有跨进程 VM 原语(mach_vm_read、mach_vm_region_recurse 等)一律返回
KERN_INVALID_SECURITY。结论:第三方应用无法旁路渲染服务进程直接窥探或操纵合成层,
所有渲染数据必须经由内核裁剪过的公开通道(Mach IPC + 受控共享内存)流动。
以下锚点全部来自 apple-oss-distributions/xnu @ xnu-7195.141.2(iOS 14.8),
本机已下载原始文件,行号可复核。
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 之间没有任何共享队列的竞态窗口。
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); ... }
// 接收端(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 机制。
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 行,端口权利与队列状态机)
/* * 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 句柄,句柄发给谁,谁就能二次映射同一份物理内存。
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); ...
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 立即可见——零拷贝。
// 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 上后台应用回前台时偶发的"白屏→内容渐显"。
// 非 macOS 平台:victim 为平台二进制(backboardd / SpringBoard 等) // 且 caller 为非平台二进制(第三方应用)→ KERN_INVALID_SECURITY。 // 实测推论:mach_vm_read_overwrite / mach_vm_region_recurse / mach_make_memory_entry_64 // 对第三方应用可用于普通进程,对平台二进制全部被拒 → 渲染数据无法被旁路窥探。
鸿蒙(HarmonyOS / OpenHarmony)的图形栈是三家中最接近"集中式渲染服务"模型的, 用户的印象基本准确。整体分三个层次:
ArkUI 采用声明式范式:ArkTS 前端描述 UI 结构,状态驱动 diff,生成/更新组件树。 组件树(Component)在应用进程内完成状态到渲染属性的换算——这一步 iOS 也在应用进程内做(对应 layer 属性计算)。
与 iOS 最大的不同在这里:渲染树(RSRenderNode)由独立的 RenderService 进程构建并持有。 应用侧把组件/属性变更通过 IPC 发给 RS,RS 在自己的进程内把组件数据物化成渲染节点树, 并负责:
大块像素/纹理数据同样不随 IPC 拷贝,走共享内存(SurfaceBuffer 由 BufferQueue 管理, 应用侧生产、RS 侧消费)。RS 进程作为系统级渲染服务同时服务多个应用, 这与 iOS backboardd 的角色定位类似,但 iOS 的 server 维护的是"应用提交的 layer 树镜像", 鸿蒙 RS 维护的是"由组件数据构建出的渲染树"——树的语义层级不同(镜像 vs 本尊)。
安卓是三家中把最多渲染工作留在应用进程内的:渲染树的构建、动画驱动、GPU 命令录制都在应用进程, 系统合成进程(SurfaceFlinger)只做最后一层合成。
RenderNode/DisplayList(GPU 命令的惰性中间表示),树始终属于应用进程;| 维度 | 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 与鸿蒙的渲染服务进程承担"树的维护+动画+合成",安卓的系统进程只承担"合成"。
给用户问题的直接回答:
"鸿蒙上先在应用内部生成 UI 组件树,发送给 RenderService 渲染进程,由渲染进程构建渲染树最终完成渲染"—— 这个描述对鸿蒙是准确的。iOS 不完全是这样:iOS 同样存在独立渲染服务进程(backboardd 承担 render server), 也同样跨进程提交渲染数据,这与鸿蒙"形似";但 iOS 的渲染树(layer 树)在应用进程内构建完毕, 提交的是编码后的事务命令流,server 侧维护的是这棵树的镜像并负责动画插值与合成—— 渲染树的"构建"不发生在渲染进程,这是与鸿蒙最本质的差异。安卓则站在更远的另一端: 渲染树在应用进程内构建、GPU 渲染也在应用进程完成,系统合成进程(SurfaceFlinger)连树都不建。
场景:在淘宝里不断 push 进入新的商品详情页,旧详情页对用户不可见。iOS 会管理这些旧页面吗?答案是: 渲染资源层面自动管理(即时),内存层面不自动管理(iOS 6+)——两件事经常被混为一谈。
渲染层面是自动且即时的:view 一旦移出 window,对应 layer 子树就从提交给 render server 的树中消失,
旧页面不再占用任何合成、绘制、GPU 资源。但内存层面,UIKit 自 iOS 6 起不会自动卸载不可见页的 view 树——
viewDidUnload/viewWillUnload 已废弃,自动卸载机制被移除,Apple 把释放责任交给了开发者。
导航栈里的 VC 对象、view 层级、图片解码位图全部保留,这是深导航应用内存线性增长的根源。
| 管理对象 | 管理者(框架) | 行为 | 证据 |
|---|---|---|---|
| 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 |
淘宝商品详情页大量是 H5(WKWebView)。WebKit 对"从 layer 树摘除的 backing store"有一套显式管理机制,
这是"框架如何主动管理不可见内容"的最佳公开范例(RemoteLayerBackingStoreCollection):
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); }
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")。
didReceiveMemoryWarning/viewDidDisappear 里的自定义代码;直接回答:iOS 对不可见详情页的管理是"渲染层全自动、内存层靠自觉"。 框架分工:生命周期与层级摘除归 UIKit(UINavigationController),渲染树与合成资源归 Core Animation + render server,可丢弃内容的标记归 WebKit(H5 场景)等上层框架, 物理页回收归 XNU 内核(purgeable + jetsam)。iOS 6 之后系统不再替应用释放不可见页的视图内存—— "看不见"不等于"不存在",这正是深导航电商应用必须自建内存管理策略的原因。
UIKit 是闭源的,但生命周期回调顺序是文档化且可实测的。pushViewController:animated: 后,
新旧两个页面的回调交错执行(不是先旧后新):
// 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
三个关键细节:
loadView 只在首次访问 .view 时触发),
所以导航栈里"从未显示过"的 VC(比如预创建的)不占 view 树内存——但显示过一次就永久占用;viewDidDisappear ≠ 释放:view 只是被移出 window(superview=nil),
oldVC.view 属性仍持整棵树;UINavigationController.viewControllers 数组仍强持有 VC 本身。
iOS 6 起 viewDidUnload 已废弃,系统不再替你卸载 view 树。| 内存项 | 量级(典型) | 持有者 | 释放时机 |
|---|---|---|---|
| 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 ~ MB | VC 强引用 | 仅应用手动;不释放则进内核压缩器 |
| H5 页面 DOM/JS/布局 | 每页数 MB ~ 数十 MB | WebContent 进程(独立地址空间) | WebKit BFCache/MemoryCache 管理(§8.3/8.4,源码可证) |
| H5 渲染 backing store | 每 surface ≈ 屏幕尺寸×4 | 应用进程持有 IOSurface | 标记 volatile → 内核 purgeable 丢页(§7.3) |
注意量级对比:对象树是 KB 级噪音,位图和 H5 才是 MB 级主旋律。旧详情页内存问题的本质是 "多份全屏位图 + 多个 H5 页面"的线性累积。
淘宝详情页大量是 H5。在同一个 WKWebView 内前进导航时,旧 H5 页不是立即销毁——WebKit 有一个 "页面前进缓存"(BFCache):把旧页挂起(停定时器、停脚本、停网络),保留完整 DOM/JS 状态, 返回时瞬间恢复。它的容量与内存压力联动全部有源码:
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); }
// 前进导航 → 挂起旧页放入缓存: 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(); // ← 最老的挂起页被销毁 ... } }
驱逐原因枚举还包括 MemoryPressure 与 ProcessSuspended(L465-470 的诊断日志映射):
系统内存压力或 WebContent 进程被挂起时,BFCache 里的旧页会被成批清空。
也就是说 H5 场景下"旧详情页"的内存有完整的容量上限与压力联动——这与 UIKit 导航栈的"无限累积、永不自动清"形成鲜明对比。
旧详情页里的商品图、背景图,在 WebKit 里由 MemoryCache 统一管理。它的核心设计是把缓存容量分成两个池: 有活跃页面引用的资源(live)与无人引用的资源(dead,即页面已销毁但数据还在缓存里):
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 拿剩余额度 }
// 对“无 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; }
三个精彩的技术细节:
pruneLiveResourcesToSize L275+,含"too new to prune"保护):
内存压力下连在用资源的解码位图都销毁,滚回可视区再解码;cTargetPrunePercentage = .95f,L54):避免抖动——刚剪完又超限又剪。应用层不释放的内存,进入内核后按页的类型分流处理(全部有 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+ |
直接回收(内容可从文件重读),最廉价 |
| 超限的最终兜底 | jetsamkern_memorystatus.c kill 原因表 L100-115、kill 选择 L499-504、hiwat kill L976 |
按 band 优先级 kill 进程("jettisoned"/"highwater"/"per-process-limit" 等原因都在 L104-115 表中)——应用不释放就整个杀掉 |
一句话回答"旧详情页内存如何处理":原生页的内存处理权在应用手里,系统只保证"不渲染" (渲染树摘除)并提供内核级的缓冲(压缩器收纳脏页、purgeable 丢位图页),超过限额由 jetsam 一刀切; H5 页则由 WebKit 全自动管理——BFCache 挂起有容量上限,图片缓存有 live/dead 分级回收,位图有 volatile 标记。 同一台手机上,"旧页面内存"的命运取决于它是 UIKit 的孩子还是 WebKit 的孩子。
直接回答:会用,但只在三个特定层上,且粒度是"框架持有的缓冲区",从来不是"某个 VC 的图片"。 更关键的是:UIKit 不会因为 viewDidDisappear 就把旧页图片标记 volatile——purgeable 的开关权在 CoreAnimation(应用级)、ImageIO(缓存级)手里,页面可见性根本不在决策输入里。
| 层 | 缓冲区归谁 | 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 文档) |
以下全部来自本机 Xcode SDK 头文件原文(MacOSX.sdk),可独立复核:
// 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);
/* 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)默认开启"解码形式缓存"
// 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.
purgeable 不是系统框架的特权——任何应用都可以把自己的缓冲区标记为 volatile。内核公开入口:
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_allocate 加 VM_FLAGS_PURGABLE 分配缓冲 →
绘制前 VM_PURGABLE_NONVOLATILE(检查是否被丢过)→ 闲置时 VM_PURGABLE_VOLATILE。
这正是上文 §8.2 表中"标记 volatile 后由内核压力下丢弃"一行的用户态入口。图片场景更简单的选择是
NSCache(内存压力自动驱逐,无需自己管理状态机)。
这是深导航场景的真正痛点。旧详情页里被 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。
这条阶梯正是整个专题的收束:"页面不可见"这个信号,只有 WebKit 在消费;原生侧的 purgeable 决策输入是"应用前后台"(CA)和"系统压力"(ImageIO),页面可见性对它们不存在。 所以原生页的旧图片内存,系统侧的所有自动机制里只有 ImageIO 解码缓存一条能帮上忙—— 其余全部依赖应用在 viewDidDisappear / 内存警告 / pop 时机自行释放。
论断"对象引用保住的是编码数据 + 重解码能力,不是解码结果"拆成三个可检验命题,本机(macOS, 与 iOS 同源 CoreGraphics/ImageIO)用 8000×8000 JPEG(编码 3.57MB,解码应为 244.14MB)实测:
| 阶段 | phys_footprint | internal | ledger_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——解码结果生命周期 ≠ 图片对象生命周期 ✓ |
三个惊人的细节:
MALLOC_LARGE 244.2M ... SM=ALI PURGE=E DefaultPurgeableMallocZone——
解码位图分配在专用 purgeable malloc zone(DefaultPurgeableMallocZone),内核对象别名模式,purgeable 状态可清除;kCGImageSourceShouldCacheImmediately 传给
CGImageSourceCreateImageAtIndex(无论 per-image 还是 source 级)在实测中并未触发立即解码
(S4/S4b footprint 无变化);真正触发完整解码的是缩略图路径
CGImageSourceCreateThumbnailAtIndex + Immediately——这正是 WebKit
createImageSourceThumbnailOptions() 的用法(ImageDecoderCG.cpp L100-105)。
头文件文档("decoding will happen at rendering time")与实测一致:默认解码推迟到渲染/需要时。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,立即解码)。
CGImageSourceCreateThumbnailAtIndex(kCGImageSourceThumbnailMaxPixelSize)按显示尺寸解码,别把 4000×4000 商品图整张解进内存;imageNamed:(系统缓存自动响应压力),一次性大图用 contentsOfFile:(不进缓存);所有 [SRC] 标注均锚定以下文件(xnu-7195.141.2 = iOS 14.8),行号可复核:
证据边界声明:本文 iOS 内核机制部分(Mach IPC、OOL 传输、memory entry、IOKit 映射、 purgeable、安全门控)基于 XNU 源码行号锚点;不可见内容管理部分基于 WebKit 开源源码(§7); 涉及闭源组件的部分(UIKit/SwiftUI 细节、backboardd 的 render server 角色、Core Animation 事务编码格式、 鸿蒙 RS 内部实现、安卓 SurfaceFlinger/HWC 细节)标注为 [ARCH] 架构层知识, 基于公开文档与行业共识,Apple/华为/Google 未完整开源,请以此为准确度预期。