Help us understand the problem. What is going on with this article?

RxSwiftなどを利用しないMVVMのサンプル

More than 1 year has passed since last update.

はじめに

RxSwiftReactiveCocoaなどのReactive Programmingを実現するフレームワークを利用する方が増えているので、もともとデータバインディングの機構がなかったiOSアプリ開発においてもMVVMを用いるられる機会が増えつつあると思います。
そのため、RxSwfitなどを用いてデータバインディングを行うデザインパターン = MVVMという雰囲気が漂っている気がしています。

MVVM

MVVMは、ViewViewModelModelによって構成されます。

Viewはユーザーからのアクションを受け取って、そのイベントをViewModelにバインドします。
そして、そのバインドされたイベントによってViewModelModelの取得や更新を行います。
ViewModelは更新された情報をイベントとしてViewにバインドし、Viewはバインドされたイベントをもとに結果を反映させます。

mvvm.png

このData Bindingが行われている部分をRxSwiftなどを使わずに、ピュアなSwiftで実装するとどのようになるでしょうか。
下図のような、簡単なカウンタアプリを実装していこうと思います。

counter.gif

仕様としては

  • 🔼をタップするとカウントが増える
  • 🔽をタップするとカウントが減る
  • カウントが0の場合は、カウントを減らせない
  • カウントが0の場合は、🔽は非活性

を満たすものとします。

ViewModel

今回はViewModel側でNotificationCenterをラップして、データバインディング機構のようなものを実装していこうと思います。
NotificationCenterを利用する理由としては、func post(name:object:userInfo:)を呼び出すとfunc addObserver(forName:object:queue:using:)などで監視をしている同一のNotification.Nameに対して通知を送ることができるからです。

また、NotificationCenterはNotificationCenter.defaultから通知や監視を行うサンプルが多いため、アプリ全体に向けて通知を送るもののように利用されることが多いと思います。
しかし、下図のようにインスタンス化したNotificationCenterを保持することで、該当のインスタンス内でのみに通知を送ることも可能です。
スクリーンショット 2018-02-06 13.15.21.png

それでは、ViewModelの実装を見ていこうと思います。
今回作成するカウンタアプリは、一画面であるため、1: ViewController1: ViewModelとなります。
ここで利用するViewModelの名前は、CountViewModelとします。CountViewModelの実装は下記になります。

final class CountViewModel {

    private enum Names {
        static let countChanged = Notification.Name(rawValue: "CountViewModel.countChanged")
        static let isDecrementEnabledChanged = Notification.Name(rawValue: "CountViewModel.isDecrementEnabledChanged")
        static let decrementAlphaChanged = Notification.Name(rawValue: "CountViewModel.decrementAlphaChanged")
    }

    private(set) var count: String = "" {
        didSet { center.post(name: Names.countChanged, object: nil) }
    }
    private(set) var isDecrementEnabled: Bool = false {
        didSet { center.post(name: Names.isDecrementEnabledChanged, object: nil) }
    }
    private(set) var decrementAlpha: CGFloat = 0.5 {
        didSet { center.post(name: Names.decrementAlphaChanged, object: nil) }
    }

    private var observers: [NSObjectProtocol] = []
    private let center: NotificationCenter

    private var _count: Int = 0 {
        didSet {
            count = String(_count)
            isDecrementEnabled = _count > 0
            decrementAlpha = isDecrementEnabled ? 1 : 0.5
        }
    }

    deinit {
        observers.forEach { center.removeObserver($0) }
    }

    init(center: NotificationCenter = .init()) {
        self.center = center
        setInitialValue()
    }

    private func setInitialValue() {
        _count = 0
    }

    @objc func increment() {
        _count += 1
    }

    @objc func decrement() {
        _count -= 1
    }

    func observe<Value1, Target: AnyObject, Value2>(keyPath keyPath1: KeyPath<CountViewModel, Value1>,
                                                    on queue: OperationQueue? = .main,
                                                    bindTo target: Target,
                                                    _ keyPath2: ReferenceWritableKeyPath<Target, Value2>) {
        let name: Notification.Name
        switch keyPath1 {
        case \CountViewModel.count             : name = Names.countChanged
        case \CountViewModel.isDecrementEnabled: name = Names.isDecrementEnabledChanged
        case \CountViewModel.decrementAlpha    : name = Names.decrementAlphaChanged
        default                                : fatalError("not support observing \(keyPath1).")
        }

        let handler: () -> () = { [weak self, weak target] in
            guard let me = self, let target = target, let value = me[keyPath: keyPath1] as? Value2 else { return }
            target[keyPath: keyPath2] = value
        }

        handler()
        let observer = center.addObserver(forName: name, object: nil, queue: queue) { _ in handler() }
        observers.append(observer)
    }
}

CountViewModelでは、_count: Intがカウント数を保持していますが、そちらは公開していません。
Viewにバインドする際に必要な値を、必要な型で公開するようにしています。

  • count (_countをStringとして公開)
  • isDecrementEnabled (カウントを減らすボタンが有効か)
  • decrementAlpha (カウントを減らすボタンを活性・非活性にする際のAlpha)

上記は_countのdidSetが呼ばれた際に更新されるようになっています。

そして、それぞれのpropertyのdidSetでは、該当するNotification.Nameを通知しています。

カウントを増やす・減らすを行う際に、UIButtonfunc addTarget(_:action:for:)から直接処理を行うことができるよう、func increment()func decrement()@objcとして定義します。

それでは、それらの流れをViewにどのようにしてデータバインディングしていくのかを見ていこうと思います。

func observe<Value1, Target: AnyObject, Value2>(keyPath keyPath1: KeyPath<CountViewModel, Value1>,
                                                on queue: OperationQueue? = .main,
                                                bindTo target: Target,
                                                _ keyPath2: ReferenceWritableKeyPath<Target, Value2>)

KeyPathを利用して上記のメソッドからCountViewModelのpropertyを監視登録し、ターゲットとなるオブジェクトに変更後の値をバインドします。

let name: Notification.Name
switch keyPath1 {
case \CountViewModel.count             : name = Names.countChanged
case \CountViewModel.isDecrementEnabled: name = Names.isDecrementEnabledChanged
case \CountViewModel.decrementAlpha    : name = Names.decrementAlphaChanged
default                                : fatalError("not support observing \(keyPath1).")
}

keyPathをswitch-caseを利用して比較し、該当するNotification.Nameを割り当てます。

let handler: () -> () = { [weak self, weak target] in
    guard let me = self, let target = target, let value = me[keyPath: keyPath1] as? Value2 else { return }
    target[keyPath: keyPath2] = value
}

通知が来た際のハンドラを定義し、keyPathを利用して通知を受け取った際の該当のpropteryの値を取得し、キャストをしてからターゲットのpropertyに対して代入します。

handler()
let observer = center.addObserver(forName: name, object: nil, queue: queue) { _ in handler() }
observers.append(observer)

現状の値を即座に渡すために、一度handlerを実行します。そして、NotificationCenterに通知登録を行います。
func addObserver(forName:object:queue:using:)はNSObjectProtocolのオブジェクトを返してくるので配列などで保持をし、CountViewModelが破棄させれる際に監視の解除を行うようにします。

deinit {
    observers.forEach { center.removeObserver($0) }
}

ViewController

上記のCountViewModelをViewControllerで利用していきます。
UIのパーツとして

  • incrementButton (カウントを増やすボタン)
  • decrementButton (カウントを減らすボタン)
  • countLabel (現在のカウントの状態を表示するラベル)

をViewControllerが保持しています。

final class MVVMSampleViewController: UIViewController {
    @IBOutlet private weak var incrementButton: UIButton!
    @IBOutlet private weak var decrementButton: UIButton!
    @IBOutlet private weak var countLabel: UILabel!

    private let viewModel = CountViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        incrementButton.addTarget(viewModel, action: #selector(CountViewModel.increment), for: .touchUpInside)
        decrementButton.addTarget(viewModel, action: #selector(CountViewModel.decrement), for: .touchUpInside)

        viewModel.observe(keyPath: \.count, bindTo: countLabel, \.text)
        viewModel.observe(keyPath: \.isDecrementEnabled, bindTo: decrementButton, \.isEnabled)
        viewModel.observe(keyPath: \.decrementAlpha, bindTo: decrementButton, \.alpha)
    }
}

viewDidLoadで、incrementButtonとdecrementButtonがそれぞれが該当する処理をaddTargetで登録します。
そして、viewModelの該当のpropertyを監視し、ViewControllerの該当のpropertyに対してバインドするようにします。

このように実装することで、RxSwiftなどを利用せずともViewからのイベントをViewModelに対してバインドし、ViewModel内で状態を変更してその変更結果をViewにバインドするという流れを実現することができます。

その他

今回のViewModel内でのメソッドの定義では、型に関係なくバインドをしようとできてしまうため

func observe<Value, Target: AnyObject>(keyPath keyPath1: KeyPath<CountViewModel, Value>,
                                       on queue: OperationQueue? = .main,
                                       bindTo target: Target,
                                       _ keyPath2: ReferenceWritableKeyPath<Target, Value>)

として、同一のValueという型にだけバインドできるようにしたり、OptinalやImplicitlyUnwrappedOptionalのWrappedがValueと同じ型の場合にという定義をする必要があります。

それらの部分も含めて、NotificationCenterを利用したデータバインディング機構をまとめた、Continuumを作ってみました。
NotificationCenterのインスタンスの.continuumにアクセスをすることで、func observe(_:on:bindTo:_:)にアクセスできるようになっています。
VariableまたはConstantという値をラップしているクラスを利用することで、値に変更があった際には内部で自動的にfunc post(name:object:userInfo:)を呼び出すようになっています。

final class ViewController: UIViewController {

    @IBOutlet weak var label: UILabel!

    private let viewModel: ViewModel = ViewModel()
    private let center = NotificationCenter()
    private let bag = ContinuumBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        center.continuum
            .observe(viewModel.text, on: .main, bindTo: label, \.text)
            .disposed(by: bag)

        viewModel.text.value = "Binding this text to label.text!"
    }
}

final class ViewModel {
    let text: Variable<String>

    init() {
        self.text = Variable(value: "")
    }
}

ちなみに、Continuumを利用して同様の仕様を満たす実装をすると下記のコードのようになります。

final class ViewController: UIViewController {
    @IBOutlet private weak var countLabel: UILabel!
    @IBOutlet private weak var incrementButton: UIButton!
    @IBOutlet private weak var decrementButton: UIButton!

    private let viewModel = CountViewModel()
    private let center = NotificationCenter()
    private let bag = ContinuumBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        incrementButton.addTarget(viewModel, action: #selector(CountViewModel.increment), for: .touchUpInside)
        decrementButton.addTarget(viewModel, action: #selector(CountViewModel.decrement), for: .touchUpInside)

        center.continuum
            .observe(viewModel.count, bindTo: countLabel, \.text)
            .disposed(by: bag)

        center.continuum
            .observe(viewModel.decrementAlpha, bindTo: decrementButton, \.alpha)
            .disposed(by: bag)

        center.continuum
            .observe(viewModel.isDecrementEnabled, bindTo: decrementButton, \.isEnabled)
            .disposed(by: bag)
    }
}

final class CountViewModel {
    let isDecrementEnabled: Constant<Bool>
    private let _isDecrementEnabled = Variable(value: false)

    let decrementAlpha: Constant<CGFloat>
    private let _decrementAlpha = Variable<CGFloat>(value: 0.5)

    let count: Constant<String>
    private let _count = Variable<String>(value: "")

    private var _rawCount: Int = 0 {
        didSet {
            _count.value = "\(_rawCount)"
            _isDecrementEnabled.value = _rawCount > 0
            _decrementAlpha.value = _isDecrementEnabled.value ? 1 : 0.5
        }
    }

    init() {
        self.isDecrementEnabled = Constant(variable: _isDecrementEnabled)
        self.decrementAlpha = Constant(variable: _decrementAlpha)
        self.count = Constant(variable: _count)
        setInitialValue()
    }

    private func setInitialValue() {
        _rawCount = 0
    }

    @objc func increment() {
        _rawCount += 1
    }

    @objc func decrement() {
        _rawCount -= 1
    }
}

最後に

RxSwiftなどを利用しないMVVMの実装の方法の一例としてNotificationCenterを利用する方法を紹介させていただきました。
ちなみに、RxSwiftを利用して同様の仕様を満たす実装をすると下記のコードのようになります。

final class MVVMWithRxSwiftSampleViewController: UIViewController {
    @IBOutlet private weak var incrementButton: UIButton!
    @IBOutlet private weak var decrementButton: UIButton!
    @IBOutlet private weak var countLabel: UILabel!

    private lazy var viewModel: CountViewModel = {
        return .init(incrementButtonTapped: self.incrementButton.rx.tap.asObservable(),
                     decrementButtonTapped: self.decrementButton.rx.tap.asObservable())
    }()

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.isDecrementEnabled
            .bind(to: decrementButton.rx.isEnabled)
            .disposed(by: disposeBag)

        viewModel.decrementAlpha
            .bind(to: decrementButton.rx.alpha)
            .disposed(by: disposeBag)

        viewModel.count
            .bind(to: countLabel.rx.text)
            .disposed(by: disposeBag)
    }
}

final class CountViewModel {
    let count: Observable<String>
    let isDecrementEnabled: Observable<Bool>
    let decrementAlpha: Observable<CGFloat>

    private let disposeBag = DisposeBag()

    init(incrementButtonTapped: Observable<Void>,
         decrementButtonTapped: Observable<Void>) {

        let _count = BehaviorSubject<Int>(value: 0)
        let _isDecrementEnabled = _count.map { $0 > 0 }

        self.isDecrementEnabled = _isDecrementEnabled
        self.count = _count.map(String.init)
        self.decrementAlpha = _isDecrementEnabled.map { $0 ? 1 : 0.5 }

        Observable.merge(incrementButtonTapped.map { 1 },
                         decrementButtonTapped.map { -1 })
            .withLatestFrom(_count.asObservable()) { $1 + $0 }
            .bind(to: _count)
            .disposed(by: disposeBag)
    }
}

この他にも、同様の仕様のカウンタアプリをいろんなデザインパターンで実装してみたので、興味がありましたらぜひご覧ください!
https://github.com/marty-suzuki/SimplestCounterSample

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした