ビジネスマッチングアプリ Yenta での MVVM + Flux 例
はじめに
こんにちは、People Techカンパニー、アトラエの川上(@_YukiKawakami)です。
アトラエでは、ビジネスマッチングアプリYentaのiOSエンジニアをやっています。
この記事は、株式会社アトラエアドベントカレンダー2021年10日目の記事です。
エンジニアとしてブログを書くのは今回が初めてで、何書こうかな〜〜〜と悩んだのですが、"新規メンバーの開発参入障壁が高い"と@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ビジネスパーソンとしても未熟者ですが、さっさと先輩エンジニアを剥がせるように、エンジニアとしても成長し、アトラエという会社を強くしていきたいと思います。
株式会社アトラエでは、一緒に働く仲間を募集しています。興味ある方は是非こちらのスライドを御覧ください!
明日はいつも一緒にYentaをつくっている@ysk_aonoさんの記事です。お楽しみにしていてください〜!!
Discussion