🦅

RxSwift&UIKitで学ぶMVVM

2022/09/20に公開約10,400字

初めに

アプリの継続的な開発・運用をスムーズに行うために「設計」はとても重要なものです。この記事ではその「設計」を実現するアーキテクチャの一例を紹介し、実際にUIKitを用いてコードに起こしアーキテクチャ適用の前後を比較してみます。

そもそもアーキテクチャとは

まずそもそもアーキテクチャってなんやねん。って方もいると思います。僕も最近まで全然知りませんでした。はい。
アーキテクチャとは、システムの設計方法や設計思想を表すものです。設計とかよくわかんないしとりあえず実装!をやってしまうと

  • クラスが肥大化し、コードを追いにくくなる
  • ロジックの煩雑化、再利用性の低下
  • チーム開発しにくくなる
  • テストがしにくい
  • 機能の追加、改修が困難
  • etc…

なんてことが起きるわけですね。心当たりがあって頷いてくださる方もいるのではないでしょうか。(ここで著者が首を縦に5億回振る)
ロジックとUIをきちんと分離することがとても大事なわけです。
でもどうすればいい分かんな〜〜〜〜〜〜い。
そんなお悩みをもっているそこのあなたにぜひ学んで欲しいのが、今回紹介するものです。

じゃあ、MVVMって何?

アーキテクチャもさまざまな思想・種類がありますが、その中でも今回この記事で使用するアーキテクチャが MVVM(Model View ViewModel) です。
ざっくりと役割を説明すると

モノ 役割
Model ビジネスロジックやデータクラスなどを置く
View View。UIに関するものを置く
ViewModel ビジネスロジックの呼び出しやデータの取得、Viewの値を更新する役割

ViewModel間ですべての通信が行われるので、ViewがModelの存在を認識しないというのがポイントです。
ViewはViewModelに依存しViewModelはModelに依存します。逆方向の依存はありません。

MVVMを実際に書いてみよう

🚨ここで注意して欲しいのが MVVM≠RxSwift だということです。今回はRxSwiftを使いますが相性がよく、使われることが多いというだけであくまで一例です。

使用するもの version
MacOS 12.5.1
Xcode 13.4.1
Swift 5.7
RxSwift 6.5.0

今回はローカルにあるJSONファイルからデータを取得し、UICollectionViewで表示するまでの流れにMVVMを適用します。
まずは適用前を示します。

HomeViewController.swift
import UIKit
import AVFoundation

class HomeViewController: UIViewController {

    let path = Bundle.main.path(forResource: "music", ofType: "json")!
    lazy var fileHandle = FileHandle(forReadingAtPath: path)!
    lazy var data = fileHandle.readDataToEndOfFile()

    lazy var initialSnapshot: NSDiffableDataSourceSnapshot<Section, Item> = {
        var initialSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        initialSnapshot.appendSections([.main])
        initialSnapshot.appendItems(self.items)
        return initialSnapshot
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        if let response = try? JSONDecoder().decode(ItemsResponse.self, from: data) {
            items = response.results.map { $0.convert() }
        }
            cell.contentConfiguration = configuration
        }
        collectionViewDataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: itemIdentifier)
        }
        collectionView.delegate = self
        collectionViewDataSource?.apply(initialSnapshot, animatingDifferences: true)
    }
}

※これは良くない設計の例として示されているものなので、冗長な表現や良くない例が多々あると思いますが、ひとまずそっとしておいてください。 
今の状態ではViewController、つまりViewに全て乗っかっている状態ですね。これではView本来の役割(ユーザーにデータを表示する役割)以外の役割も全てViewが背負っているわけです。1人に任せっきりでは辛いですよね。そんなブラック企業から救ってあげるためにも、これをMVVMに書き換えていきます。

RxSwiftの使い方

この記事のメインはRxSwiftではないのであまり詳細な説明はしません。わかりやすい記事がたくさん上がっているのでそちらを参照してみてください。
RxSwiftを扱うにはObservableというものをある程度理解する必要があるかもしれません。
ここも少し難しい話なので別記事の参照をお勧めします。

Modelの実装

まずModel部分についてです。

protocol MusicItemModelProtocol: AnyObject {
    func getMusic() -> Observable<[Item]>
}

Observableは名前の通り監視可能なものを表すクラスです。ここではItem型を持たせています。
getMusic()という関数を持つプロトコルを準拠させることによって、モックでテストしたい時などに切り替えやすくなります。

.create({ observer in
           guard let path = Bundle.main.path(forResource: "music", ofType: "json") else { return Disposables.create {}}
           guard let fileHandle = FileHandle(forReadingAtPath: path)  else { return Disposables.create {}}
           let data = fileHandle.readDataToEndOfFile()
           do {
                  let response = try JSONDecoder().decode(ItemsResponse.self, from: data)
                  let items = response.results.map { $0.convert() }
               observer.onNext(items)
           } catch {
               observer.onError(error)
           }
           return Disposables.create()
        })
    

.createは関数からObservableを生成するメソッドです。
.onNextでは正常なストリームを流します。

} catch {
               observer.onError(error)
           }

エラーをキャッチした時.onErrorメソッドでエラーを通知します。

return Disposables.create()

Disposables.createで一連の終了を表します。

ViewModelの実装

続いてViewModel部分の説明です。

protocol HomeViewModelProtocol {
    var items: Observable<[Item]> { get }
    var refresh: PublishRelay<Void> { get }
    var currentItems: [Item] { get }
}

先ほどと同じようにViewModel用のProtocolを作成し、Viewから参照したい変数やメソッドのみを定義します。このProtocolを準拠させることで余計なものを公開せず、意図せず値に参照されてしまうことを防ぎます。

let refresh = PublishRelay<Void>()

ViewModelではViewとバインドして値を伝える役割があるためRelayというものを使います。
今回Modelからストリームを流してあげるためにrefreshを生やしておきます。

  private let _items = BehaviorRelay<[Item]>(value: [])
  private let musicItemModel: MusicItemModelProtocol

_itemsはModelから取得した値をViewとバインドするための役割を担います。
musicItemModelは上で定義したProtocolを準拠することで、扱えるメソッド、変数を明示的にします。

   init(musicItemModel: MusicItemModelProtocol = MusicItemModel()){
       self.musicItemModel = musicItemModel
       refresh
           .flatMap {
               musicItemModel.getMusic()
           }
           .bind(to: _items)
           .disposed(by: disposebag)
   }
init(musicItemModel: MusicItemModelProtocol = MusicItemModel())

ここでDI(Dependency Injection) していきます。具体的なクラスに依存せず、Protocolに依存するので

init(musicItemModel: MusicItemModelProtocol = MusicItemModelMock())

同じProtocolを準拠したMockなどへの切り替えが容易になります。

refresh
           .flatMap {
               musicItemModel.getMusic()
           }
           .bind(to: _items)
           .disposed(by: disposebag)

.flatmapでModelから取得した値をアンラップしてあげます。そしてそのデータを.bind(to: _items)を使ってバインディングしてきます。
このままだと永遠に観測していてメモリリークなどを起こしてしまうので

.disposed(by: disposebag)

を呼び出して観測対象から除外します。この時disposebagを作成する場所に注意する必要があります。

ViewController(View)の実装

いよいよ最後にViewの実装をしていきます。

 private let viewModel: HomeViewModelProtocol
 private let disposebag = DisposeBag()

まずはViewModelと値をバインドするために必要な2つの定数を宣言します。
viewModelはDIしたいのでここでは宣言のみに留めておくのがポイントです。

 init(viewModel: HomeViewModelProtocol = HomeViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

そして初期化の際にHomeViewModelProtocolを準拠したHomeViewModelを注入してあげます。これによって扱えるメソッドがProtocolに依存するので予期せぬ参照などを防ぐことができるわけです。

 viewModel.items.subscribe(
            onNext: { [weak self]items in
                var initialSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()
                initialSnapshot.appendSections([.main])
                initialSnapshot.appendItems(items)
                self?.collectionViewDataSource?.apply(initialSnapshot, animatingDifferences: true)
            })
        .disposed(by: disposebag)

.subscribeでストリームを購読する処理を書きます。正常なストリームが流れてくるとonNextに流れてくるのでその中で値をUIパーツと結びつけます。

.disposed(by: disposebag)

きちんと購読ができたら観測対象から外しメモリリークを防ぎます。

viewModel.refresh.accept(())

今からデータを受け取りたい!ということをViewModelに通知する処理を書きます。これを.subscribeよりも前に書いてしまうと、Viewが受け取る準備ができる前にストリームが流れてしまう可能性があるので、.subscribeで受け取る準備をしてから通知してあげます。

全体

MusicItemModel
import UIKit
import RxSwiftprotocol MusicItemModelProtocol: AnyObject {
    func getMusic() -> Observable<[Item]>
}final class MusicItemModelMock: MusicItemModelProtocol {
    func getMusic() -> Observable<[Item]> {
        .just([])
    }
}final class MusicItemModel: MusicItemModelProtocol {
    func getMusic() -> Observable<[Item]>{
       return  Observable<[Item]>
       .create({ observer in
           guard let path = Bundle.main.path(forResource: "music", ofType: "json") else { return Disposables.create {}}
           guard let fileHandle = FileHandle(forReadingAtPath: path)  else { return Disposables.create {}}
           let data = fileHandle.readDataToEndOfFile()
           do {
                  let response = try JSONDecoder().decode(ItemsResponse.self, from: data)
                  let items = response.results.map { $0.convert() }
               observer.onNext(items)
           } catch {
               observer.onError(error)
           }
           return Disposables.create()
        })
    
   }
}
HomeViewModel
import UIKit
import RxSwift
import RxRelayprotocol HomeViewModelProtocol {
    var items: Observable<[Item]> { get }
    var refresh: PublishRelay<Void> { get }
    var currentItems: [Item] { get }
}final class HomeViewModel: HomeViewModelProtocol {
    var currentItems: [Item] {
        _items.value
    }
    
    private let disposebag = DisposeBag()
    
    var items: Observable<[Item]> { _items.asObservable() }
    let refresh = PublishRelay<Void>()
    
    private let _items = BehaviorRelay<[Item]>(value: [])
    private let musicItemModel: MusicItemModelProtocol
    
    init(musicItemModel: MusicItemModelProtocol = MusicItemModel()){
        self.musicItemModel = musicItemModel
        refresh
            .flatMap {
                musicItemModel.getMusic()
            }
            .bind(to: _items)
            .disposed(by: disposebag)
    }
}
HomeViewController
import UIKit
import RxSwift
import AVFoundation

final class HomeViewController: UIViewController {
    
    private let viewModel: HomeViewModelProtocol
    private let disposebag = DisposeBag()
    
    init(viewModel: HomeViewModelProtocol = HomeViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
           fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewModel.items.subscribe(
            onNext: { [weak self]items in
                var initialSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()
                initialSnapshot.appendSections([.main])
                initialSnapshot.appendItems(items)
                self?.collectionViewDataSource?.apply(initialSnapshot, animatingDifferences: true)
            })
        .disposed(by: disposebag)
        
        viewModel.refresh.accept(())
    }
}

いかがでしょうか?MVVM適用前に比べて

  • クラスの肥大化が解消された(一部分だけなので感じにくいかも)
  • ロジックとUIがしっかり分かれている。
  • 依存がスッキリしてわかりやすい
  • テストが書きやすそう
    になったと思いませんか?!少しでもなったと感じてもらえていたら嬉しいです。

最後に

今回不慣れな僕が解説しているので分かりにくかった点や説明が足りない部分もあったかもしれません。ですが、この記事を読んでいただいてアーキテクチャや設計に興味を持ち、調べてみたり気にしてみたりするきっかけになれば大成功かなと思います。
拙い文章でわかりにくい部分も多々あったかと思いますが、ここまで読んでいただいてありがとうございました。

Discussion

ログインするとコメントできます