LoginSignup
19

More than 1 year has passed since last update.

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

Last updated at Posted at 2019-12-07

はじめに

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

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

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

Example 1: ViewControler + ViewModel

MVVMといえば名前の上がる Kickstarter をベースに作成していきます。

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

.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に慣れている人はObservableRelayCombineのお作法になっただけなので、すんなり入ってくるかと思います。

記述が増えるので細かいところは実装を楽にしています。実際はロジック部分は色々やるはずなのでこんなにシンプルには行かないと思います。
(なので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)
            }
    }
}

デモなのでEmptyViewonAppearしたときにバインドするようにしています。
(表示度に何度もコールされるの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 {
    /* 略 */ 
}

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

スクリーンショット 2019-12-06 18.35.34.png スクリーンショット 2019-12-06 18.38.39.png

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

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

終わりに

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

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

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

その他

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

Inputをenumで管理していたのは面白いアプローチでした

MVVMとCombine絡みの記事

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19