第15章:状态模式

状态模式是一种行为模式,它允许一个对象在运行时改变其行为。它通过改变其当前状态来实现。在这里,"状态 "是指描述一个特定对象在特定时间应该如何行为的数据集。

这种模式涉及三种类型。

  1. 上下文是具有当前状态的对象,其行为发生变化。

  2. 状态协议规定了所需的方法和属性。开发人员通常用一个基态类来代替协议。通过这样做,他们可以在基类中定义存储的属性,这在协议中是不可能的。

即使使用基类,它也不打算被直接实例化。相反,它被定义的唯一目的是为了被子类化。在其他语言中,这将是一个抽象类。然而,Swift目前没有抽象类,所以这个类并没有按照惯例被实例化。

  1. 具体的状态符合状态协议,或者如果使用基类来代替,它们会对基类进行子类化。上下文保持其当前状态,但它不知道其具体的状态类型。相反,上下文使用多态性来改变行为:具体状态定义了上下文应该如何行动。如果你需要一个新的行为,你可以定义一个新的具体状态。

然而,一个重要的问题是:你究竟把改变上下文当前状态的代码放在哪里?是在上下文本身、具体状态中,还是在其他地方?

你可能会惊讶地发现,状态模式并没有告诉你把状态改变的逻辑放在哪里!相反,你要负责决定在什么情况下改变状态。相反,你要负责决定这个问题。这既是这种模式的优点,也是缺点。它允许设计是灵活的,但同时,它也没有为如何实现这一模式提供完整的指导。

在本章中,你将学习两种实现状态变化的方法。在playground的例子中,你将把变化逻辑放在上下文中,而在教程项目中,你将让具体状态本身来处理变化。

你应该在什么时候使用它?

使用状态模式来创建一个系统,该系统有两个或更多的状态,它在其生命周期内会在这些状态之间变化。这些状态可以是有限的(一个 "封闭 "的集合),也可以是无限的(一个 "开放 "的集合)。例如,一个交通灯可以用一个封闭的 "交通灯状态 "集来定义。在最简单的情况下,它从绿色到黄色到红色再到绿色。

一个动画引擎可以被定义为一个开放的 "动画状态集"。它有无限的不同的旋转、平移和其他动画,在它的生命周期中,它可以通过这些动画来发展。

状态模式的开放集和封闭集的实现都使用多态性来改变行为。因此,你通常可以用这种模式消除switch和if-else语句。

你不需要在上下文中保持对复杂条件的跟踪,而是通过对当前状态的调用;你会在playground的例子和教程项目中看到这一点的作用。如果你有一个带有多个switch或if-else语句的类,试着用状态模式来定义它。你可能会因此创建一个更灵活、更容易维护的系统。

playground实例

打开Starter目录下的IntermediateDesignPatterns.xcworkspace,然后打开State页面。

你将实现上面提到的 "交通灯 "系统。具体来说,你将使用Core Graphics来绘制一个交通灯,并将其 "当前状态 "从绿色变为黄色,再变为红色,然后再变为绿色。

注意:你需要对Core Graphics有基本的了解,才能完全理解这个playground实例。至少,你应该对CALayer和CAShapeLayer有所了解。如果你是Core Graphics的新手,请在这里阅读我们关于Core Graphics的免费教程。(http://bit.ly/rw-coregraphics)。

在代码示例后输入以下内容,以确定上下文。

下面是这个的作用。

  1. 你首先为canisterLayers定义一个属性。这将保持 "交通灯罐 "层。这些层将作为子层保持绿色/黄色/红色状态。

  2. 为了保持playground的简单,你将不支持init(coder:)。

  3. 你声明init(canisterCount:frame:)为指定的初始化器,并为canisterCount和frame提供默认值。你还将背景颜色设置为淡黄色,并调用createCanisterLayers(count:)。

你将在createCanisterLayers(count:)中完成真正的工作。在这个方法中加入以下内容。

逐一进行评论。

  1. 你首先计算yTotalPadding占bounds.height的百分比,然后用这个结果来确定每个yPadding空间。填充空间 "的总数等于count(罐子的数量)+1(底部的一个额外空间)。

  2. 使用yPadding,你可以计算canisterHeight。为了保持罐子的方形,你用canisterHeight来计算每个罐子的高度和宽度。然后,你用canisterHeight来计算每个罐子的中心所需的xPadding。

    最后,你使用xPadding、yPadding和canisterHeight来创建 canisterFrame,它表示第一个罐子的框架。

  3. 使用canisterFrame,从0到count循环,为所需数量的罐子创建canisterShape,由count给出。创建完每个canisterShape后,将其加入canisterLayers。通过保持对每个罐子层的引用,你以后就可以向它们添加 "交通灯状态 "子层。

添加下面的代码,看看你的代码的作用。

let trafficLight = TrafficLight() 
PlaygroundPage.current.liveView = trafficLight

在这里,你创建了一个trafficLight的实例,并把它设置为playground当前页面的liveView,它输出到Live View。如果你没有看到输出,按 Editor ▸ Live View。

为了防止在你继续修改这个类时出现编译器错误,请删除你刚才添加的两行代码。

为了显示灯的状态,你需要制定一个状态协议。在playground页面的底部添加以下内容。

  1. 你首先声明一个延迟属性,它规定了一个状态应该被显示的时间间隔。

  2. 然后声明apply(to:),每个具体的状态都需要实现它。

接下来,在TrafficLight中添加以下属性,就在canisterLayers之后,暂时忽略由此产生的编译器错误。

public private(set) var currentState: TrafficLightState 
public private(set) var states: [TrafficLightState]

正如其名称所暗示的,你将使用currentState来保持交通灯当前的TrafficLightState,并使用states来保持交通灯的所有TrafficLightStates。你把这两个属性都表示为private(set),以确保只有交通灯本身可以设置它们。

接下来,将init(canisterCount:frame:)改为以下内容。

  1. 你在这个初始化器中添加了状态。由于 states 为空在逻辑上是说不通的,所以如果它是空的,你就抛出一个 fatalError。否则,你将currentState设置为state中的第一个对象,并将self.state设置为传入的state。

  2. 之后,你调用super.init,设置背景颜色并调用createCanisterLayers,就像你之前做的那样。

接下来,在TrafficLight的结束类大括号前添加以下代码。

你定义了transition(to state:)来改变一个新的TrafficLightState。你首先调用removeCanisterSublayers来移除现有的canister子层;这可以确保新的状态不会被添加到现有的状态之上。然后设置currentState并调用apply。这允许状态将其内容添加到TrafficLight实例中。

接下来,在init(canisterCount:frame:states:)的末尾添加这一行。

transition(to: currentState)

这样就可以确保在初始化时把currentState加入到视图中。

现在你需要创建具体的状态。将下面的代码添加到playground的结尾。

你声明SolidTrafficLightState来代表一个 "实心灯 "状态。例如,这可以代表一个固体绿灯。这个类有三个属性:canisterIndex是TrafficLight上的canisterLayers的索引,这个状态应该被添加到其中,color是这个状态的颜色,delay是距离下一个状态应该被显示的时间。

你接下来需要使SolidTrafficLightState符合TrafficLightState。将下面的代码添加到playground的末尾。

在apply(to:)中,你为状态创建了一个新的CAShapeLayer:你设置它的路径以匹配其指定的canisterIndex的canisterLayer,使用它的颜色设置它的fillPath和strokeColor,最终,将形状添加到canister层。

接下来,将这段代码添加到playground的末尾。

在这里,你添加了便利类方法来创建常见的SolidTrafficLightStates:实心绿灯、黄灯和红灯。

你终于准备好将这段代码付诸行动了! 在playground的末尾添加以下内容。

这将创建一个典型的绿色/黄色/红色的交通灯,并将其设置为当前playground页面的liveView。

但是,等等! 交通信号灯不是应该从一个状态切换到下一个状态吗?哦--你还没有真正实现这个功能呢。状态模式实际上并没有告诉你在哪里或如何进行状态转换。在这种情况下,你实际上有两个选择:你可以把状态变化逻辑放在TrafficLight中,或者你可以把它放在TrafficLightState中。

在真正的应用中,你应该评估这些选择中哪一个对你的预期用例更好,以及从长远来看什么更好。对于这个playground上的例子,"另一个开发者"(即你卑微的作者)已经告诉你,逻辑更适合放在TrafficLight中,所以这就是你要把改变代码的地方。

首先,在TrafficLightState的结尾大括号后添加以下扩展。

这个扩展为每个符合TrafficLightState的类型增加了 "应用后 "的功能。在apply(to:after:)中,你在一个传递的延迟后派发到DispatchQueue.main,这时你会过渡到当前状态。为了打破潜在的保留循环,你把self和context都指定为闭包中的弱点。

接下来,在TrafficLight中,在removeCanisterSublayers()之后添加以下内容。

这个扩展为每个符合TrafficLightState的类型增加了 "应用后 "的功能。在apply(to:after:)中,你在一个传递的延迟后派发到DispatchQueue.main,这时你会过渡到当前状态。为了打破潜在的保留循环,你把self和context都指定为闭包中的弱点。

接下来,在TrafficLight中,在removeCanisterSublayers()之后添加以下内容。

这为nextState创建了一个方便的计算属性,你通过找到代表currentState的索引来确定它。如果在索引之后还有状态,你通过索引+1 < states.count来确定,你就返回下一个状态。如果currentState之后没有状态,你就返回第一个状态,回到起点。

最后,在transition(to state:)的末尾添加以下一行。

nextState.apply(to: self, after: currentState.delay)

这告诉nextState在当前状态的延迟过后,将自己应用到交通灯上。

看看助理编辑器,你现在会看到它在循环使用各种状态!

你应该注意什么?

小心在上下文和具体状态之间建立紧耦合。你会不会想在不同的上下文中重复使用这些状态?如果是这样,可以考虑在具体状态和上下文之间设置一个协议,而不是让具体状态调用特定上下文的方法。

如果你选择在状态本身中实现状态变化逻辑,要注意从一个状态到下一个状态的紧密耦合。

你会不会想从状态过渡到另一个状态呢?在这种情况下,可以考虑通过初始化器或属性传入下一个状态。

教程项目

你将继续上一章的Mirror Pad应用。

如果你跳过了上一章,或者你想重新开始,请打开Finder并导航到你下载本章资源的地方。然后,在Xcode中打开starter/MirrorPad\MirrorPad.xcodeproj。

构建并运行该应用程序,并在左上角的视图中画几条线。然后按下Animate键,观察应用程序对镜像画作的动画效果。在动画完成之前,尝试在左上角的视图中绘制更多的线条。应用程序允许你这样做,但这是一个糟糕的用户体验。

让我们来解决这个问题!

打开DrawView.swift,看看这个类。它目前正在做大量的工作:接受用户输入、执行复制、绘图、动画等。如果你继续扩展Mirror Pad的功能,你很可能难以维持这个类。它所做的工作实在是太多了。

你将使用状态模式来实现这两个功能,但你已经猜到了,对吗?你将把DrawView变成上下文,创建一个新的DrawViewState作为基态类,并创建几个具体的状态,将DrawViewState子类化以执行所需的行为。

首先,你需要添加新的组和文件。在视图组中创建一个新的组,名为DrawView。然后,把DrawView.swift和LineShape.swift移到新创建的DrawView组。

在DrawView组内创建另一个名为States的新组。在States组中,为每一个文件创建新的Swift文件。

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

接下来你将实现DrawViewState。将DrawViewState.swift的内容替换为以下内容。

下面是上面代码中的情况。

  1. 你首先声明一个叫做标识符的类属性。以后你会用它来切换状态。

  2. 2.然后,你声明了一个名为drawView的无主实例属性,这将是状态模式中的上下文。你通过指定的初始化器init(drawView:)传入上下文。

    这就在DrawViewState和DrawView之间建立了一个紧密的联系。在这个应用程序中,你只需要和DrawView一起使用DrawViewState,所以这种耦合不是一个问题。然而,在你自己的应用程序中,你应该考虑是否要在不同的环境中重复使用DrawViewState。

  3. 然后,你为所有可能的动作声明方法,并为每个动作提供空的实现。具体状态的子类将需要覆盖它们所支持的任何动作。如果一个具体的状态没有覆盖一个动作,它将继承这个空的实现,什么也不做。

  4. 在最后,你声明一个方法来改变不同的状态。这个方法的返回值是DrawViewState,以使你在切换到新状态后能够调用新状态的动作。不过,在完成这个方法之前,你需要对DrawView进行修改,所以你添加了一个TODO注释,并将self作为一个占位符返回。

接下来,你将把每个具体的状态存根化。基本上,你将把DrawView中的代码移到状态中,以方便重构。

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

用这个替换AnimateState.swift的内容。

用这个替换ClearState.swift的内容。

最后,将CopyState.swift的内容替换成这样。

很好! 现在你可以开始重构DrawView了。打开DrawView.swift,在现有的属性后面添加以下属性。

正如它的名字所暗示的,你将使用currentState来保持当前的具体状态。

你将在状态中保留所有可能的状态。这是一个字典,用DrawViewState上定义的标识符的计算值作为键,用具体的状态实例作为值。为什么这是一个字典而不是一个数组?这是因为具体的状态没有一个转换的顺序!相反,状态的转换是取决于具体的状态。相反,状态的转换取决于用户的交互。下面是它的工作方式。

  1. currentState首先被设置为AcceptInputState,作为其默认值。

  2. 如果用户按下Clear,AcceptInputState将把上下文的当前状态改为ClearState;Clear状态将执行 "清除 "行为;之后,它将把上下文的当前状态改回AcceptInputState。

  3. 如果用户按了Animate,AcceptInputState将把上下文的当前状态改为AnimateState;Animate状态将执行动画;完成后,它将把上下文的当前状态改回AcceptInputState。

  4. 如果copy被调用,AcceptInputState将把上下文的currentState改为CopyState;copy状态将执行复制;之后,它将把currentState改回AcceptInputState。

还记得你之前在DrawViewState上存根的那个方法吗?现在DrawView已经有了currentState和state的定义,你可以完成这个方法了。

打开DrawViewState.swift,把transitionToState(matching:)的内容替换为以下内容。

这是从drawView.states中使用传入的标识符查找状态,将值设置为drawView.currentState并返回状态。

剩下的就是把DrawView的逻辑移到适当的具体状态中。

你将会经常编辑DrawView,所以在一个新的编辑器窗口中打开它将会很有用。要做到这一点,按住Option键,在文件层次结构中左键点击DrawView.swift。这将使你在编辑DrawView的同时,轻松地编辑具体的状态类。

在第一个编辑器窗口的任何地方点击,然后左键点击AcceptInputState.swift,在这个窗口中打开它。给这个类添加以下方法。

下面是具体的玩法。

  1. animate()、clear()和copyLines(from:)非常相似。你可以通过 transitionToState(matching:) 来改变到适当的状态,然后简单地将调用转发给它。

  2. AcceptInputState负责处理touchesBegan(:with:)和touchesMoved(:with:)本身。如果你将这段代码与DrawView内的代码相比较,你会发现它几乎是相同的。唯一不同的是,你有时必须预先调用drawView.来执行对drawView而不是对状态的操作。

将DrawView中的touchesBegan(:with:)和touchesMoved(:with:)改为以下内容。

在这里,你只需将这些方法调用转发给currentState。如果currentState是AcceptInputState的一个实例,默认情况下是这样的,那么应用程序的行为将和以前一样。

构建并运行,并在左上角的视图中绘图,以验证应用程序仍能按预期工作。

接下来,打开AnimateState.swift,将这些方法添加到该类中。

这段代码几乎与DrawView相同,但有两个主要变化。

  1. 如果没有任何子层需要动画化,你就立即过渡到AcceptInputState,而不做其他事情。

  2. 每当整个动画完成后,你也同样过渡到AcceptInputState。

你也应该注意到你没有覆盖的方法,特别是touchesBegan(:with:)和touchesMoved(:with:)。因此,只要currentState被设置为AnimateState,如果用户试图在视图中绘图,你就不会做任何事情。从本质上讲,你通过什么都不做来解决了一个错误。这多棒啊!

当然,你需要确保DrawView将对animate()的调用传递给currentState。因此,将DrawView的animate()替换成这样。

public func animate() { 
  currentState.animate() 
}

然后,从DrawView中删除setSublayersStrokeEnd()和animateStrokeEnds();你不再需要这些方法了,因为现在的逻辑是由AnimateState处理的。

你只剩下两个状态了! 打开ClearState.swift,在该类中添加以下方法。

这就像DrawView的代码一样。唯一的补充是,一旦 "清除 "完成,你就过渡到AcceptInputState。

你还需要更新DrawView;用这个代替它的clear()。

从文件层次结构中打开CopyState.swift,并在类中添加这个方法。

同样,这和DrawView一样,唯一的补充是,一旦复制完成,你就过渡到AcceptInputState。

当然,你也需要在DrawView中用这个更新copyLines(from:)。

构建和运行,并验证一切都像以前一样工作。

看看现在的DrawView短了多少!你把它的责任转移到了它的具体状态上。你已经把它的责任转移到了具体的状态中。如果你想添加新的逻辑,你只需创建一个新的DrawViewState。

关键点

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

镜像垫也在不断发展 当你按下 "Animate "时,你可以看到图画被渲染出来,这非常酷。然而,如果你能在绘画时看到它们被实时添加,那不是更好吗?你肯定会这样做的!

继续下一章,学习多播委托的设计模式,并将上述实时功能添加到Mirror Pad中!


上一章 目录 下一章