17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Swiftで複雑なモーダルの状態遷移をステートマシンで実装した

Last updated at Posted at 2018-12-01

これは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
    }
}

ステートマシンの使い方

基本的には、

  1. setTransitionsで遷移図を上のステートマシンに落とし込んで
  2. 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

17
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?