【Swift】CombineをUIKitとSwiftUIで使うときの実装を比較する

# はじめに
`Combine`を学習していると、`SwiftUI`との組み合わせの実装をよく見るのですが、`UIKit`でも全然使えるとのことで、今回は、`UIKit`と`SwiftUI`で同じ動きをするサンプルアプリを作って、実装方法の違いや実装中に感じたことを記事にしました。

今回は、`MVVM`で実装しています。
当初、ViewModelは`UIKit`と`SwfitUI`で異なる実装をしていたのですが、@links_2_3_4 さんからアドバイスを頂いて、ViewModelも共通化しました。

# 結論
僕の経験値は`UIKit`>>>>>`SwiftUI`で、圧倒的に`UIKit`の経験の方が多いですが、
`Combine`を使った実装は、個人的には`SwiftUI`>`UIKit`で`SwiftUI`のほうが書きやすかったです。

実装時間も`SwiftUI`のほうが早く、今後もう少し機能を追加したいってなった時に`SwiftUI`のほうが、実装のイメージが湧きやすくて、簡単に実装できそうだと思いました。

私が`Combine`を最近始めた影響かもしれませんが、`UIKit`は思い通りに進まないこともありました。

# サンプルアプリについて
サンプルアプリの代表である、GitHubのリポジトリを検索して、テーブルとしてリスト表示するアプリを実装しました。

<img width="240" alt="GitHubサンプル.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/580445/b08c428a-d58e-cb31-84c4-c78984b107bb.gif">

# Model
今回は、Model層については、全く同じコードで利用できるように実装しています。
**なので違いはありません**

### データモデルのコード
ちなみに今回は、`id`と`fullName`以外は関係ないため、省略しています。

```swift:GithubRepositryModel.swift
struct GithubRepositryModel: Codable {
    let totalCount: Int
    let incompleteResults: Bool
    let items: [Item]

    enum CodingKeys: String, CodingKey {
        case totalCount = "total_count"
        case incompleteResults = "incomplete_results"
        case items
    }
    
    // MARK: - Item
    struct Item: Codable, Equatable, Identifiable {
        let id: Int
        let fullName: String
        // 省略
        enum CodingKeys: String, CodingKey {
            case id
            case fullName = "full_name"
            // 省略
    }
}
```

### 通信部分のコード
エラーハンドリングなどは、今回は実装していません。
特徴としては、レスポンスの結果を`eraseToAnyPublisher()`によって、
`AnyPublisher<GithubRepositryModel, Error>`として、`ViewModel`に返すところくらいです。

```swift:GithubAPIClient.swift
import Foundation
import Combine

protocol GithubAPIClientProtocl: AnyObject {
    func searchRepositories(searchWord: String) -> AnyPublisher<GithubRepositryModel, Error>
}

final class GithubAPIClient: GithubAPIClientProtocl {
    static let shared = GithubAPIClient()
    private init() {}
    
    func searchRepositories(searchWord: String) -> AnyPublisher<GithubRepositryModel, Error> {
        let url = URL(string: "https://api.github.com/search/repositories?q=\(searchWord)&per_page=20")!
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: GithubRepositryModel.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}
```

# ViewModel
冒頭でも記載の通り、ViewModelは、`UIKit`と`SwiftUI`で共通化させる方法があったため、共通化しています。
**なので違いはありません。**

```swift:SearchGithubRepositoriesViewModel.swift
class SearchGithubRepositoriesViewModel: ObservableObject {
    private let githubApiClient: GithubAPIClientProtocl
    private var cancellables = Set<AnyCancellable>()
    
    @Published var repositories = [GithubRepositryModel.Item]()
    
    init(githubApiClient: GithubAPIClientProtocl = GithubAPIClient.shared) {
        self.githubApiClient = githubApiClient
    }
    
    func searchButtonTapped(searchWord: String) {
        githubApiClient
            .searchRepositories(searchWord: searchWord)
            .sink { completion in
                switch completion {
                case .finished:
                    break
                case .failure(_):
                    break
                }
            } receiveValue: { model in
                self.repositories = model.items
            }
            .store(in: &cancellables)
    }
}
```
`Combine`を用いて実装する場合、`@Published`を使うことで、プロパティを監視対象にして、`View`に通知する事ができます。
Viewを`UIKit`で実装する場合は、`ObservableObject`に準拠しなくても良さそうです。

今回は、`repositories`を監視対象として、この値に変化があれば、View側で再描画処理が走るようなロジックになっています。

特徴としては、APIクライアントを購読し、自身で保持している`repositories`を更新するだけで、`View`が受動的に更新されるという点だと考えています。

仮に`View`にエラーなどを通知したいときなども同様に、`@Published`プロパティを追加すればいいだけなので、追加実装も簡単そうです。

# View
## SwiftUI
```swift:
struct SearchGithubRepositoriesView: View {
    @ObservedObject private var viewModel: SearchGithubRepositoriesViewModel
    
    @State private var searchText = ""
    
    init(viewModel: SearchGithubRepositoriesViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack {
            HStack {
                TextField("リポジトリ検索", text: $searchText)
                    .textFieldStyle(.roundedBorder)
                    .padding()
                
                Button {
                    viewModel.searchButtonTapped(searchWord: searchText)
                } label: {
                    Text("検索")
                        .frame(width: 60, height: 32)
                        .foregroundColor(.white)
                        .background(.blue)
                        .cornerRadius(10)
                }
                .padding(.trailing, 12)
            }
            
            List {
                ForEach(viewModel.repositories) {
                    Text($0.fullName)
                }
            }
            .listStyle(.inset)
        }
    }
}

struct SearchGithubRepositoriesView_Previews: PreviewProvider {
    
    static let testItems: [GithubRepositryModel.Item] = (1...2).map {
        .init(id: $0, fullName: "\($0) FullName", htmlURL: "", stargazersCount: $0, forksCount: $0, watchersCount: $0, description: "Description \($0)")
    }
    
    static var previews: some View {
        SearchGithubRepositoriesView(
            viewModel: SearchGithubRepositoriesViewModel(
                githubApiClient: GitHubAPIClientPreviews()
            )
        )
    }
}

class GitHubAPIClientPreviews: GithubAPIClientProtocl {
    let expectedItems: [GithubRepositryModel.Item] = (1...10).map {
        .init(id: $0, fullName: "\($0) FullName", htmlURL: "", stargazersCount: $0, forksCount: $0, watchersCount: $0, description: "Description \($0)")
    }
    
    func searchRepositories(searchWord: String) -> AnyPublisher<GithubRepositryModel, Error> {
        Just(GithubRepositryModel(totalCount: 10, incompleteResults: false, items: expectedItems))
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}
```
これまで、`Combine`を使わない、`SwiftUI`は少しやったことがありますが、`Combine`を使うと`SwiftUI`の真価を発揮できる感じがして、すごい実装が楽しかったです。

`@ObservedObject private var viewModel: SearchGithubRepositoriesViewModel`によって、`ViewModel`を監視しており、`ViewModel`の特定の値が変わったら、こちらのViewを再描画するという仕組みです。

個人的には、`SwiftUI`での実装は、`View`側に値の代入処理などを、書かなくていいので、役割の切り分けが明確化されていて、すごい好きな感じです。

## UIKit
```swift:ViewController.swift
import UIKit
import Combine

class ViewController: UIViewController {
    
    // MARK: View
    private var tableView = UITableView()
    // 省略
    
    // MARK: Combine
    private var viewModel: SearchGithubRepositoriesViewModel!
    private var cancellable: AnyCancellable?
    
    // MARK: Model  
    private var repositories = [GithubRepositryModel.Item]() {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewModel = SearchGithubRepositoriesViewModel()
        
        tableView.delegate = self
        tableView.dataSource = self
                
        addObserver()
        
        initUI()
    }
    
    private func initUI() {
        // 省略        
    }
    
    private func addObserver() {
        cancellable = viewModel.$repositories
            .sink { completion in
                switch completion {
                case .finished:
                    break
                case .failure(_):
                    break
                }
            } receiveValue: { items in
                self.repositories = items
            }
    }
    
    @objc func touchSearchButton(_ sender: UIButton) {
        viewModel.searchButtonTapped(searchWord: textField.text ?? "")
    }
}

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 40
    }
}

extension ViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        repositories.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: "Cell")
        cell.textLabel?.text = repositories[indexPath.row].fullName
        return cell
    }
}
```
`UIKit`で今回は、`UITableView`を使いますが、結構ここがもう少しいい感じにならないかなぁと思いました。`RxSwift`を採用したら、`tableView`の`Datasource`をバインドして、`UITableViewDataSource`のメソッドを追加しなくても書けるのですが、今回は、このやり方しか思いつきませんでした。

`didSet`を使って、Viewを再更新するというのは、`MVVM`のデータバインディングの利点を活かせないのでちょっと気になります。

`viewDidLoad`でオブザーバー登録さえしておけば、あとは戻ってきた値を処理するだけなので、`Combine`を使わない実装よりは、データフローが追いやすくて良きかなと思います。

`viewModel.$repositories.sink {`とすることで、`ViewModel`の`@Published`プロパティの値を監視できるようになります。

※本来は、`viewModel`は、抽象（プロトコル）で保持すべきですが、今回は割愛しております。

# まとめ
最初に書きましたが、`SwiftUI`のほうが好きでした。
ただ、ケース・バイ・ケースでどちらも使えたほうがいいので、どっちもキャッチアップできてよかったと思います。

# 参考にさせていただいた記事
https://shuhey-hashimoto.com/swiftui/publishedってなに？/

https://qiita.com/hcrane/items/4778d7951cd0e1611f5b

https://note.com/hayabusabusa/n/na7e1523d0b1e