如果你做过编辑器类产品,或者哪怕只是给自己折腾过带撤销功能的小工具,多半会遇到同一个问题:怎么把对象的状态“快照”下来,用的时候再无损还原。备忘录模式(Memento Pattern)就是专门处理这类需求的。
这个模式的名气没有单例、工厂那么大,但实际项目里用到它的地方一点都不少。文本编辑器里的 Ctrl+Z、游戏里的存档读档、数据库里的事务回滚、IDE 里那套“回退到任意历史版本”的功能,底层基本都能看到它的影子。它的核心思想一句话就能说清:在不破坏对象封装的前提下,捕获对象内部状态,并在对象外部保存,之后可以恢复到先前保存的状态。说人话就是——给对象拍张状态照片,你想什么时候回到照片里的样子都行。
这篇文章适合正在被撤销、回滚、历史记录功能折磨的开发同学读,也适合想理解设计模式但不想啃教科书的人。我会用 Java 写一个完整的代码示例,从零开始把备忘录模式拆开揉碎,最后再讲讲我实际用这个模式踩过的坑和一些真正有效的技巧。
1. 为什么需要备忘录模式:从撤销功能说起
1.1 先看大多数人会写的“朴素撤销”
假设你有一个 TextEditor 类,内部维护着文本内容、光标位置、选区范围这些字段。想给这个编辑器加撤销功能,最直接的想法是:每次修改前把整个对象存一份。
有些新手会写成这种样子:
java复制List<TextEditor> history = new ArrayList<>();
history.add(editor); // 直接把对象引用存进去
这个方案问题很大。editor 是一个引用,后续继续编辑时,history 里存的对象内容也会跟着变。等你真的想撤销时,拿出来的“历史”,其实已经是当前状态了,等于白存。
然后有人会改进:既然引用不行,那就把编辑器字段一个个拷出来,恢复时再一个个设回去。
java复制// 保存
Map<String, Object> snapshot = new HashMap<>();
snapshot.put("content", editor.getContent());
snapshot.put("cursor", editor.getCursorPosition());
// ...
// 恢复
editor.setContent((String) snapshot.get("content"));
editor.setCursorPosition((int) snapshot.get("cursor"));
// ...
这么写能跑,但缺点很明显。恢复逻辑全部散落在业务层,备忘录模式本质上就是把这个过程规范和收拢起来,给“状态保存与恢复”一个标准结构。
1.2 备忘录模式的三个角色
备忘录模式把参与者分成三个角色,各自职责边界非常清晰:
- 发起人(Originator):需要被保存状态的对象。它负责生成快照,也负责用快照恢复自己。
- 备忘录(Memento):保存发起人内部状态的载体。它是一个不透明的对象,外部只持有引用,不能随意读写里面的数据。
- 负责人(Caretaker):管理备忘录的保存时机和保存方式,比如用一个栈来记录历史。负责人只负责“管”,不负责“看”。
你可以把这套结构理解为“状态的生产者、状态的容器、状态的保管人”三方分工。发起人负责生产快照并消费快照,备忘录是快照的唯一包装,负责人只负责保存、取出、丢弃备忘录,不去关心快照里到底存了什么。
1.3 为什么要把状态封装成黑盒
最初我学这个模式时,觉得备忘录对象不就是一个 DTO(数据传输对象)嘛,搞个纯类存数据不就完了?直到后来维护一个老项目才明白,把状态封装成黑盒是有讲究的。
如果备忘录是透明的,外部代码可以直接读取和修改快照字段,那么时间一长,业务代码就会开始依赖快照内部结构。今天这里读一下 content,明天那里改一下 selectionStart,慢慢就会形成“历史记录模块依赖编辑器内部实现”的纠缠局面。一旦编辑器内部字段变更,快照读取方也要跟着改,维护成本成倍上升。
备忘录模式把备忘录设计为不透明对象,外部无法访问快照内部字段,只有发起人能读写自己的状态。这样编辑器内部无论怎么调整字段,只要不变更备忘录的创建和恢复接口,负责人都完全不受影响。这就是封装带来的隔离价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用代码拆解备忘录模式的核心实现
2.1 发起人:负责生成和接收快照
我用一个迷你文本编辑器来演示。TextEditor 是发起人,内部字段包括文本内容、光标位置、选区起点和终点。
备忘录的生成和恢复都用专门的 save() 与 restore() 方法,这两个方法直接操作内部字段,外部不需要知道内部细节:
java复制public class TextEditor {
private final StringBuilder content = new StringBuilder();
private int cursorPosition;
private int selectionStart;
private int selectionEnd;
public void append(String text) {
content.append(text);
cursorPosition = content.length();
selectionStart = cursorPosition;
selectionEnd = cursorPosition;
}
public void deleteBackward() {
if (content.length() == 0) {
return;
}
content.deleteCharAt(content.length() - 1);
cursorPosition = content.length();
selectionStart = cursorPosition;
selectionEnd = cursorPosition;
}
public String getContent() {
return content.toString();
}
public Memento save() {
return new Memento(content.toString(), cursorPosition, selectionStart, selectionEnd);
}
public void restore(Memento memento) {
content.replace(0, content.length(), memento.getSnapshotContent());
cursorPosition = memento.cursorPosition;
selectionStart = memento.selectionStart;
selectionEnd = memento.selectionEnd;
}
public static class Memento {
private final String snapshotContent;
private final int cursorPosition;
private final int selectionStart;
private final int selectionEnd;
private Memento(String snapshotContent, int cursorPosition,
int selectionStart, int selectionEnd) {
this.snapshotContent = snapshotContent;
this.cursorPosition = cursorPosition;
this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;
}
private String getSnapshotContent() {
return snapshotContent;
}
}
}
注意 Memento 的构造函数是 private 的,外部无法通过 new 来手动创建快照。快照只能由发起人的 save() 方法生成。同时 Memento 的字段都是 final 的,一旦生成就是只读,防止后续有人意外篡改。
在 Java 里我特意把 Memento 定义成 TextEditor 的静态内部类,这样外层类可以直接访问它的私有字段,而外部类比如 History 拿到 TextEditor.Memento 对象后,只能引用、不能读取里面的任何状态。这种写法天然实现了备忘录模式的黑盒特性。
2.2 备忘录:不可变是基本素养
我见过一些同学设计备忘录时会加一堆 getter 和 setter,把快照当成普通数据类来用。这其实是在破坏封装。
备忘录的数据应该具有不可变性。对象一旦生成,它的内容就不能再改变。这样做有两个直接好处:
第一个好处是安全。负责人可以放心保存和传递备忘录,不用担心某个中间环节把快照改得面目全非。整个历史记录的可信度就建立在这个不可变性之上。
第二个好处是简化排查。如果撤销出问题,排查方向只需要考虑发起人的 save() 和 restore() 是否正确,不用担心“是不是历史栈里的快照被改过”。这个信任代价很小,但价值很大。
如果你的项目用的是 Java 16 以上,把 Memento 写成 record 也可以,字段天然不可变,代码也更简洁。但要注意 record 的访问器是 public 的,如果你对封装有更严格的要求,内部类私有字段的方案依然是更稳的选择。
2.3 负责人:只保存不读取
负责人 History 负责管理快照栈。它只做两件事:压栈和弹栈。至于快照里存了什么,它完全不需要知道。
java复制import java.util.ArrayDeque;
import java.util.Deque;
public class History {
private final Deque<TextEditor.Memento> states = new ArrayDeque<>();
private final int capacity;
public History(int capacity) {
this.capacity = capacity;
}
public void push(TextEditor.Memento state) {
states.addLast(state);
while (states.size() > capacity) {
states.removeFirst();
}
}
public TextEditor.Memento pop() {
if (states.isEmpty()) {
throw new IllegalStateException("没有可撤销的历史记录");
}
return states.removeLast();
}
public int size() {
return states.size();
}
}
History 用 ArrayDeque 而不是 ArrayList,是因为历史记录的典型操作是尾部添加、尾部取出,ArrayDeque 的时间复杂度都是 O(1)。容量限制就一个 while 循环,超出容量就把最老的一条弹掉,这就天然实现了“最多可以撤销 N 步”的需求。
这里有一个容易被忽略的点:负责人对备忘录的操作应该只局限于“持有和返回引用”。我之前见过有人为了调试方便,在 History 里加了个 getLatestContent() 方法,直接去读备忘录内部数据。一旦开了这种口子,负责人地位就变味了,慢慢会从“保管员”变成“业务逻辑参与者”,代码又回到纠缠不清的状态。
3. 从编辑器撤销功能完整跑一遍实操过程
3.1 需求分析与核心字段设计
这里有一个很多人容易忽略的地方:编辑器里究竟哪些字段需要进快照?
只保存文本内容是不够的。用户输入一个字后按撤销,期望的是光标也回到原来的位置,选区也原样保留。如果只恢复内容,光标跑到文件头的体验有多糟,做过编辑器的同学都懂。
在我的例子里,快照包含四个字段:content(文本内容)、cursorPosition(光标位置)、selectionStart(选区起点)、selectionEnd(选区终点)。这四个字段已经能覆盖“输入”“删除”“移动光标”“修改选区”这些常见操作的撤销语义。
如果你的编辑器还涉及样式、缩放、滚动位置,那就要根据业务需求决定是否把这些字段也纳入快照。我的经验是:快照字段宁全勿缺。漏掉一个字段,撤销时就会出现“内容对了但状态错了”的诡异问题,这种问题在测试阶段很难发现,还挺隐蔽的。
3.2 完整代码:保存与恢复
为了演示内存估算的合理性,我给 History 设置一个容量参数,比如 100 步。现在用一个简单的主程序把完整流程跑起来:
java复制public class Demo {
public static void main(String[] args) {
TextEditor editor = new TextEditor();
History history = new History(100);
editor.append("Hello");
history.push(editor.save());
editor.append(", World");
System.out.println("当前内容: " + editor.getContent());
history.push(editor.save());
editor.append("!");
System.out.println("当前内容: " + editor.getContent());
editor.restore(history.pop());
System.out.println("撤销1次: " + editor.getContent());
editor.restore(history.pop());
System.out.println("撤销2次: " + editor.getContent());
}
}
运行结果如下:
text复制当前内容: Hello, World
当前内容: Hello, World!
撤销1次: Hello, World
撤销2次: Hello
逻辑很简单:每次修改前先 save() 压栈,需要撤销时就 pop() 出栈并调用 restore()。这里有一点要注意,save() 必须放在修改之前调用,否则快照里存的就是操作完成后的状态,撤销会失效。
当时我刚接触这个模式时,顺序就搞反过一次,结果点撤销反而跳到了更后面的状态。排查了半天,最后发现是我在 append() 之后才调的 save(),等于把新状态存成了“旧状态”。
3.3 多步撤销与历史容量设计
多步撤销的实现其实不用做额外的事,因为栈天然支持连续弹出。但实际产品里还有一个细节:撤销之后如果用户继续编辑新内容,原本撤销掉的那些历史分支应该怎么处理?
常见的处理方式有两种。一种是保留分支,用户可以“重做”(redo)回到撤销前的状态;另一种是清空分支,撤销后一旦编辑新内容,重做栈就丢弃。
文本编辑器的标准做法通常是后者。用户撤销两步后再输入新内容,意味着历史已经被篡改,原来的分支继续留着反而会让用户困惑。如果你要实现“撤销后编辑新内容清空 redo 栈”的效果,可以在 append() 或 deleteBackward() 等方法里清空 History 的重做栈。
容量设计方面要结合内存估算来定。假设编辑器文本平均 1000 个字符,字符串按 Java 内部 char 数组算大约占 2KB 左右,加上光标和选区字段、对象头等杂项,一个快照大约 2KB 到 3KB。100 步历史记录就是约 300KB。这个量级对现代设备来说完全没有压力。但如果一个快照里塞了整棵文档树、样式节点,一次快照可能就是几十 MB,这时就必须限制步数或者改用增量快照。
4. 踩坑实录:备忘录模式常见问题与排查技巧
4.1 深拷贝还是浅拷贝:一个让快照被污染的经典 bug
这是我在实际项目里遇到的第一个大坑。如果发起人的状态里包含可变集合或对象引用,直接把引用存进备忘录,后续对这些集合的修改会同步影响已经保存的快照。
举个例子:如果编辑器有 List<StyleRange> styleRanges 字段,save() 时直接 new Memento(content, styleRanges, ...),把 styleRanges 的引用放进去,那么之后 styleRanges.add(...) 时,快照里的数据也跟着变了。
原因很简单:Java 对象赋值的本质是引用拷贝,不是值拷贝。快照保存的是同一块内存区域的引用,而不是那一时刻的独立数据。
解决办法要看业务场景。如果你的状态是不可变对象或者基本类型,直接赋值没问题。如果是可变集合,就需要防御性拷贝。比如 new ArrayList<>(styleRanges),或者保存前先把集合转成不可变视图。我自己的习惯是:凡是备忘录里的集合字段,一律拷贝一份新的再放进去,宁可多花一点时间和内存,也不冒数据污染的风险。
4.2 快照内存占用:别让历史记录吃光你的内存
备忘录模式最容易被人诟病的就是内存开销。每个快照都是一个完整对象,如果历史记录不设上限,内存迟早会被撑爆。
这里有三种常用手段。第一种最简单,限制历史记录条数,比如最多保存 100 条。第二种是缩小快照粒度,只保存变化的部分,用增量快照代替全量快照。第三种是把快照序列化到磁盘,内存里只保留快照的引用信息,需要恢复时再从磁盘加载。
具体选哪种要看项目场景。桌面编辑器通常用第一种加第二种组合,历史记录保存最近 N 步,每步只保存变化区域。游戏存档通常用第三种,快照直接落盘到存档文件。
我自己的经验是:不要一开始就过早优化内存。先把历史记录条数限制加上,等内存确实成了瓶颈,再考虑增量快照或落盘方案。很多项目的“内存爆炸”其实不是快照太大,而是历史记录无限制增长。
4.3 恢复后状态不一致:保存和恢复要成对出现
还有一个很隐蔽的问题:保存了 A 字段,但恢复时忘了恢复 B 字段。这种情况多发生在字段不断增加的项目里,新增了一个字段,只记得加进 save(),忘了加进 restore()。
我给你一个我的排查技巧:每次新增字段时,强制把 save() 和 restore() 两处都过一遍,并且在测试用例里加一条“修改所有字段-保存-再修改部分字段-恢复-断言所有字段回到保存时状态”的用例。这样能在 CI 阶段就发现问题,而不是等到用户反馈。
另外,恢复时要做边界校验。比如恢复的选区范围超出了当前文本长度,或者光标位置变成了负数,这些都要在恢复后做归一化处理。我见过不少编辑器在加载历史快照后直接崩溃,就是因为没做这层安全检查。
4.4 多线程场景下保存快照的坑
如果你的发起人对象会在多个线程里被并发修改,save() 时可能正好读到一半的状态,比如 content 已经更新了,但 cursorPosition 还没更新,这样快照就是撕裂的。
解决办法也不复杂:保存快照时加锁,或者让 save() 方法只操作不可变字段。比较稳妥的做法是,在 save() 内部先拿到状态数据的副本,再基于副本创建备忘录。
如果你用的是乐观锁方案,甚至可以在快照里存一个版本号,恢复时检查版本号是否匹配,不匹配就拒绝恢复。这在高并发场景下非常有用,比如多人协作编辑器里做历史回溯。
4.5 备忘录模式常见问题速查表
| 问题现象 | 原因分析 | 解决思路 |
|---|---|---|
| 撤销后数据还是旧的,像没生效 | save() 在修改之后调用,快照存的是新状态 |
把 save() 移到修改动作之前 |
| 历史记录里所有快照内容都一样 | 快照保存的是对象引用,没有做数据拷贝 | 使用防御性拷贝或不可变对象 |
| 撤销后报下标越界 | 恢复的选区或光标位置超出当前边界 | restore() 里加边界校验与归一化 |
| 内存占用越来越高 | 历史记录无上限或快照粒度太大 | 限制条数、增量快照、必要时序列化落盘 |
| 多线程下快照状态错乱 | save() 读取时对象正在被修改 |
加锁或基于副本创建快照 |
| 旧版本存档恢复失败 | 快照结构升级后缺少版本处理 | 快照里带版本号,恢复时做迁移 |
5. 从实战角度聊聊适用场景和设计取舍
5.1 真正适合用备忘录模式的场景
不是所有需要“恢复状态”的地方都必须用备忘录模式,但有几个场景确实特别合适。
第一类是编辑器类应用,文本编辑器、代码编辑器、图片编辑器、表格工具,这类产品天然需要撤销和重做。备忘录模式能很好地区分“编辑动作”和“状态历史”,让编辑器本体保持干净。
第二类是游戏开发,角色存档、通关记录、战斗中的回合回退,都可以用备忘录模式实现。游戏的存档系统往往还要求跨进程、跨版本保存,这与备忘录的快照序列化能力很契合。
第三类是操作事务性系统,比如配置中心改配置后要能回滚,工作流引擎执行一步后要能回退,数据库连接层要保存保存点。这些场景的状态快照通常比普通业务对象更复杂,用备忘录模式建模会让代码结构清晰不少。
5.2 什么时候别硬用备忘录模式
备忘录模式也有不适用的时候。
如果对象状态非常简单,只有两三个字段,外部也只有一个调用点,直接用局部变量保存字段就够了,非要引入三个类和一堆方法反而是过度设计。我见过有人为了一个只有 id 和 name 的对象写了一套完整的备忘录结构,看着挺像那么回事,但维护起来纯属给自己加负担。
另外,如果对象的字段特别多、对象图特别深,每次全量快照的开销无法接受,这时候也不适合直接用备忘录模式。可以配合原型模式、序列化、增量存储等手段来优化,但这已经不是纯粹的原版备忘录模式了。
还有一种情况,就是你的业务本来就没有“历史时间线”的概念,只在极少数异常场景下需要恢复初始状态。这种情况下,用简单的状态缓存就够,不必上整套备忘录结构。
5.3 与命令模式的配合:更完整的可撤销系统
这里想多说一句,备忘录模式经常和命令模式配合使用,组成一套完整的可撤销操作体系。
命令模式负责把用户每次操作封装成一个命令对象,比如 InsertTextCommand、DeleteWordCommand。而备忘录模式负责在命令执行前把发起人的状态保存下来。撤销时,通过命令对象找到对应的备忘录,再调用发起人恢复状态。
这种组合的好处是,操作信息和状态信息分离,责任更加清晰。命令只管“做什么”和“怎么撤销”,备忘录只管“状态存哪”和“状态怎么恢复”。
我实际做过的一个富文本编辑器就采用了这个组合。整个系统以命令为执行单元,每个命令执行前自动生成快照,撤销时命令自身携带恢复逻辑。这样就算操作类型五花八门,撤销体系也只有一套,扩展新命令时只需要关心自己怎么执行和怎么撤销,不用关心历史记录怎么管理。
说了这么多,最后再分享一个我自己的使用体会。备忘录这个模式,真正的价值不在于那个 Memento 类怎么写,而在于它逼着你把“状态的生产、容器、保管”这三件事分开。一旦拆明白,后续加撤销、加回滚、加历史版本都会非常顺手,代码也不会像以前那样散得到处都是。
如果你正准备在项目里设计撤销体系,建议从一个小原型开始,把发起人的字段定义清楚,再把负责人的容量策略定下来,其他的坑这篇文章基本都能帮你避开。
