この記事は,フラー株式会社 Advent Calendar 2021 の 20日目の記事です。
19日目の記事は @ujikawa1026 さんによる 「コーディングしない」という不安に対してマネージャーはどう向き合うか でした。
はじめに
こんにちは!フラーでiOSエンジニアのアルバイトをしている前澤です。
これまでのiOS開発では主にUIKit
とRxSwift
を使ったMVVM設計パターンによる開発をしてきました。一方で比較的新しいフレームワークであるSwiftUI
, Combine
についてはほとんど触ったことがありませんでした。
ただ、SwiftUI
のコードベースで宣言的にUIを実装していける点が魅力的だと感じていました。また、CombineはApple標準のリアクティププログラミングのフレームワークでこちらもぜひ使ってみたいと思ってました。
そこで今回SwiftUI + Combineを使ったサンプルアプリを自身の勉強のアウトプットとして作ってみることにしました!
この記事では主にSwiftUIとCombineを使いMVVMでどう実装するかということについて、実装してみて分かったこと、気づいたことについて自分なりにまとめてみました!
実際に作成したものはこちら
サンプルアプリについて
概要
今回は映画情報を取得し一覧で表示できる映画アプリをつくりました。TMDBという映画に関する色々な情報を提供しているAPIを使いました。こちら
検索バーに文字を入力し、映画タイトルの検索結果を一覧で表示したり、映画の詳細を表示できるアプリです。
こちらのアプリ、時間の問題でまだ実装できてない画面などあるので、今後仕上げていきたいと思ってます。(時間があれば)
使用ライブラリ
- SwiftUI
- Combine
- APIKit(今回はあまり重要ではない)
環境
- Xcode 13.1
- Swift 5.5.1
実装について
このアプリの設計パターンはMVVMで実装しました。View
, ViewModel
, Model
(APIでデータを取得)をそれぞれつくり、それら各レイヤー間でのデータの受け渡しをCombine
を使う感じで実装しました。
View
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
ごとにそれぞれつくり、それを組み合わせて1つの画面を構成させるのが実装しやすく、良い感じ。
ViewModel
続いてViewModel
です。こちらはInputとOutputを以下のようにそれぞれプロパティとして実装しました。
Outputに関しては、それぞれ、プロパティラッパーである@Published
をつけるやり方で実装しました。@Published
によって、その変数が変更された場合に、それがObservableイベントとして通知されるようになるみたいです。
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については1回のみ値が流れれば良いのでFuture
型で実装しました。
基本的にAPIを叩く処理では、1回のリクエストに対して、成功したか失敗したかという結果を1度受け取れれば良いので、イベントを1回だけ流すObservableであるFutureが適していると思われます。
値の取得に成功したら.success(response)
、エラーの場合は.failure(error)
でFutureのイベントを流すようにしました。
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の1つであるresult_builder
によって実現されています。
ViewModel
View
間でのデータバインドする際に使った@Published
や@Binding
はPropertyWrapper
というものです。SwiftUI
, Combine
フレームワークではこのPropertyWrapper
も頻繁に使われているようですが、自分はあまり使ったことがなくて、まだ理解しきれてないので今後調べていきたいなと思います。
Protocol
を使って抽象化したインターフェースを実装、みたいなことがしたかったですが、
今回のViewModel
の実装の場合のようにObservable
を@Binding
でつくったときに、それが困難になるようです。というのもViewModel
のOutput
のプロパティが@Binding
などのPropertyWraper
を使った型でつくってある場合は単純なProtocol
の定義で型が解決できず、定義ができなくなります。ここが実装面においてRxSwift
と違ってるなと思いました。
いちおうPropertyWraper
を考慮した型を定義するようなやり方で実装することはできるようですがシンプルに実装するのはなかなか難しそうだと思いました。
こちらの記事を参考にさせていただきました。SwiftUIとCombineを使ったMVVMの実装
最後に
SwiftUIに関しては、宣言的にUIを実装していけるのはかなり実装しやすくて良かったです。各Viewをコンポーネントとして作りやすくてかなり良いなと思いました。
Combineに関してはオペレータやデータバインドの使用感がRxSwiftと結構違っててかなり難しかったです。こちらは慣れが必要だなと感じました。
以上になりますが、SwiftUI、Combineともに、慣れればアプリ開発がより一層面白くなると思いました!
あと、やっぱりSwiftは書いてて楽しいです✌
参考にしたサイト
- 「SwiftUIとCombineを使ったMVVMの実装」 https://tech.toreta.in/entry/2019/12/24/104612