LoginSignup
17
13

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-02-10

はじめに

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

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

結論

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

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

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

サンプルアプリについて

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

GitHubサンプル.gif

Model

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

データモデルのコード

ちなみに今回は、idfullName以外は関係ないため、省略しています。

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に返すところくらいです。

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は、UIKitSwiftUIで共通化させる方法があったため、共通化しています。
なので違いはありません。

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

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

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を採用したら、tableViewDatasourceをバインドして、UITableViewDataSourceのメソッドを追加しなくても書けるのですが、今回は、このやり方しか思いつきませんでした。

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

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

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

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

まとめ

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

参考にさせていただいた記事

17
13
3

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
17
13