Swift 3でSwiftBondを使ってMVVMしちゃおう

  • 22
    いいね
  • 1
    コメント

概要

対象

本稿は、2009年11月18日にMicrosoft DevLabsにて実験的に公開され、その後 .NET FrameworkSilverlight に導入された、ReactiveExtensionの流れを汲むRxSwiftReactiveCocoaを使うと綺麗に設計ができそうだということを小耳に挟みながらも、なかなか触れる機会がない、あるいは導入まで至っていないiOS開発者の方々が、Reactiveなプログラミングによって得られるメリットを、動くサンプルを通じて実感することを目的に書かれています。

内容など

  • 今回は、APIが比較的コンパクトにまとまっていて、学習コストの低い SwiftBond を使って、オブザーバパターンを使ったMVVM設計への導入部分を案内します。SwiftBondの内部の実装等については次回以降記載します。
  • 新しく知るべきこと、理解するべきことを可能な限り減らした解説記事にしています。
  • 今回、Reactiveの詳しい話やオブザーバーパターンの解説は行いません。
  • 次回以降は、データベース接続や通信を行うアプリケーションでのMVVMパターンの実装例について書く予定です。

環境

  • Xcode 8.2
  • Bond 5.3
  • Swift 3

動機

オブザーバパターンを用いたMVVMパターンでiOSアプリケーションを設計することで、以下のようなiOSのアプリケーションの厄介な特性に抗って、見通し良く、テスト可能な形に設計を行うことができます。

  • 様々な種類の状態を持ち管理が煩雑。
  • ViewとModelの両方に対して双方向のやり取りが必要になるため、それらの管理をViewControllerで行ってしまい、VCが巨大になりがち。
  • ViewControllerをテストし辛いことから、アプリケーションの振る舞いのうちで、テストされていない部分が多い。

はじめの一歩

準備

git clone git@github.com:kentrino/swift-bond.git
cd swift-bond
pod install

プロジェクトにBondを追加

上のリポジトリではすでに追加されています。

+ pod 'Bond', '~> 5.3'

をPodfileに追加したあとpod installを行う。

一番単純なサンプル

input(UITextField)に入力された値をそのまま、labelUILabel)に反映する(単方向のバインディング)

import UIKit
import Bond

class StartViewController: UIViewController {

    @IBOutlet weak var input: UITextField!
    @IBOutlet weak var label: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        input.bnd_text
            .bind(to: label.bnd_text)
    }
}

先程のリポジトリで git checkout minimum することでこのサンプルコードに切り替えることができます。

ちょっと工夫したサンプル

input に入力された値を少し加工して、label に反映する。


        input.bnd_text
+            .map { (str: String?) -> String? in
+                return  "Your input is '\(str!)'!"
+            }
            .bind(to: label.bnd_text)

これだけの挙動であれば、あまり面白みはあまりありませんが、アプリケーションの随所でこのように書きたいところは多々あると思います。

先程のリポジトリで git checkout second-minimum することでこのサンプルコードに切り替えることができます。

次の一歩の前に

Observableについて

SwiftBondを使うにあたって避けて通れないのが Observable の理解です。実は、エラー状態を扱ったりもできますが、今回はその基本的な挙動の解説のみを行います。

Observableとは

値の変更を監視して、変更に応じた挙動を記述することができる便利な変数です。SwiftのクラスのプロパティにdidSetを宣言することで、値の変更に従った挙動を定義することができますが、それと似ています。GoChannel と大体同じと考えることもできます。

宣言

let intObservable = Observable<Int>(0)

intの値を出し入れ可能な Observable を宣言しています。

変更に応じた挙動の定義

変数 intObservable に格納されている値が変更されたときにobserveNextの引数に指定されたクロージャーが呼ばれます。

        let _ = intObservable.observeNext{ (i: Int) -> Void in
            if i == 3 {
                print("san!")
            } else {
                print(i)
            }
        }

intObservable に現在格納されている値はintObservable.valueで取り出すことができ、以下のコードは上のコードと同じ結果を出力します。

        let _ = intObservable.observeNext{ (i: Int) -> Void in
            if i == 3 {
                print("san!")
            } else {
-                print(i)
+                print(intObservable.value)
            }
        }

変数 intObservable に新たに値の格納が行われないと通知されたときに、行う動作を記述します。

        let _ =  observable.observeCompleted { _ -> Void in
            print("Last value emitted.")
        }

変更の通知

intObservable に格納されている値を変更します。

        intObservable.next(1)
        intObservable.next(2)
        intObservable.next(3)

これ以上変更が起こらないことを通知します

        intObservable.completed()

先程のリポジトリで git checkout observable することでこのサンプルコードに切り替えることができます。

Observableを使った先程のバインディングの実装

こんな感じになります。

class StartViewController: UIViewController {

    @IBOutlet private weak var input: UITextField!
    @IBOutlet private weak var label: UILabel!

    private let observableText = Observable<String>("")

    override func viewDidLoad() {
        super.viewDidLoad()


        input.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)

        observableText.observeNext{ (text: String) -> Void in
            self.label.text = text
        }.disposeIn(bnd_bag)
    }

    func textFieldDidChange(textField: UITextField) {
        observableText.next(textField.text!)
    }
}

.disposeIn(bnd_bag) というメソッド呼び出しがありますが、こちらの解説は次回以降に回します。
先程のリポジトリで git checkout use-observable することでこのサンプルコードに切り替えることができます。

次の一歩

道具が揃ったので、MVVMパターンを使ったストップウォッチの実装に移ります。スタート機能、レジューム機能、ストップ機能、クリア機能が付いたストップウォッチを実装します。

まずはモデルから

こんな感じです。普通のストップウォッチですが、ストップウォッチが示す時間を表す time と、ストップウォッチの状態を表す stateObservable になっています。ストップウォッチの状態をenumで表現しています。 途中、 DisposeBag という見慣れぬクラスが登場していますが、この解説は次回に回します。

import Foundation
import Bond
import ReactiveKit

class StopWatch {

    enum State {
        case startable
        case stoppable
        case resumable
    }

    var time = Observable<TimeInterval>(TimeInterval(0))
    var state = Observable<State>(.startable)

    let disposeBag = DisposeBag()

    private var timer: Timer?
    private var startTime: Date?
    private var initialTime: TimeInterval = 0

    func start() {
        startTimer(withInitialTime: 0)
        state.next(.stoppable)
    }

    func resume() {
        startTimer(withInitialTime: initialTime)
        state.next(.stoppable)
    }

    func stop() {
        timer?.invalidate()
        initialTime += Date().timeIntervalSince(self.startTime!)
        state.next(.resumable)
    }

    func clear() {
        initialTime = 0
        state.next(.startable)
    }

    private func startTimer(withInitialTime initialTime: Double) {
        startTime = Date()
        timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { (timer: Timer) -> Void in
            let time = Date().timeIntervalSince(self.startTime!) + initialTime
            self.time.next(time)
        }
    }
}

Viewに思いを巡らせる

今回は、時間の表示画面とメインのボタン(ストップウォッチの状態に応じて、レジューム、ストップ、スタートが切り替わる)、とクリアボタンだけがあるものを作ります。これを念頭において、次のViewModelの実装へ移ります。

次にViewModel

StopWatchViewModelを実装して行きます。モデルの状態にどのビューの状態が対応するかを宣言的に記述するだけなので、このクラスに状態を導入する必要はありません。よく言われている「ViewModelはModelの影」を徹底しつつ実装します。

まず ViewModel の class を作成します。

import Bond

class StopWatchViewModel {

}

内部クラス: Action

やや過剰設計ですが、簡単のため、内部クラス Action を導入します。当然、不変なプロパティのみをもたせます。

  class Action {
      // Actionの文字列表現
      let description: String
      // Actionが実行可能かどうか
      let enabled: Bool
      // Actionが実行する動作
      let `do`: () -> Void

      init(description: String, enabled: Bool, `do`: @escaping () -> Void = {}) {
          self.description = description
          self.enabled = enabled
          self.do = `do`
      }
  }

StopWatchの状態に対応する、メインボタン用のActionを作る処理

private let stopWatch = StopWatch()

private func getMainAction(fromState state: StopWatch.State) -> Action {
    let start: () -> Void = {
        self.stopWatch.start()
    }

    let stop: () -> Void = {
        self.stopWatch.stop()
    }

    let resume: () -> Void = {
        self.stopWatch.resume()
    }

    switch state {
    case .startable:
        return Action(description: "Start", enabled: true, do: start)
    case .stoppable:
        return Action(description: "Stop", enabled: true, do: stop)
    case .resumable:
        return Action(description: "Resume", enabled: true, do: resume)
    }
}

StopWatchの状態に対応する、クリアボタン用のActionを作る処理

private func getClearAction(fromState state: StopWatch.State) -> Action {
    let clear: () -> Void = {
        self.stopWatch.clear()
    }

    let disabledAction = Action(description: "Clear", enabled: false, do: {})

    switch state {
    case .startable:
        return disabledAction
    case .stoppable:
        return disabledAction
    case .resumable:
        return Action(description: "Clear", enabled: true, do: clear)
    }
}

StopWatchが示す時間を文字列に加工する処理

    private static func string(fromInterval interval: TimeInterval) -> String {
        let centiSecond = Int(interval * 100) % 100
        let second = Int(interval) % 60
        let minutes = Int(interval / 60) % 60
        let hour = Int(interval / 3600) % 100
        return String(format: "%02i:%02i:%02i.%02i", hour, minutes, second, centiSecond)
    }

ViewとつなぐためのObservableのプロパティを用意

    let time = Observable<String>(StopWatchViewModel.string(fromInterval: TimeInterval(0)))
    let clearAction = Observable<Action>(Action(description: "Clear", enabled: false))
    let mainAction = Observable<Action>(Action(description: "Start", enabled: true))  

バインディング

モデルの値をプロパティにバインディングします。モデルにアクセスするのはこの部分だけです。

init() {
    stopWatch.time
        .map{ (time: TimeInterval) -> String in
            return StopWatchViewModel.string(fromInterval: time) }
        .bind(to: time)

    // stopWatchの状態に応じてmainActionの変更を通知
    stopWatch.state
        .map{ (state: StopWatch.State) -> Action in
            return self.getMainAction(fromState: state) }
        .bind(to: mainAction)

    // stopWatchの状態に応じてclearActionの変更を通知
    stopWatch.state
        .map{ (state: StopWatch.State) -> Action in
            return self.getClearAction(fromState: state) }
        .bind(to: clearAction)
}

最後にView

「ViewはViewModelの影」なので、虚心坦懐にバインドするだけです。逆に言えば、素直にバインドできないということは、ViewModelがうまく作れていないということの証左だと言えます。someButton.bnd_tap.observe {}によってボタンを押したときの挙動を指定できて便利なのでこれを使います。

import UIKit
import Bond

class StartViewController: UIViewController {

    @IBOutlet weak private var label: UILabel!
    @IBOutlet weak private var mainButton: UIButton!
    @IBOutlet weak private var clearButton: UIButton!

    private let stopWatchViewModel = StopWatchViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        mainButton.bnd_tap.observe { _ in
            self.stopWatchViewModel.mainAction.value.do()
        }.disposeIn(bnd_bag)

        clearButton.bnd_tap.observe { _ in
            self.stopWatchViewModel.clearAction.value.do()
        }.disposeIn(bnd_bag)

        stopWatchViewModel.mainAction.observeNext { (action: StopWatchViewModel.Action) -> Void in
            self.mainButton.setTitle(action.description, for: .normal)
        }.disposeIn(bnd_bag)

        stopWatchViewModel.clearAction.observeNext { (action: StopWatchViewModel.Action) -> Void in
            self.clearButton.setTitle(action.description, for: .normal)
            self.clearButton.isEnabled = action.enabled
        }.disposeIn(bnd_bag)

        stopWatchViewModel.time.bind(to: label)
    }
}

上述のリポジトリで git checkout stopwatch することで最後のサンプルコードに切り替えることができます。

サンプルを振り返り

サンプルを振り返って、改めて見直してみると、以下の特徴を備えていることが見て取れると思います。

  1. 全ロジックがViewから切り離されている
  2. ViewModel、Viewが状態をプロパティに持たず、ViewはViewModelにある情報をただ表示しているだけですし、ViewModelにはModelの状態と、ViewModelの状態の「対応関係」だけが書かれている

まとめ

ViewModelとViewの責務

ViewModelの責務

  1. Modelの状態がどのようにViewModelの状態に反映されるかを宣言的に記述する。
  2. Modelの戻り値のないメソッドを呼ぶ。

Viewの責務

  1. ViewModelの内容を忠実に画面に反映する。
  2. ユーザーのアクションと、ViewModelの戻り値のないメソッド呼び出しを対応させる。

ViewModelがあることで(MVVMで設計することで)何が期待できるか

  1. ViewController(View)に状態を記述する必要がなくなる。
  2. ViewControllerがすっきりする。
  3. テストされていないロジックが減る(次回以降実際に書いてみます)

参考文献

http://www.atmarkit.co.jp/fdotnet/introrx/introrx_01/introrx_01_01.html
http://ugaya40.hateblo.jp/entry/model-mistake