RxSwift&UIKitで学ぶMVVM
初めに
アプリの継続的な開発・運用をスムーズに行うために「設計」はとても重要なものです。この記事ではその「設計」を実現するアーキテクチャの一例を紹介し、実際に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を適用します。
まずは適用前を示します。
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
で受け取る準備をしてから通知してあげます。
全体
import UIKit
import RxSwift
protocol 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()
})
}
}
import UIKit
import RxSwift
import RxRelay
protocol 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)
}
}
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