程序重做与撤销功能的逻辑阐述

11 次阅读

程序中的撤销(Undo)与重做(Redo)功能,看似只是两个按钮,实际涉及操作记录、状态管理、历史栈以及状态恢复等多个环节。一个设计合理的撤销重做机制,不仅能够提升软件的容错能力,还能让用户放心进行编辑、修改和调整操作。

一、撤销与重做功能的基本概念

撤销的核心含义是“取消最近一次已经完成的操作”,例如用户在文本编辑器中输入了一段文字,执行撤销后,这段文字会被移除。

重做则与撤销相反。当用户执行撤销后,如果发现刚才的撤销操作并不是自己想要的结果,可以通过重做恢复被撤销的操作。

因此,两者通常形成这样的操作关系:

正常操作 → 撤销 → 重做

例如用户依次执行了 A、B、C 三个操作:

A → B → C

执行一次撤销:

A → B

再次撤销:

A

此时执行重做:

A → B

再次执行重做:

A → B → C

从用户体验来看,撤销解决的是“后悔刚才的操作”,重做解决的是“恢复刚才撤销的操作”。

二、撤销重做功能为什么需要保存历史记录

程序无法凭空知道用户之前做过什么,因此必须保存必要的操作历史。

最常见的实现方式是使用两个栈:

Undo Stack(撤销栈)
Redo Stack(重做栈)

撤销栈保存已经执行的操作,重做栈保存已经撤销、但未来可能恢复的操作。

假设用户依次执行三个操作:

操作A
操作B
操作C

此时:

Undo Stack:
[A, B, C]

Redo Stack:
[]

执行一次撤销后:

Undo Stack:
[A, B]

Redo Stack:
[C]

再次撤销:

Undo Stack:
[A]

Redo Stack:
[C, B]

执行重做:

Undo Stack:
[A, B]

Redo Stack:
[C]

通过两个栈之间的数据移动,就可以比较清晰地管理整个历史状态。

三、执行新操作时为什么必须清空重做栈

这是实现撤销重做功能时非常关键的一条逻辑。

假设当前已经完成:

A → B → C

用户撤销一次:

A → B

此时:

Undo Stack = [A, B]
Redo Stack = [C]

如果用户此时执行了一个全新的操作 D:

A → B → D

那么原来的 C 已经不再属于当前操作分支。

因此,Redo Stack 应该被清空:

Undo Stack = [A, B, D]
Redo Stack = []

否则可能出现这样的错误:

A → B → D → 重做C

这会导致程序同时存在两条互相冲突的操作路径。

因此可以总结为:

只要用户执行了新的有效操作,就应该清空重做历史。

这也是大多数编辑器、绘图工具和设计软件采用的基本规则。

四、撤销操作的核心逻辑

撤销操作通常可以抽象成以下几个步骤。

首先判断撤销栈是否为空。如果为空,说明当前没有可以撤销的操作,撤销按钮通常应该处于禁用状态。

如果存在历史记录,则取出撤销栈顶部的操作。

然后执行该操作对应的逆操作,将程序恢复到上一个状态。

最后将刚刚撤销的操作放入重做栈。

逻辑可以表示为:

Undo:

1. 判断 Undo Stack 是否为空
2. 取出最近一次操作
3. 执行该操作的逆操作
4. 将操作放入 Redo Stack

例如:

Undo Stack:[A, B, C]
Redo Stack:[]

执行撤销 C:

Undo Stack:[A, B]
Redo Stack:[C]

这里最重要的并不是简单删除 C,而是必须让程序状态真正恢复到执行 C 之前的状态。

五、重做操作的核心逻辑

重做逻辑与撤销基本对称。

首先判断重做栈是否为空。

如果存在可重做操作,则取出最近一次被撤销的操作,并重新执行该操作。

完成后,将该操作重新放入撤销栈。

逻辑可以表示为:

Redo:

1. 判断 Redo Stack 是否为空
2. 取出最近一次撤销的操作
3. 再次执行该操作
4. 将操作放回 Undo Stack

例如:

Undo Stack:[A, B]
Redo Stack:[C]

执行重做:

Undo Stack:[A, B, C]
Redo Stack:[]

这样就恢复到了原来的状态。

六、操作记录与程序状态不能简单混为一谈

实现撤销重做时,一个常见误区是只记录“发生了什么”,却没有考虑“如何恢复”。

例如用户修改了一个对象的名称:

原名称:test
新名称:demo

如果只记录:

修改名称

撤销时程序并不知道应该恢复成什么。

更合理的操作记录应该包含足够的状态信息,例如:

{
    type: "rename",
    oldValue: "test",
    newValue: "demo"
}

执行操作时使用 newValue:

test → demo

撤销时使用 oldValue:

demo → test

重做时再次使用 newValue:

test → demo

因此,一个完整的历史记录通常至少需要包含操作类型以及恢复操作所需要的数据。

七、两种常见的撤销实现方式

撤销功能主要有两种设计思路:命令模式和状态快照。

1. 命令模式

命令模式将每一次用户操作封装成一个对象,并提供执行和撤销方法。

例如:

Command
├── execute()
└── undo()

新增对象可以定义为:

AddCommand
├── execute()
└── undo()

删除对象也可以定义为:

DeleteCommand
├── execute()
└── undo()

这样,Undo Stack 中保存的不是简单的数据,而是一系列可以执行和撤销的命令。

这种方式适合操作类型较多、业务逻辑复杂的应用程序。

2. 状态快照

另一种方式是每次关键操作完成后保存整个程序状态。

例如:

State1
State2
State3
State4

用户执行撤销时,只需要恢复到前一个快照。

这种方式实现起来相对简单,尤其适合文档编辑、配置编辑或者数据量不大的场景。

但是,如果程序状态非常庞大,每次完整保存状态会消耗较多内存。

八、快照方式与命令方式如何选择

两种方案没有绝对的优劣,主要取决于应用的业务特点。

如果程序状态比较简单,例如一个小型表单编辑器,可以直接使用快照:

操作前状态
操作后状态

如果程序包含大量复杂操作,例如:

创建对象
删除对象
移动对象
缩放对象
修改属性
批量操作

使用命令模式通常更加灵活。

对于大型编辑器,还可以结合两种方法,根据不同类型的操作采用不同的历史记录策略。

九、连续操作是否应该合并

实际的软件中,并不是每一次底层事件都应该生成一个撤销记录。

例如用户连续输入:

H
e
l
l
o

如果每输入一个字符就产生一个撤销记录,那么用户按五次撤销才能删除整个单词,体验通常并不好。

因此,可以将连续且具有相同语义的操作合并:

H + e + l + l + o

最终作为:

输入 "Hello"

一次撤销即可删除整个单词。

类似情况还包括:

连续拖动对象
连续调整数值
连续输入文本
连续改变画笔位置

这类操作可以通过时间间隔、操作类型或者业务规则进行合并。

十、撤销重做中的边界处理

撤销和重做功能还需要处理几个重要边界。

当撤销栈为空时:

不能继续撤销

当重做栈为空时:

不能继续重做

因此界面按钮通常可以根据栈状态动态更新:

Undo Stack 非空 → 撤销按钮可用
Undo Stack 为空 → 撤销按钮禁用

Redo Stack 非空 → 重做按钮可用
Redo Stack 为空 → 重做按钮禁用

这样可以避免用户执行无效操作。

十一、撤销操作本身不应该再次产生历史记录

这是开发过程中非常容易出现的问题。

假设执行撤销时,程序内部又调用了普通的修改函数,而这个修改函数会自动记录历史,那么可能产生:

用户操作A
↓
记录A

执行撤销A
↓
修改状态
↓
再次记录操作

最终导致撤销记录混乱。

因此,通常需要区分“用户新操作”和“历史恢复操作”。

例如:

recordHistory = true

表示普通操作需要记录历史。

而撤销或者重做期间:

recordHistory = false

避免恢复过程被再次记录。

十二、撤销重做的完整状态流转

一个比较完整的逻辑可以概括为:

用户执行新操作
      ↓
记录操作
      ↓
加入 Undo Stack
      ↓
清空 Redo Stack

执行撤销:

Undo Stack 是否为空?
      ↓ 否
取出顶部操作
      ↓
执行逆操作
      ↓
加入 Redo Stack

执行重做:

Redo Stack 是否为空?
      ↓ 否
取出顶部操作
      ↓
重新执行操作
      ↓
加入 Undo Stack

整个过程可以表示为:

             新操作
                ↓
          ┌───────────┐
          │ Undo Stack│
          └───────────┘
                │
              Undo
                ↓
          ┌───────────┐
          │ Redo Stack│
          └───────────┘
                │
              Redo
                ↓
          ┌───────────┐
          │ Undo Stack│
          └───────────┘

十三、撤销历史不能无限增长

如果程序允许用户长时间操作,而每次操作都永久保存,那么历史记录可能持续占用内存。

实际项目通常会设置历史记录上限,例如:

最大历史记录:100

当超过限制时,可以删除最早的一条记录:

[A, B, C, ... , Z]

继续添加新记录后:

[B, C, ... , Z, New]

这样既能够提供较长的撤销范围,又可以控制内存使用。

对于大型数据,还可以采用压缩快照、增量记录或者持久化历史记录等方式进一步优化。

十四、撤销重做在不同程序中的应用

撤销重做并不仅仅存在于文本编辑器。

图形设计软件通常需要撤销:

绘制
移动
旋转
缩放
删除
修改颜色
调整属性

代码编辑器需要记录:

文本输入
文本删除
代码粘贴
格式调整
批量替换

表格软件则可能涉及:

单元格修改
插入行
删除列
调整格式
公式修改
批量填充

游戏编辑器、流程图工具、CAD软件以及网页设计工具同样需要类似的历史管理机制。

虽然这些程序的操作对象不同,但底层思想基本一致:保存可恢复的历史操作,并维护当前状态与历史状态之间的关系。

十五、设计撤销重做功能时的关键原则

一个可靠的撤销重做系统,通常需要遵循几个原则。

第一,历史记录必须能够准确恢复状态,而不是只记录操作名称。

第二,新操作发生后,应清空已经失去意义的重做历史。

第三,撤销和重做过程本身不能被重复记录。

第四,需要根据业务场景决定采用命令模式还是状态快照。

第五,对连续操作进行合理合并,可以明显改善用户体验。

第六,应限制历史记录数量,避免长期运行造成不必要的内存增长。

第七,对于批量操作,应尽量将一次完整的用户行为作为一个逻辑操作处理,而不是机械地拆成大量历史记录。

十六、一个简单的伪代码实现

采用两个栈时,基本逻辑可以写成:

undoStack = []
redoStack = []

function doAction(action):
    action.execute()
    undoStack.push(action)
    redoStack.clear()

function undo():
    if undoStack.isEmpty():
        return

    action = undoStack.pop()
    action.undo()
    redoStack.push(action)

function redo():
    if redoStack.isEmpty():
        return

    action = redoStack.pop()
    action.execute()
    undoStack.push(action)

这段逻辑虽然简单,却体现了撤销重做机制最核心的设计思想。

其中最关键的一点是:正常操作会清空重做栈,而撤销和重做操作不会按照普通新操作处理。

理解这一点之后,无论使用 Java、JavaScript、C++、Python 还是其他编程语言,都可以根据具体业务实现对应的撤销重做功能。

十七、总结

程序的撤销与重做,本质上是一套历史状态管理机制。最常见的实现方式是维护 Undo Stack 和 Redo Stack,通过操作记录或状态快照保存程序变化。

正常操作进入撤销栈,并清空重做栈;撤销操作将记录从撤销栈移动到重做栈,同时恢复之前的状态;重做操作则将记录从重做栈重新移动到撤销栈。

如果进一步结合操作合并、历史数量限制、命令模式、状态快照以及增量记录等机制,就可以构建更加稳定、高效且符合用户习惯的撤销重做系统。