备忘录模式 Memento Pattern

备忘录模式(memento pattern)用于保存对象历史状态,以便后续可以恢复至任一状态。Memento pattern 是二十三种著名的 Gof design patterns 设计模式之一,属于 Behavioral Patterns。Memento pattern 由 Noah Thompson 和 Dr.Drew Clinkenbeard 为惠普产品创建。

Memento pattern 有以下三个部分:

MementoPatternUML.png
  1. Originator:要保存或恢复的对象。
  2. Memento:负责存储 originator 对象的内部状态。
  3. CareTaker:请求 originator 保存对象,并得到 memento 响应。其负责持久化 memento,或把 memento 提供给 originator 以恢复到指定状态。

虽不是严格要求,但 iOS app 通常使用Encoder将 originator 的状态编码为 memento,使用Decoder解码 memento 还原给 originator。这允许编码、解码逻辑在 originator 之间复用。例如,JSONEncoderJSONDecoder能够把对象编码为 JSON 格式数据,并解码还原。

1. Memento pattern 解决了哪些问题

  • 对象的内部状态需要保存到外部,以便稍后可以将对象恢复至此状态。
  • 不得违反对象的封装(encapsulation)。

问题在于已经封装好的对象,其数据结构隐藏在对象内部,并且不能从对象外部访问。

2. Memento pattern 提供了哪些解决方案

使用 originator 负责:

  • 保存对象内部状态为 memento
  • 使用之前保存的 memento 恢复为之前的状态

只有创建 memento 的 originator 才能够访问该 memento。CareTaker 可以向 originator 请求当前状态的 memento,也可以把 memento 传递给 originator 以便恢复到指定状态。这样就可以在不破坏其封装的前提下实现保存、恢复需求。

例如,可以使用 memento pattern 实现保存游戏进度。其中,originator 是游戏状态(如等级、健康状况、生命值等),memento 是保存的数据,caretaker 是游戏系统。

你也可以保存一个 memento 的数组,代表之前的游戏进度。这样也可以实现IDE或图形软件中的撤销、重做堆栈等功能。

3. Demo

在这个示例中将会创建一个简单的游戏系统。

3.1 Originator

首先,定义 originator 部分。在 playground 添加以下代码:

import Foundation

// MARK: - Originator
public class Game: Codable {
    
    public class State: Codable {
        public var attempsRemaining: Int = 5
        public var level: Int = 1
        public var score: Int = 0
    }
    public var state = State()
    
    public func rackUpMassivePoints() {
        state.score += 8008
    }
    
    public func monstersEatPlayer() {
        state.attempsRemaining -= 1
    }
}

这里定义了一个Game对象,其内部状态记录了游戏属性、方法。同时,GameState遵守Codable

Apple 在 Swift 4 中增加了Codable。任何遵守Codable的对象都可以转换为外部存储,或从外部存储读取。本质上,它是一种可以自我保存、恢复的类型。这正是 originator 需要实现的。

由于GameState的属性已经遵守Codable协议,编译器会自动生成所需协议方法。Swift 提供的StringIntDouble和大部分其他类型均已遵守Codable

事实上,CodableEncodableDecodable的别名 typealias:

typealias Codable = Decodable & Encodable

可编码类型可以通过编码器编码为外部表示。外部表示的类型取决所使用的编码器。Foundation提供了几个默认的编码器,包括用于将对象转换为 JSON 格式的JSONEncoder

可解码的类型可以通过解码器从外部表示转换。Foundation为解码器提供了解决方案,包括用于从 JSON 数据转换对象的JSONDecoder

3.2 Memento

下一步声明Memento,添加以下代码:

// MARK: - Memento
typealias GameMemento = Data

事实上,不需要声明上述类型。这里只是说明GameMemento是要保存的数据。这将由Encoder在保存时生成,并由Decoder在恢复时使用。

3.3 CareTaker

接下来,需要声明CareTaker,如下所示:

// MARK: - CareTaker
public class GameSystem {
    public static let decoder = JSONDecoder()
    public static let encoder = JSONEncoder()
    
    // 1
    public static func save<T: Codable>(_ object: T, with title: String) throws {
        do {
            let url = createDocumentURL(withTitle: title)
            let data = try encoder.encode(object)
            try data.write(to: url, options: .atomic)
        } catch (let error) {
            dump(error)
            throw error
        }
    }
    
    // 2
    public static func retrieve<T: Codable>(_ type: T.Type, with title: String) throws -> T {
        let url = createDocumentURL(withTitle: title)
        return try retrieve(T.self, from: url)
    }
    
    public static func retrieve<T: Codable>(_ type: T.Type, from url: URL) throws -> T{
        do {
            let data = try Data(contentsOf: url)
            return try decoder.decode(T.self, from: data)
        } catch (let error) {
            dump(error)
            throw error
        }
    }
    
    public static func createDocumentURL(withTitle title: String) -> URL {
        let fileManager = FileManager.default
        let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
        return url.appendingPathComponent(title).appendingPathExtension("json")
    }
}
  1. save(_:with title:)封装了保存逻辑。首先,使用JSONEncoder编码传入的game参数。这一操作可能抛出异常,必须使用try关键字修饰。最后,把 data 以 title 名称保存到.documentDirectory目录。
  2. retrieve(_:from url:)封装了恢复逻辑。首先,读取.documentDirectory目录中 title 文件,最后使用JSONDecoder恢复Game对象。读取、恢复任一操作失败,均会抛出异常;读取、恢复操作都成功时,返回恢复的Game对象。

3.4 具体应用

在 playground 底部添加以下代码:

// MARK: - Example
var game = Game()
game.monstersEatPlayer()
game.rackUpMassivePoints()

这里模拟玩游戏,玩家被怪物吃掉后卷土重来,并获得大量积分。

添加以下代码:

// Save Game
try? GameSystem.save(game, with: "Best Game Ever")

这里玩家把当前进度保存,以便后续继续或者恢复。

当然,玩家仍然可以开始其他关卡游戏。如下所示:

// New Game
game = Game()
game.state.score = 200
dump(game)

这里创建了一个新的实例,输出game对象。控制台输出如下:

▿ __lldb_expr_3.Game #0
  ▿ state: __lldb_expr_3.Game.State #1
    - attempsRemaining: 5
    - level: 1
    - score: 200

玩家也可以恢复之前保存的Game,添加以下代码:

// Load Game
game = try! GameSystem.retrieve(Game.self, with: "Best Game Ever")
dump(game)

这里加载原来保存的Game,并输出game对象:

▿ __lldb_expr_3.Game #0
  ▿ state: __lldb_expr_3.Game.State #1
    - attempsRemaining: 4
    - level: 1
    - score: 8008

编码和解码都可能会触发异常,因此,添加、移除Codable属性时需要小心。如果解包时使用try!,在 app 没有此数据时会触发异常。为解决这个问题,应避免使用try!,只在确定存在时使用。

总结

以下是 Memento Design Pattern 的关键点:

  • Memento pattern 允许保存和恢复对象。其涉及三部分:originator、memento、caretaker。
  • Originator是要保存的对象,memento 是保存的状态,caretaker 保存、恢复memento对象。
  • iOS 提供的Encoder将 originator 的状态编码为 memento,使用Decoder解码 memento 还原给 originator。编码和解码逻辑在 originator 间复用。

Demo名称:MementoPattern
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/MementoPattern

参考资料:

  1. Memento pattern
  2. Design-Patterns-In-Swift

欢迎更多指正:https://github.com/pro648/tips/wiki

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,692评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,482评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,995评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,223评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,245评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,208评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,091评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,929评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,346评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,570评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,739评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,437评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,037评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,677评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,833评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,760评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,647评论 2 354

推荐阅读更多精彩内容