第21章:命令模式

命令模式是一种行为模式,它将执行一个动作的信息封装到一个命令对象中。它涉及三种类型。

  1. 调用者存储和执行命令。

  2. 命令将动作封装为一个对象。

  3. 接收者是被命令所作用的对象。

因此,这种模式允许你对执行行动的概念进行建模。

你应该什么时候使用它?

只要你想创建可以在以后某个时间点对接收者执行的动作,就可以使用这种模式。例如,你可以创建并存储要由计算机AI执行的命令,然后在一段时间内执行这些命令。

playground实例

打开Starter目录下的AdvancedDesignPatterns.xcworkspace,然后打开命令页面。

在这个playground实例中,你将创建一个简单的猜谜游戏:一个门卫将随机开关一个门的次数,你要事先猜测这个门最后是开还是关。

在代码示例后添加以下内容。

import Foundation

// MARK: - Receiver 
public class Door { 
  public var isOpen = false 
}

门是一个简单的模型,将充当接收器。它将通过设置其isOpen属性来打开和关闭。

接下来添加以下代码。

// MARK: - Command 
// 1 
public class DoorCommand { 
  public let door: Door 
  public init(_ door: Door) { 
    self.door = door 
  } 
  public func execute() { } 
}

// 2 
public class OpenCommand: DoorCommand { 
  public override func execute() { 
    print("opening the door...") 
    door.isOpen = true 
  }
}

// 3 
public class CloseCommand: DoorCommand { 
  public override func execute() { 
    print("closing the door...") 
    door.isOpen = false 
  } 
}

下面是这个的作用。

  1. 你首先要定义一个名为DoorCommand的类,它在命令中起作用。这个类旨在成为一个抽象的基类,意味着你不会直接实例化它。相反,你将实例化并使用其子类。

    这个类有一个属性,门,你在它的初始化器中设置。它也有一个方法,execute(),你可以在它的子类中重写这个方法。

  2. 接下来,你定义了一个名为OpenCommand的类,作为DoorCommand的子类。这个类重写了execute(),它打印了一条信息并将door.isOpen设置为true。

  3. 最后,你将CloseCommand作为DoorCommand的一个子类。这也同样覆盖了execute(),打印一条信息,并将door.isOpen设为false。

接下来,在playground的末尾添加以下内容。

// MARK: - Invoker 
// 1 
public class Doorman {

  // 2 
  public let commands: [DoorCommand] 
  public let door: Door

  // 3 
  public init(door: Door) { 
    let commandCount = arc4random_uniform(10) + 1   
    self.commands = (0 ..< commandCount).map { index in 
      return index % 2 == 0 ?
        OpenCommand(door) : CloseCommand(door) 
    } 
    self.door = door 
  }

  // 4 
  public func execute() {
    print("Doorman is...")
    commands.forEach { $0.execute() } 
  }
}

下面是这个的作用。

  1. 你首先要定义一个名为DoorCommand的类,它在命令中起作用。这个类旨在成为一个抽象的基类,意味着你不会直接实例化它。相反,你将实例化并使用其子类。

    这个类有一个属性,门,你在它的初始化器中设置。它也有一个方法,execute(),你可以在它的子类中重写这个方法。

  2. 接下来,你定义了一个名为OpenCommand的类,作为DoorCommand的子类。这个类重写了execute(),它打印了一条信息并将door.isOpen设置为true。

  3. 最后,你将CloseCommand作为DoorCommand的一个子类。这也同样覆盖了execute(),打印一条信息,并将door.isOpen设为false。

接下来,在playground的末尾添加以下内容:

// MARK: - Example 
public let isOpen = true 
print("You predict the door will be " + "\(isOpen ? "open" : "closed").") 
print("")

你预测门最终是打开还是关闭,由isOpen决定。你应该看到它被打印到控制台。

You predict the door will be open.

如果你认为它不会被打开,把isOpen改为false。

在playground的末尾添加以下内容:。

let door = Door() 
let doorman = Doorman(door: door) 
doorman.execute() 
print("")

你创建一个门和门卫,然后调用doorman.execute()。你应该看到像这样的东西打印到控制台。打开和关闭语句的数量将取决于所选择的任何随机数字

Doorman is... 
opening the door... 
closing the door... 
opening the door...

为了完成这个游戏,你还应该打印出你的猜测是对还是错。

要做到这一点,请在playground的末尾添加以下内容。

if door.isOpen == isOpen { 
  print("You were right! :]") 
} else {
  print("You were wrong :[")  
} 
print("The door is \(door.isOpen ? "open" : "closed").")

如果你猜对了,你会看到这个打印到控制台。

You were right! 
The door is open.

要重复游戏,按 "停止游戏 "按钮,然后按出现的 "播放 "按钮。

你应该注意什么?

命令模式会导致许多命令对象。因此,这可能导致代码更难阅读和维护。如果你以后不需要执行动作,你可能最好只是直接调用接收器的方法。

教程项目

在本章中,你将建立一个名为RayWenToe的游戏应用。这是TicTacToe的一个变种。以下是规则。

  1. 像TicTacToe一样,玩家在一个3x3的游戏板上放置X和Os。第一个玩家是X,第二个玩家是O。

  2. 与TicTacToe不同的是,每个玩家在游戏开始时秘密地做出五种选择,这些选择不能改变。然后玩家按照预先选择的顺序交替将X和O放在游戏板上。

  3. 如果一个棋手把他的标记放在一个已经有人的位置上,他的标记就会覆盖现有的标记。

  4. 棋手可以多次选择同一位置,他甚至可以在所有的选择中选择同一位置。

  5. 在所有玩家的选择都被打完后,将决定一个赢家。

  6. 像TicTacToe一样,如果只有一个玩家在垂直、水平或对角线上连续有三个标记,那么这个玩家就是赢家。

  7. 如果两个玩家都有三个标记,或者两个玩家都没有,那么第一个玩家(X)就是赢家。(X)为胜者。

  8. 因此,第一个棋手试图连续得到三个X,或阻止对手连续得到三个Os,是一种合理的策略。

  9. 第二位棋手(O)获胜的唯一方法是连续得到三个Os,而他的对手也没有连续得到三个X。

你能猜到你要用哪种模式吗?当然是 "命令 "模式。

建立你的游戏

打开Finder,导航到你下载本章资源的地方。然后,在Xcode中打开starter/RayWenToe/RayWenToe.xcodeproj。

建立并运行,你会看到一个选择游戏模式的屏幕。

选择单人模式,你会看到游戏板。

然而,如果你点击一个点,什么也不会发生。你需要实现这个逻辑。打开GameManager.swift,滚动到onePlayerMode();这个方法是一个类构造函数,用于创建单人模式的GameManager。

RayWenToe使用状态模式--如果你不熟悉它,请看第15章 "状态模式"--来支持单人和双人模式。具体来说,它使用三种状态。

  1. PlayerInputState允许用户在游戏板上选择位置。

  2. ComputerInputState为计算机AI生成游戏板上的点。

  3. PlayGameState在棋盘上交替放置player1和player2的位置。

打开PlayerInputState.swift,你会看到有几个方法含有TODO:-注释。同样,如果你打开ComputerInputState.swift和PlayGameState.swift,你会看到其他一些含有类似注释的方法。这些方法都需要一个命令对象来完成!

创建和存储命令对象

在GameManager组中添加一个名为MoveCommand.swift的新Swift文件,该组是Controllers组中的一个子组,并将其内容改为以下内容。

// 1 
public struct MoveCommand {

  // 2 
  public var gameboard: Gameboard

  // 3 
  public var gameboardView: GameboardView

  // 4 
  public var player: Player

  // 5 
  public var position: GameboardPosition
}

以下是你所做的事情。

  1. 你首先定义了一个名为MoveCommand的新结构。最终,这将把玩家的行动放到游戏板和游戏板视图上。

  2. 游戏板是一个代表TicTacToe棋盘的模型。它包含一个位置的二维数组,它保持着在棋盘上某一特定位置下棋的玩家。

  3. GameboardView是RayWenToe棋盘的一个视图。它已经包含了绘制棋盘和绘制MarkView的逻辑,MarkView代表一个X或一个O,位于给定的位置。它还包含了响应触摸时通知其委托人的逻辑,该委托人已被设置为GameplayViewController。

  4. 播放器代表执行这个动作的用户。它包含一个markViewPrototype,它使用原型模式--如果你不熟悉它,请看第14章 "原型模式"--允许通过复制它来创建一个新的MarkView。

  5. GameboardPosition是一个游戏棋盘位置的模型,在这个位置上应该执行这个动作。

为了发挥作用,你还需要声明一个执行这个命令的方法。接下来添加以下方法,就在大括号的结尾处。

public func execute(completion: (() -> Void)? = nil) { 
  // 1 
  gameboard.setPlayer(player, at: position)

  // 2 
  gameboardView.placeMarkView(
    player.markViewPrototype.copy(), 
    at: position, 
    animated: true, 
    completion: completion)
}

下面是这个的作用。

  1. 你首先在游戏板上的位置设置玩家。这并不影响视图的外观,而是用来决定游戏结束时的胜负。

  2. 然后,你创建一个玩家的markViewPrototype的副本,并将其设置在gameboardView上的指定位置。这个方法已经为你实现了,包括动画和完成时调用完成关闭。如果你想知道它是如何工作的,请看GameboardView.swift中的实现。

由于gameboard和gameboardView被这个命令所作用,它们都是接收者。

完成这些后,你就可以开始使用这个命令了。打开GameManager.swift,在gameboard属性后面添加以下内容。

internal lazy var movesForPlayer = 
  [player1: [MoveCommand](), player2: [MoveCommand]()]

你将用它来保持特定玩家的MoveCommand对象。

接下来,打开GameState.swift,在gameplayView属性后面添加以下内容。

public var movesForPlayer: [Player: [MoveCommand]] { 
  get { return gameManager.movesForPlayer } 
  set { gameManager.movesForPlayer = newValue } 
}

这里,你为movesForPlayer声明了一个计算属性,它设置并返回gameManager.movesForPlayer。

在PlayerInputState和ComputerInputState中,你会经常使用这个属性,所以这个计算的属性会让你的代码更短,更容易阅读。这就处理了存储命令对象的问题! 接下来你需要实际创建它们。打开PlayerInputState.swift,将addMove(at:)替换为以下内容。

// 1 
public override func addMove(at position: GameboardPosition) {

  // 2 
  let moveCount = movesForPlayer[player]!.count 
  guard moveCount < turnsPerPlayer else { return }

  // 3 
  displayMarkView(at: position, turnNumber: moveCount + 1)

  // 4 
  enqueueMoveCommand(at: position)   
  updateMoveCountLabel()
}

以下是这个动作的内容。

  1. addMove(at:) 被GameManager调用,而GameManager又被GamePlayViewController调用,以响应用户在GameboardView上选择一个点。在这个方法中,你需要为选择显示一个MarkView,并排队等待一个MoveCommand,以便以后执行。

  2. 接下来,你通过获取给定玩家的moveForPlayer的数量来创建一个moveCount的变量。如果moveCount不小于turnsPerPlayer,那么用户就已经选好了她的所有位置,你就提前返回。

  3. 接下来,你调用displayMarkView(at:turnNumber:),传递选定的位置和moveCount+1。由于moveCount是零指数的,你将其递增1,以显示第一轮的 "1 "而不是 "0"。

  4. 最后,你调用enqueueMoveCommand(at:) 和 updateMoveCountLabel()。

这两个都需要你使用MoveCommand,所以你接下来需要实现这些。

实现移动命令

用以下内容替换enqueueMoveCommand(at:)的内容。

let newMove = MoveCommand(gameboard: gameboard,
                          gameboardView: gameboardView, 
                          player: player, 
                          position: position)

movesForPlayer[player]!.append(newMove)

你在这里创建了一个新的MoveCommand,并将其追加到现有的数组中的 movesForPlayer[player]。

接下来,将updateMoveCountLabel()的内容替换为以下内容。

let turnsRemaining = turnsPerPlayer movesForPlayer[player]!.count 
gameplayView.moveCountLabel.text = "\(turnsRemaining) Moves Left"

你通过从turnesPerPlayer中减去已经添加的招数(由 movesForPlayer[player]!.count给出)来计算turnesRemaining,后者是每个玩家允许的总招数。然后你用它来设置moveCountLabel.text。

构建并运行,选择单人模式并点击游戏板上的一个点。现在你应该看到一个X出现了 你甚至可以多次点击同一位置,这也会被正确处理。

如果你按下Play或Undo,什么都不会发生。你需要为这些实现handleActionPressed()和handleUndoPressed()。

还是在PlayerInputState.swift中,将handleActionPressed()的内容替换为以下内容。

guard movesForPlayer[player]!.count == turnsPerPlayer 
  else { return } 
gameManager.transitionToNextState()

你首先要确认玩家已经完成了所有的选择。如果没有,你就提前返回。否则,你就调用gameManager.transitionToNextState()。该方法简单地移动到下一个游戏状态:在单人模式下,过渡到ComputerInputState,而在双人模式下,过渡到另一个玩家的PlayerInputState。

接下来,将handleUndoPressed()的内容替换为以下内容。

// 1 
var moves = movesForPlayer[player]!

guard let position = moves.popLast()?.position else { return }

// 2
movesForPlayer[player] = moves
updateMoveCountLabel()

// 3 
let markView = gameboardView.markViewForPosition[position]! 
_ = markView.turnNumbers.popLast()

// 4 
guard markView.turnNumbers.count == 0 else { return } 
gameboardView.removeMarkView(at: position, animated: false)

这里有很多事情发生。

  1. 首先,你从movesForPlayer中获得给定玩家的动作,然后你调用popLast()来移除并返回最后一个对象。如果没有任何命令可以弹出,这将返回nil,并且你提前返回。如果有一个命令被弹出,你就得到它的位置。

  2. 接下来,你用新的移动数组更新 movesForPlayer[player],并调用 updateMoveCountLabel() 来显示新的剩余回合数。

  3. 接下来,你从gameboardView中获得markView并调用turnNumbers.popLast()。MarkView使用turnNumbers来显示 它被选中的顺序。这是一个数组,因为玩家可以不止一次地选择同一个点。

  4. 最后,你检查markView.turnNumbers.count是否等于0,如果是,这意味着MarkView的所有动作都被弹出。在这种情况下,你通过调用removeMarkView(at:animated:)将其从gameboardView中移除。

构建并运行,选择单人模式,点击一个点来增加一个动作。然后,按撤消键,你的动作就会被删除。

然而,如果你按下 "播放 "键,仍然没有任何反应。这是什么原因呢?

还记得PlayGameState.swift和ComputerInputState.swift也有存根的方法吗?是的,你必须实现这些方法才能玩这个游戏!

打开PlayGameState.swift,在begin()后面添加以下方法。

private func combinePlayerMoves() -> [MoveCommand] { 
  var result: [MoveCommand] = [] 
  let player1Moves = movesForPlayer[player1]! 
  let player2Moves = movesForPlayer[player2]!   
  assert(player1Moves.count == player2Moves.count) 
  for i in 0 ..< player1Moves.count { 
    result.append(player1Moves[i]) 
    result.append(player2Moves[i]) 
  } 
  return result
}

顾名思义,该方法将Player1和Player2的MoveCommand对象合并为一个数组。你将用它来交替执行每个玩家的每个动作。

接下来,在combinedPlayerMoves()之后添加以下方法。

private func performMove(at index: Int, with moves: [MoveCommand]) {

  // 1 
  guard index < moves.count else {
    displayWinner()
    return 
  }

  // 2 
  let move = moves[index] 
  move.execute(completion: { [weak self] in
    self?.performMove(at: index + 1, with: moves) 
  })
}

下面是这个的作用。

  1. 你检查传入的索引是否小于moves.count。如果不是,那么所有的棋都已经下完了,你就调用displayWinner()来计算并显示赢家。

  2. 你获得给定索引的棋步,然后执行它。在完成闭包内,你再次递归地调用 performMove(at: with:),将索引递增1。 以这种方式,你将按顺序执行每一步棋。

你还需要调用这些方法。将begin()中的TODO注释改为以下内容。

let gameMoves = combinePlayerMoves() 
performMove(at: 0, with: gameMoves)

在这里,你只需使用你刚刚创建的方法。

真棒!你已经准备好试一试了。你已经准备好试玩这个游戏了。建立并运行,但这次要选择双人模式。

选择第一个玩家的游戏板位置,然后按 "准备"。然后,选择第二个玩家的位置,并按下 "播放"。然后,你会看到每个移动命令按顺序执行,并在屏幕上显示动画。

然而,如果你按下新游戏,你会发现有一个问题--"剩余棋步 "标签显示为0! 这是因为你目前并没有在新游戏开始时重置 "玩家的动作"。幸运的是,这很容易解决。

打开GameManager.swift,将newGame()中的TODO注释改为以下内容。

movesForPlayer = [player1: [], player2: []]

现在你可以在双人模式下玩你想玩的游戏了!

如果你身边没有朋友,你也需要完成单人模式。要做到这一点,你需要完成ComputerInputState.swift。ComputerInputState不会像PlayerInputState那样接受用户的点选,而是自动生成这些点选。

打开ComputerInputState.swift,把begin()中的TODO注释替换成这样。

movesForPlayer[player] = positions.map {
  MoveCommand(gameboard: gameboard,
              gameboardView: gameboardView, 
              player: player, 
              position: $0)
} 
gameManager.transitionToNextState()

产生游戏位置的逻辑已经通过generateRandomWinningCombination()为你实现了。在这里,你将这些位置映射到创建一个MoveCommand对象的数组上,并在 movesForPlayer上设置。然后你立即调用gameManager.transitionToNextState(),这将最终过渡到PlayGameState并开始游戏。

建立并运行,并选择单人模式。挑选你的位置,按下播放键,看着游戏的进行!

关键点

你在本章中了解了命令模式。下面是它的关键点。

接下来该怎么做?

你创建了一个有趣的TicTacToe变体,玩家可以提前选择他们的动作。你仍然可以对RayWenToe进行大量的功能和改变。

使用你已经从本书学到的现有模式,这些都是可能的。你可以随心所欲地继续用RayWenToe进行实验。

当你准备好了,继续下一章,学习责任链模式。


上一章 目录 下一章