はじめに
ありがたいことにiOS13以上の案件に携わり、Combineを書く機会があったので備忘録的に残そうと。
記事を書いているうちにNotificationCenterよりもMVVMの話がメインになってしまった気がする、、笑
(普段から RxSwift
+ MVVM
に慣れている人にとってもはつまらない記事かもしれません、、)
NotificationCenterの実装比較
Combineの実装を見る前に、通常
/ RxSwift
/ Combine
でのコード比較をさらっとしていきたいと思います。
1. NotificationCenter
画面回転の通知を受け取るためのNotificationを例にしています。
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
NotificationCenter.default.addObserver(self, selector: #selector(rotated), name: UIDevice.orientationDidChangeNotification, object: nil)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
}
@objc private func rotated() {
// do something
}
addObserver
/ removeObserver
で 登録 / 削除 を管理しています。
ハンドリングメソッドを @objc
のattributeをつけて定義する必要があります。
2.NotificationCenter + RxSwift
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.rx
.notification(UIDevice.orientationDidChangeNotification, object: nil)
.subscribe(onNext: { notification in
// do something
})
.disposed(by: disposeBag)
}
通常の書き方と違い、ハンドリングのメソッドをチェーン(subscribe
)でそのままかけるメリットがあります。
3.NotificationCenter + Combine
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter
.Publisher(center: .default, name: UIDevice.orientationDidChangeNotification, object: nil)
.sink { [weak self] notification in
// do something
}
.store(in: &cancellables)
RxSwiftとほぼ同じです。書き方がCombineのお作法になっています。
MVVMで実装する
先の例では通常の使い方(主にMVCの想定)で実装する場合のコード例を列挙してきました。
SwiftUIが今後広がることを考えると、疎結合な作りにしておくと置き換えが容易になると思います。
その1つとして、今回は MVVM にフォーカスしていきます。
では、UIKit
/ SwiftUI
でそれぞれをMVVMの実装をみていきましょう。
今回は比較のためにいくつかのアプローチで実装してみました。
実装としては画面回転の通知を受け取るためのNotificationで行っています。
(viewWillTransition
を使おう!とかは言わないでください、、笑)
Example 1: ViewControler + ViewModel
MVVMといえば名前の上がる Kickstarter をベースに作成していきます。
まず、必要なプロトコルを用意し、
protocol ViewModelInputs {
func didChangeOrientation(notification: Notification)
}
protocol ViewModelOutputs {
var isLandscape: AnyPublisher<Bool, Never> { get }
}
protocol ViewModelType {
var inputs: ViewModelInputs { get }
var outputs: ViewModelOutputs { get }
}
準拠したViewModelを作ります。
// MARK: - ViewModel
final class ViewModel: ViewModelType, ViewModelInputs, ViewModelOutputs {
// MARK: ViewModelType
var inputs: ViewModelInputs { self }
var outputs: ViewModelOutputs { self }
// MARK: ViewModelOutputs
private let _isLandscape = PassthroughSubject<Bool, Never>()
var isLandscape: AnyPublisher<Bool, Never> {
_isLandscape.eraseToAnyPublisher()
}
//MARK: ViewModelInputs
func didChangeOrientation(notification: Notification) {
_isLandscape.send(UIDevice.current.orientation.isLandscape)
}
}
ViewModelをコントローラに繋ぎます。
import UIKit
import Combine
// MARK: - Controller
final class ViewController: UIViewController {
// MARK: Propepties
private var cancellables: Set<AnyCancellable> = []
private var viewModel: ViewModelType = ViewModel()
// MARK: Overrides
override func viewDidLoad() {
super.viewDidLoad()
/// input
NotificationCenter
.Publisher(center: .default, name: UIDevice.orientationDidChangeNotification, object: nil)
.sink { [weak self] notification in
self?.viewModel.inputs.didChangeOrientation(notification: notification)
}
.store(in: &cancellables)
/// output
viewModel.outputs.isLandscape
.sink { isLandscape in
print("isLandscape: \(isLandscape)")
}
.store(in: &cancellables)
}
}
画面回転の通知を受け取るためのNotificationを、MVVMで無事に実装できました。
RxSwiftに慣れている人はObservable
とRelay
がCombineのお作法になっただけなので、すんなり入ってくるかと思います。
記述が増えるので細かいところは実装を楽にしています。実際はロジック部分は色々やるはずなのでこんなにシンプルには行かないと思います。
(なのでinitないのでインジェクションできないとか、UIDevice使ったらimport UIKit
しちゃうことになる、とかは突っ込まないでください笑)
Example 2: SwiftUI + ViewModel (output from AnyPublisher
)
Example1と同じkickstarterベースの方法を使って書きます。
ViewControllerとSwiftUIでViewModelを共通化したい時にはこの方法が有効かと思います。
先に作成した Protocol (inputs
/ outputs
/ type
) をそのまま使用する前提で進めます。
が、SwiftUIのViewはstructのため、
private var cancellables: Set<AnyCancellable>
上記のSet<AnyCancellable>
を保持していても変更を加えることができません。
なので、こちらをViewModelに持たせ、store
するメソッドもInput
に追加します。
protocol ViewModelInputs {
func didChangeOrientation(notification: Notification)
func willStore(cancellable: AnyCancellable) // 追加したメソッド
}
追加したメソッドをモデルにも追加し、ViewModelにSet<AnyCancellable>
を持たせます。
final class ViewModel: ViewModelType, ViewModelInputs, ViewModelOutputs {
/* ~~ 中略 (上と同じ) ~~ */
private var cancellables: Set<AnyCancellable> = [] // 追加したプロパティ
func willStore(cancellable: AnyCancellable) { // 追加したメソッド
cancellable.store(in: &cancellables)
}
}
ViewModelをViewに繋ぎます。
import SwiftUI
// MARK: - SwiftUIView
struct SwiftUIView: View {
// MARK: Properties
private var viewModel: ViewModelType = ViewModel()
// MARK: View
var body: some View {
EmptyView()
.onAppear() {
/// input
let orientationCancellable = NotificationCenter
.Publisher(center: .default, name: UIDevice.orientationDidChangeNotification, object: nil)
.sink { notification in
self.viewModel.inputs.didChangeOrientation(notification: notification)
}
self.viewModel.inputs.willStore(cancellable: orientationCancellable)
/// output
let isLandscapeCancellable = self.viewModel.outputs.isLandscape
.sink { isLandscape in
print("isLandscape: \(isLandscape)")
}
self.viewModel.inputs.willStore(cancellable: isLandscapeCancellable)
}
}
}
デモなのでEmptyView
がonAppear
したときにバインドするようにしています。
(表示度に何度もコールされるのonDisappear
で解除するのを忘れないでください。)
少し冗長ではありますが、Example1と同じ方法で実装することができました。
ただし、SwiftUIにはBindingの機構があるため、本来こういう実装はしないと思います。
(冒頭にも書いたが、ViewControllerとSwiftUIでViewModelを共通化したい時にはこの方法が有効かと思います。)
今回は比較のためにあえて書いています。
では、次のパターンでBindingのMVVMをみていきましょう。
Example3: SwiftUI + ViewModel (output from @Published
)
無理にExample2で実装を行うより、@Publishedを使うってバインディングを実現してあげると、SwiftUIらしくかけるかと思います。
ではViewModelから見ていきましょう。
// MARK: - ViewModel
final class UIBindingViewModel: ObservableObject {
// MARK: Properties
private var cancellables = Set<AnyCancellable>()
// MARK: Outputs
private let isLandscape$ = PassthroughSubject<Bool, Never>()
@Published var isLandscape: Bool = false
// MARK: Inputs
func onAppear() {
NotificationCenter
.Publisher(center: .default, name: UIDevice.orientationDidChangeNotification, object: nil)
.map { _ in UIDevice.current.orientation.isLandscape }
.sink { [weak self] isLandscape in
self?.isLandscape$.send(isLandscape)
}
.store(in: &cancellables)
isLandscape$
.map { $0 }
.assign(to: \.isLandscape, on: self)
.store(in: &cancellables)
}
}
Bool値を渡すだけなので直接assign
しても良かったですが、本来は複雑な加工する処理が入る想定でPassthroughSubject
を挟んでいます。
(ちなみにisLandscape$
という名前にしたのは、_isLandscape
の命名が使えなかったからです。@Published
で定義したものは裏側で_
が予約語として使われているのかもしれません。)
// MARK: - UIView
struct SwiftUIBindingView: View {
// MARK: Properties
@ObservedObject var viewModel: UIBindingViewModel
// MARK: View
var body: some View {
EmptyView()
.onAppear() {
self.viewModel.onAppear()
}
.onReceive(self.viewModel.$isLandscape) {
print($0)
}
}
}
ViewModelに処理を寄せたので、だいぶスッキリした実装になりました。
ただし、この方法にはデメリットもあり、 Example1/Example2のように inputs
/ outputs
/ type
の制約で縛ることができません。しかしながら、これを実現しようとした場合にはいくつかの弊害が存在します。
参考までに、実際に下記のようなプロトコルでViewModelを作成した際に
(差別化するため、あえて_
を付けています)
protocol _UIBindingViewModelInputs: AnyObject {
func onAppear()
}
protocol _UIBindingViewModelOutputs: AnyObject {
var isLandscape: Bool { get }
}
protocol _UIBindingViewModelType: AnyObject {
var iuputs: _UIBindingViewModelInputs { get }
var outputs: _UIBindingViewModelOutputs { get }
}
final class _UIBindingViewModel: ObservableObject, _UIBindingViewModelType, _UIBindingViewModelInputs, _UIBindingViewModelOutputs {
/* 略 */
}
実装してみると、こんな感じでエラーが出てしまいます。
抽象的なものはバインディング対象にできないようです。
上記に関しては、@lovee さんが「SwiftUI 時代の依存関係逆転のプラクティス」 でどういう問題があるまとめています。beta版なのでコードは少し古いですが、解決案がいくつか提示されているのでとても面白いです。
終わりに
2021年あたり(だいぶ先?笑)にはSwiftUIやCombineを実装したアプリが増えるかと思います。
既存アプリを書き換える際に、いきなり両方を置き換えるということはなく、最初にビジネスロジックを置き換えると思いますが、その際に少しでも役立てば光栄です。
ご指摘等あれば、編集リクエストいただけると大変助かります!
その他
この記事を書くにあたり、他の人の実装を覗いてみたので載せておきます。
Inputをenumで管理していたのは面白いアプローチでした
MVVMとCombine絡みの記事