はじめに
RxSwiftやReactiveCocoaなどのReactive Programmingを実現するフレームワークを利用する方が増えているので、もともとデータバインディングの機構がなかったiOSアプリ開発においてもMVVMを用いるられる機会が増えつつあると思います。
そのため、RxSwfitなどを用いてデータバインディングを行うデザインパターン = MVVM
という雰囲気が漂っている気がしています。
MVVM
MVVMは、View
・ViewModel
・Model
によって構成されます。
View
はユーザーからのアクションを受け取って、そのイベントをViewModel
にバインドします。
そして、そのバインドされたイベントによってViewModel
はModel
の取得や更新を行います。
ViewModel
は更新された情報をイベントとしてView
にバインドし、View
はバインドされたイベントをもとに結果を反映させます。
このData Binding
が行われている部分をRxSwiftなどを使わずに、ピュアなSwiftで実装するとどのようになるでしょうか。
下図のような、簡単なカウンタアプリを実装していこうと思います。
仕様としては
- 🔼をタップするとカウントが増える
- 🔽をタップするとカウントが減る
- カウントが0の場合は、カウントを減らせない
- カウントが0の場合は、🔽は非活性
を満たすものとします。
ViewModel
今回はViewModel側でNotificationCenterをラップして、データバインディング機構のようなものを実装していこうと思います。
NotificationCenterを利用する理由としては、func post(name:object:userInfo:)
を呼び出すとfunc addObserver(forName:object:queue:using:)
などで監視をしている同一のNotification.Name
に対して通知を送ることができるからです。
また、NotificationCenterはNotificationCenter.default
から通知や監視を行うサンプルが多いため、アプリ全体に向けて通知を送るもののように利用されることが多いと思います。
しかし、下図のようにインスタンス化したNotificationCenterを保持することで、該当のインスタンス内でのみに通知を送ることも可能です。
それでは、ViewModelの実装を見ていこうと思います。
今回作成するカウンタアプリは、一画面であるため、1: ViewController
・1: 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
を通知しています。
カウントを増やす・減らすを行う際に、UIButton
のfunc 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