如果你做过编辑器类产品,或者哪怕只是给自己折腾过带撤销功能的小工具,多半会遇到同一个问题:怎么把对象的状态“快照”下来,用的时候再无损还原。备忘录模式(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;
