はじめに
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のリポジトリを検索して、テーブルとしてリスト表示するアプリを実装しました。
Model
今回は、Model層については、全く同じコードで利用できるように実装しています。
なので違いはありません
データモデルのコード
ちなみに今回は、id
とfullName
以外は関係ないため、省略しています。
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
に返すところくらいです。
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
で共通化させる方法があったため、共通化しています。
なので違いはありません。
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
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
のほうが好きでした。
ただ、ケース・バイ・ケースでどちらも使えたほうがいいので、どっちもキャッチアップできてよかったと思います。
参考にさせていただいた記事