株式会社アドグローブ ソリューション事業部の山川です。
私はこれまでiOSアプリ開発を行ってきましたが、中々SwiftUIやCombineを使用したプロジェクトに携わることはありませんでした。
この記事を読んでいる皆様はいかがでしょうか?
WWDC2019でSwiftUIやCombineが発表されてからおよそ2年半経ちますが、私の体感ではまだまだ浸透していない印象を受けます。
なので発表当時に少し触ってから、それからほぼ触っていない方もいるのではないかと思います。
かくいう私もその一人です。
この状態ではいざ実際にプロジェクトで導入する際に時間がかかってしまうので、
今回は復習も兼ねて基礎からやってみようと思います。
本記事を読むにあたっての前提知識
- Swiftの知識がある方
- SwiftUIをある程度知っている方(今回はCombineに焦点を当てているため)
- RxSwiftなどのリアクティブプログラミングを行ったことがある方 (各所でRxSwiftで例える箇所があります)
SwiftUIとは
WWDC2019でAppleが発表した、UI作成するためのフレームワークです。
これまではInterfaceBuilderを利用してアプリのUIを作成していたと思いますが、
SwiftUIが登場したことにより、swiftコードでUIを構築することができます。
これにより、Github上などでコンフリクトした際の修正も楽になりますし、
どう修正されたかの差分もわかりやすくなります。
Combineとは
こちらもWWDC2019でAppleが発表した、Swiftを使ってリアクティブプログラミングを行うためのフレームワークです。
これまではSwiftでリアクティブプログラミングを行うにはライブラリを自作するか、
RxSwiftなどのOSSを利用する必要がありました。
ですが、Combineの登場により自作する必要性や外部ライブラリに頼る必要性がなくなりました。
Combineの基礎
●用語
Publisher
イベントを送信するオブジェクトのこと。
Publisherがイベントを送信することを「publish」と呼びます。
Subscriber/Subscription
Publisherに対して「subscribe」するオブジェクトのことを「Subscriber」と呼びます。
イベントの受信処理を設定することを「subscribe」と呼び、subscribe時の戻り値のことを「Subscription」と呼びます。
Operator
CombineではPublisherを別のPublisherに変換することができます。
この別のPublisherに変換するメソッドのことを「Operator」と呼びます。
各用語をRxSwiftで例えると
Combine -> RxSwift
Publisher -> Observable
Subscription -> Disposable
Operator -> flatmapなどの変換メソッド
●イベント Combineで扱うイベントは3種類です。
- value(値)
- .finished(イベント完了)
- .failure(エラー)
実践編
Combineについての用語を説明したのでここからは実際に使用していきましょう。
今回はサーバから天気予報を取得して画面に表示するアプリを作成してみようと思います。
取得ボタンをタップすると天気予報のリストが表示されるアプリ。
●登場するソース
- ContentView.swift(アプリ画面)
- WeatherForecast.swift(サーバから取得するデータモデル)
- WeatherViewModel.swift(サーバからデータ取得及び取得データの保持/更新)
- WeatherForecastRequest.swift(サーバへのリクエスト情報)
- BaseRequest.swift(リクエスト情報のベース)
- ApiClient.swift(サーバにリクエストするためのクライアント)
以降各種ソースコードについて説明していきます。
上記の順番通りではないのでご注意ください。
WeatherForecast.swift
サーバから取得するデータモデルの定義となります。
このデータをサーバから取得し、画面上に表示します。
import Foundation /// 天気予報 struct WeatherForecast: Codable { /// 住所 let address: String /// 天気情報 let weathers: [Weather] } /// 天気情報 struct Weather: Codable { /// 時間(0-23) let time: Int /// 天気種別 let weatherType: WeatherType /// 降水確率 let rainyPercent: Int } /// 天気種別 enum WeatherType: Int, Codable { case sunny = 0 case cloudy = 1 case rain = 2 func toString() -> String { switch self { case .sunny: return "晴れ" case .cloudy: return "曇り" case .rain: return "雨" } } }
BaseRequest.swift
サーバにリクエストするための情報のベースとなるプロトコルです。
import Foundation protocol BaseRequest { associatedtype ResponseType var method: String { get } var baseURL: URL { get } var path: String { get } var data: Data? { get } } extension BaseRequest { var baseURL: URL { return URL(string: "パスを除いたサーバURL")! } func asURLRequest() throws -> URLRequest { var urlRequest = URLRequest(url: baseURL.appendingPathComponent(self.path)) urlRequest.httpMethod = self.method if let data = self.data { urlRequest.httpBody = data } return urlRequest } }
WeatherForecastRequest.swift
サーバへのリクエスト情報の定義です。
import Foundation struct WeatherForecastRequest: BaseRequest { typealias ResponseType = WeatherForecast var method: String { return "GET" } var path: String { return "/forecast" } var data: Data? { return nil } }
ApiClient.swift
サーバAPIにリクエストするためのクライアントです。
今回はAPI通信にURLSessionを利用しています。
URLSessionにはCombineを利用できるようにメソッドが用意されています。
「dataTaskPublisher」の部分です。
このdataTaskPublisherが返すPublisherは「(data: Data, response: URLResponse)」のタプル型のデータを送信してきますので、それをmapで受信データのみを取り出しています。
取り出した受信データ(JSONデータ)をデコードすることでモデル(今回はWeatherForecast)に変換しています。
その変換したデータを受け取るスレッドを「receive」メソッド(RxSwiftであればObserverOnのようなもの)で設定して、最後に型消去を行い、汎用的なPublisherに変換するために「eraseToAnyPublisher」を使用します。
「eraseToAnyPublisher」を使用しないと型複雑になり、扱いずらくなりますので抽象化することで扱いやすくします。
import Foundation import Combine final class ApiClient { private static let contentType = "application/json" private static let decoder: JSONDecoder = { let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase return jsonDecoder }() static func request<T, V>(_ request: T) -> AnyPublisher<V, Error> where T: BaseRequest, V: Codable, T.ResponseType == V { let urlRequest = try! request.asURLRequest() return URLSession.shared .dataTaskPublisher(for: urlRequest) .map({ $0.data }) .decode(type: V.self, decoder: ApiClient.decoder) .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } }
WeatherViewModel.swift
サーバからデータ取得及び取得データの保持/更新するクラスです。
「cancellables」はpublisherをsubscribe(sink)した際の戻り値であるsubscriptionを保持します。
これはこのcancellablesを保持しているインスタンスが破棄されるタイミングで保持しているsubscription(購読)を全てキャンセルし、破棄します。
(RxSwiftではDisposeBagのようなものです)
「@Publish」属性がついている、「address」と「weathers」プロパティがあります。
これはCombineで提供されているproperty wrapperで「ObservableObject」プロトコルに準拠しているクラス(今回はWeatherViewModel)の変更をSwiftUI側に通知してくれます。
そうすることで値を変更するだけで、UIを更新することができるようになります。
「fetchWeahterForecast」メソッド内では実際にApiClientのrequestを呼びAPI通信を行います。
その際はPublisherの「sink」メソッドでsubscribeし、API通知の結果と受信データを受け取ります。
受信データは自身のプロパティに格納することで変更がSwiftUI側に通知されます。
最後にstoreメソッドでcancellablesに保持しておきます。
import Foundation import Combine class WeatherViewModel: ObservableObject { private var cancellables = Set<AnyCancellable>() @Published private(set) var address: String = "取得中" @Published private(set) var weathers: [Weather] = [] func fetchWeahterForecast() { let request = WeatherForecastRequest() ApiClient.request(request) .sink { completion in switch completion { case let .failure(error): print(error) case .finished: print("finished fetchWeatherForecast.") } } receiveValue: { [weak self] weatherForecast in guard let self = self else { return } self.weathers = weatherForecast.weathers self.address = weatherForecast.address } .store(in: &cancellables) } }
ContentView.swift
アプリ画面です。
「@ObservedObject」属性は自作クラスの変更通知を受け取るために必要となります。
これはSwiftUIが提供するproperty wrapperです。
今回使用するproperty wrapperは「@ObservedObject」ですが、他にも以下のようなものがあります。
- @State
- @Binding
- @Environment
- @EnvironmentObject
全てを解説していると長くなりすぎるので今回は割愛します。
「viewModel.fetchWeahterForecast()」の箇所でサーバから天気予報情報を取得します。
WeatherViewModelで@Publishedで定義していたプロパティをSwiftUI側で監視することで取得できたタイミングで画面が更新され、最初の部分で貼っていたスクリーンショットのように画面に表示されるようになります。
import SwiftUI struct ContentView: View { @ObservedObject var viewModel: WeatherViewModel init(viewModel: WeatherViewModel) { self.viewModel = viewModel } var body: some View { VStack { Button { viewModel.fetchWeahterForecast() } label: { Text("取得") .frame(width: 60, height: 32) .foregroundColor(.white) .background(Color.blue) .cornerRadius(10) } .padding() Text("\(viewModel.address)") Spacer() List { ForEach(0..<viewModel.weathers.count, id: \.self) { WeatherRow(weather: viewModel.weathers[$0]) } } } } } struct WeatherRow: View { var weather: Weather var body: some View { VStack(alignment: .leading) { Text("\(weather.time)時") .font(.title) Text("天気: " + weather.weatherType.toString()) Text("降水確率: \(weather.rainyPercent)%") } .padding() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView(viewModel: WeatherViewModel()) } }
まとめ
簡単なアプリを使って、SwiftUIとCombineの単純な扱い方を復習してみました。
今回だけではとても説明し切れないぐらいSwiftUIとCombineにはまだまだたくさんの機能があります。
実際に私自身も復習してみると新しくなっていた部分や追加されている機能も多々あるようなので、引き続き調べていきたいと思います。
参考文献
https://developer.apple.com/documentation/swiftui/
https://developer.apple.com/documentation/combine