GameplayKit框架详细解析(二) —— GameplayKit的实用状态机(一)

版本记录

版本号 时间
V1.0 2019.08.21 星期三

前言

GameplayKit框架,构建和组织你的游戏逻辑。 整合常见的游戏行为,如随机数生成,人工智能,寻路和代理行为。接下来几篇我们就一起看一下这个框架。感兴趣的看下面几篇文章。
1. GameplayKit框架详细解析(一) —— 基本概览(一)

开始

首先看下主要内容

在本教程中,您将使用GameplayKitGKStateMachine将iOS应用程序转换为使用状态机进行导航逻辑。

下面看下写作环境

Swift 5, iOS 13, Xcode 11

管理状态很难,但是您可以使用许多技术来管理常规iOS应用中的状态。 GameplayKit框架隐藏了一种有用的技术:GKStateMachine

状态机通常用于游戏编程。但是,它们的实用性并不止于此。程序员已经解决了状态机的问题。 GKStateMachine存在一个游戏开发框架内,但不要让这一点阻止你。没有理由你不能在任何其他有状态管理的iOS应用程序中使用它。

状态机的一些用途可能是理解复杂的业务逻辑,控制视图控制器的状态或管理导航。在本教程中,您将重构应用程序以使用状态机来控制导航。

当您完成本教程时,您将更深入地了解状态机并学习如何:

  • 使用状态机简化应用程序中的逻辑。
  • 使用GKStateMachine配置状态机。
  • 通过定义有效转换来控制状态机流。

首先,打开示例项目。Kanji List是一个学习日语Kanji字符的应用程序,这对你下次去东京旅行肯定是不可或缺的。

该应用程序显示了Kanji列表。单击Kanji将显示该Kanji的含义以及使用Kanji的单词列表。单击任何一个单词将带您进入单词中的Kanji列表,您可以在其中重复该过程到您的内容。

在本教程中,您将重构Kanji List的导航逻辑以使用状态机。 目前,Kanji List使用协调器模式(coordinator pattern)进行导航。 这很棒;这意味着导航代码已经从视图控制器中提取出来。 您只需要添加状态机来组织coordinators

如果您不熟悉协调器模式,请不要担心。 这是一个简单的模式,用于处理视图控制器之间的应用程序流。


Understanding State Machines

状态机是用于表示一次只能处于一个状态的系统的数学抽象。 听起来很复杂吧? 它实际上非常简单,也是一种非常有用的方法来查看一些问题。 例如,想想iPhone中的电池。 您可以将电池看作状态机:

这个状态机有四种状态:

  • 1)Charging:手机已插入并处于充电状态。
  • 2)Fully Charged:电池已满,手机正在通过充电器供电。
  • 3)Discharging:手机已拔下,依靠电池供电。
  • 4)Flat:电池电量不足。

将手机插入充电器会使其进入Charging状态。 从那里,它可以在拔出时转换到Discharging状态。 如果您将电话插入电池直到电池达到100%,它将进入Fully Charged状态。 拔下电话时,如果让电池完全放电,它将进入Flat状态。

状态机表示哪些状态转换有效。 电池在充电时不会flat(除非您需要新电池!),因此状态机中没有从ChargingFlat的转换。


Different Between States

首先,您需要定义不同的状态,以将Kanji列表中的导航表示为状态机。 在Xcode中打开入门项目后,构建并运行应用程序。

该应用程序以所有支持的Kanji列表开始。 这将是All状态。 接下来,点击Kanji以显示详细信息屏幕。 这将是Detail状态。 最后,点击使用Kanji的一个单词转到Kanji中的单词列表,您将其称为List状态。

很好!由于Kanji List是一个简单的应用程序,这三个状态足以代表应用程序的功能。随着应用程序功能的增长,状态机将成为控制事物的有用工具。

1. Transitioning Between States

在应用程序中定义不同的状态有助于您更好地理解应用程序的工作方式。但是,如果没有定义状态之间的有效转换,状态机就不完整了。

每个屏幕都包含导航栏中的All按钮,以转换到All屏幕。这意味着从Detail信息屏幕或List屏幕到All屏幕的转换有效。

点击一个单词可将应用程序带到List屏幕,该屏幕显示单词中的所有kanji。因此,您只能通过Detail屏幕点击单词转换到List屏幕。

此外,点击kanji导航到Detail屏幕。这意味着从All屏幕或List屏幕到Detail屏幕的转换有效。

将所有这些结合起来呈现状态机的这个图:


Creating the State Machine

现在,关于状态已经讨论很多了。 是时候写一些代码了! 首先,在State Machine组下创建一个名为KanjiStateMachine.swift的文件。 用以下内容替换import语句:

import UIKit
import GameplayKit.GKStateMachine

class KanjiStateMachine: GKStateMachine {
  let presenter: UINavigationController
  let kanjiStorage: KanjiStorage

  init(presenter: UINavigationController,
       kanjiStorage: KanjiStorage,
       states: [GKState]) {
    // 1
    self.presenter = presenter

    // 2
    self.kanjiStorage = kanjiStorage

    // 3
    super.init(states: states)
  }
}

虽然不需要继承GKStateMachine,但是这个子类允许您存储状态稍后需要的一些重要数据。 初始化程序很简单,下面是正在发生的事情:

  • 1) 应用程序的协调器模式使用UINavigationController作为视图控制器的演示者(presenter)。 状态机将拥有演示者。
  • 2) KanjiStorage类本质上是应用程序的字典。 它存储所有kanji和包含它们的单词。 KanjiStateMachine管理要使用的每个状态的KanjiStorage对象。
  • 3) GKStateMachine的初始化程序需要您正在使用的每个状态的实例,因此将其传递给GKStateMachine.init(states:)

接下来,打开ApplicationCoordinator.swift。 这是应用程序的根协调器,它创建根视图控制器并将其添加到应用程序的UIWindow。 在类的顶部为状态机添加新属性:

let stateMachine: KanjiStateMachine

init(window :)结束时,添加以下内容以创建状态机:

stateMachine = KanjiStateMachine(
  presenter: rootViewController,
  kanjiStorage: kanjiStorage,
  states: [])

因为您还没有创建任何GKState类,所以您只需暂时为状态传递一个空数组。


Creating Each State

现在您已经创建了状态机,现在是时候添加一些状态了。 现在,应用程序中的每个协调员都会根据需要创建其他协调器以导航到不同的屏幕。 因此,因为您想要移动决定导航到哪个屏幕,所以您需要从协调器中删除该逻辑。

ApplicationCoordinator开始。 此类将保留状态机,但所有其他协调器将由状态机中的某个状态创建。 因此,删除ApplicationCoordinator上的allKanjiListCoordinator属性。 稍后您将在AllState类中重新创建它。 删除创建协调器的init(window :)中的这一行:

allKanjiListCoordinator = KanjiListCoordinator(
  presenter: rootViewController, 
  kanjiStorage: kanjiStorage,
  list: kanjiStorage.allKanji(), 
  title: "Kanji List")

还有启动协调器的start()内部的这个方法:

allKanjiListCoordinator.start()

构建并运行应用程序。 好像它失去了一些功能:

当你开始创建状态时,你将获得它。

1. All State

State Machine组下添加名为AllState.swift的新文件。 用以下内容替换其import语句:

import GameplayKit.GKState

class AllState: GKState {
  // 1
  lazy var allKanjiListCoordinator = makeAllKanjiCoordinator()

  // 2
  override func didEnter(from previousState: GKState?) {
    allKanjiListCoordinator?.start()
  }

  private func makeAllKanjiCoordinator() -> KanjiListCoordinator? {
    // 3
    guard let kanjiStateMachine = stateMachine as? KanjiStateMachine else {
      return nil
    }
    
    let kanjiStorage = kanjiStateMachine.kanjiStorage

    // 4
    return KanjiListCoordinator(
      presenter: kanjiStateMachine.presenter,
      kanjiStorage: kanjiStorage,
      list: kanjiStorage.allKanji(),
      title: "Kanji List")
  }
}

下面进行细分:

  • 1) 在这里,您重新创建从ApplicationCoordinator.swift中删除的协调器。
  • 2) 只要状态机进入新状态,didEnter(from :)就会触发。 它是触发allKanjiListCoordinator导航到All屏幕的理想场所。
  • 3) 您可以使用GKState上的stateMachine属性来获取其状态机。 在这里,您将其强制转换为KanjiStateMachine以访问您之前添加的属性。
  • 4) 要构建KanjiListCoordinator,请为其提供显示All屏幕所需的所有数据。

接下来,打开ApplicationCoordinator.swift并找到在init(window :)中创建状态机的行。 创建一个AllState实例并将其传递给states数组,如下所示:

stateMachine = KanjiStateMachine(
  presenter: rootViewController,
  kanjiStorage: kanjiStorage,
  states: [AllState()])

start()中,将以下行添加到方法的开头:

stateMachine.enter(AllState.self)

这会导致状态机进入AllState并触发allKanjiListCoordinator导航到All屏幕。 构建并运行应用程序。 一切都在顺利进行!

2. Detail State

打开KanjiListCoordinator.swift并在底部的扩展中找到kanjiListViewController(_:didSelectKanji :)。 此方法创建并启动KanjiDetailCoordinator,使应用程序导航到Detail屏幕。 删除方法的内容,将其留空。

构建并运行应用程序。 它应该仍然显示All屏幕。 但是,因为您从kanjiListViewController(_:didSelectKanji :)中删除了导航逻辑,所以KanjiListCoordinator不会创建下一个协调器来移动到不同的屏幕。 点击一个kanji什么也没做。

要解决此问题,您需要将刚刚删除的代码添加到新的状态对象中。 在State Machine组下添加名为DetailState.swift的新文件。 用以下内容替换其import语句:

import GameplayKit.GKState

class DetailState: GKState {
  // 1
  var kanji: Kanji?
  var kanjiDetailCoordinator: KanjiDetailCoordinator?

  override func didEnter(from previousState: GKState?) {
    guard 
      let kanji = kanji,
      let kanjiStateMachine = (stateMachine as? KanjiStateMachine) 
      else {
        return
    }
    
    // 2
    let kanjiDetailCoordinator = KanjiDetailCoordinator(
      presenter: kanjiStateMachine.presenter,
      kanji: kanji,
      kanjiStorage: kanjiStateMachine.kanjiStorage)

    self.kanjiDetailCoordinator = kanjiDetailCoordinator
    kanjiDetailCoordinator.start()
  }
}

下面进行细分

  • 1) KanjiDetailCoordinator需要一个Kanji来显示Detail屏幕。 你需要在这里设置它。
  • 2) 创建并启动KanjiDetailCoordinator,类似于之前在kanjiListViewController(_:didSelectKanji :)中的操作。

3. Communicating to the State Machine

您需要一种与进入DetailState所需的状态机进行通信的方法。 因此,您将使用NotificationCenter提交通知,然后在ApplicationCoordinator中监听它。 回到KanjiListCoordinator.swift,将此行添加到kanjiListViewController(_:didSelectKanji :)

NotificationCenter.default
  .post(name: Notifications.KanjiDetail, object: selectedKanji)

Notifications.KanjiDetail只是提前为您创建的NSNotification.Name对象。 这会发布通知,传递显示Detail屏幕所需的selectedKanji

再次打开ApplicationCoordinator.swift。 转到在init(window :)中创建状态机的行。 创建一个DetailState实例并将其传递给states数组,就像之前为AllState所做的那样:

stateMachine = KanjiStateMachine(
  presenter: rootViewController,
  kanjiStorage: kanjiStorage,
  states: [AllState(), DetailState()])

下面,添加这一行

@objc func receivedKanjiDetailNotification(notification: NSNotification) {
  // 1
  guard 
    let kanji = notification.object as? Kanji,
    // 2
    let detailState = stateMachine.state(forClass: DetailState.self) 
    else {
      return
  }

  // 3
  detailState.kanji = kanji

  // 4
  stateMachine.enter(DetailState.self)
}

下面进行细分:

  • 1) 获取随通知notification一起传递的Kanji对象
  • 2) GKStateMachine.state(forClass :)返回传递给状态机初始值设定项的状态实例。 在这里获取该实例。
  • 3) 存储在创建其KanjiDetailCoordinator时要使用的DetailStatekanji
  • 4) 最后,输入DetailState,它将创建并启动KanjiDetailCoordinator

您仍然需要订阅KanjiDetail通知,因此将其添加到subscribeToNotifications()

NotificationCenter.default.addObserver(
  self, selector: #selector(receivedKanjiDetailNotification),
  name: Notifications.KanjiDetail, object: nil)

构建并运行应用程序。 您应该可以点击一个Kanji并再次到达详细信息Detail屏幕。

4. List State

实现ListState的过程与您之前看到的类似。 您将从协调器中删除导航逻辑,将其移动到新的GKState类并与stateMachine通信它应该进入新状态。

首先,打开KanjiDetailCoordinator.swift。 当用户点击详细信息屏幕上的单词时,会触发kanjiDetailViewController(_:didSelectWord :)。 然后,它创建并启动一个KanjiListCoordinator,以显示该单词中所有汉字的列表屏幕。

删除kanjiDetailViewController(_:didSelectWord :)的内容并将其替换为:

NotificationCenter.default.post(name: Notifications.KanjiList, object: word)

回到ApplicationCoordinator.swift,创建一个新的空方法来接收通知:

@objc func receivedKanjiListNotification(notification: NSNotification) {
}

然后,添加以下代码以订阅subscribeToNotifications()中的通知。

NotificationCenter.default.addObserver(
  self, selector: #selector(receivedKanjiListNotification),
  name: Notifications.KanjiList, object: nil)

State Machine组下,创建一个名为ListState.swift的新文件。 用以下内容替换其import语句:

import GameplayKit.GKState

class ListState: GKState {
  // 1
  var word: String?
  var kanjiListCoordinator: KanjiListCoordinator?

  override func didEnter(from previousState: GKState?) {
    guard 
      let word = word,
      let kanjiStateMachine = (stateMachine as? KanjiStateMachine) 
      else {
        return
    }
    
    let kanjiStorage = kanjiStateMachine.kanjiStorage

    // 2
    let kanjiForWord = kanjiStorage.kanjiForWord(word)

    // 3
    let kanjiListCoordinator = KanjiListCoordinator(
      presenter: kanjiStateMachine.presenter, kanjiStorage: kanjiStorage,
      list: kanjiForWord, title: word)

    self.kanjiListCoordinator = kanjiListCoordinator
    kanjiListCoordinator.start()
  }
}

它与您用于DetailState的模式相同,但这是正在发生的事情:

  • 1) 列表屏幕显示单词中的所有kanji。 所以,在这里存储这个词,以便从中获取kanji
  • 2) 使用KanjiStorage对象从单词中获取kanji列表。
  • 3) 将所有必要的数据传递到KanjiListCoordinator的初始化程序中,并调用start()导航到List屏幕。

现在您已经拥有了ListState,您可以将其传递到状态机并在需要时进入状态。 回到ApplicationCoordinator.swift,在init(window :)中将ListState的实例传递给KanjiStateMachine的初始化器:

stateMachine = KanjiStateMachine(
  presenter: rootViewController,
  kanjiStorage: kanjiStorage,
  states: [AllState(), DetailState(), ListState()])

将以下内容添加到receivedKanjiListNotification(notification :)以配置并输入ListState

// 1
guard 
  let word = notification.object as? String,
  let listState = stateMachine.state(forClass: ListState.self) 
  else {
    return
}

// 2
listState.word = word

// 3
stateMachine.enter(ListState.self)

这是细分:

  • 1) 从通知和状态机中的ListState实例获取单词。
  • 2) 在ListState上设置状态以配置KanjiListCoordinator
  • 3) 输入ListState,使KanjiListCoordinator开始导航到列表屏幕。

构建并运行应用程序。 一切都应该顺利进行,全部由GKStateMachine管理。


Using Other Abilities of GKStateMachine

还记得状态机状态之间的不同转换吗?

好吧,您可以将这些转换添加到GKState类,以防止任何无效转换发生。 打开AllState.swift并添加以下方法:

override func isValidNextState(_ stateClass: AnyClass) -> Bool {
  return false
}

isValidNextState(_ :)允许您定义此GKState可以达到的状态。 因为它返回false,状态机将无法从此状态转换到任何其他状态。 构建并运行应用程序。 点击kanji什么也不做:

因为只有将AllState移动到特定kanji的详细信息屏幕才有意义,唯一有效的下一个状态是DetailState。 用以下内容替换isValidNextState(_ :)的内容:

return stateClass == DetailState.self

构建并运行应用程序,您应该能够再次访问详细信息屏幕。 接下来,将其添加到DetailState.swift

override func isValidNextState(_ stateClass: AnyClass) -> Bool {
  return stateClass == AllState.self || stateClass == ListState.self
}

DetailState可以移动到其余状态中的任何一个,因此对于任一状态都返回true

DetailState类似,将以下内容添加到ListState.swift

override func isValidNextState(_ stateClass: AnyClass) -> Bool {
  return stateClass == DetailState.self || stateClass == AllState.self
}

构建并运行应用程序。 一切都应该仍然有用。

现在,对AllState进行最后一次更改。 打开ApplicationCoordinator.swift并查看receivedAllKanjiNotification()

当点击导航栏中的All按钮时,它会触发通知,ApplicationCoordinator会弹出到根视图控制器。 协调器不应该具有关于导航层次结构的这种知识。 它应该知道的是该应用程序是为了进入AllState。 因此,删除receivedAllKanjiNotification()的内容并将其替换为:

stateMachine.enter(AllState.self)

现在,不是直接弹出到根视图控制器,receiveAllKanjiNotification()将只转换到AllState。 构建并运行应用程序。 点击All按钮时,它会将新的视图控制器推入堆栈。 您仍然希望它pop到根视图控制器,而不是push到新的视图控制器。 打开AllState.swift并用以下内容替换didEnter(from :)的内容:

if previousState == nil {
  allKanjiListCoordinator?.start()
} else {
  (stateMachine as? KanjiStateMachine)?.presenter
    .popToRootViewController(animated: true)
}

当您调用GKStateMachine.enter(_ :)时,先前的状态将传递到didEnter(from :)到当前状态。 如果这是状态机的第一个状态,则没有先前的状态,因此previousState将为nil。 在这种情况下,您可以在allKanjiListCoordinator上调用start()。 但是如果存在先前的状态,则意味着您应该pop到根视图控制器以返回到All屏幕。

构建并运行应用程序。 在List screenDetail screen上,All按钮应该会返回到All屏幕。

都完成了,您重构了Kanji列表以使用GKStateMachine来管理应用中的导航。 做得好!

Apple关于GKStateGKStateMachine的文档非常宝贵。 您可能也有兴趣了解有关state machines的更多信息。

后记

本篇主要讲述了GameplayKit的实用状态机,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容