SwiftUIとCombineの基礎

株式会社アドグローブ ソリューション事業部の山川です。

私はこれまで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