RxAutomaton - 有限状态机实践 Yep

前几天看到了一个非常有趣的 repo RxAutomaton (opens new window),我 clone 下来玩了一下,感觉非常有趣,演示了状态机在登录中的应用例子,应用中登录的逻辑如下。

登录逻辑

而这一部分的逻辑可以用代码描述,而且很清晰。

/*  Input   |   fromState => toState     |      Effect       */
/* --------------------------------------------*/
.Login    | .LoggedOut  => .LoggingIn  | loginOKProducer,
.LoginOK  | .LoggingIn  => .LoggedIn   | .empty(),
.Logout   | .LoggedIn   => .LoggingOut | logoutOKProducer,
.LogoutOK | .LoggingOut => .LoggedOut  | .empty(),

.ForceLogout | canForceLogout => .LoggingOut | forceLogoutOKProducer

对于一个理解状态机不深的我,本文就不再赘述状态机的概念了,这里有一篇很有趣的文章,介绍了为什么有时候我们要考虑使用状态机,使用状态机的好处 (opens new window)

其实看到 repo 中的登录例子,我惊了个呆,代码竟然还可以这样下,这样的代码写法相比之前的代码会更清晰很多,毕竟我们已经将代码逻辑流程都写成了上面的样子,这很容易理解。比如 .Login | .LoggedOut => .LoggingIn | loginOKProducer 描述的是在已经登出的状态时,我们可以进行登录的操作,当进行登录的操作时,状态会有已登出变为正在登录,此时便会进行登录的操作,转而变成登录成功的状态。更简洁的描述就是点击登录,从注销状态变成登录状态

这很有趣,特别适合涉及到状态切换繁杂、交互逻辑复杂的场景。

Yep (opens new window) 中有一个场景很适合使用状态机解决问题。

当然,如果你还没有使用过 Yep (opens new window) ,那我建议你可以先去下载 (opens new window)之后体验一番再继续阅读本文。

这里是存在很多状态的,比如未录音、录音中、播放中、已暂停、已停止等,而触发的逻辑也很多,录音、停止录音、播放、暂停、重置等。这让我们处理代码逻辑显得很头疼,我们需要很多的状态变量维护当前状态,在进行某个行为时,需要先检查当前状态,根据当前状态走不同的逻辑。

比如。

private var audioPlaying: Bool = false
 // ...
func playOrPauseAudio(sender: UIButton) {
    if AudioBot.playing {
        AudioBot.pausePlay()
        audioPlaying = false
    } else {
        guard let fileURL = feedVoice?.fileURL else {
            return
	}
	// ...
}

这个就非常尴尬了。随着代码量的增加。理解逻辑也会变得更加复杂。我在这个页面实践了一下基于 RxAutomaton 状态机的应用,实践了一下自己对于状态机的理解。

理清逻辑非常重要!!!

# 理清状态切换逻辑

这里我们用一张图解释。这就非常尴尬了 嗯。

录音 + 播放逻辑

而代码也很好的对应了上述状态切换逻辑。

描述好切换逻辑,接下来要关注的就只有两件事。

  1. 触发 Input 逻辑,即谁来(如何)改变状态
  2. 不同状态对应的 UI

# 触发 Input 场景

比如一个 reset (录音完成后,重置到最初状态) 的场景。

resetButton.rx.tap
    .map { Input.reset }
    .subscribe(onNext: inputObserver.onNext)
    .addDisposableTo(disposeBag)

上述代码表明触发 reset 的原因是点击了 resetButton

# 不同状态对应的 UI

最后我们就只需要根据不同状态场景展示不同的 UI 。

automaton.state.asObservable()
    .subscribe(onNext: { (state) in
        switch state {
        case .recording:
            // 正在录音的 UI
        case .recorded:
            // 已录音的 UI
        case .playing:
            // 正在播放的 UI
        case .reset:
            // 重置的 UI
        case .playPausing:
            // 暂停播放的 UI
        case .canceled:
            // cancel 的 UI
        case .playStopped:
            self.playButton.setImage(R.image.button_voice_play(), for: .normal)
        }
        })
    .addDisposableTo(disposeBag)

不得不说,这样的写法很有意思,在没有状态机的支持下,完成上述的代码也不是件复杂的事情,但代码的逻辑就不如上面这种状态机的形式更清晰,易读性得到了极大的增加。相比原有代码,我们从实现功能转变到了。

  • 专注状态变化逻辑 (State -> State)
  • 专注触发变化逻辑(Input)
  • 专注状态样式(State -> UI)

这样一来代码就变得非常清晰,更改起来也很方便。 比如,添加逻辑的变化关系时只需要添加 State -> State 的逻辑。

# 待改进的地方

可以注意到在播放时,Input 的 playCompleted 是自动从 playingplayStopped 的,即这一行为不是(用户)主动触发的,更好的写法大概如下。

/*  Input      | fromState => toState       |  Effect              */
/* ------------------------------------------------*/
.playCompleted | (.playing => .playStopped) | playCompletedProducter

如果你对这一步的实现很感兴趣,可以参考 RxAutomaton (opens new window) 中的例子,这里就不再给出代码也不再赘述了。这不会很复杂。

# 补充

完成上述代码并不轻松,将代码都迁移到 Swift 3 就是一件非常麻烦的事情,我遇到了无数次的 Xcode 崩溃、高亮崩溃。当然这不是最想说的。

可以展望的一点是,我们有可能通过 enum 的关联值特性,移除代码中的 private var feedVoice: FeedVoice?,这一属性被用来保存播放的音频。但如果使用 enum 则有可能将 feedVoice 做为状态切换时传递的值(这里是音频)。

本文 Demo 地址 https://github.com/DianQK/RxYepRecord (opens new window)