CombineとSwiftUIを組み合わせて、MVVMとVIPERの学習メモ(MVVM編)
こんにちは。iOSエンジニアのTanです。
いよいよ年末になってきましたね、2021年はコロナの緊急事態制限で本当に短かったと思ってます。
今年は弊社のiOSアプリのホームページリニューアルや検索ページリニューアルにあたって、SwiftUIを導入しております。SwiftUIを導入するメリットや実用例をこちらの記事をご覧ください!👇
せっかくSwiftUIで開発をしていることもあり、Combineと組み合わせたら何か便利になるだろうという疑問も湧いてきたので、この記事でSwiftUIとCombineを組み合わせ、MVVMでサンプルのアプリをいじりながら手を動かしたいと思います。
ちなみに、今回参考した記事は下のリンクです。ソースコードはページ内にダウンロードできるので、興味ある方はぜひ〜
MVVMとCombineを理解しよう
MVVMって何
基本にはApple社が推奨しているMVCは下の図通りです。
Model はアプリが何をするかの実質内容
View はアプリをユーザーにどのように提示するかの方法
ControllerはViewとModelのやりとりをハンドルする役割
MVCはわかりやすいですが、ロジックが増えてしまうと、Controllerの肥大化やテストもしづらいということもあり、開発効率の低下やプロダクト品質が悪くなってしまいます。
これを回避するために、MVVMが誕生しました。コアの部分はViewModelです。
ViewはViewModelを持つ
ViewModelはModelを持つ
こうなると、Modelが更新されたらどうやってViewにお知らせするのと思われますよね。ここからCombineが登場します。
Combineって何?
Apple社がWWDC19で発表したイベントの発行と購読をすることができるフレームワークです。
簡単にいうと、Modelが更新されたらData BindingによってViewModelと連携することができます。ViewModelが更新されたデータをViewに反映します。これはReactive programmingの一般的な概念です。
さっそく手を動かしましょう
今回はこういうアプリをやってみたいと思います。
スタート画面はこれです👇
struct OpenWeatherAPI {
...
static let key = "<your key>" // Replace with your own API Key
}
data decodeはParsing.swiftを開いて以下のコードを貼り付けてください。
import Foundation
import Combine
func decode<T: Decodable>(_ data: Data) -> AnyPublisher<T, WeatherError> {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return Just(data)
.decode(type: T.self, decoder: decoder)
.mapError { error in
.parsing(description: error.localizedDescription)
}
.eraseToAnyPublisher()
}
最初はAPIからデータをfetchするファイルを実装したいと思います。Protocolでmethodを定義します。WeatherFetcher.swiftを開いて以下のコードを一番上に貼り付けてください。
protocol WeatherFetchable {
func weeklyWeatherForecast(
forCity city: String
) -> AnyPublisher<WeeklyForecastResponse, WeatherError>
func currentWeatherForecast(
forCity city: String
) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError>
}
そして、WeatherFetcherがWeatherFetchableを継承し、methodをImplementします。同じファイルの一番下に、以下のコードを貼り付けてください。
// MARK: - WeatherFetchable
extension WeatherFetcher: WeatherFetchable {
func weeklyWeatherForecast(
forCity city: String
) -> AnyPublisher<WeeklyForecastResponse, WeatherError> {
return forecast(with: makeWeeklyForecastComponents(withCity: city))
}
func currentWeatherForecast(
forCity city: String
) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError> {
return forecast(with: makeCurrentDayForecastComponents(withCity: city))
}
private func forecast<T>(
with components: URLComponents
) -> AnyPublisher<T, WeatherError> where T: Decodable {
// 1
guard let url = components.url else {
let error = WeatherError.network(description: "Couldn't create URL")
return Fail(error: error).eraseToAnyPublisher()
}
// 2
return session.dataTaskPublisher(for: URLRequest(url: url))
// 3
.mapError { error in
.network(description: error.localizedDescription)
}
// 4
.flatMap(maxPublishers: .max(1)) { pair in
decode(pair.data)
}
// 5
.eraseToAnyPublisher()
}
}
ViewModelを触る
WeeklyWeatherViewModel.swiftを開いて次のコードを貼り付けてください。
import SwiftUI
import Combine
// 1
class WeeklyWeatherViewModel: ObservableObject, Identifiable {
// 2
@Published var city: String = ""
// 3
@Published var dataSource: [DailyWeatherRowViewModel] = []
private let weatherFetcher: WeatherFetchable
// 4
private var disposables = Set<AnyCancellable>()
init(weatherFetcher: WeatherFetchable) {
self.weatherFetcher = weatherFetcher
}
}
ここからWeatherFetcherを使ってデータをfetchしてきたいと思います。
initializerの下に以下のコードを貼り付けてください。
func fetchWeather(forCity city: String) {
// 1
weatherFetcher.weeklyWeatherForecast(forCity: city)
.map { response in
// 2
response.list.map(DailyWeatherRowViewModel.init)
}
// 3
.map(Array.removeDuplicates)
// 4
.receive(on: DispatchQueue.main)
// 5
.sink(
receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
// 6
self.dataSource = []
case .finished:
break
}
},
receiveValue: { [weak self] forecast in
guard let self = self else { return }
// 7
self.dataSource = forecast
})
// 8
.store(in: &disposables)
}
Viewを触る
WeeklyWeatherViewを開いて以下のコードを貼り付けてください。
@ObservedObject var viewModel: WeeklyWeatherViewModel
init(viewModel: WeeklyWeatherViewModel) {
self.viewModel = viewModel
}
@ObservedObject (観察されるオブジェクト)を viewModel 変数につけることで、 @ObservableObject である ViewModel の公開する変更があったときに、 View は即時に body var から関連する UI を変更できます。
次はSceneDelegateのweeklyViewのプロパティを書き換えます。
let fetcher = WeatherFetcher()
let viewModel = WeeklyWeatherViewModel(weatherFetcher: fetcher)
let weeklyView = WeeklyWeatherView(viewModel: viewModel)
そして、WeeklyWeatherViewのbodyを書き換えます。
dataSourceが空の時にemptySectionを表示します。
var body: some View {
NavigationView {
List {
searchField
if viewModel.dataSource.isEmpty {
emptySection
} else {
cityHourlyWeatherSection
forecastSection
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Weather ⛅️")
}
}
一番下に以下のコードを貼り付けてください。
private extension WeeklyWeatherView {
var searchField: some View {
HStack(alignment: .center) {
// 1
TextField("e.g. Cupertino", text: $viewModel.city)
}
}
var forecastSection: some View {
Section {
// 2
ForEach(viewModel.dataSource, content: DailyWeatherRow.init(viewModel:))
}
}
var cityHourlyWeatherSection: some View {
Section {
NavigationLink(destination: CurrentWeatherView()) {
VStack(alignment: .leading) {
// 3
Text(viewModel.city)
Text("Weather today")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
var emptySection: some View {
Section {
Text("No results")
.foregroundColor(.gray)
}
}
}
ここまでアプリをrunしても画面は変わったですが、データがまだ表示できません。原因はWeeklyWeatherViewModelに作られたfetchWeatherをまだ使ってないから、入力されたcityはまだHTTP通信してません。
WeeklyWeatherViewModelに以下のコードを書き換えてください。
// 1
init(
weatherFetcher: WeatherFetchable,
scheduler: DispatchQueue = DispatchQueue(label: "WeatherViewModel")
) {
self.weatherFetcher = weatherFetcher
// 2
$city
// 3
.dropFirst(1)
// 4
.debounce(for: .seconds(0.5), scheduler: scheduler)
// 5
.sink(receiveValue: fetchWeather(forCity:))
// 6
.store(in: &disposables)
}
これで一回り実装が終わります。
いかがでしょうか?MVVMとCombineはすっきりしましたか?少し役に立てたら嬉しいです。🥳
触ってみた感想
ViewModelはビジネスロジックをもつことによって、ViewControllerの肥大化を解消します。
Combineの力で非同期処理は書きやすく、データの統一性も守れます。
各ファイルの役割がMVCより明確です。
SwiftUIはUIKitより読みやすいです。
続き
今回はSwiftUI+Combine+MVVMでやってみましたが、次回の記事はSwiftUI+Combine+VIPERにしたいと思います!
参考した記事
エンジニア募集中!
スペースマーケットでは現在アプリエンジニアを積極採用中です。
アプリで社会課題を解決しながら、あたりまえの世界を作っていきたい方がいらっしゃれば、ぜひお話しさせていただきたいです!
この記事が気に入ったらサポートをしてみませんか?