视频编辑器的滤镜图内核:Node/Pin 模型怎么设计,撤销重做怎么才不崩
这篇讲的是视频编辑引擎最底下那一层:效果是怎么组织的、渲染顺序是怎么算出来的、撤销重做为什么总是最后才崩。
中文互联网上讲 CIFilter、讲 Metal 后处理的文章很多,讲编辑器内核数据模型的几乎没有。我从 0 设计过一套,这里把踩坑顺序原样摊开。
零、你会经历这样一个过程
第一版效果链,所有人都是这么写的:
swift
var filters: [Filter] = [Brightness(0.2), LUT("film"), Vignette(0.4)]
// 渲染:for f in filters { texture = f.apply(texture) }
它能跑,能上线,能扛一年。然后产品排期里出现三个需求,顺序不重要,来一个就够:
- 画中画:两路视频,一路叠在另一路上面。
- 蒙版:用一张灰度图控制某个效果只作用于人像区域。
- 一个节点给两个下游用:同一段模糊结果,既做背景又做反光。
数组表达不了「两路输入」,树表达不了「一个输出被两处引用」。于是你开始在 Filter 里加 secondaryInput、maskInput、sharedResultCache,加到第四个字段时你会意识到:这不是在打补丁,这是在用数组硬模拟一个有向无环图。
再往后是撤销重做。第一版 undo 也是所有人都会写的那种:每次改动前把整个工程 deepCopy 一份压栈。它在 demo 里完美,然后:
- 用户拖一次亮度滑杆,60Hz 回调,两秒钟压了 120 份工程快照进栈;
- 快照里的节点持有 Metal 纹理,
deepCopy要么把纹理也拷了(显存翻倍),要么没拷(撤销后纹理是别人的); - 用户删了一个节点又撤销,节点回来了,但另一个正在编辑它的面板还握着旧对象的引用,改了半天改在一个不在图里的东西上。
这两条路(图模型、撤销)看起来是两个问题,我做完之后的结论是:它们是同一个问题。撤销系统的正确性,九成由数据模型决定,剩下一成才是撤销代码本身。 这篇文章就是围绕这个结论展开的。
一、先把约束算死:为什么中间结果不能随便存
图模型的设计要服从渲染约束,先把渲染约束量化。
一张 4K 帧,RGBA8:
yaml
3840 × 2160 × 4 byte ≈ 33 MB
一个二十来个节点的效果图,如果每个节点的输出都独占一张中间纹理:
20 × 33 MB ≈ 660 MB
iPhone 上一个前台 App 的舒适显存预算远没有这么多,更别提 Broadcast Extension 那种 50MB 的场景(上一篇讲过)。而且这还只是一帧的中间结果,还没算解码缓冲、预览缓存、导出队列。
所以图内核的第一条硬约束就出来了:中间纹理必须复用,一个节点的输出在最后一个消费者用完之后,那块显存要立刻让给别人。这件事决定了渲染不能「拿着图现遍历」,得先编译出一份带资源分配的执行计划。这是后面第三、第四个坑的根源。
第二条约束来自交互:滑杆拖动时每帧都会改参数,而图的结构一帧都不会变。参数变更和结构变更的成本必须分开,参数变了不能触发重新编译整张图。
二、坑 1:用数组(或树)建模,画中画来了就得重写
症状 :Filter 类型里长出 secondaryInput、maskInput 这种字段,渲染代码里到处是 if let mask = f.maskInput。
根因:数组是「一进一出的链」,树是「多进一出」,而编辑器需要的是「多进多出、一个输出可以被多个输入引用」。这就是 DAG,别的结构都是在硬模拟它。
解法:Node / Pin / Edge 三个概念,一次到位。
swift
struct NodeID: Hashable, Codable { let raw: UUID }
/// Pin 用「节点 ID + 名字」定位,而不是用索引。
/// 索引会在节点增删 pin 后失效,名字不会。
struct PinID: Hashable, Codable {
let node: NodeID
let name: String
}
enum PinType: String, Codable {
case image // RGBA 纹理
case mask // 单通道
case scalar // Float
case color // simd_float4
}
struct PinSpec: Codable {
let name: String
let type: PinType
}
enum Param: Codable, Equatable {
case scalar(Float)
case color(SIMD4<Float>)
case string(String)
}
struct Node: Codable {
let id: NodeID
var kind: String // "blur" / "lut" / "blend" / "source"
var params: [String: Param]
var inputs: [PinSpec]
var outputs: [PinSpec]
}
struct Edge: Hashable, Codable {
let from: PinID // 某个节点的 output
let to: PinID // 某个节点的 input
}
三个设计判断,每个都对应后面一个坑,先立在这里:
- Pin 有类型。 连线时校验类型,
mask不能接到image上。这不是洁癖,是为了让「连错线」在编辑时报错,而不是在渲染时得到一张全黑的帧然后花一下午找原因。 - 一个 input pin 最多一条入边,一个 output pin 可以有任意多条出边。 这条规则让「连线」这个操作有了确定的语义:往一个已占用的 input 上连线 = 替换旧连线。撤销系统要靠这个确定性。
Node和Edge是纯数据(struct+Codable),不持有任何 GPU 资源。 纹理、编译好的 pipeline state、解码器,全都不在这里。这是整篇文章最重要的一条边界,后面撤销和多线程都靠它。
图本身:
swift
struct Graph: Codable {
private(set) var nodes: [NodeID: Node] = [:]
private(set) var edges: Set<Edge> = []
/// 结构版本号:节点/连线变化时递增。参数变化不动它。
private(set) var structureVersion: UInt64 = 0
enum ConnectError: Error {
case unknownPin, typeMismatch, wouldCycle
}
func pinType(_ pin: PinID, isInput: Bool) -> PinType? {
guard let n = nodes[pin.node] else { return nil }
let specs = isInput ? n.inputs : n.outputs
return specs.first { $0.name == pin.name }?.type
}
/// 返回被替换掉的旧连线(如果有),调用方需要它来生成撤销
@discardableResult
mutating func connect(_ e: Edge) throws -> Edge? {
guard let ft = pinType(e.from, isInput: false),
let tt = pinType(e.to, isInput: true) else { throw ConnectError.unknownPin }
guard ft == tt else { throw ConnectError.typeMismatch }
guard !reaches(from: e.to.node, to: e.from.node) else { throw ConnectError.wouldCycle }
let displaced = edges.first { $0.to == e.to }
if let d = displaced { edges.remove(d) }
edges.insert(e)
structureVersion += 1
return displaced
}
mutating func disconnect(_ e: Edge) {
if edges.remove(e) != nil { structureVersion += 1 }
}
mutating func insert(_ n: Node) {
nodes[n.id] = n
structureVersion += 1
}
/// 删除节点并返回所有受影响的边。撤销要靠这份返回值。
@discardableResult
mutating func remove(_ id: NodeID) -> (Node, [Edge])? {
guard let n = nodes.removeValue(forKey: id) else { return nil }
let touched = edges.filter { $0.from.node == id || $0.to.node == id }
edges.subtract(touched)
structureVersion += 1
return (n, Array(touched))
}
mutating func setParam(_ id: NodeID, _ key: String, _ v: Param) -> Param? {
guard nodes[id] != nil else { return nil }
let old = nodes[id]!.params[key]
nodes[id]!.params[key] = v
return old // 注意:不动 structureVersion
}
/// a 能否沿有向边走到 b(用于成环检测)
private func reaches(from a: NodeID, to b: NodeID) -> Bool {
var stack = [a], seen = Set<NodeID>()
while let cur = stack.popLast() {
if cur == b { return true }
guard seen.insert(cur).inserted else { continue }
for e in edges where e.from.node == cur { stack.append(e.to.node) }
}
return false
}
}
注意每个 mutating 方法都把被覆盖的旧信息返回出去 :connect 返回被顶掉的边,remove 返回节点和它的所有连线,setParam 返回旧值。现在看像是过度设计,到坑 5 你会发现撤销系统一行额外的状态都不用存,全靠这些返回值。
成环检测放在 connect 里而不是渲染时,理由和 pin 类型校验一样:图的非法状态要在进入图之前拦住,而不是让一张非法的图存在、然后每个消费者各自防御。
三、坑 2:节点用对象引用,删除再撤销后所有引用都是悬空的
症状:删除节点 → 撤销 → 节点回来了 → 属性面板改参数没反应;或者更阴的:改在了一个已经不在图里的对象上,工程保存时把它序列化进去,下次打开图里多了一个孤儿。
根因:撤销恢复的是一个「等价的新对象」,不是原来那个对象。所有握着旧引用的地方(面板、选中态、正在进行的手势、另一条撤销记录)全部失效。这不是撤销代码的 bug,是身份模型的 bug。
解法 :图里没有对象,只有 ID。
Graph是struct,Node是struct。图的任何一个「版本」都是一个完整的值,可以随便拷贝、比较、序列化。- UI 层、撤销记录、渲染计划,一律只持有
NodeID/PinID,要数据时去graph.nodes[id]现查。 - 撤销恢复节点时用原来的 ID (因为
remove返回的就是原节点,ID 在里面),所以恢复之后所有引用自动重新有效,什么都不用通知。
这个决定的代价是 UI 每次都要查表,收益是整个撤销系统不需要处理「引用失效」这类问题。我的判断:这笔交易在任何有撤销功能的工具型产品里都稳赚,Swift 的值语义 + 写时复制让它几乎没有性能成本。
有人会问:struct 的图,每次改参数都要整个拷贝一份?不会。Graph 里的字典是写时复制的,只要没有第二个引用同时存在,mutating 原地改;有第二个引用(比如渲染线程正拿着上一版快照)才拷贝,而且只拷贝被改的那一层。这正是我们想要的行为(坑 7 会用到)。
四、坑 3:渲染时直接遍历图,参数一动就重新排序
症状:拖滑杆时预览掉帧。Profile 一看,每帧都在做拓扑排序、每帧都在重新决定哪个节点先算,而图的结构从头到尾没变过。
根因:把「图」和「执行计划」当成了一个东西。图是给人编辑的,执行计划是给 GPU 跑的,两者变化频率差两个数量级:结构几分钟改一次,参数每帧都在改。
解法 :把图当源码,把执行计划当编译产物。 结构版本号变了才重新编译,参数变了只更新 uniform。

swift
struct RenderPlan {
struct Step {
let node: NodeID
let inputSlots: [Int?] // 按 node.inputs 顺序;nil = 该 input 没接线
let outputSlots: [Int] // 按 node.outputs 顺序
}
let steps: [Step] // 已经是拓扑序
let slotCount: Int // 需要几块中间纹理
let structureVersion: UInt64 // 对应哪个版本的图
}
编译第一步是拓扑排序(Kahn 算法),第二步是纹理槽位分配,下一节单讲。渲染线程拿到 RenderPlan 后每帧只做一件事:按 steps 顺序、从 graph.nodes[step.node].params 读当前参数、把 slot 映射到真实纹理、提交 draw call。
这个分层带来一个不明显但很值钱的性质:渲染线程永远不需要理解图的拓扑 。它只认一个线性的 steps 数组。图再复杂,渲染代码都是一个 for 循环。
五、坑 4:每个节点独占一张中间纹理,显存爆了
症状:图一大,预览就开始丢帧、或者干脆收到内存警告;导出 4K 时更明显。
根因:回到第一节的算术,20 个节点 × 33MB。绝大多数中间结果的生命周期非常短:被下一个节点消费完就没用了。独占纹理是在为一个「万一以后有人用」的假设付 30MB 的租金。
解法 :编译时做生命周期分析,给中间结果分配「槽位」而不是纹理。这和编译器的寄存器分配是同一个问题:每个值有一个「最后一次被使用」的位置,过了那个位置槽位就能回收给别人。
swift
extension RenderPlan {
static func compile(_ g: Graph) -> RenderPlan {
let order = topoSort(g) // [NodeID],Kahn 算法,略
// 每个 output pin 的最后一个消费者在拓扑序里的位置
var lastUse: [PinID: Int] = [:]
for (i, id) in order.enumerated() {
for e in g.edges where e.to.node == id {
lastUse[e.from] = max(lastUse[e.from] ?? -1, i)
}
}
var slotOf: [PinID: Int] = [:]
var free: [Int] = []
var next = 0
var steps: [Step] = []
for (i, id) in order.enumerated() {
let node = g.nodes[id]!
// 输入:找到上游 output pin 的槽位
let ins: [Int?] = node.inputs.map { spec in
let pin = PinID(node: id, name: spec.name)
guard let e = g.edges.first(where: { $0.to == pin }) else { return nil }
return slotOf[e.from]
}
// 先释放:所有「最后一次使用就是这一步」的上游输出
// 注意顺序------释放要在给本节点分配输出之前,才能实现 in-place 复用
for e in g.edges where e.to.node == id {
if lastUse[e.from] == i, let s = slotOf[e.from] {
free.append(s)
}
}
// 输出:优先复用刚释放的槽位
let outs: [Int] = node.outputs.map { spec in
let pin = PinID(node: id, name: spec.name)
let s = free.popLast() ?? { defer { next += 1 }; return next }()
slotOf[pin] = s
return s
}
steps.append(Step(node: id, inputSlots: ins, outputSlots: outs))
}
return RenderPlan(steps: steps, slotCount: next,
structureVersion: g.structureVersion)
}
}

「先释放再分配」这一个顺序,是能不能做到 in-place 的关键:一个单输入单输出的节点(亮度、LUT),它的输入在本步用完,输出可以直接写进同一个槽位,只要这个节点的 shader 支持读写同一张纹理,或者你在槽位到纹理的映射层做 ping-pong。这个细节在文档里查不到,只有把槽位分配跑起来看内存曲线时才会想到。
一条经验:槽位是逻辑概念,纹理是物理资源,两者之间再放一层按(尺寸, 格式)键控的纹理池。 4K 导出和 720p 预览用同一份 RenderPlan,只是槽位映射到不同尺寸的纹理。这一层也是节点输出尺寸不同(比如画中画的小画面)时的兜底。
六、坑 5:撤销用快照,显存翻倍、拖一次滑杆压 120 层栈
现在进入撤销。前面四个坑都是在为它铺路。
症状:撤销栈内存随操作数线性涨;快照里如果带 GPU 资源则显存翻倍,不带则撤销后资源丢失。
根因:快照式撤销把「状态」当成撤销的单位。而状态里有两类东西:可以廉价复制的纯数据,和不能复制的资源。快照要么全拷、要么全不拷,没有中间路线。
解法 :命令式撤销。撤销的单位不是「状态」,是「变更」。每条命令是纯数据,apply 到图上时返回一条能撤销自己的命令。
swift
/// 命令是 enum,不是闭包。
/// 闭包会捕获对象引用(坑 2 的问题),而且不能序列化(坑 8 需要序列化)。
indirect enum Command: Codable {
case setParam(NodeID, String, Param)
case insertNode(Node)
case removeNode(NodeID)
case restoreNode(Node, [Edge]) // removeNode 的逆
case connect(Edge)
case disconnect(Edge)
case group([Command]) // 复合命令,作为一个撤销步骤
/// 应用到图上,返回逆命令。
/// 逆命令在「做」的时候捕获,而不是在「撤销」的时候计算。
func apply(to g: inout Graph) throws -> Command {
switch self {
case let .setParam(id, key, v):
guard let old = g.setParam(id, key, v) else {
throw UndoError.targetMissing
}
return .setParam(id, key, old)
case let .insertNode(n):
g.insert(n)
return .removeNode(n.id)
case let .removeNode(id):
guard let (n, touched) = g.remove(id) else {
throw UndoError.targetMissing
}
return .restoreNode(n, touched)
case let .restoreNode(n, touched):
g.insert(n)
for e in touched { _ = try? g.connect(e) } // 恢复时不该失败:拓扑与删除前一致
return .removeNode(n.id)
case let .connect(e):
let displaced = try g.connect(e)
if let d = displaced {
// 顶掉了旧连线:撤销 = 断开新的 + 接回旧的
return .group([.disconnect(e), .connect(d)])
}
return .disconnect(e)
case let .disconnect(e):
g.disconnect(e)
return .connect(e)
case let .group(cmds):
var inverses: [Command] = []
for c in cmds { inverses.append(try c.apply(to: &g)) }
return .group(inverses.reversed()) // 逆序!
}
}
}

三个细节,每一个都是真实翻过车的:
「逆命令在做的时候捕获」。 setParam 的逆需要旧值,旧值只在改之前存在;removeNode 的逆需要被删的那些边,边只在删之前存在。如果你把撤销实现成「在 undo 时算出相反操作」,会发现要的信息已经没了。这就是为什么第二节让每个 mutating 方法把旧信息返回出去。
connect 的逆可能不是 disconnect。 因为 input 单入边的规则,connect 可能顶掉一条旧线。撤销必须把旧线接回去,否则撤销一次之后图和之前不一样。单入边规则在坑 1 立下的时候,就注定了这里要处理它。
group 的逆是逆序的逆。 「删节点」在 UI 上是一步,但内部是先断 N 条线再删节点(或者反过来)。撤销要按相反顺序回放。这一条写错的症状特别隐蔽:多数情况下顺序不影响结果,只在「连线依赖节点存在」这类情况下才炸,而且炸在撤销的第二步。
UndoError.targetMissing 值得说一句:一条针对某个节点的命令,应用时节点不在,这在正常流程里不该发生。它发生,说明撤销栈和图的状态已经对不上了(多半是有人绕过命令直接改了图)。这时正确的处理是清空撤销栈并上报,而不是吞掉错误继续。一个对不上的撤销栈比没有撤销栈更危险。
七、坑 6:拖一次滑杆 120 条命令,两次拖动又被合成一条
症状:拖一下亮度,Cmd+Z 要按 120 次才回到起点。改成按时间窗口合并(300ms 内同一参数合并)后,新症状:用户拖亮度、停顿一下、再拖,两次操作合成了一条撤销。
根因 :合并的正确边界是手势,不是时间。用户在同一次按下抬起之间的所有变更是一个意图;两次按下是两个意图,中间隔多久都不该合并。
解法:命令带一个「合并键」,键里包含手势 ID。
swift
final class History {
struct Entry {
var forward: Command
var inverse: Command
var coalesceKey: String?
}
private var undoStack: [Entry] = []
private var redoStack: [Entry] = []
func perform(_ cmd: Command, key: String? = nil, on g: inout Graph) throws {
let inverse = try cmd.apply(to: &g)
redoStack.removeAll()
if let key, let last = undoStack.last, last.coalesceKey == key {
// 合并:forward 用最新的,inverse 保留最早的。
// 这样撤销一步回到手势开始前,重做一步跳到手势结束。
undoStack[undoStack.count - 1].forward = cmd
return
}
undoStack.append(Entry(forward: cmd, inverse: inverse, coalesceKey: key))
}
func undo(on g: inout Graph) throws {
guard let e = undoStack.popLast() else { return }
let redoInverse = try e.inverse.apply(to: &g)
redoStack.append(Entry(forward: e.inverse, inverse: redoInverse, coalesceKey: nil))
}
func redo(on g: inout Graph) throws {
guard let e = redoStack.popLast() else { return }
let undoInverse = try e.inverse.apply(to: &g)
undoStack.append(Entry(forward: e.inverse, inverse: undoInverse, coalesceKey: nil))
}
}
调用方:
swift
// 手势开始时生成一次
let gesture = UUID().uuidString
// 每帧
try history.perform(.setParam(nodeID, "brightness", .scalar(v)),
key: "\(nodeID.raw)/brightness/\(gesture)",
on: &graph)
合并规则里「inverse 保留最早的、forward 用最新的」这一条,让合并后的一条记录在语义上等价于「一步从起点跳到终点」。撤销回起点、重做到终点,中间的 118 个值不需要存。
注意 undo / redo 里都是重新 apply 并重新捕获逆命令 ,而不是直接把 forward 搬回去。原因:重做一条 connect,它可能再次顶掉一条线(如果用户在撤销之后又手动接了一条),新的逆命令必须反映这个事实。凡是「缓存了逆命令然后复用」的写法,在这种交叉操作下都会对不上。
复合操作(删节点、粘贴一组节点、应用一个预设)用 .group,作为一个撤销步骤。不要自己再在 History 里搞 beginGrouping / endGrouping 那种状态机,把分组表达成数据(.group)比表达成状态简单得多,而且能序列化。
八、坑 7:撤销改了图,渲染线程正在读
症状:偶发的渲染线程崩溃或读到半新半旧的图;或者为了避免它加了一把大锁,然后撤销卡住预览。
根因:图是主线程改的,渲染线程读的,两边共享一个可变对象。
解法 :这一条在坑 2 已经埋好了。图是值类型,渲染线程拿的是快照,不是引用。
swift
actor RenderState {
private(set) var graph: Graph
private(set) var plan: RenderPlan
func publish(_ g: Graph) {
graph = g
if plan.structureVersion != g.structureVersion {
plan = RenderPlan.compile(g) // 只在结构变化时重编译
}
}
}
主线程改完图(不管是用户操作还是撤销)调用一次 publish,渲染线程每帧取一次 (graph, plan)。因为 Graph 是 struct,取出来的就是一份不会再变的值,主线程接着改自己那份,两边不共享任何可变内存。写时复制保证这个「拷贝」只在真的有并发读写时才发生,而且只拷被改的部分。
而 GPU 资源(纹理池、pipeline state、解码器)不在 Graph 里 ,它们属于渲染线程独占,按 NodeID 或 (尺寸, 格式) 键控。撤销删掉一个节点,渲染侧的资源缓存可以先留着(LRU),用户随后撤销恢复这个节点时资源还在,不需要重新加载 LUT、重新起解码器。这个「撤销是瞬时的」体验,是把资源从模型里剥离出去的直接收益,不是额外做的优化。
九、坑 8:崩溃后草稿丢了
症状:用户编辑了二十分钟,App 被系统杀了(内存、后台超时都有可能),再打开是上次手动保存的版本。
根因:保存整个工程太重,不能每次操作都存;不存又丢。
解法 :这是命令式撤销的第二个红利,命令本身就是变更日志 。Command 是 Codable 的纯数据,每条命令应用后追加写入一个日志文件,成本是几十字节。崩溃恢复 = 加载上一次完整快照 + 重放日志。
swift
// 每次 perform 成功后
let data = try JSONEncoder().encode(cmd)
journal.append(data) // 追加写,不重写整个文件
// 恢复
var g = try loadLastSnapshot()
for cmd in try journal.readAll() { _ = try cmd.apply(to: &g) }
这一条能成立的前提,回头看全在前面:命令是数据不是闭包(坑 5)、节点用 ID 不用引用(坑 2)、图是可序列化的值(坑 1)。如果撤销是用快照或闭包做的,这条路根本不存在。
十、诚实一点:什么时候不需要这一套
上面这套东西,我认为只在下面几种情况下值得自己写:
- 效果之间有真正的图结构:画中画、蒙版、一个中间结果被多处引用。
- 你有自定义的 Metal pass,而且节点可能多输出(比如一个节点同时输出图像和它算出来的蒙版)。
- 效果由非程序员配置(设计师通过协议文件定义新特效、上线不发版),那协议的载体就是这张图。
- 撤销需要跨越复合操作和崩溃恢复。
如果你的产品是「顺序叠几个滤镜 + 一个 LUT + 导出」:
- 用
[Filter]数组,撤销用快照,够了。图上没有分叉,快照里没有资源,上面每一个坑你都不会踩。 - Core Image 的
CIFilter链本身就是一个惰性求值的 DAG,中间结果的复用和 kernel 拼接它替你做了。只要你的所有效果都能表达成CIKernel,你不需要自己的图内核。 AVVideoComposition+ 自定义 compositor 能覆盖「多轨叠加」这一类需求,而且时间线模型是现成的。
我做的场景是四条全中,才把这套写出来。不要因为它看起来「架构正确」就上它,它的复杂度是实打实的,只有需求把你逼到这里时才值。
还有一件这篇没讲的事:时间。关键帧、变速、时间线上的 clip 怎么和这张图接起来,是另一个话题,数据模型上的选择也不少,留到后面写。
十一、三个反直觉的观察
1. 撤销系统的 bug,九成不在撤销代码里。 悬空引用、恢复的节点没连线、撤销后渲染线程崩,追下去全是数据模型的问题:用了引用而不是 ID、资源和数据混在一起、图不是值。撤销系统只是把这些问题第一个暴露出来的地方。如果你发现撤销很难写对,先别改撤销,回头看模型。
2. 「撤销 = 做相反的操作」是错的,「撤销 = 把做的时候记下来的旧值写回去」才对。 相反操作需要的信息(旧值、被顶掉的边、被删节点的连线)在操作之后就不存在了。逆命令必须在正向操作执行的那一刻捕获。这个区别决定了你的 mutating 方法要不要返回旧状态。
3. 图是源码,渲染计划是编译产物,纹理槽位分配就是寄存器分配。 把编辑器内核当编译器来看,很多问题都有现成答案:什么时候重编译(结构变了)、中间结果放哪(生命周期分析)、多线程怎么处理(源码不可变,产物只读)。编译器这套东西几十年了,不用重新发明。
写在最后
这篇里没有一个「高级」技术:值类型、ID 代替引用、命令模式、拓扑排序、生命周期分析,每一个单拎出来都是教科书内容。难的不是知道它们,是在第一天就把它们放对位置,因为后面每一个坑都是前面某个决定的直接后果:
- 用 ID 不用引用 → 撤销能恢复身份、渲染线程能拿快照、命令能序列化
- 数据和资源分离 → 撤销不碰显存、崩溃恢复不需要存纹理
- 图和计划分层 → 参数变化不重编译、渲染线程不理解拓扑
反过来,第一天用了对象引用,后面每一个功能都要为它付利息,直到某天付不起。
这个判断可以迁移到任何有「编辑 + 撤销 + 渲染」三件事的工具型产品:图形编辑器、音频工作站、节点式 shader 编辑器、甚至一个复杂的表单引擎。模型的值语义和身份稳定性,是撤销系统的地基,不是撤销系统的一部分。
代码说明 :本文的示例代码用于说明设计取舍,省略了拓扑排序(
topoSort)、纹理池、UndoError等辅助定义,也未处理 pin 增删导致的边失效等边界情况。这些片段未逐行编译验证,直接用到工程里需要按你的实际类型和线程模型调整。文中涉及的架构决策均为这类场景的通用工程权衡,不对应任何特定产品的内部实现。
如果这篇对你有用,欢迎交流。iOS 音视频、录屏、编解码、编辑器内核相关的问题都可以聊。
GitHub: @DongQi-Yang