8
6

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.

MVVMとFluxを勉強してみた。

Last updated at Posted at 2019-05-14

個人でアプリを作成するついでにMVVMとFluxを勉強してみました。
間違えや改善点あれば忌憚ない意見をお願いします。

サンプルの内容

デバイスの向きを検知して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の部分のやり取りは書籍を参考にしました。
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

8
6
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
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?