第五章:策略模式

策略模式定义了一个可互换的对象系列,可以在运行时进行设置或切换。这个模式有三个部分。

什么时候应该使用它?

当你有两个或多个不同的行为可以互换时,可以使用策略模式。

这种模式类似于委托模式:这两种模式都依赖于一个协议,而不是具体的对象来增加灵活性。因此,任何实现策略协议的对象都可以在运行时作为策略使用。

与委托不同的是,策略模式使用了一系列的对象。

委托通常在运行时被固定下来。例如,UITableView的dataSource和委托可以从Interface Builder中设置,而且在运行时很少会改变。

然而,策略的目的是在运行时容易互换。

Playground例子

打开Starter目录下的FundamentalDesignPatterns.xcworkspace,然后打开Overview页面。

你会看到,策略被列在行为模式下。这是因为策略模式是关于一个对象使用另一个对象来做某事。

点击策略链接,打开该页面。

对于代码示例,考虑一个使用几个 "电影评级服务 "的应用程序,如Rotten Tomatoes®、IMDb和Metacritic。与其在视图控制器中直接为这些服务编写代码,并可能在其中有复杂的if-else语句,你可以使用策略模式来简化事情,创建一个协议,为每个服务定义一个通用的API。

首先,你需要创建一个策略协议。在代码示例后面添加以下内容。

import UIKit

public protocol MovieRatingStrategy {

  // 1 
  var ratingServiceName: String { get }

  // 2 
  func fetchRating(for movieTitle: String, success: (_ rating: String, _ review: String) -> ())
}
  1. 你将使用ratingServiceName来显示哪个服务提供的评级。例如,这将返回 "Rotten Tomatoes"。

  2. 2.你将使用fetchRatingForMovieTitle(_:success:)来异步获取电影评级。在一个真正的应用程序中,你也可能有一个失败的闭包,因为网络调用并不总是成功。

接下来,为RottenTomatoesClient添加以下实现。

public class RottenTomatoesClient: MovieRatingStrategy { 
  public let ratingServiceName = "Rotten Tomatoes"

  public func fetchRating(
    for movieTitle: String, 
    success: (_ rating: String, _ review: String) -> ()) {

    // In a real service, you’d make a network request... 
    // Here, we just provide dummy values...

    let rating = "95%" 
    let review = "It rocked!"
    success(rating, review)  
  }
}

最后,为IMDbClient添加以下实现。

public class IMDbClient: MovieRatingStrategy { 
  public let ratingServiceName = "IMDb"

  public func fetchRating(

    for movieTitle: String, 
    success: (_ rating: String, _ review: String) -> ()) {
    let rating = "3 / 10" let review = """
      It was terrible! The audience was throwing rotten
      tomatoes!
      """ 
    success(rating, review)
  }
}

由于这两个客户端都符合MovieRatingStrategy,消费对象不需要直接了解这两个客户端。相反,他们可以只依赖协议。

例如,在文件的末尾添加以下代码。

public class MovieRatingViewController: UIViewController {
  // MARK: - Properties 
  public var movieRatingClient: MovieRatingStrategy!

  // MARK: - Outlets 
  @IBOutlet public var movieTitleTextField: UITextField!   
  @IBOutlet public var ratingServiceNameLabel: UILabel!
  @IBOutlet public var ratingLabel: UILabel!
  @IBOutlet public var reviewLabel: UILabel!

  // MARK: - View Lifecycle 
  public override func viewDidLoad() {
    super.viewDidLoad()
    ratingServiceNameLabel.text = movieRatingClient.ratingServiceName 
  }

  // MARK: - Actions 
  @IBAction public func searchButtonPressed(sender: Any) {
   guard let movieTitle = movieTitleTextField.text
     else { return }

   movieRatingClient.fetchRating(for: movieTitle) { 
     (rating, review) in 
     self.ratingLabel.text = rating 
     self.reviewLabel.text = review 
    } 
  }
}

每当这个视图控制器在应用程序中被实例化时(无论发生什么情况),你都需要设置movieRatingClient。请注意,视图控制器并不知道MovieRatingStrategy的具体实现。

决定使用哪种MovieRatingStrategy的工作可以推迟到运行时进行,如果你的应用程序允许的话,这甚至可以由用户选择。

你应该注意什么?

要小心过度使用这种模式。特别是,如果一个行为永远不会改变,直接把它放在消耗的视图控制器或对象上下文中是可以的。这种模式的诀窍是知道何时拉出行为,当你确定哪里需要时,可以懒洋洋地这样做。

教程项目

你将继续上一章的RabbleWabble应用程序。如果你跳过了上一章,或者你想重新开始,请打开Finder并导航到你下载本章资源的地方,然后在Xcode中打开starter ▸ RabbleWabble ▸ RabbleWabble.xcodeproj。

与其每次总是以相同的顺序显示问题,不如将它们随机化,这不是很好吗?然而,有些用户可能也想按顺序研究问题。你将使用策略模式来允许这两种选择!

在黄色的RabbleWabble组上点击右键,选择新建组并命名为策略。

再次右击黄色的RabbleWabble组,选择按名称排序。

你的文件层次结构现在应该是这样的。

在你新添加的策略组上点击右键,选择新建文件。在iOS标签下,选择Swift文件并按下一步。在名称中输入QuestionStrategy.swift,然后按创建。

将QuestionStrategy.swift的内容替换为以下内容。

public protocol QuestionStrategy: class { 
  // 1 
  var title: String { get }

  // 2 
  var correctCount: Int { get } 
  var incorrectCount: Int { get }

  // 3 
  func advanceToNextQuestion() -> Bool

  // 4 
  func currentQuestion() -> Question
  
  // 5 
  func markQuestionCorrect(_ question: Question) 
  func markQuestionIncorrect(_ question: Question)

  // 6 
  func questionIndexTitle() -> String
}

这就形成了你要使用的策略模式的核心协议。

以下是你将如何使用协议的每个部分。

  1. 标题将是选择哪一组问题的标题,如 "Basic Phrases."。

  2. correctCount和incorrectCount将分别返回当前正确和错误的问题数量。

  3. advanceToNextQuestion()将被用来进入下一个问题。如果没有下一个问题可用,该方法将返回false。否则,它将返回true。

  4. currentQuestion() 将简单地返回当前问题。由于advanceToNextQuestion()将阻止用户超越可用的问题,currentQuestion()将总是返回一个问题,而不会是nil。

  5. 正如其方法名称所暗示的,markQuestionCorrect(:)将标记一个问题为正确,而markQuestionIncorrect(:)将标记一个问题为不正确。

  6. questionIndexTitle()将返回当前问题的 "索引标题",以显示进度,如 "1/10 "表示总共十个问题中的第一个。

在策略组下创建另一个名为SequentialQuestionStrategy.swift的文件。将其内容改为以下内容。

public class SequentialQuestionStrategy: QuestionStrategy { 
  // MARK: - Properties 
  public var correctCount: Int = 0 
  public var incorrectCount: Int = 0 
  private let questionGroup: QuestionGroup 
  private var questionIndex = 0

  // MARK: - Object Lifecycle 
  public init(questionGroup: QuestionGroup) { 
    self.questionGroup = questionGroup 
  }
  // MARK: - QuestionStrategy 
  public var title: String { 
    return questionGroup.title 
  }

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

  public func advanceToNextQuestion() -> Bool { 
    guard questionIndex + 1 < 
      questionGroup.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)/" + 
      "\(questionGroup.questions.count)" 
  }
}

SequentialQuestionStrategy通过其指定的初始化器init(questionGroup:)接收一个QuestionGroup,它的功能与目前的应用程序一样;它按照questionGroup.questions所规定的顺序从一个问题到下一个问题。

在策略组下创建一个名为RandomQuestionStrategy.swift的文件。将其内容替换为以下内容。

// 1 
import GameplayKit.GKRandomSource

public class RandomQuestionStrategy: QuestionStrategy { 
  // MARK: - Properties 
  public var correctCount: Int = 0 
  public var incorrectCount: Int = 0 
  private let questionGroup: QuestionGroup 
  private var questionIndex = 0 
  private let questions: [Question]
  // MARK: - Object Lifecycle 
  public init(questionGroup: QuestionGroup) { 
    self.questionGroup = questionGroup

    // 2 
    let randomSource = GKRandomSource.sharedRandom()    
    self.questions =
      randomSource.arrayByShufflingObjects( 
        in: questionGroup.questions) as! [Question]
  }

  // 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)" 
  }
}

让我们来看看有趣的部分。

  1. 虽然你可以自己实现随机化逻辑,但GameplayKit.GKRandomSource已经为你做到了,而且效果非常好。尽管有GameplayKit的名字,但这实际上是一个相当小的范围内的导入,所以使用它真的没有什么坏处。

  2. 这里你使用GKRandomSource.sharedRandom(),它是GKRandomSource的 "默认 "或单子实例。又是一种设计模式! 苹果框架中充满了这种模式,你将在下一章中了解这种模式。现在,只需接受它给你一个GKRandomSource的实例。

arrayByShufflingObjects方法正如它所说的那样:它接收一个数组并随机地洗掉其中的元素。这正是你所需要的。唯一的缺点是它返回一个NSArray,因为苹果仍然在其核心框架中完全采用Swift。不过,你可以简单地把它投到[Question]中,就可以了。

接下来,你需要更新QuestionViewController,使用QuestionStrategy而不是直接使用QuestionGroup。

打开QuestionViewController.swift,在delegate下面添加以下属性。

public var questionStrategy: QuestionStrategy! { 
  didSet { 
    navigationItem.title = questionStrategy.title 
  } 
}

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

private func showQuestion() {

  // 1 
  let question = questionStrategy.currentQuestion()

  questionView.answerLabel.text = question.answer   
  questionView.promptLabel.text = question.prompt 
  questionView.hintLabel.text = question.hint

  questionView.answerLabel.isHidden = true   
  questionView.hintLabel.isHidden = true

  // 2 
  questionIndexItem.title = questionStrategy.questionIndexTitle()
}

在这里,你使用questionStrategy来获取(1) currentQuestion()和(2)

questionIndexTitle(),而不是从questionGroup中获得这些。

最后,将handleCorrect(:)和handleIncorrect(:)改为以下内容。

@IBAction func handleCorrect(_ sender: Any) { 
  let question = questionStrategy.currentQuestion()
  questionStrategy.markQuestionCorrect(question)

  questionView.correctCountLabel.text =    
    String(questionStrategy.correctCount)   
  showNextQuestion()
}

@IBAction func handleIncorrect(_ sender: Any) {
  let question = questionStrategy.currentQuestion()   
  questionStrategy.markQuestionIncorrect(question)

  questionView.incorrectCountLabel.text = 
    String(questionStrategy.incorrectCount)   
  showNextQuestion()
}

你再次用questionStrategy来代替questionGroup的使用。

你需要更新的最后一个方法是 showNextQuestion()。然而,这有点棘手,因为你调用了委托方法,而这需要一个questionGroup参数。

你现在面临着一个选择。你可以在QuestionStrategy协议中加入questionGroup,或者更新QuestionViewControllerDelegate方法,使用QuestionStrategy而不是QuestionGroup。

当你在自己的应用程序中面临这样的选择时,你应该试着考虑每一种选择的后果。

根据你的答案,你需要选择一个或另一个......幸运的是,你有50/50的机会是正确的(或错误的)!

在这种情况下,另一个开发者(咳咳,一个知道接下来几章内容的开发者),建议你更新QuestionViewControllerDelegate,把QuestionGroup改成QuestionStrategy。

用以下内容替换现有的QuestionViewControllerDelegate协议(暂且忽略编译器错误)。

public protocol QuestionViewControllerDelegate: class { 
  func questionViewController( 
    _ viewController: QuestionViewController, 
    didCancel questionGroup: QuestionStrategy)

  func questionViewController(
    _ viewController: QuestionViewController, 
    didComplete questionStrategy: QuestionStrategy)
}

接下来,向下滚动并将showNextQuestion()替换为以下内容。

private func showNextQuestion() { 
  guard questionStrategy.advanceToNextQuestion() else { 
    delegate?.questionViewController(self, 
      didComplete: questionStrategy) 
      return 
  } 
  showQuestion() 
}

最后,你还需要将handleCancelPressed(sender:)替换为以下内容。

@objc private func handleCancelPressed( 
  sender: UIBarButtonItem) {
  delegate?.questionViewController(self, 
    didCancel: questionStrategy)
}

因为你已经更新了所有直接使用questionGroup的地方,所以删除questionGroup属性。

在这一点上,你不应该在QuestionViewController上看到任何编译器错误或警告。然而,如果你尝试构建和运行,你仍然会得到编译器错误。

这是因为你还需要更新SelectQuestionViewController,它创建QuestionViewController实例并实现QuestionViewControllerDelegate。

打开SelectQuestionGroupViewController.swift,替换prepare(for:sender:)中的这一行。

viewController.questionGroup = selectedQuestionGroup

......伴随以下内容:

viewController.questionStrategy = 
RandomQuestionStrategy(questionGroup: selectedQuestionGroup)

最后,将实现QuestionViewControllerDelegate的整个扩展替换为以下内容。

extension SelectQuestionGroupViewController: 
QuestionViewControllerDelegate {

  public func questionViewController( 
    _ viewController: QuestionViewController, 
    didCancel questionGroup: QuestionStrategy) { 
      navigationController?.popToViewController(self, animated: true) 
  }

  public func questionViewController( 
    _ viewController: QuestionViewController, 
    didComplete questionGroup: QuestionStrategy) { 
      navigationController?.popToViewController(self, animated: true) 
  }
}

建立并运行你的项目。选择任何单元格,按几次绿色的复选或红色的X按钮,按Back,然后再按同一单元格,重复这一过程。你应该看到,现在的问题是随机的

切换回Xcode中的SelectQuestionGroupViewController.swift,并替换prepare(for:sender:)中的这一行。

viewController.questionStrategy = 
RandomQuestionStrategy(questionGroup: selectedQuestionGroup)

...用这个代替。

viewController.questionStrategy = 
SequentialQuestionStrategy(questionGroup: selectedQuestionGroup)

建立并运行,并尝试再次完成同一组问题。这一次,它们现在应该是以相同的顺序排列。

这多酷啊?你现在可以根据需要轻松地调换不同的策略了!

关键点

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

你已经为Rabble Wabble在运行时切换问题策略奠定了基础。然而,你还没有为用户在运行应用程序时实际创建一个方法来实现这个功能 你将使用另一种模式来保持用户的偏好:单子设计模式。

继续在下一章中学习单例设计模式,并继续构建Rabble Wabble。


上一章 目录 下一章