对于小型的内存型 Mac 图形编辑器,建议从值状态快照开始。当编辑频繁且范围明确,并且较小的负载足以抵消额外逻辑时,可使用逆向命令。两种方法都需要一个稳定的所有者,并为每个用户操作划定清晰的提交边界。

本指南聚焦于一项任务:在你自己的 Swift 编辑器中,为移动、删除和重新排列合成图形实现可预测的撤销与重做。下文的决策框架是基于 Apple API 得出的工程建议;Apple 并未规定适用于所有情况的快照与命令选择标准。

每个撤销操作应该记录什么?

快照记录先前可编辑的状态,例如按顺序排列的图形数组。撤销时恢复该数组。逆向命令记录撤销特定编辑所需的操作,例如将某个图形的中心设回先前的坐标。

这两种表示方式都通过 Foundation 的 UndoManager 运作。它会针对某个对象或其所有者记录操作;它不会自动监视模型中的任意赋值。你必须在修改方法中注册相应的恢复操作。UndoManager

选择历史记录的表示方式之前,先定义可编辑状态。对于此编辑器,它包括图形 ID、坐标和数组顺序。将悬停指示器和拖动预览排除在外。明确决定是否将选择状态纳入历史记录:恢复选择状态可以让删除操作更容易理解,但这也意味着撤销会改变当前选择。

针对每种操作使用下表进行选择,无需强求整个编辑器只使用一种表示方式:

操作或条件 建议的表示方式 需要保留的信息
画布较小;多个属性会一起变化 快照 完整的先前可编辑值
移动一个图形 逆向设值 稳定的图形 ID 和先前的中心坐标
删除一个图形 初期使用快照;需要时改用逆向命令 若使用逆向命令:被删除图形的完整数据及其原始位置
重新排列多个图形 除非逆向操作很直接,否则使用快照 先前的顺序和所有受影响的属性
测得的历史记录内存开销过高 定向逆向命令 精确恢复所需的一切信息

关键问题是:你能否列出逆向操作必须恢复的所有内容?如果删除图形还会改变选择状态和顺序,那么只记住被删除的 ID 并不够。当操作会影响多个相关值时,快照通常更容易审核。

快照在什么情况下才真正独立?

Swift 结构体和数组具有值语义。因此,完全由值类型构成的图形数组能够表示独立于后续修改的先前状态。但是,复制一个包含类实例的数组只会保留这些实例的引用;修改实例后,你原以为是快照的内容也可能随之改变。将这些引用封装在结构体中并不能解决这个问题。Value And Reference Types In Swift

对于小型编辑器,历史记录使用纯值数据:标识符、坐标、尺寸及其他可编辑属性。将视图和可变渲染对象排除在快照之外。如果实时模型使用类,则构造一个独立的值表示,其中包含恢复模型所需的所有属性。

不要仅凭“快照”一词就推断固定的内存开销。使用有代表性的合成画布,测量保留的历史记录和编辑延迟。举例来说,100 条记录各自保留 1,000 个图形记录,表示有 100,000 个记录实例;而 100 条单图形逆向记录只保留 100 个先前中心坐标及其标识符。这些数量并非测得的字节数,也不代表 Swift 的存储分配情况。

有意设置历史记录深度。Apple 文档指出,levelsOfUndo 限制顶层撤销组的数量,其默认值 0 表示不限制组数。组数上限很有用,但它并不限制字节数:批量快照和单坐标编辑保留的数据量可能大不相同。levelsOfUndo

快照和逆向命令如何保留重做能力?

恢复方法应在更改模型之前注册相反的恢复操作。撤销调用该方法时,它注册的操作便提供了重做能力。对于快照,在恢复先前状态之前,先捕获当前已安装的状态。对于位置命令,在设置已保存的位置之前,先捕获当前中心坐标。

Apple 的归档版撤销架构指南明确说明了这种相互注册模式:在撤销期间调用的设值方法会注册重做所需的值。指南中的示例使用 Objective-C;下方的完整 Swift 示例使用当前的闭包 API。Registering Undo Operations

不要仅因为 isUndoing 为 true 就抑制注册。在此设计中,这么做会移除用于创建重做操作的注册。恢复位置时,也不要重新执行相对移动;存储确切的先前中心坐标,能清楚表达预期的恢复结果。

完整的模型层示例

以下独立的 macOS 程序将基于快照的删除操作与逆向位置设值结合起来。它只使用内存中的合成值和 Foundation,不依赖持久化或 UI。将其保存为 EditorUndo.swift,并使用当前的 Swift 6 工具链和 macOS SDK 编译:

swiftc -parse-as-library EditorUndo.swift -o EditorUndo
./EditorUndo

此程序有意使用专用管理器并禁用事件分组,因此断言不依赖正在运行的应用事件循环。每次初始编辑都会显式创建一个组。撤销或重做时,恢复方法不会再打开其他组。Apple 要求在禁用自动分组时,撤销前必须显式关闭分组。groupsByEvent

import Foundation

struct Center: Equatable {
    var x: Double
    var y: Double
}

struct Item: Equatable {
    let id: Int
    var center: Center
}

@MainActor
final class Editor {
    private(set) var items: [Item]
    let history: UndoManager

    init(items: [Item], history: UndoManager) {
        precondition(Set(items.map(\.id)).count == items.count)
        self.items = items
        self.history = history
    }

    func replaceItems(_ next: [Item], name: String) {
        precondition(Set(next.map(\.id)).count == next.count)
        guard next != items else { return }
        let previous = items
        history.registerUndo(withTarget: self) { target in
            target.replaceItems(previous, name: name)
        }
        items = next
        history.setActionName(name)
    }

    func setCenter(id: Int, to next: Center) {
        guard let index = items.firstIndex(where: { $0.id == id }) else {
            preconditionFailure("History refers to a missing item")
        }
        let previous = items[index].center
        guard previous != next else { return }
        history.registerUndo(withTarget: self) { target in
            target.setCenter(id: id, to: previous)
        }
        items[index].center = next
        history.setActionName("Move Shape")
    }
}

@main
struct Demo {
    @MainActor
    static func main() {
        let history = UndoManager()
        history.groupsByEvent = false
        history.levelsOfUndo = 100

        let original = [
            Item(id: 1, center: Center(x: 20, y: 30)),
            Item(id: 2, center: Center(x: 80, y: 30))
        ]
        let editor = Editor(items: original, history: history)

        func edit(_ operation: () -> Void) {
            history.beginUndoGrouping()
            defer { history.endUndoGrouping() }
            operation()
        }

        edit {
            editor.setCenter(id: 1, to: Center(x: 50, y: 40))
        }
        let moved = editor.items

        edit {
            editor.replaceItems(
                editor.items.filter { $0.id != 2 },
                name: "Delete Shape"
            )
        }
        let deleted = editor.items

        history.undo()
        precondition(editor.items == moved)
        history.undo()
        precondition(editor.items == original)
        history.redo()
        precondition(editor.items == moved)
        history.redo()
        precondition(editor.items == deleted)
        print("Undo and redo checks passed")
    }
}

预期输出为 Undo and redo checks passed。我检查了此示例,但由于研究环境中没有 Swift 编译器,因此没有执行。它展示了历史记录的核心逻辑;SwiftUI 界面还需要一个可观察模型或适配器来刷新视图。

闭包使用其 target 参数,而不是直接捕获编辑器。Apple 文档指出,注册目标必须是引用类型,管理器会持有该目标的无主引用,并且使用闭包参数可以避免通过捕获目标形成强引用循环。registerUndo(withTarget:handler:)

前置条件会显式暴露模型假设被破坏的情况,而不是悄悄丢弃撤销操作。生产环境中的命令边界应在注册之前拒绝无效编辑,并给出适当的应用级错误提示。历史记录重放期间找不到 ID,说明有其他操作在预期的历史记录顺序之外修改了模型。

如何将一次拖动变成一个可撤销的操作?

拖动会产生许多次更新,而用户期望一次完整移动只对应一次撤销。Apple 的自动分组会遵循运行循环的各轮执行,因此不能把它当作整个手势的定义。显式分组可以组合多项注册,但仍需谨慎管理分组边界。groupsByEvent、beginUndoGrouping()

对此编辑器,使用预览后提交的流程:

  1. 拖动开始时,保留目标 ID 和已提交的起始中心坐标,作为手势基准。
  2. 使用 @GestureState 和 updating(_:body:) 保存临时位移。以起始位置加上该位移来渲染图形,不要更改已提交的项目。
  3. 在成功触发 onEnded(_:) 时,根据最终手势值计算最终中心坐标,并只调用一次模型的可撤销设值方法。
  4. 如果结果中心坐标与已提交的中心坐标相同,则跳过注册。
  5. 如果手势取消,则丢弃预览并保留已提交的模型。

这一流程遵循 SwiftUI 文档中的区别:手势结束或取消时,手势状态会重置;而 onEnded(_:) 只会在成功完成时运行。因此,取消后也必须执行的清理操作,不能只依赖 onEnded(_:)。Adding interactivity with gestures

预览期间保持编辑器已提交的状态稳定。一种简单的策略是在拖动期间禁用其他画布修改和画布撤销,并在完成或取消后重新启用。否则,在接受拖动提交之前,需定义如何处理使已保存基准失效的中间编辑。

如果编辑器的其他部分必须持续观察不断更新的已提交坐标,则仅使用预览渲染可能不合适。这种情况下,应实现显式手势事务:保存起始状态,在不产生单独历史记录的情况下应用中间更新,并在成功时注册一次恢复操作。取消时必须恢复起始状态。避免在任意回调或异步工作期间一直打开共享的撤销组。

模型和撤销管理器应由谁持有?

每个独立的可编辑画布都应有一个稳定的编辑器所有者。它负责持有已提交的项目,并作为历史操作的目标。模型修改和撤销交互都应在主执行器上进行;Apple 当前文档将 UndoManager 及其闭包处理器标记为隔离于主执行器。UndoManager

对于 SwiftUI,使用 @Environment(\.undoManager) 读取视图的管理器。将该管理器传递给稳定的编辑器或命令层,而不是在每次视图求值时都创建新的管理器。环境值是可选的:Apple 文档指出,不支持的上下文会返回 nil,并允许在此情况下跳过撤销注册。编辑仍可继续,但此路径不应显示撤销可用。EnvironmentValues.undoManager

对于 AppKit 画布,通过适当的响应者获取管理器。基类 NSResponder.undoManager 实现会向下一个响应者询问。因此,如果希望标准菜单操作能够访问私有创建的管理器,就必须将其明确接入画布的响应者上下文。仅将它存储在模型中并不能建立这种连接。NSResponder.undoManager

独立示例中的分组配置属于它的专用管理器。除非你负责所有参与者的分组策略,否则不要将该配置应用到环境提供的管理器上。在 UI 中,使用上下文现有的事件策略注册已完成的画布编辑;如果若干同步子操作构成一个动作,则将它们分组。

在释放编辑器目标之前,应在可控的生命周期边界移除其待处理的注册。Apple 提供了 removeAllActions(withTarget:),用于移除以该目标为操作对象的撤销和重做操作。如果管理器是共享的,清空整个管理器也会影响其他参与者。removeAllActions(withTarget:)

选择最终设计前应验证什么?

执行一段跨越不同表示方式的短操作序列:移动一个图形,删除另一个,然后撤销两次,再重做两次。每一步都检查值、ID 和顺序。再加入一次已取消的拖动、一次最终位置与起始位置相同的拖动,以及两个独立编辑器窗口。这些情况分别检验恢复数据、手势完成、无操作处理和所有权边界。

将画布的批量替换视为一项明确操作。要么通过充足的恢复状态使其可撤销,要么在有意清除过时历史记录后建立新的基线。遗漏可编辑字段的快照,以及无法找到目标的逆向命令,都属于不完整实现。

对于基于值的小型画布,从快照开始,保留一个稳定的所有者,并将每个手势只提交一次。在恢复数据明确且测量结果证明有必要时,再引入逆向命令。这样即可改变历史记录的表示方式,而不改变一次用户操作的含义。

来源