個人でアプリを作成するついでにMVVMとFluxを勉強してみました。
間違えや改善点あれば忌憚ない意見をお願いします。
Github
サンプルの内容
デバイスの向きを検知してFlux構造で回しています。
①Store(BehaviorRelayのため初期値を渡す)→ViewModel→View
②Viewで検知→ViewModel→ActionCretorアクション生成
→Dispatcher→Store→ViewModel→View
ActionCreatorでもStoreを監視しつつ、WebAPIやロジックを呼び出し、
新たなActionを作成する想定です。
RxCocoaの使い方
【6/2追記】すみません。asObservable()は不要かと思います。
asObservable()しなくてもbind可能です。
// 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の部分のやり取りは書籍を参考にしました。
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()
}
}