SwiftUI + CombineでMVVMサンプルアプリを作ってみた！

この記事は，[フラー株式会社 Advent Calendar 2021](https://qiita.com/advent-calendar/2021/fuller-inc/) の 20日目の記事です。

19日目の記事は [@ujikawa1026](https://qiita.com/ujikawa1026) さんによる [「コーディングしない」という不安に対してマネージャーはどう向き合うか](https://namaninotiteti1026.hatenadiary.jp/entry/2021/12/19/000815) でした。

## はじめに
こんにちは！フラーでiOSエンジニアのアルバイトをしている前澤です。

これまでのiOS開発では主に`UIKit`と`RxSwift`を使ったMVVM設計パターンによる開発をしてきました。一方で比較的新しいフレームワークである`SwiftUI`, `Combine`についてはほとんど触ったことがありませんでした。
ただ、`SwiftUI`のコードベースで宣言的にUIを実装していける点が魅力的だと感じていました。また、CombineはApple標準のリアクティププログラミングのフレームワークでこちらもぜひ使ってみたいと思ってました。
そこで今回SwiftUI + Combineを使ったサンプルアプリを自身の勉強のアウトプットとして作ってみることにしました！

この記事では主にSwiftUIとCombineを使いMVVMでどう実装するかということについて、実装してみて分かったこと、気づいたことについて自分なりにまとめてみました！
実際に作成したものは[こちら](https://github.com/maezawa1234/MovieApp)


## サンプルアプリについて
### 概要
今回は映画情報を取得し一覧で表示できる映画アプリをつくりました。TMDBという映画に関する色々な情報を提供しているAPIを使いました。[こちら](https://developers.themoviedb.org/3/getting-started/introduction)

検索バーに文字を入力し、映画タイトルの検索結果を一覧で表示したり、映画の詳細を表示できるアプリです。
こちらのアプリ、時間の問題でまだ実装できてない画面などあるので、今後仕上げていきたいと思ってます。（時間があれば）

<img src="https://user-images.githubusercontent.com/52379412/142819317-6056d14b-72c6-4938-bf2b-6269252ee203.png" width="300" />  <img src="https://user-images.githubusercontent.com/52379412/142819367-839d895d-45c0-4a91-b332-9246af892131.png" width="300" />

### 使用ライブラリ
* SwiftUI
* Combine
* APIKit（今回はあまり重要ではない）

### 環境
* Xcode 13.1
* Swift 5.5.1


## 実装について
このアプリの設計パターンはMVVMで実装しました。`View`, `ViewModel`, `Model`(APIでデータを取得)をそれぞれつくり、それら各レイヤー間でのデータの受け渡しを`Combine`を使う感じで実装しました。

### View
```swift:SearchMovieView.swift
import SwiftUI

struct SearchMovieView: View {

    @ObservedObject var viewModel: SearchMovieViewModel

    var body: some View {
        NavigationView {
            VStack {
                HStack {
                    TextField("Search movies...", text: $viewModel.searchBarText)
                        .frame(height: 40)
                        .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8))
                        .border(Color.gray, width: 1)
                        .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
 
                    Button<Text>(LocalizedStringKey("Search")) { self.viewModel.searchButtonClickedInput.send(()) }
                        .frame(height: 40)
                        .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8))
                        .border(Color.blue, width: 1)
                        .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
                }

                List {
                    ForEach(viewModel.movies) { movie in
                        
                        NavigationLink(destination: MovieDetailView(viewModel: MovieDetailViewModel(with: movie))) {
                            SearchMovieCellView(viewModel: SearchMovieCellViewModel(with: movie))
                        }
                    }
                }
                .listStyle(.plain)
                .navigationTitle(Text("Search Movie 🔍"))
            }
        }
    }
}
```
まず、`viewModel`を`@ObservedObject`で保持しています。この`@ObservedObject`をつけることで`ObservableObject`に準拠した`viewModel`のプロパティの変更を検知できるようになります。

`viewModel`から受け取った値をUIに反映します。

`TextField`、`Button`、`List`を使いそれぞれのUIをコードで設定して実装しています。

#### SwiftUIの良いと思ったところ
* `TableView`, `CollectionView`の実装（ストリームにitem流してバインドさせる）や各セクション内に横スクロールViewを入れるような実装がかなりやりやすい
* Combineとのシナジーが高く、一緒に使うことでデータバインディングがかなりやりやすい
* 宣言的なUIの実装が直感的で分かりやすい
* 各`View`ごとにそれぞれつくり、それを組み合わせて１つの画面を構成させるのが実装しやすく、良い感じ。

### ViewModel
続いて`ViewModel`です。こちらはInputとOutputを以下のようにそれぞれプロパティとして実装しました。
Outputに関しては、それぞれ、プロパティラッパーである`@Published`をつけるやり方で実装しました。`@Published`によって、その変数が変更された場合に、それがObservableイベントとして通知されるようになるみたいです。

```swift:ViewModel.swift
import Combine
import SwiftUI

final class SearchMovieViewModel: ObservableObject {
    // MARK: - Inputs
    var searchBarText: String = ""
    let searchButtonClickedInput = PassthroughSubject<Void, Never>()
    
    // MARK: - Outputs
    @Published private(set) var movies: [Movie] = []
    @Published private(set) var errorMessage: String? = nil
    @Published private(set) var isLoasing = false
    
    private var cancellables = Set<AnyCancellable>()
    
    init(movieRepository: MovieRepositoryProtocol = MovieRepository()) {
        let searchEvent = searchButtonClickedInput
            .map { [unowned self] in self.searchBarText }
            .filter { !$0.isEmpty }
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
        
        let apiResponse = searchEvent
            .flatMap {
                return movieRepository.searchMovies(query: $0)
            }
            .share()
        
        apiResponse
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("finished")
                    self.isLoasing = false
                case .failure(let error):
                    print("failure")
                    print(error.localizedDescription)
                    self.isLoasing = false
                }
            }, receiveValue: { response in
                print(response)
                self.movies = response.results
            })
            .store(in: &cancellables)
    }
```

### Repository
`Repository`ではAPIを叩いて取得したデータをCombineのストリームとして流します。
こちらのメソッドの戻り値のObservableについては１回のみ値が流れれば良いので`Future`型で実装しました。
基本的にAPIを叩く処理では、１回のリクエストに対して、成功したか失敗したかという結果を１度受け取れれば良いので、イベントを１回だけ流すObservableであるFutureが適していると思われます。
値の取得に成功したら`.success(response)`、エラーの場合は`.failure(error)`でFutureのイベントを流すようにしました。

```swift:MovieRepository.swift
import Combine
import APIKit

protocol MovieRepositoryProtocol {
    func searchMovies(query: String) -> Future<SearchResponse<Movie>, Error>
}

final class MovieRepository: MovieRepositoryProtocol {
    func searchMovies(query: String) -> Future<SearchResponse<Movie>, Error> {
        let request = MovieAPI.SearchMovieRequest(query: query)
        return Future<SearchResponse<Movie>, Error> { promiss in
            Session.send(request) { result in
                switch result {
                case .success(let response):
                    promiss(.success(response))
                case .failure(let error):
                    promiss(.failure(error))
                }
            }
        }
    }
}
```


## 分かったこと
　SwiftUIでViewを実装していく際の宣言的なコーディングに関して、これはSwift5.4で新たに追加されたAttributeの１つである`result_builder`によって実現されています。
　`ViewModel`　`View`間でのデータバインドする際に使った`@Published`や`@Binding`は`PropertyWrapper`というものです。`SwiftUI`, `Combine`フレームワークではこの`PropertyWrapper`も頻繁に使われているようですが、自分はあまり使ったことがなくて、まだ理解しきれてないので今後調べていきたいなと思います。

　`Protocol`を使って抽象化したインターフェースを実装、みたいなことがしたかったですが、
今回の`ViewModel`の実装の場合のように`Observable`を`@Binding`でつくったときに、それが困難になるようです。というのも`ViewModel`の`Output`のプロパティが`@Binding`などの`PropertyWraper`を使った型でつくってある場合は単純な`Protocol`の定義で型が解決できず、定義ができなくなります。ここが実装面において`RxSwift`と違ってるなと思いました。
いちおう`PropertyWraper`を考慮した型を定義するようなやり方で実装することはできるようですがシンプルに実装するのはなかなか難しそうだと思いました。
こちらの記事を参考にさせていただきました。[SwiftUIとCombineを使ったMVVMの実装](https://tech.toreta.in/entry/2019/12/24/104612)


## 最後に
SwiftUIに関しては、宣言的にUIを実装していけるのはかなり実装しやすくて良かったです。各Viewをコンポーネントとして作りやすくてかなり良いなと思いました。
Combineに関してはオペレータやデータバインドの使用感がRxSwiftと結構違っててかなり難しかったです。こちらは慣れが必要だなと感じました。
以上になりますが、SwiftUI、Combineともに、慣れればアプリ開発がより一層面白くなると思いました！

あと、やっぱりSwiftは書いてて楽しいです✌

## 参考にしたサイト
* 「SwiftUIとCombineを使ったMVVMの実装」 https://tech.toreta.in/entry/2019/12/24/104612