视频编辑器的滤镜图内核:Node/Pin 模型怎么设计,撤销重做怎么才不崩

视频编辑器的滤镜图内核: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) }

它能跑,能上线,能扛一年。然后产品排期里出现三个需求,顺序不重要,来一个就够:

  1. 画中画:两路视频,一路叠在另一路上面。
  2. 蒙版:用一张灰度图控制某个效果只作用于人像区域。
  3. 一个节点给两个下游用:同一段模糊结果,既做背景又做反光。

数组表达不了「两路输入」,树表达不了「一个输出被两处引用」。于是你开始在 Filter 里加 secondaryInputmaskInputsharedResultCache,加到第四个字段时你会意识到:这不是在打补丁,这是在用数组硬模拟一个有向无环图。

再往后是撤销重做。第一版 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 类型里长出 secondaryInputmaskInput 这种字段,渲染代码里到处是 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
}

三个设计判断,每个都对应后面一个坑,先立在这里:

  1. Pin 有类型。 连线时校验类型,mask 不能接到 image 上。这不是洁癖,是为了让「连错线」在编辑时报错,而不是在渲染时得到一张全黑的帧然后花一下午找原因。
  2. 一个 input pin 最多一条入边,一个 output pin 可以有任意多条出边。 这条规则让「连线」这个操作有了确定的语义:往一个已占用的 input 上连线 = 替换旧连线。撤销系统要靠这个确定性。
  3. NodeEdge 是纯数据(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

  • GraphstructNodestruct。图的任何一个「版本」都是一个完整的值,可以随便拷贝、比较、序列化。
  • 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)。因为 Graphstruct,取出来的就是一份不会再变的值,主线程接着改自己那份,两边不共享任何可变内存。写时复制保证这个「拷贝」只在真的有并发读写时才发生,而且只拷被改的部分。

而 GPU 资源(纹理池、pipeline state、解码器)不在 Graph ,它们属于渲染线程独占,按 NodeID(尺寸, 格式) 键控。撤销删掉一个节点,渲染侧的资源缓存可以先留着(LRU),用户随后撤销恢复这个节点时资源还在,不需要重新加载 LUT、重新起解码器。这个「撤销是瞬时的」体验,是把资源从模型里剥离出去的直接收益,不是额外做的优化。


九、坑 8:崩溃后草稿丢了

症状:用户编辑了二十分钟,App 被系统杀了(内存、后台超时都有可能),再打开是上次手动保存的版本。

根因:保存整个工程太重,不能每次操作都存;不存又丢。

解法 :这是命令式撤销的第二个红利,命令本身就是变更日志CommandCodable 的纯数据,每条命令应用后追加写入一个日志文件,成本是几十字节。崩溃恢复 = 加载上一次完整快照 + 重放日志。

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)。如果撤销是用快照或闭包做的,这条路根本不存在。


十、诚实一点:什么时候不需要这一套

上面这套东西,我认为只在下面几种情况下值得自己写

  1. 效果之间有真正的图结构:画中画、蒙版、一个中间结果被多处引用。
  2. 你有自定义的 Metal pass,而且节点可能多输出(比如一个节点同时输出图像和它算出来的蒙版)。
  3. 效果由非程序员配置(设计师通过协议文件定义新特效、上线不发版),那协议的载体就是这张图。
  4. 撤销需要跨越复合操作和崩溃恢复

如果你的产品是「顺序叠几个滤镜 + 一个 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

相关推荐
CocoaKier13 小时前
苹果续费成功,几天后付费协议突然失效,导致线上苹果支付全部失败
ios·apple
深念Y1 天前
PVE 安装黑苹果并直通 Quadro P400
macos·ios·黑苹果·驱动·苹果·英伟达·p400
weixin-a153003083161 天前
7.Appium-ios端自动化
ios·appium·自动化
Anhty2 天前
2026九月最新变声器测评:iOS安卓双端适配,低延迟运行更稳定
android·人工智能·功能测试·ios·智能手机
_瑞2 天前
APM_OOMDetector
ios
秋雨梧桐叶落莳2 天前
【iOS】从源码深入理解RunLoop机制
macos·ios·objective-c·cocoa·cocoapods·uikit
烂蜻蜓2 天前
Flask入门教程(二十七):Session Interface API——自定义Session存储后端
网络·ios·flask
用户38034165882972 天前
VideoToolbox 硬编解码的十个坑:为什么它几乎从不报错
ios
bcbnb2 天前
Flutter-Notebook代码混淆:Android与iOS平台安全配置
后端·ios