软件设计

使用 Swift 命令模式实现撤销功能

开发应用时,“只加一个撤销按钮”这样的需求,出现得比想象中更频繁。

4 分钟阅读
使用 Swift 命令模式实现撤销功能 封面图

开发应用时,“只加一个撤销按钮”这样的需求,出现得比想象中更频繁。

真正接入 undo 后,代码很容易变成意大利面。因为仅仅为了一个按钮,就要把撤销逻辑分散到删除、移动和修改代码的各个位置。

解决这一问题的正是Swift 命令模式

本文将介绍命令模式为什么能成为撤销功能的基础,并讲解实际使用的最小代码结构。重点不是复杂理论,而是“可以立即接入项目的形式”。

什么是命令模式?一行总结

先用一句话概括。

命令模式把“执行某个操作”本身封装成对象,将执行和撤销组合成一个单元。

按下按钮时,不直接执行逻辑,而是创建一个写着“执行此操作”的命令(Command 对象)。

命令中同时包含执行方式(execute)和恢复方式(undo)。

总结如下:

  1. 将操作创建为对象——删除、移动和输入分别对应一个命令
  2. 每个命令成对拥有 execute() 和 undo()
  3. 将执行过的命令压入栈
  4. 从栈中取出一个命令并调用 undo(),撤销就完成了

借助这种结构,撤销逻辑不会散落在各处,而会紧跟在各个命令内部。


用 Swift 从命令协议开始构建

首先定义所有命令都必须遵守的约定,也就是协议。

下面是强制执行和撤销的最小骨架。

protocol Command {
    func execute()   // 执行操作
    func undo()      // 撤销执行
}

只需要这两个方法。

现在创建一个包含实际操作的具体命令。例如,实现添加文本的命令。

下面的代码会“添加文字,撤销时再将其移除”。

final class AddTextCommand: Command {
    private let document: Document
    private let text: String

    init(document: Document, text: String) {
        self.document = document
        self.text = text
    }

    func execute() { document.content += text }
    func undo()    { document.content.removeLast(text.count) }
}

execute 负责添加文字,undo 负责移除添加的内容。执行和撤销位于同一个对象中,这就是该模式的全部。

Command 协议从短短两行开始
Command 协议从短短两行开始

撤销的核心是栈

创建命令后,还需要一个负责管理它们的“管理器”。这个角色通常称为 Invoker。

管理器会将执行过的命令依次压入栈中。

点击撤销时,取出最近压入的命令并调用 undo()。先撤销最后执行的操作,这就是撤销的自然顺序。

下面是管理器的骨架。

final class CommandManager {
    private var undoStack: [Command] = []

    func run(_ command: Command) {
        command.execute()
        undoStack.append(command)   // 执行后保存到栈中
    }

    func undo() {
        guard let last = undoStack.popLast() else { return }
        last.undo()                 // 从最后一项开始撤销
    }
}

现在的用法就非常简单了。

在 buttonTapped 中调用 manager.run(command),在撤销按钮中只调用 manager.undo() 即可。

撤销顺序和栈管理由 CommandManager 自动处理,因此界面代码可以保持整洁。

三个 Command 部件这样组合在一起
三个 Command 部件这样组合在一起

如果还需要重做(redo),可以将撤销的命令单独保存到 redoStack 中。由于结构是对称的,扩展起来并不困难。


命令模式什么时候该用,什么时候该避免?

命令模式也不是万能的。下面用表格整理了适用和过度设计的场景。

场景 命令模式
需要撤销和重做 非常适合
需要保留操作记录(历史) 适合
将操作放入队列稍后执行 适合
无需撤销的简单按钮 过度设计,直接用函数

命令模式在需要“撤销”或“记录”时最有价值。画图软件、文本编辑器、支付取消和游戏中的撤销功能,都是典型案例。

相反,如果只是给页面跳转按钮套用命令模式,反而只会增加代码。

把所有按钮都做成命令,很容易增加大量文件,因此只应应用于需要撤销的操作。

从最上面的卡片开始逐个取出并撤销
从最上面的卡片开始逐个取出并撤销

总结

撤销功能看似难以接入,但只要先搭好命令模式这个基础,就能比想象中更整洁地运行。

先把今天整理的协议、具体命令和管理器三个部分接入一个小页面。熟悉结构后,扩展 redo 或历史记录也会顺理成章。现在就自信地为你的应用加上撤销按钮吧。

延伸阅读