flux
MVVM
Swift
RxSwift
RxCocoa

MVVMとFluxを勉強してみた。

個人でアプリを作成するついでにMVVMとFluxを勉強してみました。

間違えや改善点あれば忌憚ない意見をお願いします。


サンプルの内容

デバイスの向きを検知してFlux構造で回しています。

①Store(BehaviorRelayのため初期値を渡す)→ViewModel→View

②Viewで検知→ViewModel→ActionCretorアクション生成

→Dispatcher→Store→ViewModel→View

ActionCreatorでもStoreを監視しつつ、WebAPIやロジックを呼び出し、

新たなActionを作成する想定です。


RxCocoaの使い方

// 1.自分のクラスにRelay変数を持つ

private let portraitRelay = BehaviorRelay<Bool?>(value: nil)
private let portraitToViewRelay = PublishRelay<Bool?>()

// 2.受信するクラスにObservableクラスを渡す
// 例1
var portraitObservable: Observable<Bool?> {
return portraitRelay.asObservable()
}
// 例2
portraitViewModel = PortraitViewModel(
portraitObservable: portraitRelay.asObservable())

// 3.別のクラスでObservableを監視または、Bindする
// disposeBagのインスタンス解放に合わせて、監視も止まります。
// 監視
portraitViewModel.portraitObservable
.bind(to: Binder(self) { `self`, portrait in
self.portrait = portrait
}).disposed(by: disposeBag)
// Bind
portraitObservable.bind(to: portraitToActionRelay).disposed(by: disposeBag)

// 4.自分のクラスで変更を通知
portraitToViewRelay.accept(true)


Bind関係

Rxでデータのやり取りをしている部分は、矢印の方向にObservableを渡して、

自分を監視してもらいます。また、ViewModelはActionCreatorに通知する場合と、

Viewに通知する場合がありそれぞれにRelayを用意します。

Fluxの部分のやり取りは書籍を参考にしました。

MVVM+Flux.png


BaseViewControllerクラス

ViewControllerの基本クラスです。処理をプロトコルに切り分けることで、

今回のサンプルのデバイス向き検知を全画面ではなく、特定の画面のみで検知したくなった場合など、

実装変更時のフットワークが軽くなります。

//

// BaseViewController.swift
//

import UIKit
import RxSwift
import RxCocoa

class BaseViewController: UIViewController, ClassInfoAdded, PortraitDetectionView {
private var baseViewModel: BaseViewModel!
private let viewDidLoadRelay = PublishRelay<Void>()

// PortraitDetectionView
internal var portraitViewModel: PortraitViewModel!
internal var portrait: Bool?
internal let portraitRelay = PublishRelay<Bool?>()

internal let disposeBag = DisposeBag()

deinit {
print("deinit BaseViewController")
}

override func viewDidLoad() {
super.viewDidLoad()

addPortraitDetection()

baseViewModel = BaseViewModel(viewControllerName: ViewControllerName(
rawValue: className)!, viewDidLoadObservable:
viewDidLoadRelay.asObservable())
viewDidLoadRelay.accept(())
}
}


ClassInfoAddedクラス

クラス情報の取得を追加します。拡張される範囲(スコープ)を狭めるために、

プロトコルに処理を持たせました。

//

// ClassInfoAdded.swift
//

import Foundation

protocol ClassInfoAdded: class {}

extension ClassInfoAdded {
var className: String {
get {
return String(describing: type(of: self))
}
}
}


PortraitDetectionViewクラス

プロトコルでデバイスの向きを検知する機能を切り出しています。

//

// PortraitDetectionView.swift
//

import UIKit
import RxSwift
import RxCocoa

protocol PortraitDetectionView: class {
var portraitViewModel: PortraitViewModel! { get set }
var portrait: Bool? { get set }
var portraitRelay: PublishRelay<Bool?> { get }
var disposeBag: DisposeBag { get }
}

extension PortraitDetectionView {
func addPortraitDetection() {
portraitViewModel = PortraitViewModel(
portraitObservable: portraitRelay.asObservable())
portraitViewModel.portraitObservable
.bind(to: Binder(self) { `self`, portrait in
self.portrait = portrait
}).disposed(by: disposeBag)

NotificationCenter.default.rx.notification(
UIDevice.orientationDidChangeNotification)
.bind(to: Binder(self) { `self`, notification in
self.orientationDidChange(notification: notification)
}).disposed(by: disposeBag)
}

func orientationDidChange(notification: Notification) {
let orientation = UIDevice.current.orientation
//print("orientation: \(orientation)")
if orientation != .portrait && orientation != .landscapeLeft && orientation != .landscapeRight {
return
}

let currentPortrait = orientation == .portrait
if portrait == nil || currentPortrait != portrait {
portrait = currentPortrait
portraitRelay.accept(portrait)
}
}
}


BaseViewModelクラス

BaseViewModelは検知した結果をActionCreatorに渡すだけなので、

単一方向バインドになっています。

//

// BaseViewModel.swift
//

import Foundation
import RxSwift
import RxCocoa

class BaseViewModel {
private let actionCreator: ActionCreator = .shared
private let viewDidLoadToActionRelay = PublishRelay<Void>()
private let disposeBag = DisposeBag()

init(viewControllerName: ViewControllerName, viewDidLoadObservable: Observable<Void>) {
// View -> ViewModel
viewDidLoadObservable.bind(to: viewDidLoadToActionRelay).disposed(by: disposeBag)

// ViewModel -> ActionCreator
actionCreator.setViewDidLoadObservable(
viewControllerName: viewControllerName,
viewDidLoadToActionRelay.asObservable(), disposeBag: disposeBag)
}
}


PortraitViewModelクラス

Viewとの双方向バインド+Flux間とも双方向バインドになります。

View行きのRelayとActionCreator行きのRelayを、

二つ作ることになります。

//

// PortraitViewModel.swift
//

import Foundation
import RxSwift
import RxCocoa

class PortraitViewModel {
private let actionCreator: ActionCreator = .shared
private let portraitToViewRelay = PublishRelay<Bool?>()
private let portraitToActionRelay = PublishRelay<Bool?>()
private let disposeBag = DisposeBag()

init(portraitObservable: Observable<Bool?>) {
// View -> ViewModel
portraitObservable.bind(to: portraitToActionRelay).disposed(by: disposeBag)

// ViewModel -> ActionCreator
actionCreator.setPortraitObservable(
portraitToActionRelay.asObservable(), disposeBag: disposeBag)

// Store -> ViewModel
ScreenInfoStore.shared.portraitObservable
.bind(to: portraitToViewRelay).disposed(by: disposeBag)
}

var portraitObservable: Observable<Bool?> {
// ViewModel -> View
return portraitToViewRelay.asObservable()
}
}


Actionクラス

FluxのActionを示すEnumになります。TableViewの特定のセルを押したなど、

データの変更がないアクションもCaseに加えます。Actionを発生させて、

ActionCreatorでActionに伴うActionを発生させます。

//

// Action.swift
//

import Foundation

enum Action {
case changedPortrait(Bool?)
}


ActionCreatorクラス

膨大にならないように、Repositoryクラスなどを作る必要があります。

全画面の処理を一括で制御できるため、操作性と可読性がよくなると思います。

//

// ActionCreator.swift
//

import Foundation
import RxSwift
import RxCocoa

class ActionCreator {
static let shared = ActionCreator()
private let dispatcher: Dispatcher = .shared

init() {
// Storeなどを監視して新たなActionを作る。
}

func setPortraitObservable(_ observable: Observable<Bool?>, disposeBag: DisposeBag) {
observable.bind(to: Binder(self) { `self`, portrait in
self.dispatcher.dispatch(.changedPortrait(portrait))
}).disposed(by: disposeBag)
}

func setViewDidLoadObservable(viewControllerName: ViewControllerName,
_ observable: Observable<Void>, disposeBag: DisposeBag) {

switch viewControllerName {
case .calendar:

break
}
}
}


Dispatcherクラス

Storeを分割するために存在するのかと思います。

//

// Dispatcher.swift
//

import Foundation
import RxSwift
import RxCocoa

class Dispatcher {
static let shared = Dispatcher()
private let actionRelay = PublishRelay<Action>()

func register(callback: @escaping (Action) -> ()) -> Disposable {
return actionRelay.subscribe(onNext: callback)
}

func dispatch(_ action: Action) {
actionRelay.accept(action)
}
}


ScreenInfoStoreクラス

ViewModelを外部から変更可能なのはStoreのみです。

画面情報を1つのStoreにしてみました。

//

// ScreenInfoStore.swift
//

import Foundation
import RxCocoa
import RxSwift

class ScreenInfoStore {
static let shared = ScreenInfoStore()
private let portraitRelay = BehaviorRelay<Bool?>(value: nil)
private let disposeBag = DisposeBag()

init() {
let dispatcher: Dispatcher = .shared
dispatcher.register(callback: { action in
switch action {
case let .changedPortrait(portrait):
self.portraitRelay.accept(portrait)
}
}).disposed(by: disposeBag)
}

var portraitObservable: Observable<Bool?> {
return portraitRelay.asObservable()
}
}


ContentView乱用

最後に、作成途中ですがContentViewを使いまくってみました。

こんな時MVVMとFluxは重宝すると思います。

ContentView乱用.png