应使用两个独立的arraydeque分别存储撤销和重做命令对象,执行新操作时调用undostack.push(cmd)并clear() redostack,撤销时pop()并调undo(),重做时pop()并调execute(),始终用push/pop配对保证lifo语义。

Deque怎么存撤销和重做操作才不丢状态
直接用 Deque 存操作对象本身(比如自定义的 Command 类实例),别存原始数据快照或中间状态。因为快照容易过期,而命令对象自带 execute() 和 undo() 方法,能主动控制状态流转。
常见错误是把 String 或 Map 当作“操作”塞进 Deque,结果撤销时没法还原行为逻辑,只能硬编码回滚规则——这会让后续加新功能变得脆弱。
- 撤销栈用
ArrayDeque(非线程安全但快),重做栈也用ArrayDeque,两个栈独立维护 - 每次执行新操作前,清空重做栈(
redoStack.clear()),否则用户撤销后又执行新操作,再点重做会跳到旧分支 - 操作对象必须实现无副作用的
undo():它只恢复上一步状态,不触发额外事件或网络请求
addFirst() 还是 push()?为什么不能用 addLast()
push() 和 addFirst() 在 ArrayDeque 里等价,都往队头加;但 addLast() 是往队尾加——这会导致撤销顺序错乱。比如用户连续执行 A→B→C,若用 addLast(),栈里是 [A,B,C],调 removeLast() 得到 C,看似对,但下次撤销想取 B 时得再 removeLast(),实际变成了 LIFO 的反向模拟,极易和重做逻辑冲突。
标准做法是:执行新操作 → undoStack.push(command);撤销 → undoStack.pop() 并调 undo();重做 → redoStack.pop() 并调 execute()。
立即学习“Java免费学习笔记(深入)”;
- 永远用
push()/pop()配对,语义清晰,且所有Deque实现都保证这两个方法的栈行为一致 - 避免混用
offerLast()+pollLast(),它们在LinkedList上表现不同,在ArrayDeque上虽可用,但偏离栈意图 -
pop()在空栈会抛NoSuchElementException,务必先判空:if (!undoStack.isEmpty()) { ... }
撤销后立即执行新操作,重做栈为啥没清干净
这是最常被忽略的边界:用户撤销两步(A→B→C → 撤销到 A),然后执行 D,此时重做栈应只剩 [C,B] 可重做,但 D 执行后,必须显式调 redoStack.clear()。漏掉这句,后续点重做会先重做 C,再重做 B,最后重做 D —— 明显错乱。
关键不是“什么时候清”,而是“谁负责清”:执行新操作的入口函数(比如 doAction(Command cmd))必须包揽三件事:执行 cmd、push 到 undoStack、clear redoStack。
- 不要依赖 UI 层判断是否要清重做栈,逻辑下沉到命令调度器
- 如果支持批量操作(如“撤销最近 5 步”),每 pop 一次就要把对应命令
push()进 redoStack,而不是等批量结束再统一 push -
ArrayDeque.clear()是 O(n) 时间,但 n 是已撤销步数,通常很小,不用提前优化
并发修改下为什么 ArrayDeque 比 LinkedList 更稳
单线程场景下两者都行,但一旦涉及异步回调(比如编辑器里自动保存触发的后台校验,可能并发修改文档状态),ArrayDeque 的 fail-fast 行为比 LinkedList 更易暴露问题。后者在迭代中被修改只抛 ConcurrentModificationException,而前者在多线程争用时更容易触发可见性问题,反而倒逼你加锁或改用线程安全封装。
真正该警惕的是:别让多个线程直接读写同一个 Deque 实例。即使用了 CopyOnWriteArrayList 之类,也不适合当撤销栈——拷贝开销太大,且无法保证 push/pop 原子性。
- 撤销/重做栈默认按单线程设计,UI 事件、键盘快捷键、菜单点击都应串行 dispatch 到一个命令队列
- 真需要跨线程协作(如插件系统注入命令),用
BlockingDeque+ 显式锁,而不是指望Deque自身线程安全 -
ArrayDeque内部数组扩容是懒加载,初始容量设为 16 足够大多数编辑场景,避免频繁 resize 影响撤销响应速度










