[Swift] Combineを使ってMVVMでNotificationCenterを実装する


# はじめに
ありがたいことにiOS13以上の案件に携わり、Combineを書く機会があったので備忘録的に残そうと。

記事を書いているうちにNotificationCenterよりもMVVMの話がメインになってしまった気がする、、笑
（普段から ``RxSwift`` + ``MVVM`` に慣れている人にとってもはつまらない記事かもしれません、、）


# NotificationCenterの実装比較

Combineの実装を見る前に、``通常`` / ``RxSwift`` / ``Combine``でのコード比較をさらっとしていきたいと思います。

## 1. NotificationCenter

画面回転の通知を受け取るためのNotificationを例にしています。

```.swift
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

```.swift
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

```.swift
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が今後広がることを考えると、**疎結合**な作りにしておくと置き換えが容易になると思います。
その１つとして、今回は **MVVM** にフォーカスしていきます。

では、``UIKit`` / ``SwiftUI``でそれぞれをMVVMの実装をみていきましょう。
今回は比較のためにいくつかのアプローチで実装してみました。

実装としては**画面回転の通知**を受け取るためのNotificationで行っています。
（``viewWillTransition``を使おう！とかは言わないでください、、笑）

## Example 1: ViewControler + ViewModel

MVVMといえば名前の上がる [Kickstarter](https://github.com/kickstarter/ios-oss) をベースに作成していきます。

まず、必要なプロトコルを用意し、

```.swift
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を作ります。

```.swift
// 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をコントローラに繋ぎます。

```.swift
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**のため、

```.swift
private var cancellables: Set<AnyCancellable>
```

上記の``Set<AnyCancellable>``を保持していても**変更を加えることができません**。
なので、こちらをViewModelに持たせ、``store``するメソッドも``Input``に追加します。

```.swift
protocol ViewModelInputs {
    func didChangeOrientation(notification: Notification)
    func willStore(cancellable: AnyCancellable) // 追加したメソッド
}
```

追加したメソッドをモデルにも追加し、ViewModelに``Set<AnyCancellable>``を持たせます。

```.swift
final class ViewModel: ViewModelType, ViewModelInputs, ViewModelOutputs {

    /* ~~   中略 （上と同じ）  ~~ */

    private var cancellables: Set<AnyCancellable> = [] // 追加したプロパティ
    func willStore(cancellable: AnyCancellable) { // 追加したメソッド
        cancellable.store(in: &cancellables)
    }
}
```

ViewModelをViewに繋ぎます。

```.swift
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から見ていきましょう。

```.swift
// 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``で定義したものは裏側で``_``が予約語として使われているのかもしれません。）

```.swift
// 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を作成した際に
(差別化するため、あえて``_``を付けています)

```.swift
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 {
    /* 略 */ 
}
```

実装してみると、こんな感じでエラーが出てしまいます。

<img width="655" alt="スクリーンショット 2019-12-06 18.35.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/f2b36331-2fd9-5875-dfdf-0d03b9f7ebd9.png">

<img width="762" alt="スクリーンショット 2019-12-06 18.38.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/4ef99ec1-ef7e-7955-c606-303acfed3c52.png">

抽象的なものは**バインディング対象にできない**ようです。

上記に関しては、@lovee さんが[「SwiftUI 時代の依存関係逆転のプラクティス」](https://qiita.com/lovee/items/0edb7e692416172222a5) でどういう問題があるまとめています。beta版なのでコードは少し古いですが、解決案がいくつか提示されているのでとても面白いです。


# 終わりに

2021年あたり（だいぶ先？笑）にはSwiftUIやCombineを実装したアプリが増えるかと思います。

既存アプリを書き換える際に、いきなり両方を置き換えるということはなく、最初にビジネスロジックを置き換えると思いますが、その際に少しでも役立てば光栄です。

ご指摘等あれば、編集リクエストいただけると大変助かります！


## その他

この記事を書くにあたり、他の人の実装を覗いてみたので載せておきます。

Inputをenumで管理していたのは面白いアプローチでした
- [kitasuke/SwiftUI-MVVM](https://github.com/kitasuke/SwiftUI-MVVM)

MVVMとCombine絡みの記事
- [MVVM with Combine in SwiftUI](https://qiita.com/akifumi1118/items/aa5734b1f14d57072456)
- [SwiftUI+Combineの備忘録とMVVM](https://qiita.com/kazy_dev/items/8794c0fef0ae808205f5)