备忘录模式允许一个对象被保存和恢复。它有三个部分。
发起人是要被保存或恢复的对象。
备忘录代表一个存储状态。
看护者从发起者那里请求保存,并收到一个备忘录作为回应。看护者负责持久化该备忘录,并在以后将备忘录提供给发起者,以恢复发起者的状态。
虽然没有严格的要求,但iOS应用程序通常使用编码器将发起人的状态编码为备忘录,并使用解码器将备忘录解码回给发起人。这使得编码和解码逻辑可以在不同的发起人之间重复使用。例如,JSONEncoder和JSONDecoder允许一个对象分别被编码成JSON数据和解码。
只要你想保存并在以后恢复一个对象的状态,就可以使用备忘录模式。
例如,你可以用这种模式来实现一个保存游戏的系统,其中始作俑者是游戏状态(如等级、健康状况、生命数等),备忘录是保存的数据,而看管者是游戏系统。
你也可以持久化一个备忘录的数组,代表以前状态的堆栈。你可以用它来实现IDE或图形软件中的撤销/重做堆栈等功能。
打开Starter目录下的FundamentalDesignPattern.xcworkspace,或者从上一章中你自己的playground工作空间继续,然后打开Overview页面。
你会看到Memento被列在行为模式下。这是因为这个模式是关于保存和恢复行为的。点击Memento的链接来打开该页面。
你将为这个例子创建一个简单的游戏系统。首先,你需要确定发起人。在代码示例后直接输入以下内容。
import Foundation
// MARK: - Originator
public class Game: Codable {
public class State: Codable {
public var attemptsRemaining: Int = 3
public var level: Int = 1
public var score: Int = 0
}
public var state = State()
public func rackUpMassivePoints() {
state.score += 9002
}
public func monstersEatPlayer() {
state.attemptsRemaining -= 1
}
}
在这里,你定义了一个游戏:它有一个内部的状态,可以保持游戏的属性,并且它有处理游戏中的动作的方法。你还声明Game和State符合Codable。
什么是可编码?好问题!
苹果在 Swift 4 中引入了 Codable。任何符合Codable的类型,用苹果的话说,都可以 "将自身转换为外部表示"。本质上,这是一个可以保存和恢复自身的类型。听起来很熟悉吧?是的,这正是你希望发起人能够做到的。
由于Game和State使用的所有属性已经符合Codable,编译器会自动为你生成所有需要的Codable协议方法。String、Int、Double和大多数其他Swift提供的类型都符合Codable的规定。这有多棒?
更正式地说,Codable是一个类型别名,它结合了Encodable和Decodable协议。它是这样声明的。
typealias Codable = Decodable & Encodable
可编码的类型可以被一个编码器转换为外部表示。外部表示的实际类型取决于你使用的具体Encoder。幸运的是,Foundation为你提供了几个默认的编码器,包括用于将对象转换为JSON数据的JSONEncoder。
可解码的类型可以通过一个解码器从外部表示转换。Foundation也为你提供了解码器,包括JSONDecoder,用于从JSON数据转换对象。
很好! 现在你已经掌握了理论知识,你可以继续编码了。
你接下来需要一个备忘录。在前面的代码后面添加以下内容。
// MARK: - Memento typealias GameMemento = Data
技术上讲,你根本不需要声明这一行。相反,它在这里是为了通知你GameMemento实际上是Data。这将在保存时由编码器生成,并在恢复时由解码器使用。
接下来,你需要一个看守人。在前面的代码后面添加以下内容。
// MARK: - CareTaker
public class GameSystem {
// 1
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let userDefaults = UserDefaults.standard
// 2
public func save(_ game: Game, title: String) throws {
let data = try encoder.encode(game)
userDefaults.set(data, forKey: title)
}
// 3
public func load(title: String) throws -> Game {
guard let data = userDefaults.data(forKey: title),
let game = try? decoder.decode(Game.self, from: data)
else {
throw Error.gameNotFound
}
return game
}
public enum Error: String, Swift.Error {
case gameNotFound
}
}
下面是这个的作用。
你首先声明解码器、编码器和userDefaults的属性。你将使用解码器从数据中解码游戏,使用编码器将游戏编码为数据,使用userDefaults将数据持久化到磁盘。即使应用程序被重新启动,保存的游戏数据仍然可以使用。
save(_:title:) 封装了保存逻辑。你首先使用编码器对传入的游戏进行编码。这个操作可能会出错,所以你必须在前面加上try。然后,你在userDefaults中的给定标题下保存结果数据。
load(title:)同样封装了加载逻辑。你首先从userDefaults获取给定标题的数据。然后,你用解码器从数据中解码游戏。如果这两个操作都失败了,你就抛出一个Error.gameNotFound的自定义错误。如果这两个操作都成功了,你就返回结果游戏。
你已经准备好了有趣的部分:使用这些类!
在Playground页面的末尾添加以下内容。
// MARK: - Example
var game = Game()
game.monstersEatPlayer()
game.rackUpMassivePoints()
在这里,你模拟玩游戏:玩家被怪物吃掉了,但她卷土重来,积累了大量的分数
接下来,在操场页面的末尾添加以下代码。
// Save Game
let gameSystem = GameSystem()
try gameSystem.save(game, title: "Best Game Ever")
在这里,你模拟玩家胜利地拯救了她的游戏,之后不久可能会向她的朋友们夸耀。
当然,她会想尝试打破自己的记录,所以她会开始一个新的游戏。在操场页面的末尾添加以下代码。
// New Game
game = Game()
print("New Game Score: \(game.state.score)")
在这里,你创建一个新的游戏实例并打印出game.state.score。这应该在控制台中打印以下内容。
New Game Score: 0
这证明game.state.score的默认值已经设定。
玩家也可以恢复她之前的游戏。在操场页面的末尾添加以下代码。
// Load Game
game = try! gameSystem.load(title: "Best Game Ever")
print("Loaded Game Score: \(game.state.score)")
在这里,你加载玩家之前的游戏,并打印游戏的分数。你应该在你的输出中看到这个。
Loaded Game Score: 9002
在添加或删除Codable属性时要小心:编码和解码都会产生一个错误。如果你使用try!强制解开这些调用,而你缺少任何所需的数据,你的应用程序就会崩溃。
为了减轻这个问题,避免使用try!,除非你绝对确定该操作会成功。当改变你的模型时,你也应该提前计划。
例如,你可以对你的模型进行版本管理,或者使用一个有版本的数据库。然而,你需要仔细考虑如何处理版本升级。你可能会选择在遇到新版本时删除旧数据,创建一个升级路径来从旧数据转换到新数据,或者甚至使用这些方法的组合。
你将继续上一章的RabbleWabble应用程序。
如果你跳过了上一章,或者你想重新开始,请打开Finder并导航到你下载本章资源的地方。然后,在Xcode中打开starter ▸ RabbleWabble ▸ RabbleWabble.xcodeproj。
你将使用备忘录模式来添加一个重要的应用程序功能:保存QuestionGroup分数的能力。
打开QuestionGroup.swift,并在开头的类大括号后添加以下内容。
public class Score: Codable {
public var correctCount: Int = 0
public var incorrectCount: Int = 0
public init() { }
}
在这里,你要创建一个名为Score的新类,你将用它来保存分数信息。
然后,在问题后面添加以下属性,暂时忽略编译器的错误。
public var score: Score
为了消除编译器的错误,你需要声明一个新的初始化器。在结束类的大括号前添加以下内容。
public init(questions: [Question],
score: Score = Score(),
title: String) {
self.questions = questions
self.score = score
self.title = title
}
这个初始化器的score属性有一个默认值,创建一个空白的Score对象。这意味着应用程序中所有在使用init(questions:title:)之前创建QuestionGroup的人仍然可以这样做,他们会得到这个空白的Score对象。
最后,将public struct QuestionGroup替换为下面的内容,同样,暂时忽略产生的编译器错误。
public class QuestionGroup: Codable
QuestionGroup将充当发起者。你把它从一个结构变为一个类,使其成为一个引用类型而不是一个值类型,所以你可以传递和修改QuestionGroup对象而不是复制它们。你还使它符合Codable,以实现编码和解码。
由于 Question 目前不符合 Codable,编译器不能为你自动生成所需的协议方法。幸运的是,这很容易解决。
打开Question.swift,用这个替换public struct Question。
public class Question: Codable
你把Question从一个结构体改为一个类,使之成为一个引用类型,你也使之符合Codable。
你还需要为这个类添加一个初始化器。在类的结尾大括号前添加以下内容。
public init(answer: String, hint: String?, prompt: String) {
self.answer = answer
self.hint = hint
self.prompt = prompt
}
构建你的项目以验证你已经解决了所有的编译器错误。
接下来,右击黄色的RabbleWabble组,选择新建组并命名为Caretakers。
再次右击黄色的RabbleWabble组,并选择按名称排序。
你的文件层次结构现在应该看起来像这样。
右键点击你新添加的Caretakers组,选择新文件。在iOS标签下,选择Swift文件并点击下一步。输入DiskCaretaker.swift作为名称,然后点击创建。
用以下内容替换DiskCaretaker.swift的内容。
import Foundation
public final class DiskCaretaker {
public static let decoder = JSONDecoder()
public static let encoder = JSONEncoder()
}
DiskCaretaker最终将提供从设备的Documents目录保存和检索Codable对象的方法。你将使用JSONEncoder将对象编码为JSON数据,并使用JSONDecoder将JSON数据解码为对象。
在关闭类的大括号之前添加下一个代码块。
public static func createDocumentURL(
withFileName fileName: String) -> URL {
let fileManager = FileManager.default
let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
return url.appendingPathComponent(fileName)
.appendingPathExtension("json") }
你将使用这个方法来创建一个给定文件名的文件URL。这个方法只是找到Documents目录,然后添加给定的文件名。
在createDocumentURL(withFileName:)之前添加这个方法。
// 1
public static func save<T: Codable>(
_ object: T, to fileName: String) throws {
do {
// 2
let url = createDocumentURL(withFileName: fileName)
// 3
let data = try encoder.encode(object)
// 4
try data.write(to: url, options: .atomic)
} catch (let error) {
// 5
print("Save failed: Object: `\(object)`, " + "Error: `\(error)`")
throw error
}
}
你将使用这个方法来保存Codable对象。
下面是它是如何工作的,逐行说明。
你首先声明一个通用方法,该方法接收任何符合Codable的对象。
然后调用createDocumentURL,为给定的fileName创建一个文档URL。
使用encoder将对象编码为数据。这个操作可能会出错,所以你要在前面加上try。
你调用data.write将数据写入给定的URL中。你使用原子操作符来指示iOS创建一个临时文件,然后将其移动到所需的路径。这在性能上有一点损失,但它能确保文件的数据不会被破坏。这个操作有可能会出错,所以你必须在前面加上try。
如果你捕捉到一个错误,你就把对象和错误打印到控制台,然后抛出错误。
接下来,在save(_:to:)之后添加这些方法。
// 1
public static func retrieve<T: Codable>(
_ type: T.Type, from fileName: String) throws -> T {
let url = createDocumentURL(withFileName: fileName)
return try retrieve(T.self, from: url)
}
// 2
public static func retrieve<T: Codable>(
_ type: T.Type, from url: URL) throws -> T {
do {
// 3
let data = try Data(contentsOf: url)
// 4
return try decoder.decode(T.self, from: data)
} catch (let error) {
// 5
print("Retrieve failed: URL: `\(url)`, Error: `\(error)`")
throw error
}
}
下面是发生的事情。
你声明了一个检索对象的方法,给定一个类型和fileName,这是一个字符串。这个方法首先创建一个文件的URL,然后调用retrieve(_:from:)。你很快就会发现,在检索持久化对象时,传递一个字符串或URL是多么有用。
你还声明了一个方法,它接受一个URL而不是一个String,它进行实际的加载。前面的方法只是简单地调用到这个方法。你会需要这两个方法,所以这两个方法都是公共的。
在这里,你试图从给定的URL中创建一个数据实例。这个操作有可能会失败,所以你要在这个调用前加上try。
然后你用解码器将对象解码成数据。这个操作可能会出错,所以你要用try来做前置处理。
如果你捕捉到一个错误,你就把网址和错误打印到控制台,然后抛出这个错误。
好的开始! 你很快就会发现这个辅助类是多么有用。不过,你需要先创建另一个文件。
右键点击看护人组,选择新文件。在iOS标签下,选择Swift文件并点击下一步。输入QuestionGroupCaretaker.swift作为名称,然后点击创建。
用以下内容替换QuestionGroupCaretaker.swift的内容。
下面是这个的作用。
你声明了一个名为QuestionGroupCaretaker的新类。你将用它来保存和检索QuestionGroup对象。
你声明了三个属性:fileName决定了你要保存和检索问题组对象的文件;questiongroups将保持正在使用的问题组;selectedQuestionGroup将保持用户的任何选择。
你在init()中调用loadQuestionGroups(),加载问题组。
你在loadQuestionGroups()中执行检索动作。首先,你尝试使用fileName从用户的文档目录中加载问题组。如果该文件还没有被创建,例如在应用程序首次启动时,这将会失败,并返回nil。
在失败的情况下,你从Bundle.main中加载QuestionGroups,然后调用save()把这个文件写到用户的Documents目录中。
然而,你还没有把QuestionGroupsData.json添加到主捆绑包中。接下来你需要这样做。
打开Finder,导航到你为本章下载项目的地方。在Starter和Final目录旁边,你会看到一个Resources目录,其中包含QuestionGroupData.json。
将Finder窗口置于Xcode上方,然后像这样将QuestionGroupData.json拖到资源组中。
在出现的新窗口中,确保选中 "Copy items if needed",并点击 "Finish "来复制文件。
接下来,你实际上需要使用QuestionGroupCaretaker。
打开SelectQuestionGroupViewController,将let questionGroups一行改为以下内容。
private let questionGroupCaretaker = QuestionGroupCaretaker()
private var questionGroups: [QuestionGroup] {
return questionGroupCaretaker.questionGroups
}
将var selectedQuestionGroup一行替换为以下内容。
private var selectedQuestionGroup: QuestionGroup! {
get { return questionGroupCaretaker.selectedQuestionGroup }
set { questionGroupCaretaker.selectedQuestionGroup = newValue }
}
由于你不再使用QuestionGroupData.swift,在文件导航器中选择这个文件并点击删除。在出现的新窗口中,选择移动到垃圾箱。
建立和运行,并验证一切都像以前一样。
在你第一次运行该应用程序时,你会看到一个包含该文本的错误打印。
The file "QuestionGroupData.json" couldn’t be opened because there is no such file.
这是因为QuestionGroupCaretaker的loadQuestionGroups()试图从Documents目录中读取QuestionGroupData.json,但这个文件在应用程序首次启动时并不存在。然而,应用程序会很好地处理这个问题;它从主捆绑包中读取QuestionGroupData.json,并将其保存到Documents目录中,以便将来读取。
再次构建并运行,你应该不会看到任何记录在控制台的错误。到目前为止一切正常。然而,如何保存QuestionGroup的分数呢?
打开SequentialQuestionStrategy.swift,替换这两行。
public var correctCount: Int = 0
public var incorrectCount: Int = 0
用这个替换:
public var correctCount: Int {
get { return questionGroup.score.correctCount }
set { questionGroup.score.correctCount = newValue }
}
public var incorrectCount: Int {
get { return questionGroup.score.incorrectCount }
set { questionGroup.score.incorrectCount = newValue }
}
与其使用存储的correctCount和incorrectCount属性,你不如分别获取和设置questionGroup.score.correctCount和questionGroup.score.incorrectCount。
但是等等,RandomQuestionStrategy.swift中不是也有类似的逻辑吗?是的,有的。虽然你可以尝试把这个逻辑也复制过来,但你最终会重复很多代码。
这带来了一个重要的问题:当你为你的应用程序添加新的设计模式和功能时,你需要偶尔重构你的代码。在这种情况下,你要拉出一个基类,将你的共享逻辑移入。
在策略组上点击右键,选择新建file.... 在iOS标签下,选择Swift文件并点击下一步。输入BaseQuestionStrategy.swift作为名称,然后点击创建。
用以下内容替换BaseQuestionStrategy.swift的内容。
如果你把它与RandomQuestionStrategy相比,你会发现它非常相似。但是,也有几个重要的区别。
你使用底层的questionGroup.score.correctCount和questionGroup.score.incorrectCount而不是存储的属性。
3.在这里,你已经添加了一个新的初始化器来接受一个QuestionGroupCaretaker和Questions,而不是一个QuestionGroup。你将使用questiongroupCaretaker来
持久变化到磁盘,而问题将是一个有序的数组,用于显示问题。
剩下的代码几乎都是RandomQuestionStrategy和SequentialQuestionStrategy中已经存在的内容。
你接下来需要重构RandomQuestionStrategy,使之成为BaseQuestionStrategy的子类。
打开RandomQuestionStrategy.swift,将其内容改为以下内容,暂时忽略由此产生的编译器错误。
这段代码比以前短多了,不是吗?这是因为大部分的逻辑都是在BaseQuestionStrategy中处理的。
RandomQuestionStrategy只是按随机顺序洗题,并将得到的问题数组传递给init(questionGroupCaretaker:questions:),这是基类的初始化器。
接下来,打开SequentialQuestionStrategy.swift,将其内容改为以下内容;同样,暂时忽略其他文件中的任何编译器错误。
SequentialQuestionStrategy简单地将问题按照它们被定义的相同顺序传递给
到init(questiongroupCaretaker:questions:)。
接下来,你需要消除由这些变化引起的编译器错误。
打开AppSettings.swift,将QuestionStrategyType中的questionStrategy(for:)替换为以下内容,忽略任何产生的编译器错误。
你把它改成接受一个QuestionGroupCaretaker而不是一个QuestionGroup,这样你就可以使用你刚才在RandomQuestionStrategy和SequentialQuestionStrategy上创建的便利初始化器。
接下来,将AppSettings中的questionStrategy(for:)替换为以下内容;同样,暂时忽略产生的编译器错误。
还有一个编译器错误,你需要解决。打开SelectQuestionGroupViewController.swift,替换这一行。
viewController.questionStrategy =
appSettings.questionStrategy(for: selectedQuestionGroup)
用如下代码:
viewController.questionStrategy =
appSettings.questionStrategy(for: questionGroupCaretaker)
建立和运行,并选择一个QuestionGroup单元格,以验证一切工作。
真棒! 你终于准备好从QuestionGroups中保存分数了。
打开BaseQuestionStrategy.swift,在advanceToNextQuestion()中添加以下内容,就在这个方法的开头大括号之后。
try? questionGroupCaretaker.save()
这样,每当请求下一个问题时,就会执行一次保存。
为了验证这一点,打开SelectQuestionGroupViewController.swift,在SelectQuestionGroupViewController主类定义的最后添加以下代码。
在这里,你为每个问题组打印标题、score.correctCount和score.incorrectCount。
建立并运行;选择你想要的任何QuestionGroup单元;点击绿色复选标记和红色X按钮几次,将问题标记为正确和不正确。然后,停止应用程序,再次构建和运行。你应该在控制台中看到这样的输出。
Hiragana: correctCount 22, incorrectCount 8
Katakana: correctCount 0, incorrectCount 0
Basic Phrases: correctCount 0, incorrectCount 0
Numbers: correctCount 0, incorrectCount 0
很好!这表明分数在不同的应用程序中都被保存了。这表明分数在不同的应用程序启动时都被保存。
你在本章中了解了备忘录模式。下面是它的关键点。
备忘录模式允许一个对象被保存和恢复。它涉及三种类型:发起者、备忘录和看管者。
发起者是要保存的对象;备忘录是保存的状态;看管者处理、坚持和检索备忘录。
iOS提供了编码器(Encoder),用于对备忘录进行编码,以及解码器(Decoder),用于从备忘录进行解码。这使得编码和解码逻辑可以在不同的发起人之间使用。Rabble Wabble真的在进步,你现在可以保存和恢复分数了!但是,该应用并没有显示出你的得分。然而,该应用程序还没有向用户显示分数。你将使用另一个模式来做这件事。观察者模式。
上一章 | 目录 | 下一章 |
---|