58
48

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.

Flux+RxSwiftで楽しくiOSアプリを開発している話

Last updated at Posted at 2017-12-19

プライベートで開発していたアプリも無事リリースでき
スッキリ晴れやかな気持ちになったので、去年に続き今年もポエムを書きます:clap:

はじめに

仕事の方で、新規案件を行うにあたり、RxSwiftとFluxを採用して開発することにしました。
(ディレクトリ構成はducks、View部分はAtomic designを参考にしています)
悩む所は諸々に出てくるのですが、良い感触を得ているので
そのあたりの話を書いていこうと思います。
(Rx要素は薄いです)

Fluxってなに?

だいたいどんなものかは知ってると思いますので、こちらを参考に。
facebook
Flux with RxSwift
iOS meets Flux

なぜFluxにしたの??

決定した理由はこちら。

  1. 状態の変化が色々とあるアプリを作ることになった。
  2. 状態を管理するというStoreという役割がFluxにある。
  3. 単一方向なフローで、状態の変更もActionを通じてしか行えないので、追跡はしやすい
  4. Reduxとも悩んだのですが、Fluxはあくまで考え方なので、既存のライブラリなどの影響で多少外れてもなんとかなるかなと考えた。

やっぱり、表示しているページに関する状態を、どこで保持するのか。というのがアーキテクチャで提示されていたのが決定した理由の大きいところだと思います。
(理解が足りないと言われればそれまでですが、Clean ArchitetureやMVVMを触っていた時もどこに状態を置こうと悩んだ記憶があります)

構成イメージ

スクリーンショット 2017-12-19 8.43.39.png

ベースはFluxをイメージしつつも、外界から取得する部分はRepositoryを用意しています。(clean architectureでやってたのを無駄にしないスタイル)
また、基本アプリに関するロジックはModelに集約させており、Flux部分はModelを用意してStore、ViewControllerに流してあげるだけを意識します。
プリミティブな型ではなく、もっとまとまった型で会話するイメージです :smile:

流れを文字にするとこんな感じです。

1.ViewController -> ActionCreatorを実行
2. ActionCreator -> Action -> Dispatcher
  a. ActionCreator内で、必要であればRepositoryなどからデータを取得し
  b. Translatorを用いてReponse型からアプリで利用するModelに変換
  c. 用意されたActionオブジェクトを生成し、Dispatch
3. Dispatcher -> Store(Reducer)
  Dispatcherを購読したStoreがあり
  dispatchされたActionオブジェクトをReducerを通して、必要な形に整えてStoreに登録
4. Store -> ViewController -> View
  ViewContoller側では、Storeを購読しているので、変更があれば通知がきて再描画

ざっくりと登場人物を説明

DataLayer

  • APIやDB、ファイルなどの外界からのデータを取得、永続化するもの
    (この辺りはClean Architectureのデータ層チックにしています)
  • 基本的にはFlux関係なく、RxSwiftを利用しているだけです。
    RequestableでRequestオブジェクトを用意して、結果をDecodeするDecoderオブジェクトを用意して、Api処理を実行するだけです。
func item(parameter: QiitaItemParameter) ->Single<QiitaItemResponse?> {
    let request = QiitaRequest.item(inputParameter: parameter)
    let decode = QiitaItemDecoder()
    return ApiClient.execute(request: request, decode: decode)
}
Apiclient.swift
static func execute<T: Requestable, U: Decodable, V>(request:T, decode: U) -> Single<V> where U.ResponseType == V {
         
         return Single<U.ResponseType>.create { single -> Disposable in
             
             guard let urlRequest = try? request.asURLRequest() else {
                 single(.error( Errors.systemError.createError(title: "url request error.")))
                 return Disposables.create()
             }
                     
             Alamofire
                 .request(urlRequest)
                 .validate(statusCode: 200..<300)
                 .validate(contentType: ["application/json", "text/plain", "application/xml"])
                 .responseData(queue: request.queue, completionHandler: { response in
                     
                     switch response.result {
                     case .success:
                         single(.success(decode.decode(data: response.result.value)))
                         break
                     case .failure(let error):
                         single(.error(error))
                         break
                     }
                 })
             
             return Disposables.create()
         }
     }

ActionCreator/Translator/Action

  • ActionCreatorはActionオブジェクトを生成する役割があります。
    Repositoryを利用してデータを取得したりします。
  • Translatorは、Repositoryから流れてきたResponseの型をアプリで利用するModelに変換します。(Reponse to Model)
  • Actionはデータをもったオブジェクトで、Store内でこのActionの型を見てReduceしていきます。
    また、ReSwiftを参考に、Protocol Action {} という何もないActionというprotocolを採用しています。
    (Dispatcherのプロパティをたくさん作らなく済みます)
/// Actionオブジェクト
enum QiitaHomeActions {
    
    struct SetHomeResult: Action {
        var itemList: [QiitaItem] = []
        var userList: [QiitaUser] = []
    }
    
    struct SetErrorResult: Action {
        var error: AppError
    }
}

/// ActionCreator
final class QiitaHomeActionCreator: QiitaHomeActionProtocol {
    :
    :
    :
 func show(page: Int, perPage: Int) {
        
        /// Repositoryから取得
        Single.zip(
            repository.itemList(parameter: QiitaItemListParameter(page: page, perPage: perPage)),
            repository.userList(parameter: QiitaUserListParameter(page: page, perPage: perPage))
        ) { items, users in
            return (items, users)
            }
            /// TranslatorでModelに変換、そしてActionオブジェクトへ
            .map { response -> QiitaHomeActions.SetHomeResult in
                let itemList = QiitaItemTranslator.translate(data: response.0)
                let userList = QiitaUserTranslator.translate(data: response.1)
                return QiitaHomeActions.SetHomeResult(itemList: itemList, userList: userList)
            }
            .subscribeOn(Dependencies.shared.backgroundScheduler)
            .subscribe(
                onSuccess: { [weak self] action in
                    self?.dispatcher.action.dispatch(action)
                },
                onError: { [weak self] error in
                    let error = AppError(message: error.localizedDescription)
                    self?.dispatcher.action.dispatch(
                        QiitaHomeActions.SetErrorResult(error: error)
                    )
                }
            ).disposed(by: disposeBag)
    }
}

Dispatcher

  • 購読しているStoreに通知するもの。
    1本にしても良い気がするのですが、今の所Store = Dispatcherの数にしています。
    Singletonです。 (場合によってはインスタンスも利用することがあります)
final class QiitaDispatcher {
    static let shared = QiitaDispatcher()
    let action = DispatchSubject<Action>()
}

Store/Reducer/State

  • 状態を保持するところ。1ページ1Storeではなく、もっと大きく機能単位にしています(例えばTabBarのタブ単位ぐらい)
  • Singletonです。 (場合によってはインスタンスも利用することがあります)
  • Reducerで、Actionの型をas句でdowncastして、actionと今の状態をみて、新しい状態を登録します
  • Stateは、例えば特定のページを表現するのに必要な集合体です。(集約なイメージです)
    func reducer(action: Action, state: QiitaStore) {
        switch action {
        case let action as QiitaHomeActions.SetHomeResult:
            let homeState = QiitaHomeState(itemList: action.itemList, userList: action.userList)
            state.home.accept((homeState, nil))
        case let action as 
            :
            :
            :

Model

データと振る舞いを持っています。
Imuutableなオブジェクトです。

struct QiitaTag {

    private(set) var name: String?

    init (name: String?) {
        self.name = name
    }
}
extensiton QiitaTag {
    /// 何かの振る舞いが入る
}

ディレクトリ構成

Ducksの採用

Action, Storeのようにfunction-firstはなく、機能単位(ログイン、〇〇ページなど)のfeature-firstにしています。
(以前はfunction-firstでしたが、やっぱりページや機能単位で修正を行うことが多かったので、こちらを採用しています)

UI系をViewsにおき、状態系をStatesという様に分けています。

├── FluxSample
│   ├── Models
│   ├── States
│   └── Views

State

├── States
│   ├── Pages
│   │   └── Qiita
│   │       ├── Home
│   │       │   ├── QiitaHomeAction.swift
│   │       │   ├── QiitaHomeIndex.swift
│   │       │   └── QiitaHomeState.swift
│   │       ├── QiitaDispatcher.swift
│   │       ├── QiitaStore.swift
│   └── Utilities
│       └── Translator
│           ├── QiitaItemTranslator.swift
│           ├── QiitaTagTranslator.swift
│           └── QiitaUserTranslator.swift

Action, Dispatcher, Storeのように状態を管理するために動くものが集約されます。
Translatorもここにあります。

Views

└── Views
    ├── Components
    │   ├── Atoms /// 原子: 最小の要素 (Labelを拡張したものtoka)
    │   ├── Molecules /// 分子: Atomsを組み合わせたもの
    │   └── Organisms /// 構造体: 分子や原子を組み合わせて、具体的な用途になっている部品
    ├── Layouts
    └── Pages

Atomic Designを参考に原子、分子、構造体の部品を用意し、ページは部品を組み合わせるだけというようにしました。

/// 構造体
@IBOutlet weak var itemListView: ItemListView!

index?.store.home
    .skip(1)
    .subscribe(
        onNext: { [weak self] data, error in
            guard let data = data, error == nil else { return }
        /// データを渡すだけでハッピー
            itemListView.data = data.itemList
        }
    ).disposed(by: disposeBag)
}

まとめのところで書こうと思いますが、開発してて良かったと思う反面、悩むことも多いです。

ViewとStateのつなぎこみ

ViewはStateのIndexが提供するIFを利用してActionやStoreを参照します。
これによって、View側は状態に関して最低限しか関わらないようになります。

index.swift
protocol QiitaHomeIndexProtocol {
    var action: QiitaHomeActionProtocol { get }
    var store: QiitaStoreProtocol { get }
}

final class QiitaHomeIndex: QiitaHomeIndexProtocol {
    private(set) var action: QiitaHomeActionProtocol
    private(set) var store: QiitaStoreProtocol
    
    init() {
        let dispatcher = QiitaDispatcher.shared
        let repository = MockRepository()
        store = QiitaStore.shared
        action = QiitaHomeActionCreator(repository: repository, dispatcher: dispatcher)
    }
}
ViewController.swift

fileprivate var index: QiitaHomeIndexProtocol?

override func viewDidLoad() {
    super.viewDidLoad()

    /// indexが提供するプロパティのみ知っている
    index = QiitaHomeIndex()
}

関係図で見るとこんなイメージです。ViewとStateは最低限
スクリーンショット 2017-12-19 8.43.54.png

実際のサンプル

こんなイメージですよーというサンプルです。
https://github.com/kirou/RxSwiftWithFluxSample

まだサンプルにはないのですが
本来は差分更新とかをIGListKitやDwifftなどを利用していくのがいいかなと思います。
(ModelにOSSをimportするのはうーんかもですが、やっぱり差分更新は欲しいところ)
あと、テストもまたどこかのタイミングで入れるようかと、、、 :bow:

よかった点、課題点

  • よかった点
  • 状態がどこで持つのかスッキリした。
  • いろんな人が関わる開発をする場合でも、だいたい一緒の作りになる
  • Flux部分は単一方向となっていて、Push型メソッドのみとなり依存関係が薄くなった
  • Atomic Designの話ですが、部品を組み合わせて作れるので、ViewControllerで色々やることが少なくなった。
  • 課題点
  • Fluxに当てはめようとした時、正しくできているのかわからなくなる。(これは常につきまとう気がします)
  • なんとなくReduxに寄って行く気がしてReduxで良かったのでは?という気にもなる
  • 差分更新はOSSに頼ることになりそう
  • やっぱり多少は初期コストはかかります。
  • Atomic Designの話になりますが、命名が難しい。 作られるデザイン上構造体に本来分子にあたる部分が混じる時があり切り分けが悩ましい。

まとめ

  • 色々とよかった点、課題点がありますが、それなりの人数が出入りする環境においては、人によって大きくぶれないので、実際にやってよかったとは思っています。(まだまだ改善の余地はありますが)
  • また、今回、自分たちが行う案件はこれが合ってそうだ、というところで採用しましたが、もちろん向かないケースや見合わないケースがあるので、いくつもある手段の1つの参考としていただけると幸いです。

参考

facebook
Flux with RxSwift
iOS meets Flux
ducks
Atomic Design
almin.js // 構成を考える上で参考にしました
ReSwift

58
48
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
58
48

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?