SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

DeveloperZine(デベロッパージン)- エンジニアの意思決定を支える技術情報メディア ProductZine

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

freee、マジ価値開発の現場から

iOSアプリへFluxアーキテクチャを導入! そのメリットとは

freee、マジ価値開発の現場から 第2回

iOSアプリへのFluxの導入(2)

具体的な実装の紹介

 それでは、freee for チームで利用しているFluxのコンポーネントを紹介します。

 とはいっても、実際に必要だったのはDispatcherとActionのみで、そのコードは100行もありません。オブザーバーパターンの土台としてRxSwiftを、Storeへ渡るペイロードの型としてResultをそれぞれ利用しています。

Dispatcher

Dispatcher
class Dispatcher: NotificationCenter {
    static let shared = Dispatcher()
}
extension Dispatcher {
    func rx_notification<T: ActionBase>(_ action: T.Type)
        -> Observable<Result<T.Payload, T.ActionError>> {
        return rx.notification(action.name).map { n in
            let object = n.object as! Result<T.Payload, T.ActionError>
            return object
        }
    }
}

 Dispatcherは、NotificationCenterをベースにSingleton Classで、任意のActionのPayloadを値(正確にはResultでラップされた値)とした、RxのObservableを返すメソッドのみ実装されています。本記事ではこれらのライブラリの説明は割愛するので、必要に応じて公式ドキュメント等を参照しながら読み進めてください。

 例えば、

Dispatcher.shared.rx_notification(ToDoAction.Fetch.self)
  .subscribe(onNext: { result in
  })

このように実装すると、取得したToDoのリストが受け取れるイメージです。

Action

Action
protocol ActionBase {
    associatedtype Payload
    associatedtype ActionError: Error = NSError
}

extension ActionBase {
    static var name: Notification.Name {
        return Notification.Name(String(reflecting: self))
    }
    
    func dispatch(_ result: Result<Payload, ActionError>) {
        Dispatcher.shared.post(
            name: type(of: self).name,
            object: result
        )
    }
}

protocol Action: ActionBase {
    func invoke()
}

protocol AsyncAction: ActionBase {
    func invoke() -> Observable<Void>
}

 Actionは、ActionBaseプロトコルを継承した、「Action」と「AsyncAction」の、2つのプロトコルを定義しています。

 その名の通り、同期的なActionはActionプロトコルを、非同期的なActionはAsyncActionプロトコルに適合し、それぞれのinvokeメソッドの中でペイロードを用意した上で、dispatchメソッドを経由してStoreに渡します。

 AsyncActionプロトコルのinvokeメソッドは、非同期に行った処理の完了を、呼び出し元に通知するためにObservableを返すようにしています。

使用例

 それでは、ここまでに紹介した最低限のFlux実装をベースに、月並みではありますが、例としてToDoリストの初期値表示(fetch)・追加(add)・削除(delete)が可能なToDoアプリを作ってみましょう。

 コードを抜粋しながら、FluxベースのiOSアプリを作る流れを解説します。

ViewControllerとToDoAction、ToDoStore
ViewControllerとToDoAction、ToDoStore

 この図の赤枠で囲んだ部分(ViewControllerとToDoActionとToDoStore)が、今回実装する部分です。

Action

 まずはActionから説明します。

ToDoActionの実装
struct ToDoAction {
    struct Fetch: AsyncAction {
        typealias Payload = [ToDo]
        
        func invoke() -> Observable<Void> {
            return fetchDefaultItems().`do`(onNext: { result in
                self.dispatch(Result(value: result))
            }, onError: { error in
                self.dispatch(Result(error: NSError.error(from: error)))
            }).map { _ in () }
        }
        
        private func fetchDefaultItems() -> Observable<[ToDo]> {
            return Observable.just([
                ToDo(text: "First item"),
                ToDo(text: "Second item"),
                ToDo(text: "Third item")
            ])
        }
    }
    
    struct Add: Action {
        typealias Payload = ToDo
        let text: String
        
        func invoke() {
            self.dispatch(Result(value: ToDo(text: text)))
        }
    }
    
    struct Delete: Action {
        typealias Payload = Int
        let index: Int
        
        func invoke() {
            self.dispatch(Result(value: index))
        }
    }
}

 Fetch、Add、Deleteの3つのアクションを実装しました。

 Fetch ActionはAsyncActionプロトコルに適合しているため、invokeメソッドでObservableを返しています。こうすることで、

FooAction().invoke()
            .concat(BarAction().invoke())
            .concat(BazAction.invoke())
            .subscribe()

というように、複数のActionを連鎖させることができます。

Store

 次にStoreです。

ToDoStoreの実装
final class ToDoStore {
    let todos = Variable<[ToDo]>([])
    private(set) var addedIndex = -1
    private(set) var deletedIndex = -1
    
    private let disposeBag = DisposeBag()
    
    init() {
        let d = Dispatcher.shared
        
        d.rx_notification(ToDoAction.Fetch.self)
            .subscribe(onNext: { [weak self] result in
                switch result {
                case .success(let todos):
                    self?.resetIndices()
                    self?.todos.value = todos
                default:
                    break
                }
            }).disposed(by: disposeBag)
        
        d.rx_notification(ToDoAction.Add.self)
            .subscribe(onNext: { [weak self] result in
                switch result {
                case .success(let todo):
                    self?.resetIndices()

                    let current = self?.todos.value ?? []
                    self?.addedIndex = current.count
                    self?.todos.value = current + [todo]
                default:
                    break
                }
            }).disposed(by: disposeBag)
        
        d.rx_notification(ToDoAction.Delete.self)
            .subscribe(onNext: { [weak self] result in
                switch result {
                case .success(let index):
                    guard let `self` = self else { return }
                    self.resetIndices()

                    self.deletedIndex = index
                    var todos = self.todos.value
                    todos.remove(at: index)
                    self.todos.value = todos
                default:
                    break
                }
            }).disposed(by: disposeBag)
    }
    
    private func resetIndices() {
        addedIndex = -1
        deletedIndex = -1
    }
}

 ToDoStoreはプレーンなクラスで、イニシャライザでToDoActionを購読し、Actionの種類によって自身のプロパティを更新します。todosプロパティをRxのVariableにしているのには大きく2つ理由があります。

 ひとつはActionをトリガーに値が変更された際、todosプロパティを購読しているViewへ自動的にその変更が通知するための仕組みとして利用するためです。もうひとつはViewの更新範囲を最小限に抑えるためですが、これについて詳しくは後述します。

 不要なデータがメモリに残らないようにするため、基本的にStoreのライフサイクルは画面のライフサイクルに一致させています。DispatcherをRxベースにしたのも、Actionの購読解除にDisposableの仕組みを使いたかったからです。disposed(by: disposeBag)を呼び出しておけば、Storeが破棄されるときに勝手にActionの購読も解除してくれるので、メモリリークへの懸念が解消されます。

 ここで、todosプロパティがVariableであると、実装者がStoreを参照しているViewからもその値を意図的に変更できてしまうということになります。今回は簡略化のため利用していませんが、これを仕様的に制限するには、RxPropertySignatureを利用する方法を検討するといいでしょう。

View

 次はViewです。

ViewControllerの実装
final class ToDoViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
    @IBOutlet var tableView: UITableView!
    @IBOutlet var textField: UITextField!
    
    private let todoStore = ToDoStore()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        todoStore.todos.asDriver().drive(onNext: { [unowned self] todos in
            if self.todoStore.addedIndex > 0 {
                let indexPaths = [IndexPath(row: self.todoStore.addedIndex, section: 0)]
                self.tableView.insertRows(at: indexPaths, with: .fade)
                self.textField.text = ""
            } else if self.todoStore.deletedIndex > 0 {
                let indexPaths = [IndexPath(row: self.todoStore.deletedIndex, section: 0)]
                self.tableView.deleteRows(at: indexPaths, with: .fade)
            } else {
                self.tableView.reloadData()
            }
        }).disposed(by: disposeBag)
        
        _ = ToDoAction.Fetch().invoke().subscribe()
    }
    
    @IBAction func tapAdd(_ sender: Any) {
        if let text = textField.text, !text.isEmpty {
            ToDoAction.Add(text: text).invoke()
        }
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return todoStore.todos.value.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell")
        cell?.textLabel?.text = todoStore.todos.value[indexPath.row].text
        return cell!
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        ToDoAction.Delete(index: indexPath.row).invoke()
    }
}

 Viewでは、Storeのtodosプロパティの変更のたびにリストを更新しています。ToDoアイテムの追加・削除時には、Storeのプロパティとして公開されたインデックスを参照することで、行単位の更新メソッドを使うことができ、アニメーションを適用したり、再描画の負荷を抑えたりすることができます。

 先ほどのStoreのプロパティをVariableにする2つ目の理由の続きになりますが、Webで用いられる「emit changeで変更通知を受け、DOM treeを作り直すことでViewを更新するアプローチ」は、現状Virtual DOMほど洗練された差分更新の仕組みがiOS開発の世界にはないため、パフォーマンスの観点から採用しづらい、というポイントがあります。StoreのプロパティをVariableにすることで、自然な形でemit changeよりも細かな更新を通知することが可能となり、Viewの更新範囲を必要最小限に抑えることができます。

ユニットテスト

 最後に、ユニットテストにも少し触れます。以下のコードは、このToDoアプリのビジネスロジックが実装されている、ToDoStoreに対するユニットテストの一部です。

func testFetch() {
        let store = ToDoStore()
        XCTAssertTrue(store.todos.value.isEmpty)
        
        ToDoAction.Fetch.dispatch(Result(value: [
            ToDo(text: "foo"),
            ToDo(text: "bar")
        ]))
        
        XCTAssertEqual(store.todos.value.count, 2)
        XCTAssertEqual(store.todos.value.first?.text, "foo")
        XCTAssertEqual(store.todos.value.last?.text, "bar")
}

 Actionのロジックを通さずStoreに対して直接Actionのペイロードを与え、その前後にAssertionを記述します。Storeの状態はActionによってしか変更されず、ViewはStoreの状態を画面に反映する原則があるため、Storeに対してのシンプルな入出力でアプリ内の重要なロジックをテストすることができます。

 なお、ToDoAction.Fetchのstaticなdispatchメソッドを呼び出していますが、これはTest向けに以下のextensionで拡張することで利用可能になります。

extension ActionBase {
    static func dispatch(_ result: Result<Payload, ActionError>) {
        Dispatcher.shared.post(
            name: self.name,
            object: result
        )
    }
}

 ToDoの追加・削除のユニットテストについても同じ要領であるため、ここでの詳細説明は割愛します。今回作成したToDoアプリの全コードはGitHubに置いてあるので、動作させながら確認していただくことが可能です。

まとめ

 Fluxアーキテクチャを採用後、しばらくfreee for チームの開発を続けてきた上での印象として、一番のメリットはやはりデータの流れがシンプルで分かりやすい、といったところにあります。弊社の他のプロダクトではReactiveCocoaをベースにしたMVVMアーキテクチャが採用されているものもありますが、画面や機能の複雑さに比例して、Streamをうまくつなげて機能を実現させていく難易度も上がる印象がありました。しかし、Fluxでは機能を変更・追加する際にAction、Store、Viewと、似たようなパターンで書くことが多くなり、複雑な画面でも比較的単純に機能を追加していくことができました。複数人で開発する場合、その効果はより大きく感じられます。

 一方デメリットは、メリットと引き換えのその冗長さです。たとえちょっとした変更でも、ViewからStoreのメソッドを叩いて状態を変更させることはできません。常にActionを発行し、Storeでそれを受けて状態を変化させ、Viewが変更を外観に反映するという流れを実装する必要があります。また、Virtual DOMのような差分更新の仕組みがないことも、その出自であるWebフロントエンドの世界と比べると親和性は低く感じます。

 しかし本記事で解説してきた通り、プラットフォームに合わせてFluxの実装を再考することで、iOSアプリにおいても単方向データフローの恩恵を受けることができます。freee for チームは従業員向けのアプリのため、一般的なコンシューマー向けアプリと比べると、複雑な画面やフォームの実装が求められやすい性質があります。そのようなアプリだと特に、単方向データフローという制約はアプリの機能をスケールさせていく助けになるはずです。

この記事は参考になりましたか?

連載通知を行うには会員登録(無料)が必要です。
既に会員の方はを行ってください。
freee、マジ価値開発の現場から連載記事一覧

もっと読む

この記事の著者

高野 智史(freee K.K)(タカノ サトシ)

 freeeでiOS/Androidアプリ開発を行うエンジニア。平日はfreee、週末はスタートアップ企業でアプリ開発のWワークを実現している。iOS/Androidアプリを中心に、サーバーサイド・Webフロントエンド・分析・デザインなど業務経験は幅広く、なんでもやる雑食系。ソフトウェアで人の生活を豊かにしたい。 GitHub

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

CodeZine(コードジン)
https://codezine.jp/article/detail/10499 2017/11/22 14:00

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー