使用状态机的好处

所谓(有限)状态机?就是一个有着不同状态的黑盒子。我们可以看到它的状态,也可以改变它的状态,无论是从内部还是从外部。当然,我们希望能根据它不同的状态来做一些设置或操作。根据时机的需要,我们可能也希望能在它转换状态之前或之后做一些操作。

作者:@nixzhu


下图是一个火箭(你可能会觉得不像),上面有两个按钮,Fire 就是“点火”,Abort 就是“中止”。在点火之后,会倒计时 5 秒,如果在这 5 秒之内没有被中止,那么火箭就会发射。

Rocket

这个火箭是一个 RocketView,除了背景图,上面只有两个 Button 和 一个 Label,非常简单。这个 RocketView 被放置在一个 VC 的 view 里,除了它的高宽之外,一并用 AutoLayout 约束其横向居中,以及 bottom 的位置。

初始时,只有 Fire 可以点击,当它被点击后,它自己就不能再被点击了,同时 Abort 变为可以点击。当倒计时结束,火箭真正发射时,要设置这两个按钮都不能点击,因为它们已没有作用了。这一点很容易理解:因为状态不一样了,外观(UI)自然该不一样。

假如我们现在要编写一些逻辑代码,为两个按钮增加 target-action 以便完成一些操作。我们可以在 VC 里访问到这个 RocketView,再拿到它的 Button,然后 addTarget… 就可以了。

大概类似如下:

  1. // in VC
  2. let rocketView = RocketView()
  3. rocketView.fireButton.addTarget(self, action: "fire", forControlEvents: .TouchUpInside)
  4. func fire() {
  5. // 改变 rocketView 的状态(设置按钮等)
  6. // 再做其它事情
  7. }

但这样代码就会很散乱。一种更好的办法是在 RocketView 里就绑定好 target-action,然后用 delegate 或“闭包”来触发外部代码。

  1. // in RocketView
  2. var fireAction: (() -> Void)?
  3. fireButton.addTarget(self, action: "fire", forControlEvents: .TouchUpInside)
  4. func fire() {
  5. // 改变 rocketView 的状态(设置按钮等)
  6. // 再……
  7. if let action = fireAction {
  8. action()
  9. }
  10. }
  11. // in VC
  12. let rocketView = RocketView()
  13. rocketView.fireAction = {
  14. // 做其它事情
  15. }

这样写的好处是类似“改变 rocketView 的状态”这样的操作就不需要暴露在 VC 中。而且,如果“外部”的 VC 认为不需要在 fire 时“做其它事情”,那它完全可以不去设置 fireAction,非常自然。对于另外一个 abortButton,我们照样写一个内部的 target-action,以及一个 abortAction 给外部就行了。

但这样做仍然不够完美。现在有两个按钮,那就有两个“改变 rocketView 的状态”这样的操作。还因为有倒计时,它也会触发一些操作,并“改变 rocketView 的状态”,代码就更分散了。如果有更多状态,状态转换时的逻辑更加不好理清。

例如对于我们 RocketView 的状态:

States

当按下 fireButton 时,火箭从 Standby 状态进入 CountDown 状态,也就是发射倒计时。当倒计时结束时,火箭就会进入 Launch 状态,也就发射了。若在倒计时结束前按下 abortButton,那么火箭就会停止倒计时,回到 Standby 状态。当然了,火箭上了太空完成任务后,我们可以遥控它着陆,于是它会从 Launch 状态返回 Standby 状态,由此可以重复利用。

我们希望在代码层面做到什么样呢?制造一个集中管理状态的地方,这样分散的几个“改变内部状态”就可以集中起来管理了。同时,我们也应该为 RocketView 暴露出一个闭包,它提供“之前的状态”和“现在的状态”给外部操作,这样外面的 VC 就好利用状态信息做一些操作。比如当火箭进入 Launch 状态时,我们希望 RocketView 向上飞行,那只需用动画改变其 AutoLayout 的 bottom 约束即可。

合理利用 Swift 的 enum、属性的 willSet 和 didSet 以及闭包,我们可以写出这样的代码:

  1. // 所有必要的状态
  2. enum State: Printable {
  3. case Standby
  4. case CountDown
  5. case Launch
  6. var description: String {
  7. switch self {
  8. case .Standby:
  9. return "Standby"
  10. case .CountDown:
  11. return "CountDown"
  12. case .Launch:
  13. return "Launch"
  14. }
  15. }
  16. }
  17. // 前一个状态:根据具体需求,有可能不需要考虑前一个状态,就可以省去
  18. var previousState: State = .Standby
  19. // 当前状态
  20. var currentState: State = .Standby {
  21. willSet {
  22. // 先更新“之前”的状态(因为是 will,所以 currentState 还未改变)
  23. previousState = currentState
  24. // 再做一些状态改变之前要做的事情(可以 switch newValue 或 previousState 来做不同的操作)
  25. // ...
  26. }
  27. didSet {
  28. // 执行状态转换后操作
  29. if let stateTransitionAction = stateTransitionAction {
  30. stateTransitionAction(previousState: previousState, currentState: currentState)
  31. }
  32. // 还可以再做一些“内部”才关心的事情
  33. // ...
  34. switch currentState {
  35. case .Standby:
  36. fireTimer?.invalidate()
  37. countDown = 5
  38. countDownLabel.text = "\(currentState)"
  39. fireButton.enabled = true
  40. abortButton.enabled = false
  41. case .CountDown:
  42. countDown = 5
  43. fireTimer = makeNewFireTimer()
  44. fireButton.enabled = false
  45. abortButton.enabled = true
  46. case .Launch:
  47. fireTimer?.invalidate()
  48. countDownLabel.text = "\(currentState)"
  49. fireButton.enabled = false
  50. abortButton.enabled = false
  51. default:
  52. break
  53. }
  54. }
  55. }
  56. // 用闭包来将状态转换操作暴露给”外部“,外部可以利用状态的不同执行不同的操作
  57. var stateTransitionAction: ((previousState: State, currentState: State) -> Void)?

我们成功的将不同状态时“改变内部状态”的代码都集中在 currentState 的 didSet 里。

而按钮以及定时器的 Action 只需要简单地改变状态即可,看起来格外清爽:

  1. func fire() {
  2. currentState = .CountDown
  3. }
  4. func abort() {
  5. currentState = .Standby
  6. }
  7. func countDownToFire(timer: NSTimer) {
  8. countDown--
  9. if (countDown == 0) {
  10. currentState = .Launch
  11. }
  12. }

而 VC 里只需要关心 RocketView 的飞行和着陆:

  1. rocketView.stateTransitionAction = { (previousState, currentState) in
  2. println("state from \(previousState) to \(currentState)")
  3. switch (previousState, currentState) {
  4. case (.CountDown, .Launch):
  5. UIView.animateWithDuration(3.0, delay: 0, options: .CurveEaseIn, animations: { () -> Void in
  6. self.rocketViewBottomConstraint.constant = CGRectGetHeight(self.view.bounds)
  7. self.view.layoutIfNeeded()
  8. }, completion: { (finished) -> Void in
  9. self.landingButton.enabled = true
  10. })
  11. default:
  12. if currentState == .Standby {
  13. self.landingButton.enabled = false
  14. UIView.animateWithDuration(1.0, delay: 0, options: .CurveEaseOut, animations: { () -> Void in
  15. self.rocketViewBottomConstraint.constant = 20
  16. self.view.layoutIfNeeded()
  17. }, completion: { (finished) -> Void in
  18. })
  19. }
  20. }
  21. }

由此,在 VC 里设置一个按钮做返回遥控器也就一句话的事情:

  1. @IBAction func landing(sender: UIBarButtonItem) {
  2. rocketView.currentState = .Standby
  3. }

最后的效果请查看并运行 Demo 代码:https://github.com/nixzhu/StateMachineDemo

小结

这是一个放在 View 内部的很简单的状态机(实际上状态机的原理就是这么简单),通过它我们能将状态转换时的各种操作集中管理,并且 VC 会变得更轻量。不同的状态机可能有不同的要求,比如有的可能需要在状态转换之前做一些操作,本例中的状态机也可以扩展。

状态机是一种对可变模型的抽象,实际上几乎没有不变的模型。从状态机的视角,我们可以站在更高层面观察问题。而且很可能你在无意中就已经在使用状态机的视角,只不过没有太明确而已。

虽然很多时候你不会面临很复杂的情况,但懂一些状态机的知识可以让你写出更易读的代码,维护起来更加轻松。而且很酷,对吧?

补记

一个迷你的状态机:Redstone,实现简单,使用方便,可满足绝大部分需求。


欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog