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の表面的な使い方やアーキテクチャ選定のお役に立てれば幸いです。
ここまでお読みいただきまして、ありがとうございました。


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

【2018年】ヒト・モノ・カネの時代からヒト・ヒト・ヒトの時代へ【新年のご挨拶】

2018年、新年あけましておめでとうございます!
代表の大井田です。昨年、弊社は、様々な変化を迎え、新たな発見の多い1年となりました。
月並みですが、新年第一回目ということで、昨年の振り返りと僕なりのIT業界の時流を受けた今後の展望など、ご挨拶として投稿させていただきたいと思います。

昨年(2017年)の印象的だった出来事、振り返り

自社スタッフによる開発ブログのスタート、オウンドメディアの立ち上げなど、外部への発信を強化。その中で、素敵な出会いもありました。一方、手放したものも。現在取り組み中の、自社サービス開発の経緯なども合わせて紹介させていただきます。

「Developer’s Blog | カタリストシステム開発者ブログ」のスタート

スタッフによる、開発者ブログを立ち上げました。(今ご覧いただいているブログです)

導入の理由としては、

  • 弊社に興味を持っていただいた方へ、どんな会社かを知っていただくため
  • 弊社エンジニアのアウトプットする訓練の場

という目的のもと、実験的な導入でしたが、じわじわと PV数も増えていっており、外に配信することの重要性を感じる機会となりました。
弊社のエンジニアも、みんなブツブツ言いながらもそれなりに楽しんで記事を書いてくれているようなので、本当に感謝です(この場でお礼を言います。協力してくれて本当にありがとう。)。
そしてみんな普通に文章を書くのが上手かった、という良きことが発見できました。

オウンドメディア「TeamHackers」運営の開始

現在開発中の自社サービス・ローンチに向けた準備として、
「TeamHackers ~最高のチームを作り上げるアイデアハック~」
という働き方についての情報をまとめたオウンドメディアの運営を始めました。

僕たちのようなIT業界(特に、弊社のようなベンチャー)では、常に変化する時流に柔軟に対応し、いかにメンバーの能力を生かし、生産性を向上させられるかが鍵を握ります。
現在、それを達成することを根底から支えるサービスを開発中です。その分野にお悩みの方と出会うため、このようなメディアの方向性を決め運用をスタートしました。

メディア立ち上げに当たって、人づてに紹介していただいた、メディア運営の責任者として元リクルート出身のコンサルタントNさん、グロースハック・デザイナーのHさんとご縁をいただき、出会いに恵まれて運営を実現することができました。インターンで運営のアシスタントをしてくれているMくんもめちゃくちゃ優秀と絶賛です。とても優秀な方々なので、全幅の信頼を寄せてオウンドメディア運営をお任せしています。

弊社としても、投稿されていく記事を読む中で、働き方というものに自然と目を向ける機会となっています。運営側の僕が言うのもなんですが、かなり面白い記事が揃っていますので、ぜひ見ていただけると幸いです。

大きな決断、飲食事業の譲渡

僕は、弊社(株式会社カタリストシステム)とは別に株式会社ソラーレという飲食事業を運営していました。様々な事情があり、こちらは別の法人様へ事業譲渡することになりました。(ソラーレ社員の名誉のためにも一応言っておきますが、赤字で撤退したという理由ではありません。現場のスタッフが本当に頑張ってくれていたので、しっかり利益は出ていました。)

飲食事業を始めると決めて、人探し、物件探しから始まって店舗開業、そして事業譲渡まで丸5年運営していました。大変な苦労もあり、現場の思いもあり、そして楽しさもあり、普通では味わえない良い経験と知見を得ることができました。関係者には感謝しかありません。本当にどうもありがとうございました。

このように昨年は、新たに始めたこともあり、長い時を経て終わったこともあり、僕の中では昨年末で1つの区切りがついたように思います。ひとえに思うのは、ブログやメディアの運営しかり、飲食事業しかり、人との出会いがあってこそのものだと強く実感しています。今年、新たな一歩を踏み始めるに当たって僕の中で「ヒト」というのが、大きなキーワードになっていきました。

2018年 自社サービスへ挑戦への想い

そして、2017年、最も大きなウェイトを占めたのが、自社サービス開発への挑戦でした。受託開発の波に乗り成長してきた弊社が、さらに次のステップへ向かうため始めた取り組みですが、とても難しいチャレンジだと感じています。今まではクライアントありきのシステム構築のため、クライアント様に「何をやりたいのか?」を聞いて、それをシステムとして実現させてあげることができれば良かったのです。しかし今回は自社サービス。「なにをやりたいのか?」を自分で考えなければなりません。

何をやりたいか? 時流と課題

これがまた難しいお題でした。やはりIT業界にいる身としては、最近の流行っている時流に乗りたい気持ちが満載です。

例えば、

  • AI
  • ディープラーニング
  • IoT
  • VR,AR,MR
  • 自動運転
  • 電気自動車

等があります。しかしながら、皆さんそこに向かっていち早くサービス化するために邁進している状況を肌で感じています。そのため、僕は敢えてそちらの方向には進みませんでした。というか進めませんでした…。最近の技術の進歩はとても速くなっています。そして、そんな時流に乗った分野へ本音を言えば行きたいのですが、どうしても資本がある大手には勝ちづらいことは目に見えています。小資本の弊社が行くべきではない。圧倒的な技術力があれば巨人に勝つことも可能かもしれませんが、これらの時流の分野で圧倒的な技術力は、残念ながら弊社にはありません…。

であれば、どこを目指すべきか。「勝てないのでやらない」では、あまりにもネガティブな話で終わってしまうので、弊社だからこそ勝負ができる分野を徹底的に考えました。

ヒト・モノ・カネの時代からヒト・ヒト・ヒトの時代へ

少し話は変わりますが、よく経営の大事な資源として「ヒト、モノ、カネ」と言われます。しかし時代は変わって21世紀は「ヒト、ヒト、ヒト」だ、という方も現れました。確かにこの考え方は納得しています。前述した内容でも述べましたが、開発者ブログ、メディアの運営、飲食事業、何をやるにしても大事なのはやっぱり「人」です。それは間違いありません。

それならば「人」をベースに考えてみよう。

開発会社の経営をしていると、「人」について本当によく考えます。それは本当に面白いもので、例えば1人が10の働きをしたとして、では単純に3人集まれば30の働きになるかというとそんなことはないのです。なぜか5の働きに低下する場合もあるし、逆に50の働きができるケースもあります。それは何故起きるのか?非常に興味を惹かれました。

僕たちは社会で生きる以上、人との関係性の外で生きることはできません。とりわけ、開発はチームで行います。人と人の関係性が大きくプロジェクトへ影響を与えます。チームで働くことを前提としたサービス。この分野ならば弊社とも親和性があり、また多くの人が日々課題解決を目指している分野です。ここなら勝負ができるかもしれないと感じました。

それからは、この分野のどこに課題や問題があるのか?を探して、それを解決するサービスを目指しました。ローンチ前なので詳しくは語れませんが、エンジニア集団が作るからには、システムにはこだわり抜こう!と、エンジニア魂が燃え上がるようなシステム設計になっています。

2018年に向けて抱負

まずは前述した現在開発中の「チームの課題を解決するサービス」のローンチを何としてでも行いたいと思っています。そして、そこから始まり、ご縁のある皆様に対して価値あるものを提供していきたいと思います。また「ヒト、モノ、カネ」ではなく「ヒト、ヒト、ヒト」を指針に、今まで以上に、人との関係性を大切にする会社を目指していきたいと思っています。自社サービスを作り上げることの難しさを本当に噛みしめる日々ですが、そんな至らない自分を助けてくれる会社の皆に本当に感謝しています。この場を借りて、お礼を言いたいです。「いつもありがとう」、と。

最後になりましたが、どうぞ、今年も何卒よろしくお願いします。