見出し画像

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です

  • ViewViewModelを持つ

  • ViewModelModelを持つ

こうなると、Modelが更新されたらどうやってViewにお知らせするのと思われますよね。ここからCombineが登場します。

Combineって何?

Apple社がWWDC19で発表したイベントの発行と購読をすることができるフレームワークです。
簡単にいうと、Modelが更新されたらData BindingによってViewModelと連携することができます。ViewModelが更新されたデータをViewに反映します。これはReactive programmingの一般的な概念です。


さっそく手を動かしましょう

今回はこういうアプリをやってみたいと思います。

スタート画面はこれです👇


Note:このリポジトリはOpenWeatherMapの無料APIを使用してます。個人用のAPI Keyを取得し、WeatherFetcher.OpenWeatherAPIをアップデートしてください

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>
}

そして、WeatherFetcherWeatherFetchableを継承し、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()
  }
}

Note:1…5の説明は割愛します。本記事にご参考ください。currentWeatherForecastは本記事に使わないので、Implementしなくてもいいです。

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
  }
}

1.WeeklyWeatherViewModelObservableObject と Identifiableを継承します。これによって、観察可能なオブジェクトになります。
2.cityは観察可能なプロパティになります。
3.dataSourceViewModelが持ちます。
4.Publisherをキャンセル用。ここは次にあるネットと連続通信をやめます。

ここから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)
}

Note:1…6の説明は割愛します。本記事をご参考ください。
7.取ってきたデータをdataSourceに渡します。
8.Publisherをキャンセルします。

Viewを触る

WeeklyWeatherViewを開いて以下のコードを貼り付けてください。

@ObservedObject var viewModel: WeeklyWeatherViewModel

init(viewModel: WeeklyWeatherViewModel) {
  self.viewModel = viewModel
}


@ObservedObject (観察されるオブジェクト)を viewModel 変数につけることで、 @ObservableObject である ViewModel の公開する変更があったときに、 View は即時に body var から関連する UI を変更できます。

次はSceneDelegateweeklyViewのプロパティを書き換えます。

let fetcher = WeatherFetcher()
let viewModel = WeeklyWeatherViewModel(weatherFetcher: fetcher)
let weeklyView = WeeklyWeatherView(viewModel: viewModel)


そして、WeeklyWeatherViewbodyを書き換えます
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)
    }
  }
}

1.viewModelに$をつけたことによって、データがbindされます。
2.DailyWeatherRowを初期化します。
3.入力されたcityを表示するだけ。

ここまでアプリを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)
}

1.schedulerというparameterを追加することによって、どのqueueでHTTP通信を管理します。
2.city$をつけることによって、観察されます。
3.一個目のデータを取ります。
4.連続でHTTP通信を回避するため、0.5秒を遅延させます。
5.データを受け取って、fetchWeatherで処理します。
6.Publisherをキャンセルします。

これで一回り実装が終わります。

いかがでしょうか?MVVMとCombineはすっきりしましたか?少し役に立てたら嬉しいです。🥳

触ってみた感想

  • ViewModelはビジネスロジックをもつことによって、ViewControllerの肥大化を解消します。

  • Combineの力で非同期処理は書きやすく、データの統一性も守れます。

  • 各ファイルの役割がMVCより明確です。

  • SwiftUIはUIKitより読みやすいです。

続き

今回はSwiftUI+Combine+MVVMでやってみましたが、次回の記事はSwiftUI+Combine+VIPERにしたいと思います!

参考した記事

エンジニア募集中!

スペースマーケットでは現在アプリエンジニアを積極採用中です。
アプリで社会課題を解決しながら、あたりまえの世界を作っていきたい方がいらっしゃれば、ぜひお話しさせていただきたいです!


この記事が気に入ったらサポートをしてみませんか?