iOS アーキテクチャ Redux + ApplicationCoordinator

はじめまして。今回がエンジニアブログ初投稿で、カタリストシステム最年少のホンダです!
今回は、新規プロダクト開発時に悩み抜いて選定したアーキテクチャについて書きます。
今までは、iOSの基本的な設計思想であるApple’s MVCを使って、iOSアプリを開発していました。

しかし、開発が進むにつれて以下のような問題が発生しました。

  • ViewControllerが肥大化する
  • 画面遷移が複雑化する
  • データの引き渡しがやりづらい

上記の問題を解決するために、今回新たに開発する新規プロダクトでは、Redux(ReSwift) + ApplicationCoordinator を使った、データの同期と遷移の共通化を実装しています。
本記事では、まず概要を説明し、記事の後半で実際のコードをご紹介致します。

Reduxとは

ReSwiftの根本には、Reduxの思想があります。
Reduxは、UIのstate(状態)を管理をするためのフレームワークです。stateでデータフロー管理する実装にFluxが提案されていますが、ReduxはFluxの概念を拡張してより扱いやすく設計されています。
Reduxのデータフローは以下になります。

  • Action 「何をする」という情報を持ったオブジェクトです。
  • Store アプリケーションの状態(state)を保持している場所です。最大で1つです。
  • State アプリケーションでの状態を表します。
  • Reducer ActionとStateを使ってStore内のStateを更新します。

Reduxの3原則

Reduxの基本設計は以下の3つの原則に基づいて設計されています。
上記のデータフローはこの原則に則っていることがよく分かると思います。

  1. Single source of truth
    アプリケーション内でStoreは1つのみとし、Stateは単独のオブジェクトとしてStoreに保持される。
  2. State is read-only
    Stateを直接変更することはできず、actionをStoreへdispatchすることでしかStateは変更できない。
  3. Mutations are written as pure functions
    Stateを変更する関数(Reducer)は純粋な関数にする。


ReSwift

今回、ReduxのSwift実装の


を使用して実装しています。
Redux原則を元にデータフローを制御することで、変更の反映漏れやデータの引き回しの不要になります。

ApplicationCoordinator

ライブラリに頼らない遷移管理パターンで、AppDelegate内でUIWindowを継承して初期化することで、遷移関連を統一した1ファイルで管理することが可能です。
ViewController内のDelegateの宣言が前提の為、一時的に渡したい変数等は簡単に引き継ぐことが可能です。

サンプル

今回はサンプルとして以下のような簡単な実装をReSwift + ApplicationCoordinatorで構築したサンプルコードを紹介します。
入力ボタンで入力専用画面へ遷移されて、入力した内容が最初の画面に反映されています。

実行環境

  • mac OS Sierra 10.12.6
  • Xcode 9.2
  • Swift 4
  • ReSwift 4.0.0

ReSwiftは、Cocoapodsで導入しています。

pod 'ReSwift'

Storyboard

Storyboardには簡単なUIコンポーネントを配置しています。

Model

最初にReSwiftの登場するState、Action、Reducerを定義します。
状態は、AppStateの中にツリー構造で定義します。Reducerは、MainStateのextensionで宣言することで、名前空間を制御しています。

//
//  AppState.swift
// AppDelgateで初期化し、全体のStateを管理する構造体です。
//
import Foundation
import ReSwift

struct AppState: StateType {
    var main = MainState()
}
//
//  MainState.swift
//  子State
//
import Foundation
import ReSwift

struct MainState: StateType {
    var text: String?
}
//
//  InputAction.swift
//  Actionの構造体です。変更したいStateの値を宣言しています。
//
import Foundation
import ReSwift

struct InputAction: Action {
    var text: String
}

//
//  AppReducer.swift
//  大本のReducerです。
//
import Foundation
import ReSwift

func AppReducer(action: Action, state: AppState?) -> AppState {
    return AppState(
        main: MainState.MainReducer(action: action, state: state?.main)
    )
}
//
//  MainReducer.swift
//  子のReducerで、今回は、InputAction構造体に反応して新しいStateを生成して返す処理をしています。
//
import Foundation
import ReSwift

extension MainState {
    static func MainReducer(action: Action, state: MainState?) -> MainState {
        let state = state ?? MainState(text: "")
        var newState = state
        var mainState = state
        switch action {
        case _ as InputAction:
            mainState = MainState(text: (action as! InputAction).text)
        default:
            break
        }
        newState = mainState
        return newState
    }
}

ApplicationCoordinator

本クラスで各ViewController上で定義した、protocolを使用して遷移を管理します。
AppDelgateで初期化する為、UIWindowをイニシャライザに持ちます。
ViewControllerのprotocolは、extensionで定義するのがオススメです。

//
//  ApplicationCoordinator.swift
//
import UIKit

final class ApplicationCoordinator {
    
    private let window: UIWindow
    
    init(window: UIWindow) {
        self.window = window
    }
    
    public func start() {
        guard let vc = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "VC") as? ViewController else {
            return
        }
        vc.transitionDelegate = self
        self.window.rootViewController = UINavigationController(rootViewController: vc)
        self.window.makeKeyAndVisible()
    }
}

extension ApplicationCoordinator: ViewControllerTransitionDelegate {
    func push() {
        guard let vc = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "SecondVC") as? SecondViewController  else {
            return
        }
        (self.window.rootViewController as? UINavigationController)?.pushViewController(vc, animated: true)
    }
}

AppDelegate

Storeの初期化とApplicationCoordinatorの初期化をします。


//
//  AppDelegate.swift
//

import UIKit
import ReSwift

// NOTE: アプリで1つのStoreを作成生成する
let mainStore = Store(
    reducer: AppReducer,
    state: nil
)

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    // NOTE: lazyで定義して、始めて使用されるタイミングでApplicationCoordinatorを初期化しています。
    lazy var applicationCoordinator: ApplicationCoordinator = {
        return ApplicationCoordinator(window: self.window!)
    }()

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        self.window = UIWindow(frame: UIScreen.main.bounds)
        self.applicationCoordinator.start()
        return true
    }
...

Controller

//
//  ViewController.swift
//
import UIKit
import ReSwift

protocol ViewControllerTransitionDelegate: class {
    func push()
}

final class ViewController: UIViewController {
    weak var transitionDelegate: ViewControllerTransitionDelegate?
    
    @IBOutlet weak var label: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 監視作成
        // NOTE: 特定の状態のみ監視し、同じ値の場合はSkipする
        mainStore.subscribe(self) {
            $0.select {
                $0.main
                }.skipRepeats {
                    $0.text == $1.text
            }
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // 監視削除
        mainStore.unsubscribe(self)
    }
    
    @IBAction func event(_ sender: Any) {
        self.transitionDelegate?.push()
    }
}

extension ViewController: StoreSubscriber {
    typealias StoreSubscriberStateType = MainState
    // NOTE: 監視しているの値が変更されてた場合に呼ばれる
    func newState(state: MainState) {
        self.label.text = state.text ?? ""
    }
}


//
//  SecondViewController.swift
//
import UIKit
import ReSwift

final class SecondViewController: UIViewController {
    
    @IBOutlet weak var textField: UITextField!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.textField.text = mainStore.state.main.text
    }
}

extension SecondViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField, reason: UITextFieldDidEndEditingReason) {
        // NOTE: dispatch関数を使用して、Actionを送信する。対応するReduserが検知し、新たなStateが作成され更新されます。
        mainStore.dispatch(InputAction(text: textField.text ?? ""))
    }
}

まとめ

iOS開発の課題を全て解決しているわけではありませんが、状態変化が起きた際の処理を一箇所にまとめるこで、可読性が改善し、処理が散らばらないのでデバッグしやすいと感じています。
興味のある方は実際に触ってみると面白いと思います。
ReSwiftの表面的な使い方やアーキテクチャ選定のお役に立てれば幸いです。
ここまでお読みいただきまして、ありがとうございました。


次回は社員インタビュー(若手編)を記事にさせていただく予定です。乞うご期待ください!