くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

ReSwiftライブラリの紹介

はじめに、

こんにちは、エンジニアのDuyです。

現在、アプリを開発し続けるにつれて、MVCというアーキテクチャには弱点があらわれつつあります。システムが複雑になるにつれて、アプリの状態を管理しにくかったり、データの同期の問題もあります。その問題を解決したいという理由で、ReSwiftが作られました。今回アプリの開発に関して、ReSwiftを簡単に紹介します。

ReSwiftとは?

最近、JavaScriptのReduxというライブラリをよく耳にしますが、Swiftに関しても、Reduxのようなライブラリは存在するのか?答えは、存在します。

I have Redux. I have Swift. Ughhh! ReSwift!

f:id:curama-tech:20180410115849p:plain

そうです。ReSwiftとは、

ReSwift is a Redux-like implementation of the unidirectional data flow architecture in Swift.

詳細的には下の図を一緒に見ましょう。

f:id:curama-tech:20180409100121p:plain

図を見ながら、ReSwiftの主要なコンポネントを単に説明したいと思います。

The Store: アプリに対して、Storeはユニークです。アプリの現在のState(状態)を保存することがThe Storeの役割です。その状態を変更したいとき、必ず、Actionsをディスパッチしないといけない。 そして、Stateの値が変化したら、The StoreがすべてのSubscribersに通知を送信します。アプリの状態を全てStoreに保存するため、アプリの状態を管理しやすいです。

Actions: Actionはプレーンオブジェクトです。ActionがStateの変化を記述します。

Reducers: ReducersがActionとStateを受け取り、新しいStateを返します。

Subscribers: Storeに保存されるStateを変化したら、SubscribersがそのStateを画面に表示します。

イメージとしては、下記のようなフローになります。

  1. SubscribersがStoreにActionを発送します。
  2. StoreがそのActionを消費し、同時に、アプリの現在StateをReducer宛にフォワードします。
  3. ReducerがActionによって、新しくStateを作成して、StoreにそのStateを保存します。
  4. StoreのStateが変化したので、Subscribersが新しいStateをもらって、表示します。

現在、うちの会社ではたくさんのStateを管理します。例えば、カレンダーとか、メッセージとか、店舗の情報とかサービス等、それぞれ項目のStateを作って管理します。 実際の使った結果によって、アドバンテージがあります。

  • コンポネントの任務が分割できます。
  • アプリの状態はStoreしか管理しないので、データの同期の問題を解決でき、変化したら、すぐにSubscribers(Views)に反映される。
  • コンポネントを拡張しやすい。
  • Unidirectional data flowなので、ログして、テストしやすい。

シンプルプロジェクトを開発してみよう

実際に、どういうふうに実装しますかって言う質問がある方がいると思うので、Demoで説明したいと思います。

まずは、こちらのプログラムをダウンロードしましょう。

Note: Xcode 9 + swift3を使うDemoです。

ダウンロードが完了したら、Cocoapodで、ReSwiftをインストールします。 Cocoapodについて、こちらに参考できます。

ダウンロードしたリポジトリを開いて、Terminalでpod コマンドを実行します。

$ pod install

とりあえず、BlogContactDemo.xcworkspaceを開いて、ビルドして見ましょう。 結果は:

f:id:curama-tech:20180410122658p:plain:w240 f:id:curama-tech:20180410122716p:plain:w240

詳細画面で何か変更して、更新ボタンを押して、バックボタンを押してみたら、リスト内容は前の状態のままです。変更したところは反映されていなかったです。

変更した内容を反映したい時、NotificationCenterを観察して、内容が変わったら、表示するのも一つのやり方ですが、今回はReSwiftで処理します。

まずは、List画面のActionsを定義します。

// Actions/ContactListAction.swift

import ReSwift

struct RequestGetContactListAction: Action {}

struct ResponseGetContactListAction: Action {
    let model: [Contact]
}

extension ContactListState {
    static func getContactList() -> Store<AppState>.AsyncActionCreator {
        return { (state, store, callback) in
            store.dispatch(RequestGetContactListAction())
            callback { _,_ in ResponseGetContactListAction(model: ContactFixtures.currentData) }
        }
    }
}

ここは、2つActionを定義します。

  • RequestGetContactListActionには、Contactの一覧を習得したいというActionです。今回は簡単に説明したいので、APIを使わずに、Fixtureデータを使います。
  • ResponseGetContactListActionには、想定的にはAPIから結果をもらう時、結果を返しましたよというActionです。

次、List画面のReducerを定義します。

 // Reducers/ContactListReducer.swift

    import ReSwift

    struct ContactListReducer {}

    extension ContactListReducer {
        func handleAction(action: Action, state: ContactListState?) -> ContactListState {
            let prevState = state ?? ContactListState()
            var nextState = prevState
            
            switch action {
            case is RequestGetContactListAction:
                nextState.model = nil
            case let action as ResponseGetContactListAction:
                nextState.model = action.model
            default:
                break
            }
            
            return nextState
        }
    }

ContactListReducerにはそれぞれのContactList画面のActionをハンドリングして、新しいStateを返します。

詳細画面にはActionが2つあると思います。

    // Actions/ContactDetailAction.swift

    import ReSwift

    struct RequestUpdateContactDetailAction: Action {}

    struct ResponseUpdateContactDetailAction: Action {
        let model: Contact
    }

    extension ContactDetailState {
        static func updateContact(with id: Int, name: String, phone: String) -> Store<AppState>.AsyncActionCreator {
            return { (state, store, callback) in
                store.dispatch(RequestUpdateContactDetailAction())
                let contact = ContactFixtures.updateData(with: id, name: name, phone: phone)
                callback { _,_ in ResponseUpdateContactDetailAction(model: contact) }
            }
        }
    }

また、ContactDetailActionをハンドリングするため、ContactDetailReducerを作ります。

    // Reducers/ContactDetailReducer.swift

    import ReSwift

    struct ContactDetailReducer {}

    extension ContactDetailReducer {
        func handleAction(action: Action, state: ContactDetailState?) -> ContactDetailState {
            let prevState = state ?? ContactDetailState()
            var nextState = prevState
            
            switch action {
            case is RequestUpdateContactDetailAction:
                nextState.model = nil
            case let action as ResponseUpdateContactDetailAction:
                nextState.model = action.model
            default:
                break
            }
            
            return nextState
        }
    }

ContactListStateとContactDetaiStateにはAppStateの属性ですので、StoreにActionが発送される時AppStateを変化します。そのため、アプリのReducerを作りましょう。

    // Reducers/AppReducer.swift
    import ReSwift

    func appReducer(action: Action, state: AppState?) -> AppState {
        return AppState(
            contactListState: ContactListReducer().handleAction(action: action, state: state?.contactListState),
            contactDetailState: ContactDetailReducer().handleAction(action: action, state: state?.contactDetailState)
        )
    }

上にメンションしましたが、The Storeまだ見ていないですよね。では、Storeを作りましょう。 リマインダー:The Storeは唯一です。

ReSwift公式ページに対して、The Storeの定義はAppDelegateに書きます。

import UIKit
import ReSwift

var store = Store<AppState>(reducer: appReducer, state: nil) //追加したコード

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    ...

StateとかActionとかReducer等、全て定義しました。これから、使いかたの説明をします。

ControllersのContactListViewController.swiftにReswiftをimportして、下記のコードをファイルの最後に入れましょう。

    // ファイルの最後に書きます。
    extension ContactListViewController: StoreSubscriber {
        func newState(state: AppState) {
            if let model = state.contactListState.model {
                dataSources = model
            }
        }
    }

次はContactListViewControllerのclasの中にfetchDataとviewWillAppearとviewDidDisappearという関数を入れます。

    private func fetchData() {
        store.dispatch(ContactListState.getContactList())
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        store.subscribe(self)
        if store.state.contactListState.model == nil {
            fetchData()
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        store.unsubscribe(self)
    }

dataSourcesの定義、少し変更します。

    fileprivate var dataSources: [Contact] = [] {
        didSet {
            tableView.reloadData()
        }
    }

ここで、アプリをビルドしてみます。一覧画面からセルを選択して、詳細画面に遷移します。名前と電話番号を変更して、更新ボタンをクリックして、バックボタンを押したら、 何も変わらないことがわかります。

Animated GIF - Find & Share on GIPHYgph.is

原因は、詳細画面にはまだStoreにsubscribeしていなかったので、StoreにActionをディスパッチされていない。

一覧画面みたいな書き方に応じて、ContactDetailViewController.swiftに下記のコードを入れます。

import:

import UIKit
import ReSwift //追加したコード

class ContactDetailViewController: UIViewController {
...

subscribe:

    // ファイルの最後に書きます
    extension ContactDetailViewController: StoreSubscriber {
        func newState(state: AppState) {
            if let _ = state.contactDetailState.model {
                let _ = navigationController?.popViewController(animated: true) // 更新したあとで、すぐに一覧画面に戻ります。
            }
        }
    }

と、

    ...

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
    }
    
    <b>
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        store.subscribe(self)
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        store.unsubscribe(self)
    }
    </b>
    private func setupViews() {
        view.backgroundColor = .white
    
    ...

ContactDetailViewControllerのupdateContact()関数には下記になります

    @objc private func updateContact() {
        // ボタンをくりっくして、見やすくしたいので、ボタンの色を変更します。
        submitBtn.backgroundColor = .darkGray
        view.endEditing(true)

        let name = detailView.nameTextField.text ?? ""
        let phoneNumber = detailView.phoneTextField.text ?? ""
        
        store.dispatch(ContactDetailState.updateContact(with: contact.id, name: name, phone: phoneNumber))

        // 前のボタンの色を戻します。
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in  
            self?.submitBtn.backgroundColor = .purple
        }
    }

最後のステップ、ContactListReducerにResponseUpdateContactDetailActionをハンドリングします。

    ...
    case is ResponseUpdateContactDetailAction:
        nextState.model = nil

    ...

じゃあ、実行してみよう!

詳細画面に変更した内容が反映されましたが、また、詳細画面に開いたら?

そうです。すぐに一覧画面に戻ってしまいます。

Animated GIF - Find & Share on GIPHYgph.is

原因は? ResponseUpdateContactDetailActionに発送したが、state.contactDetailStateのmodelが値を持っているので、すぐさま親の画面に戻ります。下記のコードの原因です。

    if let _ = state.contactDetailState.model {
        let _ = navigationController?.popViewController(animated: true)
    }

解決方法はContactDetailActions.swiftに新しいActionを追加します。 ResponseUpdateContactDetailActionの下にコードを入れます。

    ...

    struct RefreshContactDetailAction: Action {}
    ...

Actionがありました。ハンドリングしよう。ContactDetailReducerにはこういう感じになります。

import ReSwift

struct ContactDetailReducer {}

extension ContactDetailReducer {
    func handleAction(action: Action, state: ContactDetailState?) -> ContactDetailState {
        let prevState = state ?? ContactDetailState()
        var nextState = prevState
        
        switch action {
        case is RefreshContactDetailAction: //追加したコード
            nextState.model = nil //追加したコード
        case is RequestUpdateContactDetailAction:
            nextState.model = nil
        case let action as ResponseUpdateContactDetailAction:
            nextState.model = action.model
        default:
            break
        }
        
        return nextState
    }
}

後、ContactDetailViewControllerのviewDidDisappear関数にはこういう感じになります。

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    store.dispatch(RefreshContactDetailAction()) //追加したコード
    store.unsubscribe(self)
}

修正が完了しました。ビルドしてみたら、うまく動きました。

Animated GIF - Find & Share on GIPHYgph.is

完了したものはこちらです

終わりに

上記にReSwiftについて、初期的に紹介しました。ReSwiftに関して、ReSwift-RouterとかReSwiftRecorder等もあります。 さらにStoreのStateが変化したら、すぐにSubscriberに反映するのはReactive感があります。ReactiveProgramingとReSwiftを結合するのはReactiveReSwiftです。 読者がReSwiftの親戚に興味があれば、公式ページにサンプルコードを参考できます。

また、猫ちゃんの言いたいことがあります。

こちらは私の友達のシーちゃんです。

f:id:curama-tech:20180410123945j:plain

"技術について、興味がある方、ぜひ、しーちゃんのチームに参加しましょう。"

次回は、Tuyenさんによる凸最適化問題の応用の記事です。