🐊

ビジネスマッチングアプリ Yenta での MVVM + Flux 例

2021/12/10に公開

はじめに

こんにちは、People Techカンパニー、アトラエの川上(@_YukiKawakami)です。
アトラエでは、ビジネスマッチングアプリYentaのiOSエンジニアをやっています。

この記事は、株式会社アトラエアドベントカレンダー2021年10日目の記事です。
https://qiita.com/advent-calendar/2021/atrae

エンジニアとしてブログを書くのは今回が初めてで、何書こうかな〜〜〜と悩んだのですが、"新規メンバーの開発参入障壁が高い"と@ysk_aonoさんがおっしゃっていたのを思い出し、Yentaのアーキテクチャについて振り返りも含めて書いたらよいのでは?と思ったので、今回は、Yenta での MVVM + Flux 例について記事を書きたいと思います。

一方で感じるのが、実装していく上で作成するファイルが多くて疲れる...という個人的な所感と、新規メンバーの開発参入障壁の高さです。前者は半分冗談ではありますが、後者は現在まさに実感しているところです。ちょうど弊社内定者のエンジニアがiOS版の開発を手伝ってくれているのですが、RxSwiftという初学者殺しのライブラリを活用していることも相まって、コード全体の把握がなかなか難しくなってしまっているなと思います (クリーンアーキテクチャetc.の概念に慣れているなら話は変わってくると思うのですが) 。
https://atraetech.hatenablog.com/entry/2021/03/22/140624

Fluxについて


(Facebookが作成したアーキテクチャパターン)

Action:Viewなどの操作により、イベントを発生させる
Dispatcher:Actionから流れてきたイベントをStoreに流す
Store:Dispatcherから流れてきたイベント内の値を保持する
View:ユーザーに情報を表示する / ユーザーからの入力を受け取る

公式のコンセプトはこちら

メリット

  • View -> Action Creator -> Store -> Viewのデータフローが単一方向であるため、役割が明確
  • 画面が持ちうる状態を把握しやすい

デメリット

  • 複数画面でSubscribeされるので、処理が追いづらい
  • コードの量やファイルの量が多くなる

MVVM + Fluxについて

Yentaのアーキテクチャ全体は、だいたい上記の画像の通りとなっており、View ⇄ ViewModel ⇄ UseCase
⇄ Repository ⇄ DataStore ⇄ API etcというフローでデータを渡しています。
また、View ⇄ ViewModel でのデータバインディングをさせ、Viewの責務を減らすようにもしています。

MVVM + Flux と MVVM の違いについては、下記の画像をご覧いただきたいです。

一画面で収まるデータのみを扱うMVVMと違い、MVVM + Fluxは「複数画面をまたいで(アプリ全体など)データを扱うことができる」と言えると認識しています。

Yentaでのデータバインディングの例について


今回は、上記の例について説明したいと思います。

具体的には、「フィルタの設定をする際に、半モーダルで選択をした項目と、遷移元のViewでの選択中の項目の情報を同期させ、表示を変更する」という状況です。

前提として、Yentaでは、以下のプロトコルを必ず実装し、View と ViewModelをバインディングするようにしています。

protocol ViewModelType {
    associatedtype Dependency
    associatedtype Input
    associatedtype Output

    init(dependency: Dependency)

    func transform(input: Input) -> Output
}
// ViewModel
class ViewModel {
    init(dependency: Dependency) { ... }
}

Fluxの実際の例

Dispatcher

final class SearchElementDispatcher {
    static let shared = SearchElementDispatcher()

    let ages = PublishRelay<[AgeSearchOption]>()
}

Action

public final class SearchElementActionCreator {
    public static let shared = SearchElementActionCreator()

    private let dispatcher: SearchElementDispatcher

    private init(dispatcher: SearchElementDispatcher = .shared) {
        self.dispatcher = dispatcher
    }

    public func setAges(_ ages: [AgeSearchOption]) {
        dispatcher.ages.accept(ages)
    }
}

Store


※ Propetyは、RxPropertyを参考にして作っています

public final class SearchElementStore {
    public static let shared = SearchElementStore()

    public let ages: Property<AgeSearchOption?>

    private let disposeBag = DisposeBag()
    private let _ages = BehaviorRelay<AgeSearchOption?>(value: nil)

    private init(dispatcher: SearchElementDispatcher = .shared) {
        self.ages = Property(_ages)
	
        dispatcher.ages.bind(to: _ages).disposed(by: disposeBag)
}

下記のように、Fluxはシングルトンで管理をするようにします。

public final class SearchElementFlux {
    public static let shared = SearchElementFlux()

    private let dispatcher: SearchElementDispatcher
    public let actionCreator: SearchElementActionCreator
    public let store: SearchElementStore

    init(
        dispatcher: SearchElementDispatcher = .shared,
        actionCreator: SearchElementActionCreator = .shared,
        store: SearchElementStore = .shared
    ) {
        self.dispatcher = dispatcher
        self.actionCreator = actionCreator
        self.store = store
    }
}

Fluxの概念を用いてのデータバインディングの実際の例

① セルのタップ操作からActionCreatorでイベントを発生させる

ActionCreatorを呼んで、dispatcherに必要であれば値を渡します。(今回の場合は、選択したAgesを渡します)

final class AgeSearchInputViewModel {
    private let flux: SearchElementFlux
    private let initialAges: [AgeSearchOption]

    private let disposeBag = DisposeBag()
    private let _currentOptions = BehaviorRelay<[AgeSearchOption]>(value: [])

    init(dependency: Dependency) {
        self.flux = dependency.flux
        self.initialAges = dependency.initialAges
    }
}

extension AgeSearchInputViewModel: ViewModelType {
    struct Dependency {
        let flux: SearchElementFlux
        let initialAges: [AgeSearchOption]
    }

    struct Input {
        let itemDidSelect: ControlEvent<IndexPath>
    }

    struct Output {
    }

    func transform(input: Input) -> Output {
        input.cellDidSelect.map { _ in () }
            .withLatestFrom(_currentOptions)
            .subscribe(
                onNext: { [weak self] options in
                    self?.flux.actionCreator.setAges(options)
                }
            )
            .disposed(by: disposeBag)
    }
    
    return Output(
    )
}

② Dispatcherから流れてきたイベントの値を受け取り、変化したらデータを更新する

Storeの値が変化したら、以下のように、最新の値に更新してあげます。

searchFlux.store.setAges.changed.subscribe(onNext: { updateAges($0) }).disposed(by: disposeBag)

最後に

最後までご覧頂きありがとうございました。

入社当初、プログラミングの経験が一切なく、「エンジニアやります」と言ったは良いものの、Yentaのコードの把握が少し難しく、 よくわからないなーと思いながらコードを読んでは書いていたのですが、ようやくアーキテクチャ全体の把握ができ、実装もある程度できるようになりました。

まだまだエンジニアとしても1ビジネスパーソンとしても未熟者ですが、さっさと先輩エンジニアを剥がせるように、エンジニアとしても成長し、アトラエという会社を強くしていきたいと思います。

株式会社アトラエでは、一緒に働く仲間を募集しています。興味ある方は是非こちらのスライドを御覧ください!

https://atrae.co.jp/

明日はいつも一緒にYentaをつくっている@ysk_aonoさんの記事です。お楽しみにしていてください〜!!

Discussion