文章目录
定义
在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可以将该对象恢复至先前保存的状态
图纸
一个例子:带有限制的扑克牌元素拖动
道友,玩过蜘蛛纸牌吗?就是那个预装在Windows XP上的纸牌整理游戏。玩家可以通过鼠标拖拽屏幕上的扑克牌,把他们按照顺序排列完成游戏,就像这样:
这个鼠标拖动的动作,就是我们这次的例子,准备好了吗?我们开始了:
扑克牌和桌面
于是乎,我们为这个游戏设计了这样的类结构:
Point & Poker
/**
* 坐标点
*/
public class Point {
/**
* 相对于左上角的x坐标
*/
private final double x;
/**
* 相对于左上角的y坐标
*/
private final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() {
return x;
}
public double getY() {
return y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return Double.compare(point.x, x) == 0 &&
Double.compare(point.y, y) == 0;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
/**
* 扑克牌信息
*/
public class Poker {
public static final String[] SUITS = new String[]{
"spades", "hearts", "clubs", "diamonds"
};
/**
* 点数
*/
private int number;
/**
* 花色
*/
private String suit;
public Poker(int number, String suit) {
setNumber(number);
setSuit(suit);
}
public int getNumber() {
return number;
}
public void setNumber(int number) {
if (number > 0 && number <= 13) {
this.number = number;
} else {
throw new RuntimeException(String.format("%s 不符合扑克牌点数要求", number));
}
}
public String getSuit() {
return suit;
}
public void setSuit(String suit) {
for (String s : SUITS) {
if (s.equals(suit)) {
this.suit = suit;
return;
}
}
throw new RuntimeException(String.format("%s 不符合扑克牌花色要求", suit));
}
}
Card & Deck
/**
* 扑克牌UI元素
*/
public class Card {
public static Card createCard(Poker message,boolean isFront){
Card card = new Card();
card.setMessage(message);//写入扑克牌信息
card.setFront(isFront);//写入正反面信息
//初始化UI事件
//鼠标按住事件:当鼠标按住当前card的时候,card的position会随着鼠标的移动而移动
return card;
}
/**
* 当前这张卡牌元素的渲染位置
*/
private Point position;
/**
* 扑克信息
*/
private Poker message;
/**
* 是正面朝上的吗?
* true:是的,当前扑克牌正面朝上
*/
private boolean isFront = false;
//width height等N多个信息略
/**
* 判断参数牌是否在这张扑克牌的UI元素内
*
* @param card 要被判断的牌
* @return true 是的,这张牌在这个UI元素内
*/
public boolean isInside(Card card) {
//return 参数牌是否在UI元素内;
return true;
}
/**
* 绘制当前这张牌
*/
public void draw() {
//绘制单张卡牌的方法
}
//setter & getter 略
}
/**
* 牌堆,在桌面上N张牌叠在一起则称之为牌堆
*/
public class Deck {
/**
* 用于表示牌堆里面的牌
*/
private final List<Card> cardList = new ArrayList<>();
/**
* 当前这个牌堆的渲染位置
*/
private Point position;
public Deck(Point position) {
this.position = position;
}
public boolean add(Card card) {
cardList.add(card);
draw();//重新绘制画面
return true;
}
/**
* 绘制
*/
public void draw() {
/*
* 遍历 cardList,把牌堆里面的card对齐,然后逐一调用card里面的draw方法
*/
}
/**
* 判断参数牌是否在这张扑克牌的UI元素内
*
* @param card 要被判断的牌
* @return true 是的,这个牌在这个UI元素内
*/
public boolean isInside(Card card) {
//如果参数点在最有一张牌里,那就是落在这个牌堆里面
return cardList.get(cardList.size() - 1).isInside(card);
}
/*
* 根据参数牌的点数判断他是否可以插入到这个牌堆里面
*
*/
public boolean isNext(Card card){
if(cardList.isEmpty()){
return card.getMessage().getNumber() == 13;
}else {
return card.getMessage().getNumber() == cardList.get(cardList.size() - 1).getMessage().getNumber() - 1;
}
}
}
Table
/**
* 牌桌
*/
public class Table {
/**
* 单例对象
* 一场游戏里只有一个牌桌
*/
private static final Table current = new Table();
/**
* 牌桌上的牌堆列表
*/
private final List<Deck> deckList = new ArrayList<>();
/**
* 返还单例牌桌对象
*/
public static Table getCurrent() {
return current;
}
//setter && getter 略
}
除开 Point(坐标点)
和 Poker(扑克信息)
这两个基础类,我们的游戏主体分为三个部分 Table(牌桌)
、Card(扑克牌UI)
和 Deck(牌堆)
,他们在游戏里呈现出来的效果是这样:
在上面的GIF中,我们看到 Card 是可以用鼠标拖拽的,所以在 Card 的静态工厂方法 createCard 里面,我们让所有的 Card 在创建出来的时候就有一个鼠标按住事件的监听器,以实现在鼠标拖拽的功能
这些都是必做的事情,可问题在于,当你松开 Card 元素的元素应该发生什么事情呢?
你会说那不就是让 Card 停在那里吗?那看看下面这两个动图吧:
带有限制的扑克牌移动
第一种情况,拖到一半就松手,他要回到之前的位置
第二种情况,把牌放到错误的位置(8后面不是A),他也要回到之前的位置
也就是说,当我们松开 Card 的时候,应该去向 Table 确认,自己有没有被放在正确的位置上,如果没有则撤销刚刚的操作,回到原位
要实现这样的效果,我们需要在拖拽 Card 之前,就保留一个当前 Card 的状态的快照
这个状态最好保留在 Card 的内部,你应该发现了,Card 是没有向 Table 或者 Deck 暴露过和 position 这个属性相关的内容的,我甚至可以不给 position 写 get方法。但如果我把这个状态快照放在外部,那我就至少要向保存这个快照的类公开 position 属性的存在
所以我们的实现是这样的:
Table
/**
* 牌桌
*/
public class Table {
/**
* 单例对象
* 一场游戏里只有一个牌桌
*/
private static final Table current = new Table();
/**
* 牌桌上的牌堆列表
*/
private final List<Deck> deckList = new ArrayList<>();
/**
* 返还单例牌桌对象
*/
public static Table getCurrent() {
return current;
}
public boolean verify(Card card){
for(Deck deck:deckList){
//遍历当前牌桌上的所有牌堆,逐个验证
if(deck.isInside(card)){
return deck.isNext(card);
}
}
return false;//不符合要求
}
//setter && getter 略
}
Card
/**
* 扑克牌UI元素
*/
public class Card {
……
public static Card createCard(Poker message, boolean isFront) {
Card card = new Card();
//card.setMessage(message);//写入扑克牌信息
//card.setFront(isFront);//写入正反面信息
//初始化UI事件
CardMemento cardMemento;//快照
//鼠标按住事件:创建一个快照
{
cardMemento = card.createCardMemento();//在鼠标按住拖拽之前
}
//鼠标拖动事件:card的position会随着鼠标的移动而移动
{
……
}
//鼠标松开事件:到Table中验证
{
if (Table.getCurrent().verify(card)) {
//可以放到那里,消除快照
cardMemento = null;
……
} else {
//还原
card.setMemento(cardMemento);
}
}
return card;
}
……
/**
* 创建一个当前状态的快照
*/
public CardMemento createCardMemento() {
CardMemento memento = new CardMemento();
memento.lastPosition = position;
return memento;
}
/**
* 通过参数备忘录还原状态
*/
public void setMemento(CardMemento cardMemento) {
setPosition(cardMemento.lastPosition);//还原状态
draw();
}
public class CardMemento {
//快照状态
private Point lastPosition;
private CardMemento(){}
}
}
我们在 Table 中添加了一个用来验证 Card 位置是否正确的方法 verify
在 Card 中创建了一个内部类 CardMemento(Card状态备忘录)
,而且通过 createCardMemento
方法创建当前 Card 状态的快照和允许通过 setMemento
方法还原 Card 的状态
最后,在 createCard 创建 Card 对象时定义了在鼠标松开 Card 的时候的事件监听:
- 在按住之前创建备忘录快照
- 在松开之后进行验证,如果验证不通过则用上一步创建的快照进行还原
至此,我们的需求已经完成
备忘录和回滚
备忘录很好的实现了我们的需求,但这并不是备忘录的全部功能。你可能已经发现了我给 CardMemento 的修饰符是 public,而不是 private。这是有讲究的,这意味着我们可以在别的类里面使用 CardMemento,就像这样:
Table
/**
* 牌桌
*/
public class Table {
……
//之前执行过操作的Card的备忘录
private final List<Card.CardMemento> mementoList = new ArrayList<>();
/**
* 新增一个备忘录
*/
public void addMemento(Card.CardMemento memento) {
mementoList.add(memento);
}
/**
* 撤销上一步
*/
public void back() {
if (!mementoList.isEmpty()) {
Card.CardMemento memento = mementoList.get(mementoList.size() - 1);
memento.getCard().setMemento(memento);
mementoList.remove(memento);
}
}
}
Card
/**
* 扑克牌UI元素
*/
public class Card {
……
public static Card createCard(Poker message, boolean isFront) {
Card card = new Card();
//card.setMessage(message);//写入扑克牌信息
//card.setFront(isFront);//写入正反面信息
//初始化UI事件
CardMemento cardMemento;//快照
//鼠标按住事件:创建一个快照
{
cardMemento = card.createCardMemento();//在鼠标按住拖拽之前
}
//鼠标拖动事件:card的position会随着鼠标的移动而移动
{
}
//鼠标松开事件:到Table中验证
{
if (Table.getCurrent().verify(card)) {
//可以放到那里,存储快照到Table中
Table.getCurrent().addMemento(cardMemento);
cardMemento = null;
} else {
//还原
card.setMemento(cardMemento);
}
}
return card;
}
public class CardMemento {
//快照状态
private Point lastPosition;
private Card card;
private CardMemento() {
card = Card.this;
}
public Card getCard() {
return card;
}
}
}
我们在 Table 里面新增了 mementoList
,用于存储本局游戏中进行过移动的 Card 的备忘录,并在 Card 的松开鼠标事件监听器中要求在动作验证成功之后把备忘录写进 mementoList 中。然后提供 back
方法,逆向访问 mementoList ,把那些移动过的 Card 一个一个还原掉
而这正是一个标准的备忘录实现
碎碎念
备忘录和封装
如果所有的类里面的属性都是公开的,那么备忘录这种设计模式是不会以现在这种形式出现的,在备忘录的定义里面说得很清楚,在不破坏封装性的前提下
所以备忘录其实是一种被动的保护机制。如果你的代码面临如下问题,那你就应该考虑备忘录了:
- 你必须备份一个对象在某个时刻的状态快照,以备还原
- 如果让外部对象获取到这个对象需要备份的状态,会暴露对象的实现细节并破坏这个对象的封装性
备忘录和命令
在命令模式中我们提到,因为每个 执行者 都专注于自己要执行的任务,这让依次撤销命令成为了可能
而当你真的需要撤销命令这种功能的时候,那么备忘录将会是很好的实现方式(各个命令所影响的组件上一个状态被保存,然后在撤销命令被调用的时候恢复状态)
也就是说,命令模式提供了这种撤销的可能性,而备忘录则为这种可能提供了具体的可执行方案
多个存档
备忘录就像我们在游戏中的存档一样,绝大多数游戏并不是一定要一命到底的。我们可以创建多个游戏备份,用来走不同的路线
备忘录也一样,你可以创建很多个备忘录快照来存储对象不同时间点的状态,这样你可以还原的选择就会更加多样
可是备忘录并不是越多越好的,虽然理论上来说我们希望备忘录这种东西越多越好,这样我们的容错率会更高,做起事情来可以更加肆无忌惮。但内存终究是有限的,我们不可能用有限的内存去记录无限的时间
所以要避免备忘录的滥用,在够用的前提下,存档还是越少越好
万分感谢您看完这篇文章,如果您喜欢这篇文章,欢迎点赞、收藏。还可以通过专栏,查看更多与【设计模式】有关的内容