备忘录模式实战:Java实现游戏存档与撤销恢复
发布时间:2026/10/14 8:45:36 锦皓数字建站

如果你写过任何带撤销功能的编辑器或者做过游戏存档大概率会遇到同一个麻烦怎么在完全不破坏封装的前提下把对象状态完整地存下来、再原样恢复回去备忘录模式就是为这个问题而生的。作为23种设计模式中行为型家族的成员它靠“快照”这一朴素思想解决了对象状态保存与恢复的经典难题。这篇是《从零入门23种设计模式》系列的第18篇我会从使用场景讲起手写一份可运行的Java代码再聊几个只有实战才会踩到的坑。不管你是刚开始学设计模式还是想在项目里落地撤销、回滚、存档功能这篇都值得你读完。1. 没有备忘录模式时状态恢复为什么这么难1.1 一次改崩的文本编辑器我在项目里做过一个简易的文本编辑器核心需求是支持 CtrlZ 撤销。一开始的想法特别朴素给文档对象增加两个方法saveState和restoreState方法里直接把成员变量的值复制到另一个对象里。听起来没问题直到我把状态对象设计成一个普通类成员变量全是 public任何代码都能直接改里面的数据。结果某个同事在维护时不小心在撤销逻辑里多写了一行setText把历史快照给改了整个编辑器的历史记录就全部错乱了。这个例子带出两个经典痛点。第一是状态快照的可见性。如果要保证快照不被外部篡改就不得不把成员变量私有化再提供一堆 getter/setter但一旦提供了 setter又等于打开了修改窗口。第二是快照与业务对象的耦合。状态字段分散在多个类里每次增加一个新字段快照类就要同步改还要担心漏掉某个不影响显示但影响行为的临时字段。这些痛点恰恰是备忘录模式想要解决的。1.2 备忘录模式的定义与三个角色备忘录模式Memento Pattern的定义并不复杂在不破坏封装性的前提下捕获一个对象的内部状态并在该对象之外保存这个状态以后就可以将对象恢复到原先保存的状态。它属于经典GoF设计模式中的行为型模式核心思想就是“把你的状态用一张快照保存下来随时可以读档”。整个模式只有三个角色非常好记。角色职责一句话理解发起人Originator需要被存档的对象负责创建备忘录也负责根据备忘录恢复自身状态游戏里的角色备忘录Memento装载状态的容器对外尽可能屏蔽细节角色存档文件管理者Caretaker负责保存和持有备忘录但永远不修改备忘录内容保存游戏的存档管理器实际编码时最值得琢磨的不是“怎么把数据存下来”而是“怎么让外部碰不到快照内部”。这就要进入白箱和黑箱的讨论了。2. 备忘录模式核心实现白箱、黑箱与角色拆解2.1 白箱实现简单但封装性容易漏白箱实现最容易理解。把备忘录类设计成一个普通类里面有需要保存的字段和公开的 getter/setter。发起人要存档时就 new 一个备忘录把字段填进去管理者拿到备忘录后也可以直接读字段甚至改字段。这种方式的优点是实现简单不需要额外接口非常适合快速验证和教学演示。缺点也显而易见备忘录的内部状态对管理者完全透明管理者一旦误操作修改了字段历史数据就被污染了。白箱模式下备忘录和普通的数据类没什么区别适合团队不大、约定比较强的场景。但一旦代码库变大约束就会迅速失效。我在一个老项目里见过管理者里的历史列表被业务方直接遍历后修改责任边界完全模糊掉了。后来排查半天才发现是有人把“历史状态”当成“普通DTO”在使用。2.2 黑箱实现封装性更好的实战选择黑箱实现是用接口把备忘录的对外可见性收窄。对外只暴露一个窄接口MementoIF里面不声明任何 getter/setter真正的实现类作为发起人的内部类只有发起人才能访问其具体字段。这样管理者拿到的是接口引用无法读取或修改状态真正做到了“数据只能由发起人自己动”。我用 Java 写黑箱时关键点是让备忘录实现一个窄接口同时把备忘录类声明为发起人的私有内部类。Java 嵌套类的访问规则允许外部类访问内部类的私有成员所以发起人可以正常读取快照而其他对象只能拿到窄接口连内部类的名字都不知道更别说访问字段了。接口本身可以简单至极public interface MementoIF { // 窄接口不暴露任何状态读写方法 }管理者持有这个接口引用做保存和取出就完了。想要读取具体状态编译期就过不去因为接口上根本没有这些方法。这才是黑箱真正的价值所在。2.3 白箱与黑箱到底选哪个很多文章只会告诉你两种方式的存在但不会告诉你落地的选择逻辑。我的建议是能上黑箱就上黑箱。虽然白箱写起来省事但设计模式本身就是为了管理复杂度如果模式用完了反而把封装边界破坏掉那就本末倒置了。对比项白箱实现黑箱实现状态可见性对管理者可见可读写对管理者不可见只有发起人可读写封装强度弱快照容易被篡改强外部无法访问具体字段实现复杂度低一个普通类搞定稍高需要接口 内部类适用场景团队小、耦合可接受、教学示例中大型项目、需要严格封装边界调试难度容易直接看字段需要借助发起人内部方法才能检查状态如果你在做开源项目或者多人协作建议选黑箱如果只是自己搭个 demo白箱也能跑通。但下面手写案例我直接用黑箱版因为这才是生产环境下更值得参考的写法。3. 手写游戏存档系统备忘录模式的完整落地3.1 需求与类设计我用一个 RPG 游戏角色存档的例子来说明。角色GameRole有三个字段hp生命值、atk攻击力、def防御力。打 BOSS 之前保存一份状态打完发现血亏立刻读档回去。按照备忘录模式类划分如下GameRole发起人Originator负责创建快照和从快照恢复。RoleStateMemento备忘录Memento负责存储角色的生命值、攻击力、防御力。RoleStateCaretaker管理者Caretaker负责保存备忘录但不关心备忘录内容。为了让黑箱效果更直观我会把RoleStateMemento声明成GameRole的私有内部类并让它实现MementoIF窄接口。3.2 发起人GameRole 与黑箱备忘录直接上代码先看发起人的实现public class GameRole { private int hp; private int atk; private int def; // 初始化状态 public void initState() { this.hp 100; this.atk 10; this.def 10; } // 打BOSS状态被大幅削弱 public void fightBoss() { this.hp 20; this.atk 5; this.def 3; } // 创建快照 public MementoIF createMemento() { return new RoleStateMemento(hp, atk, def); } // 从快照恢复 public void restoreMemento(MementoIF memento) { RoleStateMemento m (RoleStateMemento) memento; this.hp m.getHp(); this.atk m.getAtk(); this.def m.getDef(); } public void displayState() { System.out.println(HP hp , ATK atk , DEF def); } // 黑箱备忘录私有内部类实现窄接口 private class RoleStateMemento implements MementoIF { private int hp; private int atk; private int def; private RoleStateMemento(int hp, int atk, int def) { this.hp hp; this.atk atk; this.def def; } private int getHp() { return hp; } private int getAtk() { return atk; } private int getDef() { return def; } } }这里有个新手容易疑惑的点内部类的方法都是 private外部类GameRole居然能调用getHp()。这是 Java 嵌套类的特殊规则外部类可以访问内部类的私有成员。所以代码能编译也能正常运行。而其他类哪怕拿到了MementoIF引用也无法强行调用getHp()因为接口上没有这个方法。3.3 管理者RoleStateCaretaker管理者在备忘录模式里就是一个“保存箱”负责持有备忘录对象。最简单的实现如下public class RoleStateCaretaker { private MementoIF memento; public void setMemento(MementoIF memento) { this.memento memento; } public MementoIF getMemento() { return memento; } }注意管理者的方法参数和返回值都用了窄接口MementoIF这样就把黑箱约束刻进了类型系统里。如果你只想保存一份快照这个类就够了。如果要支持多步历史可以把字段改成ListMementoIF或者直接用栈结构。3.4 运行验证打BOSS也能随时读档写一个简单的客户端来模拟“打 BOSS 前存档打完后读档”的过程public class MementoPatternDemo { public static void main(String[] args) { // 创建发起人和管理者 GameRole role new GameRole(); RoleStateCaretaker caretaker new RoleStateCaretaker(); // 初始状态 role.initState(); System.out.println(开局); role.displayState(); // 打BOSS前存档 caretaker.setMemento(role.createMemento()); System.out.println(打BOSS前已存档。); // 打BOSS状态削弱 role.fightBoss(); System.out.println(打BOSS后); role.displayState(); // 读档恢复 role.restoreMemento(caretaker.getMemento()); System.out.println(读档后); role.displayState(); } }运行结果应该类似开局 HP100, ATK10, DEF10 打BOSS前已存档。 打BOSS后 HP20, ATK5, DEF3 读档后 HP100, ATK10, DEF10程序运行完全符合预期角色打完 BOSS 后能通过读档恢复到初始状态而管理者从始至终没有接触到任何一个字段的 getter。如果你试图在MementoPatternDemo里写caretaker.getMemento().getHp()会在编译期直接报错因为MementoIF接口上根本没有getHp()方法。3.5 关键点拆解为什么 Memento 要这样设计很多新手会问直接在GameRole里定义 public int 字段再复制一份不行吗行但那就把快照和业务对象耦合在了一起等字段一多就乱了。另一类常见问题是为什么不能用HashMap保存状态当然可以但用HashMap就失去了类型约束也容易在拼 key 时出错。备忘录模式最大的价值是让“状态快照”成为一个有明确边界、有类型约束的独立对象。从上面的代码能看出RoleStateMemento作为私有内部类天然就不会被外部类误用。管理者保存的是接口引用恢复时由发起人自己强转为具体实现类这个强转过程也只发生在发起人内部。只要不把内部类改成 public黑箱的封装边界就始终存在。4. 再进一步备忘录模式与撤销重做、状态快照优化4.1 与命令模式组合实现多步撤销和重做单张快照只能满足“存档/读档”真正要做到编辑器里的多步撤销通常要和命令模式组合使用。基本思路是每次执行一个修改命令前先创建备忘录压入undoStack执行命令后清空redoStack。执行撤销时从undoStack弹出快照把当前状态压入redoStack然后恢复快照。执行重做时正好反过来。关键伪代码如下void undo() { if (!undoStack.isEmpty()) { // 把当前操作后的状态放进 redo 栈 redoStack.push(currentState.createMemento()); // 恢复上一个状态 currentState.restoreMemento(undoStack.pop()); } } void redo() { if (!redoStack.isEmpty()) { // 把当前状态放回 undo 栈 undoStack.push(currentState.createMemento()); // 恢复“未来”的状态 currentState.restoreMemento(redoStack.pop()); } }这里有一个容易混淆的点保存的到底是操作前状态还是操作后状态。我的经验是如果是修改类命令通常保存的是修改前的状态这样撤销时才能回到“没改之前”。但具体业务可能不同比如某些场景想撤销的是“某一步操作之后的结果”那就要保存操作后的状态。没有万能公式需要结合交互语义定义。4.2 状态备份的深浅拷贝哪些坑必须躲如果状态里只有int、String这类不可变字段直接复制引用没问题。一旦出现List、Map、自定义对象就得小心。GameRole只有几个基本类型直接赋值就行。但现实中的角色往往有装备列表、buff 列表如果Memento里直接存了ArrayList的引用那么后续向列表添加装备时所有快照里的装备列表都会跟着变因为它们指向同一个对象。这就是浅拷贝问题。解决办法通常在创建Memento时对可变引用做深拷贝。最省事的深拷贝方式是序列化把对象写到字节流再读回来。Java 里可以用ObjectOutputStream实现但要注意处理Serializable接口和transient字段。也可以用 JSON 或 Protobuf 这类方案各自利弊都很明显JSON 可读性好、性能一般序列化快但容易出现版本兼容问题。我的建议是先判断快照对象里有多少可变引用。如果只有一两个手写深拷贝方法就够了如果嵌套层次很深直接上序列化方案更稳妥。盲目堆缓存反而容易让代码失控。4.3 快照管理策略增量快照与历史栈限流备忘录模式最被诟病的一点是内存开销大。每按一次保存就存一份完整状态存上几百份内存很容易爆掉。尤其游戏存档这种场景完整地图数据可能几十 MB。常见的优化思路有两个。第一是只保存增量也就是状态变化的部分。比如记录从某个时间点开始哪些字段变了恢复时间线时再重放变化。第二是给历史栈加容量上限比如最多保留 30 个快照超过就把最老的丢弃。用循环队列或固定容量的Deque都可以实现。另外快照也不一定都要放在内存里可以持久化到数据库或文件。游戏读档本质上就是把Memento序列化到磁盘。数据库事务回滚使用的 undo 日志也可以看作是增量快照的持久化版本。如果做的是高并发服务还要考虑快照生成过程对业务线程的影响否则每次存档都会卡顿明显。5. 实战排坑记录备忘录模式最容易翻的车5.1 症状恢复后状态“不完整”我在项目里做过一个表单编辑器表单对象里有一个计算字段total每次保存快照时只保存了每条明细的价格没保存total。结果恢复后明细是对的total却显示成旧值。排查后才发现total在每次price变化时都会用监听器重新计算而我从备份恢复时是通过反射直接赋值绕过了监听器。这个坑说明了一个规矩createMemento时要保存所有影响对象行为的字段包括看似可以重新计算的派生字段。如果派生字段的计算有副作用比如需要通知监听器恢复时必须显式触发重新计算或者直接把派生结果也存下来。否则就会出现“数据看起来恢复了但行为没有恢复”的诡异情况。5.2 症状一次存档把历史全改了这个就是浅拷贝问题的典型表现。某项目中角色背包ListItem直接以引用方式放进了备忘录。每当背包里新增一件装备所有历史存档里的背包都跟着多一件装备。最麻烦的是用户读档之后发现回到的不是当时的背包状态而是最新的背包状态。排查方法是在恢复前后打印List的元素数量或hashCode一旦发现引用共享问题就浮出水面了。修复方式很直接创建快照时对List深拷贝或者改用不可变列表。注意不可变列表对已有数据的修改要重新生成新实例不要试图在原来的列表上改。5.3 症状多线程环境下拿到的是“半截状态”如果逻辑线程和存档线程不是同一个createMemento执行时可能正好有另一个线程在改hp导致快照里的hp是 20atk还是旧值 10整体状态是一个不可能出现的组合。这种脏读问题在低并发时不容易暴露一旦并发上来存档就会随机损坏。解决方案一般有三种。第一是把所有状态读取和写入放到同一把锁里简单但会降低吞吐第二是给状态字段加上volatile并使用不可变对象建模保证读取过程始终看到一致快照第三是把整个存档过程设计成快照事务在逻辑线程空闲时统一生成。我的建议是生成快照时不要把读取和写入拆成多个步骤尽量在一个同步块里完成。5.4 症状历史栈越界或内存溢出没有给历史栈设置上限用户连续操作几百次每个快照又很大内存很容易直接爆掉。这类问题在代码层面不容易一眼看到但通过压测可以暴露出来。解决办法就是在管理者里做容量限制比如用固定容量的循环队列或者记录快照的占用大小超过阈值就触发清理策略。还有一个容易被忽略的点快照不一定只能存在内存里可以定期把快照持久化到文件或数据库释放内存。曾经有一个系统把完整快照全存在内存里结果一次大版本发布后用户连续操作十分钟内存占用就开始飙升。后来改成只保留最近 50 个快照老快照落到本地磁盘问题才解决。5.5 避坑心得速查表坑典型表现解决方向状态不完整恢复后派生字段、临时状态错误存储所有关键字段或显式触发派生计算浅拷贝污染历史快照被后续修改影响深拷贝可变引用或使用不可变对象多线程脏读快照出现不可能的状态组合加锁、不可变对象、快照事务内存爆炸历史栈过大、快照体积大限制栈容量、增量快照、持久化接口泄露管理者随意读取或修改备忘录内容使用黑箱 窄接口这些坑单独看都不复杂但往往凑在一起爆发。尤其是浅拷贝和状态不完整属于那种“代码跑着没问题上线没多久就出bug”的典型场景。提前在设计阶段列清楚状态边界比后期查问题要省力得多。最后说一点个人体会。备忘录模式真正麻烦的地方从来都不是写那三个类而是状态边界怎么划。我曾见过一个团队把备忘录设计得非常干净却因为保存了太多无意义的热点字段把内存吃满也见过另一个团队用白箱方式硬生生把历史记录写成了可以被随时篡改的普通列表最后重构时才意识到黑箱有多重要。如果你现在正在做撤销、回滚、存档之类的功能我建议先别急着写代码把对象的状态先列出来哪些是源头字段、哪些是派生字段、哪些是可变引用再决定快照要保存哪些内容、用深拷贝还是浅拷贝、要不要限量。想清楚这几步备忘录模式会在项目中给你省下很多不必要的麻烦。后续如果大家对命令模式组合、序列化快照实现感兴趣我也可以单独展开写一篇。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。