概要
対象
本稿は、2009年11月18日にMicrosoft DevLabsにて実験的に公開され、その後 .NET Framework
や Silverlight
に導入された、ReactiveExtensionの流れを汲むRxSwift
やReactiveCocoa
を使うと綺麗に設計ができそうだということを小耳に挟みながらも、なかなか触れる機会がない、あるいは導入まで至っていない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
)に入力された値をそのまま、label
(UILabel
)に反映する(単方向のバインディング)
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
を宣言することで、値の変更に従った挙動を定義することができますが、それと似ています。Go
の Channel
と大体同じと考えることもできます。
宣言
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
と、ストップウォッチの状態を表す state
が Observable
になっています。ストップウォッチの状態を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
することで最後のサンプルコードに切り替えることができます。
サンプルを振り返り
サンプルを振り返って、改めて見直してみると、以下の特徴を備えていることが見て取れると思います。
- 全ロジックがViewから切り離されている
- ViewModel、Viewが状態をプロパティに持たず、ViewはViewModelにある情報をただ表示しているだけですし、ViewModelにはModelの状態と、ViewModelの状態の「対応関係」だけが書かれている
まとめ
ViewModelとViewの責務
ViewModelの責務
- Modelの状態がどのようにViewModelの状態に反映されるかを宣言的に記述する。
- Modelの戻り値のないメソッドを呼ぶ。
Viewの責務
- ViewModelの内容を忠実に画面に反映する。
- ユーザーのアクションと、ViewModelの戻り値のないメソッド呼び出しを対応させる。
ViewModelがあることで(MVVMで設計することで)何が期待できるか
- ViewController(View)に状態を記述する必要がなくなる。
- ViewControllerがすっきりする。
- テストされていないロジックが減る(次回以降実際に書いてみます)
参考文献
http://www.atmarkit.co.jp/fdotnet/introrx/introrx_01/introrx_01_01.html
http://ugaya40.hateblo.jp/entry/model-mistake