第7章:备忘录模式

备忘录模式允许一个对象被保存和恢复。它有三个部分。

  1. 发起人是要被保存或恢复的对象。

  2. 备忘录代表一个存储状态。

  3. 看护者从发起者那里请求保存,并收到一个备忘录作为回应。看护者负责持久化该备忘录,并在以后将备忘录提供给发起者,以恢复发起者的状态。

虽然没有严格的要求,但iOS应用程序通常使用编码器将发起人的状态编码为备忘录,并使用解码器将备忘录解码回给发起人。这使得编码和解码逻辑可以在不同的发起人之间重复使用。例如,JSONEncoder和JSONDecoder允许一个对象分别被编码成JSON数据和解码。

你应该什么时候使用它?

只要你想保存并在以后恢复一个对象的状态,就可以使用备忘录模式。

例如,你可以用这种模式来实现一个保存游戏的系统,其中始作俑者是游戏状态(如等级、健康状况、生命数等),备忘录是保存的数据,而看管者是游戏系统。

你也可以持久化一个备忘录的数组,代表以前状态的堆栈。你可以用它来实现IDE或图形软件中的撤销/重做堆栈等功能。

Playground例子

打开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 
    }
}

下面是这个的作用。

  1. 你首先声明解码器、编码器和userDefaults的属性。你将使用解码器从数据中解码游戏,使用编码器将游戏编码为数据,使用userDefaults将数据持久化到磁盘。即使应用程序被重新启动,保存的游戏数据仍然可以使用。

  2. save(_:title:) 封装了保存逻辑。你首先使用编码器对传入的游戏进行编码。这个操作可能会出错,所以你必须在前面加上try。然后,你在userDefaults中的给定标题下保存结果数据。

  3. 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对象。

下面是它是如何工作的,逐行说明。

  1. 你首先声明一个通用方法,该方法接收任何符合Codable的对象。

  2. 然后调用createDocumentURL,为给定的fileName创建一个文档URL。

  3. 使用encoder将对象编码为数据。这个操作可能会出错,所以你要在前面加上try。

  4. 你调用data.write将数据写入给定的URL中。你使用原子操作符来指示iOS创建一个临时文件,然后将其移动到所需的路径。这在性能上有一点损失,但它能确保文件的数据不会被破坏。这个操作有可能会出错,所以你必须在前面加上try。

  5. 如果你捕捉到一个错误,你就把对象和错误打印到控制台,然后抛出错误。

接下来,在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 
    }
}

下面是发生的事情。

  1. 你声明了一个检索对象的方法,给定一个类型和fileName,这是一个字符串。这个方法首先创建一个文件的URL,然后调用retrieve(_:from:)。你很快就会发现,在检索持久化对象时,传递一个字符串或URL是多么有用。

  2. 你还声明了一个方法,它接受一个URL而不是一个String,它进行实际的加载。前面的方法只是简单地调用到这个方法。你会需要这两个方法,所以这两个方法都是公共的。

  3. 在这里,你试图从给定的URL中创建一个数据实例。这个操作有可能会失败,所以你要在这个调用前加上try。

  4. 然后你用解码器将对象解码成数据。这个操作可能会出错,所以你要用try来做前置处理。

  5. 如果你捕捉到一个错误,你就把网址和错误打印到控制台,然后抛出这个错误。

好的开始! 你很快就会发现这个辅助类是多么有用。不过,你需要先创建另一个文件。

右键点击看护人组,选择新文件。在iOS标签下,选择Swift文件并点击下一步。输入QuestionGroupCaretaker.swift作为名称,然后点击创建。

用以下内容替换QuestionGroupCaretaker.swift的内容。

下面是这个的作用。

  1. 你声明了一个名为QuestionGroupCaretaker的新类。你将用它来保存和检索QuestionGroup对象。

  2. 你声明了三个属性:fileName决定了你要保存和检索问题组对象的文件;questiongroups将保持正在使用的问题组;selectedQuestionGroup将保持用户的任何选择。

  3. 你在init()中调用loadQuestionGroups(),加载问题组。

  4. 你在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相比,你会发现它非常相似。但是,也有几个重要的区别。

  1. 你使用底层的questionGroup.score.correctCount和questionGroup.score.incorrectCount而不是存储的属性。

    1. questionGroup实际上是一个计算属性,它返回questionGroupCaretaker.selectedQuestionGroup。
  2. 3.在这里,你已经添加了一个新的初始化器来接受一个QuestionGroupCaretaker和Questions,而不是一个QuestionGroup。你将使用questiongroupCaretaker来

持久变化到磁盘,而问题将是一个有序的数组,用于显示问题。

  1. 在这里,你将分数重置为一个新的实例,即Score(),所以每当你启动一个QuestionGroup时,评分总是重新开始。

剩下的代码几乎都是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:)替换为以下内容;同样,暂时忽略产生的编译器错误。

同样地,你也要更新这个方法,使其接受一个QuestionGroupCaretaker而不是一个QuestionGroup。

还有一个编译器错误,你需要解决。打开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

很好!这表明分数在不同的应用程序中都被保存了。这表明分数在不同的应用程序启动时都被保存。

关键点

你在本章中了解了备忘录模式。下面是它的关键点。


上一章 目录 下一章