状态模式是一种行为模式,它允许一个对象在运行时改变其行为。它通过改变其当前状态来实现。在这里,"状态 "是指描述一个特定对象在特定时间应该如何行为的数据集。
这种模式涉及三种类型。
上下文是具有当前状态的对象,其行为发生变化。
状态协议规定了所需的方法和属性。开发人员通常用一个基态类来代替协议。通过这样做,他们可以在基类中定义存储的属性,这在协议中是不可能的。
即使使用基类,它也不打算被直接实例化。相反,它被定义的唯一目的是为了被子类化。在其他语言中,这将是一个抽象类。然而,Swift目前没有抽象类,所以这个类并没有按照惯例被实例化。
然而,一个重要的问题是:你究竟把改变上下文当前状态的代码放在哪里?是在上下文本身、具体状态中,还是在其他地方?
你可能会惊讶地发现,状态模式并没有告诉你把状态改变的逻辑放在哪里!相反,你要负责决定在什么情况下改变状态。相反,你要负责决定这个问题。这既是这种模式的优点,也是缺点。它允许设计是灵活的,但同时,它也没有为如何实现这一模式提供完整的指导。
在本章中,你将学习两种实现状态变化的方法。在playground的例子中,你将把变化逻辑放在上下文中,而在教程项目中,你将让具体状态本身来处理变化。
使用状态模式来创建一个系统,该系统有两个或更多的状态,它在其生命周期内会在这些状态之间变化。这些状态可以是有限的(一个 "封闭 "的集合),也可以是无限的(一个 "开放 "的集合)。例如,一个交通灯可以用一个封闭的 "交通灯状态 "集来定义。在最简单的情况下,它从绿色到黄色到红色再到绿色。
一个动画引擎可以被定义为一个开放的 "动画状态集"。它有无限的不同的旋转、平移和其他动画,在它的生命周期中,它可以通过这些动画来发展。
状态模式的开放集和封闭集的实现都使用多态性来改变行为。因此,你通常可以用这种模式消除switch和if-else语句。
你不需要在上下文中保持对复杂条件的跟踪,而是通过对当前状态的调用;你会在playground的例子和教程项目中看到这一点的作用。如果你有一个带有多个switch或if-else语句的类,试着用状态模式来定义它。你可能会因此创建一个更灵活、更容易维护的系统。
打开Starter目录下的IntermediateDesignPatterns.xcworkspace,然后打开State页面。
你将实现上面提到的 "交通灯 "系统。具体来说,你将使用Core Graphics来绘制一个交通灯,并将其 "当前状态 "从绿色变为黄色,再变为红色,然后再变为绿色。
注意:你需要对Core Graphics有基本的了解,才能完全理解这个playground实例。至少,你应该对CALayer和CAShapeLayer有所了解。如果你是Core Graphics的新手,请在这里阅读我们关于Core Graphics的免费教程。(http://bit.ly/rw-coregraphics)。
在代码示例后输入以下内容,以确定上下文。
下面是这个的作用。
你首先为canisterLayers定义一个属性。这将保持 "交通灯罐 "层。这些层将作为子层保持绿色/黄色/红色状态。
为了保持playground的简单,你将不支持init(coder:)。
你声明init(canisterCount:frame:)为指定的初始化器,并为canisterCount和frame提供默认值。你还将背景颜色设置为淡黄色,并调用createCanisterLayers(count:)。
你将在createCanisterLayers(count:)中完成真正的工作。在这个方法中加入以下内容。
逐一进行评论。
你首先计算yTotalPadding占bounds.height的百分比,然后用这个结果来确定每个yPadding空间。填充空间 "的总数等于count(罐子的数量)+1(底部的一个额外空间)。
使用yPadding,你可以计算canisterHeight。为了保持罐子的方形,你用canisterHeight来计算每个罐子的高度和宽度。然后,你用canisterHeight来计算每个罐子的中心所需的xPadding。
最后,你使用xPadding、yPadding和canisterHeight来创建 canisterFrame,它表示第一个罐子的框架。
使用canisterFrame,从0到count循环,为所需数量的罐子创建canisterShape,由count给出。创建完每个canisterShape后,将其加入canisterLayers。通过保持对每个罐子层的引用,你以后就可以向它们添加 "交通灯状态 "子层。
添加下面的代码,看看你的代码的作用。
let trafficLight = TrafficLight()
PlaygroundPage.current.liveView = trafficLight
在这里,你创建了一个trafficLight的实例,并把它设置为playground当前页面的liveView,它输出到Live View。如果你没有看到输出,按 Editor ▸ Live View。
为了防止在你继续修改这个类时出现编译器错误,请删除你刚才添加的两行代码。
为了显示灯的状态,你需要制定一个状态协议。在playground页面的底部添加以下内容。
你首先声明一个延迟属性,它规定了一个状态应该被显示的时间间隔。
然后声明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:)改为以下内容。
你在这个初始化器中添加了状态。由于 states 为空在逻辑上是说不通的,所以如果它是空的,你就抛出一个 fatalError。否则,你将currentState设置为state中的第一个对象,并将self.state设置为传入的state。
之后,你调用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文件。
AcceptInputState.swift
AnimateState.swift
ClearState.swift
CopyState.swift
DrawViewState.swift
现在你的视图组在文件层次结构中应该是这样的。
接下来你将实现DrawViewState。将DrawViewState.swift的内容替换为以下内容。
下面是上面代码中的情况。
你首先声明一个叫做标识符的类属性。以后你会用它来切换状态。
2.然后,你声明了一个名为drawView的无主实例属性,这将是状态模式中的上下文。你通过指定的初始化器init(drawView:)传入上下文。
这就在DrawViewState和DrawView之间建立了一个紧密的联系。在这个应用程序中,你只需要和DrawView一起使用DrawViewState,所以这种耦合不是一个问题。然而,在你自己的应用程序中,你应该考虑是否要在不同的环境中重复使用DrawViewState。
然后,你为所有可能的动作声明方法,并为每个动作提供空的实现。具体状态的子类将需要覆盖它们所支持的任何动作。如果一个具体的状态没有覆盖一个动作,它将继承这个空的实现,什么也不做。
在最后,你声明一个方法来改变不同的状态。这个方法的返回值是DrawViewState,以使你在切换到新状态后能够调用新状态的动作。不过,在完成这个方法之前,你需要对DrawView进行修改,所以你添加了一个TODO注释,并将self作为一个占位符返回。
接下来,你将把每个具体的状态存根化。基本上,你将把DrawView中的代码移到状态中,以方便重构。
将AcceptInputState.swift的内容替换为以下内容。
用这个替换AnimateState.swift的内容。
最后,将CopyState.swift的内容替换成这样。
正如它的名字所暗示的,你将使用currentState来保持当前的具体状态。
你将在状态中保留所有可能的状态。这是一个字典,用DrawViewState上定义的标识符的计算值作为键,用具体的状态实例作为值。为什么这是一个字典而不是一个数组?这是因为具体的状态没有一个转换的顺序!相反,状态的转换是取决于具体的状态。相反,状态的转换取决于用户的交互。下面是它的工作方式。
currentState首先被设置为AcceptInputState,作为其默认值。
如果用户按下Clear,AcceptInputState将把上下文的当前状态改为ClearState;Clear状态将执行 "清除 "行为;之后,它将把上下文的当前状态改回AcceptInputState。
如果用户按了Animate,AcceptInputState将把上下文的当前状态改为AnimateState;Animate状态将执行动画;完成后,它将把上下文的当前状态改回AcceptInputState。
如果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,在这个窗口中打开它。给这个类添加以下方法。
下面是具体的玩法。
animate()、clear()和copyLines(from:)非常相似。你可以通过 transitionToState(matching:) 来改变到适当的状态,然后简单地将调用转发给它。
AcceptInputState负责处理touchesBegan(:with:)和touchesMoved(:with:)本身。如果你将这段代码与DrawView内的代码相比较,你会发现它几乎是相同的。唯一不同的是,你有时必须预先调用drawView.来执行对drawView而不是对状态的操作。
将DrawView中的touchesBegan(:with:)和touchesMoved(:with:)改为以下内容。
在这里,你只需将这些方法调用转发给currentState。如果currentState是AcceptInputState的一个实例,默认情况下是这样的,那么应用程序的行为将和以前一样。
构建并运行,并在左上角的视图中绘图,以验证应用程序仍能按预期工作。
接下来,打开AnimateState.swift,将这些方法添加到该类中。
这段代码几乎与DrawView相同,但有两个主要变化。
如果没有任何子层需要动画化,你就立即过渡到AcceptInputState,而不做其他事情。
每当整个动画完成后,你也同样过渡到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中!
上一章 | 目录 | 下一章 |
---|