Chapters

Hide chapters

Design Patterns by Tutorials

Third Edition · iOS 13 · Swift 5 · Xcode 11

7. Memento Pattern
Written by Joshua Greene

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

The memento pattern allows an object to be saved and restored. It has three parts:

  1. The originator is the object to be saved or restored.

  2. The memento represents a stored state.

  3. The caretaker requests a save from the originator and receives a memento in response. The caretaker is responsible for persisting the memento and, later on, providing the memento back to the originator to restore the originator’s state.

While not strictly required, iOS apps typically use an Encoder to encode an originator’s state into a memento, and a Decoder to decode a memento back to an originator. This allows encoding and decoding logic to be reused across originators. For example, JSONEncoder and JSONDecoder allow an object to be encoded into and decoded from JSON data respectively.

When should you use it?

Use the memento pattern whenever you want to save and later restore an object’s state.

For example, you can use this pattern to implement a save game system, where the originator is the game state (such as level, health, number of lives, etc), the memento is saved data, and the caretaker is the gaming system.

You can also persist an array of mementos, representing a stack of previous states. You can use this to implement features such as undo/redo stacks in IDEs or graphics software.

Playground example

Open FundamentalDesignPattern.xcworkspace in the Starter directory, or continue from your own playground workspace from the last chapter, and then open the Overview page.

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
  }
}
typealias Codable = Decodable & Encodable
// MARK: - Memento
typealias 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
  }
}
// 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)")
New Game Score: 0
// Load Game
game = try! gameSystem.load(title: "Best Game Ever")
print("Loaded Game Score: \(game.state.score)")
Loaded Game Score: 9002

What should you be careful about?

Be careful when adding or removing Codable properties: both encoding and decoding can throw an error. If you force unwrap these calls using try! and you’re missing any required data, your app will crash!

Tutorial project

You’ll continue the RabbleWabble app from the previous chapter.

public class Score: Codable {
  public var correctCount: Int = 0
  public var incorrectCount: Int = 0
  public init() { }
}
public var score: Score
public init(questions: [Question],
            score: Score = Score(),
            title: String) {
  self.questions = questions
  self.score = score
  self.title = title
}
public class QuestionGroup: Codable
public class Question: Codable
public init(answer: String, hint: String?, prompt: String) {
  self.answer = answer
  self.hint = hint
  self.prompt = prompt
}

import Foundation

public final class DiskCaretaker {
  public static let decoder = JSONDecoder()
  public static let encoder = JSONEncoder()
}
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")
}
// 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
  }
}
// 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
  }
}
import Foundation

// 1
public final class QuestionGroupCaretaker {

  // MARK: - Properties
  // 2
  private let fileName = "QuestionGroupData"
  public var questionGroups: [QuestionGroup] = []
  public var selectedQuestionGroup: QuestionGroup!

  // MARK: - Object Lifecycle
  public init() {
    // 3
    loadQuestionGroups()
  }

  // 4
  private func loadQuestionGroups() {
    if let questionGroups =
      try? DiskCaretaker.retrieve([QuestionGroup].self,
                                  from: fileName) {
      self.questionGroups = questionGroups
    } else {
      let bundle = Bundle.main
      let url = bundle.url(forResource: fileName,
                           withExtension: "json")!
      self.questionGroups = try!
        DiskCaretaker.retrieve([QuestionGroup].self, from: url)
      try! save()
    }
  }

  // MARK: - Instance Methods
  // 5
  public func save() throws {
    try DiskCaretaker.save(questionGroups, to: fileName)
  }
}

private let questionGroupCaretaker = QuestionGroupCaretaker()
private var questionGroups: [QuestionGroup] {
  return questionGroupCaretaker.questionGroups
}
private var selectedQuestionGroup: QuestionGroup! {
  get { return questionGroupCaretaker.selectedQuestionGroup }
  set { questionGroupCaretaker.selectedQuestionGroup = newValue }
}

The file "QuestionGroupData.json" couldn’t be opened because there is no such file.
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 }
}
public class BaseQuestionStrategy: QuestionStrategy {

  // MARK: - Properties
  // 1
  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 }
  }
  private var questionGroupCaretaker: QuestionGroupCaretaker

  // 2
  private var questionGroup: QuestionGroup {
    return questionGroupCaretaker.selectedQuestionGroup
  }
  private var questionIndex = 0
  private let questions: [Question]

  // MARK: - Object Lifecycle
  // 3
  public init(questionGroupCaretaker: QuestionGroupCaretaker,
              questions: [Question]) {
    self.questionGroupCaretaker = questionGroupCaretaker
    self.questions = questions

    // 4
    self.questionGroupCaretaker.selectedQuestionGroup.score =
      QuestionGroup.Score()
  }

  // MARK: - QuestionStrategy
  public var title: String {
    return questionGroup.title
  }

  public func currentQuestion() -> Question {
    return questions[questionIndex]
  }

  public func advanceToNextQuestion() -> Bool {
    guard questionIndex + 1 < questions.count else {
      return false
    }
    questionIndex += 1
    return true
  }

  public func markQuestionCorrect(_ question: Question) {
    correctCount += 1
  }

  public func markQuestionIncorrect(_ question: Question) {
    incorrectCount += 1
  }

  public func questionIndexTitle() -> String {
    return "\(questionIndex + 1)/\(questions.count)"
  }
}
import GameplayKit.GKRandomSource

public class RandomQuestionStrategy: BaseQuestionStrategy {

  public convenience init(
    questionGroupCaretaker: QuestionGroupCaretaker) {
    let questionGroup =
      questionGroupCaretaker.selectedQuestionGroup!
    let randomSource = GKRandomSource.sharedRandom()
    let questions = randomSource.arrayByShufflingObjects(
      in: questionGroup.questions) as! [Question]
    self.init(questionGroupCaretaker: questionGroupCaretaker,
              questions: questions)
  }
}
public class SequentialQuestionStrategy: BaseQuestionStrategy {

  public convenience init(
    questionGroupCaretaker: QuestionGroupCaretaker) {
    let questionGroup =
      questionGroupCaretaker.selectedQuestionGroup!
    let questions = questionGroup.questions
    self.init(questionGroupCaretaker: questionGroupCaretaker,
              questions: questions)
  }
}
public func questionStrategy(
  for questionGroupCaretaker: QuestionGroupCaretaker)
  -> QuestionStrategy {
    switch self {
    case .random:
      return RandomQuestionStrategy(
        questionGroupCaretaker: questionGroupCaretaker)
    case .sequential:
      return SequentialQuestionStrategy(
        questionGroupCaretaker: questionGroupCaretaker)
    }
}
public func questionStrategy(
  for questionGroupCaretaker: QuestionGroupCaretaker)
  -> QuestionStrategy {
  return questionStrategyType.questionStrategy(
    for: questionGroupCaretaker)
}
viewController.questionStrategy =
  appSettings.questionStrategy(for: selectedQuestionGroup)
viewController.questionStrategy =
  appSettings.questionStrategy(for: questionGroupCaretaker)

try? questionGroupCaretaker.save()
// MARK: - View Lifecycle
public override func viewDidLoad() {
  super.viewDidLoad()
  questionGroups.forEach {
    print("\($0.title): " +
      "correctCount \($0.score.correctCount), " +
      "incorrectCount \($0.score.incorrectCount)"
    )
  }
}
Hiragana: correctCount 22, incorrectCount 8
Katakana: correctCount 0, incorrectCount 0
Basic Phrases: correctCount 0, incorrectCount 0
Numbers: correctCount 0, incorrectCount 0

Key points

You learned about the memento pattern in this chapter. Here are its key points:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now