これはYahoo! JAPAN 18 新卒 Advent Calendar 2018の1日目の記事です!
Yahoo! JAPAN の新卒1年目が有志で行っているアドベントカレンダーです!
次回は @cagpie さんによる「必ず押さえたい、JavaScript開発の大技小技」です。
自分は今、業務でiOSアプリ開発を担当しており、そこで設計&導入したステートマシンについて内容をまとめたいと思います。
実装するサンプルのモーダル
ステートマシンの実装
Swiftでステートマシンの実装は様々な方が既にされているため、参考にしつつ以下のものを使いました。
import Foundation
// MARK: - Transition
struct Transition<S, E> {
let from: S
let to: S
let by: E
}
// MARK: - Automaton
class Automaton<S: Hashable, E: Hashable> {
private var routes: [S: [E: S]] = [:]
private func addRoute(_ transition: Transition<S, E>) {
var dictionary = routes[transition.from] ?? [:]
dictionary[transition.by] = transition.to
routes[transition.from] = dictionary
}
func setTransitions(_ transitions: [Transition<S, E>]) {
for transition in transitions {
addRoute(transition)
}
}
func transition(from: S, by: E) -> S? {
guard let next = routes[from].flatMap({ $0[by] }) else {
return nil
}
return next
}
}
ステートマシンの使い方
基本的には、
-
setTransitions
で遷移図を上のステートマシンに落とし込んで -
transition
で状態をクルクル変える
ように使用します。
アプリに組み込む
遷移のロジックをViewControllerに書くのがいやでステートマシンを組み込みたいなと考えていました。
その結果、自分がやってみたのはこのステートマシンを持つControllerを作成し、それをViewControllerからクルクル回す様に実装してみました。
実際のコード
ステートマシンを持つControllerは以下の通りです。
import UIKit
// MARK: - ModalState
enum ModalState {
case root
case checkingLogin
case showingLoginModal
case showingTutorialModal
case finish
}
// MARK: - ModalEvent
enum ModalEvent {
case next
// CheckingLogin case
case login
case notLogin
// View
case tappedOk
case tappedLogin
case tappedNoLogin
}
// MARK: - ModalViewType
enum ModalViewType {
case loginModal
case tutorialModal
}
// MARK: - ModalControllerDelegate
protocol ModalControllerDelegate: class {
func modalControllerDidComplete(with event: ModalEvent)
func modalControllerShowModalView(type: ModalViewType)
}
// MARK: - ModalController
class ModalController {
private weak var delegate: ModalControllerDelegate?
let machine = Automaton<ModalState, ModalEvent>()
var state: ModalState {
didSet {
guard state != oldValue, state != .finish else {
return
}
let event = run(state: state)
delegate?.modalControllerDidComplete(with: event)
}
}
let transitions: [Transition<ModalState, ModalEvent>] = [
Transition(from: .root, to: .checkingLogin, by: .next),
Transition(from: .checkingLogin, to: .showingTutorialModal, by: .login),
Transition(from: .checkingLogin, to: .showingLoginModal, by: .notLogin),
Transition(from: .showingLoginModal, to: .showingTutorialModal, by: .tappedLogin),
Transition(from: .showingLoginModal, to: .finish, by: .tappedNoLogin),
Transition(from: .showingTutorialModal, to: .finish, by: .tappedOk),
]
init(state: ModalState = .root) {
self.state = state
self.machine.setTransitions(transitions)
}
func setUp(delegate: ModalControllerDelegate) {
self.delegate = delegate
}
func nextState(event: ModalEvent) {
guard let nextState = machine.transition(from: state, by: event) else {
return
}
state = nextState
}
private func run(state: ModalState) -> ModalEvent {
switch state {
case .checkingLogin:
return checkingLogin()
case .root, .finish:
return .none
}
}
}
// MARK: - ModalController State Function
extension ModalController {
func checkingLogin() -> ModalEvent {
if ログインチェックするロジック {
return .login
}
return .notLogin
}
// ViewControllerでモーダルを表示する様にdelegate使う
func showingTutorialModal() -> ModalEvent {
delegate?.modalControllerShowModalView(type: .tutorialModal)
return .none
}
// ViewControllerでモーダルを表示する様にdelegate使う
func showingLoginModal() -> ModalEvent {
delegate?.modalControllerShowModalView(type: .loginModal)
return .none
}
}
次に、モーダル画面の実装は
import UIKit
// MARK: - ModalPopupViewDelegate
protocol ModalPopupViewDelegate: class {
func modalViewDidTap(event: ModalEvent)
}
// MARK: - ModalPopupViewInterface
protocol ModalPopupViewInterface {
var delegate: ModalPopupViewDelegate? { get set }
static func make(delegate: ModalPopupViewDelegate) -> UIView
}
// MARK: - TutorialModalView
final class TutorialModalView: UIView, ModalPopupViewInterface {
weak var delegate: ModalPopupViewDelegate?
static func make(delegate: ModalPopupViewDelegate) -> UIView {
let view = Bundle.main.loadView(from: self)
view.delegate = delegate
return view
}
@IBAction func didTapOK(_ sender: Any) {
delegate?.modalViewDidTap(event: .tappedOk)
}
}
// MARK: - LoginModalView
final class LoginModalView: UIView, ModalPopupViewInterface {
weak var delegate: ModalPopupViewDelegate?
static func make(delegate: ModalPopupViewDelegate) -> UIView {
let view = Bundle.main.loadView(from: self)
view.delegate = delegate
return view
}
@IBAction func didTapLogin(_ sender: Any) {
delegate?.modalViewDidTap(event: .tappedLogin)
}
@IBAction func didTapNoLogin(_ sender: Any) {
delegate?.modalViewDidTap(event: .tappedNoLogin)
}
}
最後に、モーダルを表示するViewControllerの実装は
import UIKit
// MARK: - ViewController
final class ViewController: UIViewController {
private let modalController: ModalController = ModalController()
override func viewDidLoad() {
super.viewDidLoad()
modalController.setUp(delegate: self)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
modalController.nextState(event: .next)
}
}
// MARK: - ModalControllerDelegate
extension ViewController: ModalControllerDelegate {
func modalControllerDidComplete(with event: ModalEvent) {
modalController.nextState(event: event)
}
func modalControllerShowModalView(type: ModalViewType) {
switch type {
case .loginModal:
let view = LoginModalView.make(delegate: self)
// UIWindowとかにaddSubViewする
case .tutorialModal:
let view = TutorialModalView.make(delegate: self)
// UIWindowとかにaddSubViewする
}
}
}
// MARK: - ModalPopupViewDelegate
extension ViewController: ModalPopupViewDelegate {
func modalViewDidTap(event: ModalEvent) {
// ModalViewを閉じる処理
modalController.nextState(event: event)
}
}
これを元にテストを書く
こういう実装の何が嬉しいかというと、個人的にですがテストの書きやすさがあると思います。
主に書くテストは、
- 遷移ロジックのテスト
func test_ログインチェック_ログイン済_遷移ロジックのテスト() {
let subject = ModalController(state: .checkingLogin)
subject.nextState(event: .login)
expect(subject.state).to(equal(.showingTutorialModal))
}
- それぞれのステートで動く関数のテスト
func test_ログイン済_ログインロジックのテスト() {
let event = ModalController().checkingLogin()
expect(event).to(equal(.login))
}
- ユーザーアクションからイベントが発火されるかのテスト
func test_OKボタン_アクションのテスト() {
let subject = ViewController() // 何かしらDIしておく
subject.loadViewIfNeeded()
subject.viewDidAppear(false)
subject.modalControllerShowModalView(type: .loginModal)
let view = UIApplication.shared.delegate?.window??.subviews... // みたいにゴニョゴニョして取ってくる
view?
.findButton(withText: "ログイン")?
.sendActions(for: .touchUpInside) // みたいにボタンを押す
// delegate見てみたりログ見てみたりするテスト書く
}
みたいな感じになると思います。
本当にいい感じに分離できて実装してて気持ちよかったです。
まとめ
今回は簡単な状態遷移図を使って実装してみました。
業務で扱うような状態遷移図はもっと複雑だったため、遷移ロジックのテストを切り出せたのはものすごく大きな利益になりました。
またそのような実装にしたため、元のViewControllerにゴリゴリ書かず、肥大化せずに済んで良かったです。
別の話ですが、RxSwift等を取り入れている現場ではdelegateを使わずにもっと綺麗に書けるかと思いますので、別の案件でそういった場面があったら提案、実装してみたいと思います。
参考
https://tech.mercari.com/entry/2017/11/17/161508
https://qiita.com/herara_ofnir3/items/4d28bf2615bebcba9b13
https://qiita.com/inamiy/items/cd218144c90926f9a134