定义

在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可以将该对象恢复至先前保存的状态




图纸

设计模式——2_5 备忘录(Memento)-LMLPHP




一个例子:带有限制的扑克牌元素拖动

道友,玩过蜘蛛纸牌吗?就是那个预装在Windows XP上的纸牌整理游戏。玩家可以通过鼠标拖拽屏幕上的扑克牌,把他们按照顺序排列完成游戏,就像这样:

设计模式——2_5 备忘录(Memento)-LMLPHP

这个鼠标拖动的动作,就是我们这次的例子,准备好了吗?我们开始了:



扑克牌和桌面

于是乎,我们为这个游戏设计了这样的类结构:

设计模式——2_5 备忘录(Memento)-LMLPHP

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(牌堆),他们在游戏里呈现出来的效果是这样:

设计模式——2_5 备忘录(Memento)-LMLPHP

在上面的GIF中,我们看到 Card 是可以用鼠标拖拽的,所以在 Card 的静态工厂方法 createCard 里面,我们让所有的 Card 在创建出来的时候就有一个鼠标按住事件的监听器,以实现在鼠标拖拽的功能


这些都是必做的事情,可问题在于,当你松开 Card 元素的元素应该发生什么事情呢?

你会说那不就是让 Card 停在那里吗?那看看下面这两个动图吧:



带有限制的扑克牌移动

设计模式——2_5 备忘录(Memento)-LMLPHP

设计模式——2_5 备忘录(Memento)-LMLPHP

第一种情况,拖到一半就松手,他要回到之前的位置

第二种情况,把牌放到错误的位置(8后面不是A),他也要回到之前的位置


也就是说,当我们松开 Card 的时候,应该去向 Table 确认,自己有没有被放在正确的位置上,如果没有则撤销刚刚的操作,回到原位

要实现这样的效果,我们需要在拖拽 Card 之前,就保留一个当前 Card 的状态的快照

这个状态最好保留在 Card 的内部,你应该发现了,Card 是没有向 Table 或者 Deck 暴露过和 position 这个属性相关的内容的,我甚至可以不给 position 写 get方法。但如果我把这个状态快照放在外部,那我就至少要向保存这个快照的类公开 position 属性的存在

所以我们的实现是这样的:

设计模式——2_5 备忘录(Memento)-LMLPHP

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,就像这样:

设计模式——2_5 备忘录(Memento)-LMLPHP

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 一个一个还原掉


而这正是一个标准的备忘录实现




碎碎念

备忘录和封装

如果所有的类里面的属性都是公开的,那么备忘录这种设计模式是不会以现在这种形式出现的,在备忘录的定义里面说得很清楚,在不破坏封装性的前提下

所以备忘录其实是一种被动的保护机制。如果你的代码面临如下问题,那你就应该考虑备忘录了:

  1. 你必须备份一个对象在某个时刻的状态快照,以备还原
  2. 如果让外部对象获取到这个对象需要备份的状态,会暴露对象的实现细节并破坏这个对象的封装性



备忘录和命令

在命令模式中我们提到,因为每个 执行者 都专注于自己要执行的任务,这让依次撤销命令成为了可能

而当你真的需要撤销命令这种功能的时候,那么备忘录将会是很好的实现方式(各个命令所影响的组件上一个状态被保存,然后在撤销命令被调用的时候恢复状态)

也就是说,命令模式提供了这种撤销的可能性,而备忘录则为这种可能提供了具体的可执行方案



多个存档

备忘录就像我们在游戏中的存档一样,绝大多数游戏并不是一定要一命到底的。我们可以创建多个游戏备份,用来走不同的路线

备忘录也一样,你可以创建很多个备忘录快照来存储对象不同时间点的状态,这样你可以还原的选择就会更加多样

可是备忘录并不是越多越好的,虽然理论上来说我们希望备忘录这种东西越多越好,这样我们的容错率会更高,做起事情来可以更加肆无忌惮。但内存终究是有限的,我们不可能用有限的内存去记录无限的时间


所以要避免备忘录的滥用,在够用的前提下,存档还是越少越好




万分感谢您看完这篇文章,如果您喜欢这篇文章,欢迎点赞、收藏。还可以通过专栏,查看更多与【设计模式】有关的内容

03-20 11:52