[Swift] HTTP通信部分にCombineを使ってみる #WWDC19

WWDC19で発表された次期iOS, macOS, tvOS, watchOSに新規追加されるフレームワークの概要のまとめです。
2019.06.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは。モバイルアプリサービス部の平屋です。

WWDC 19で初登場したCombineを使用してHTTP通信を行う実装を紹介します。

以下の記事のCombine版です。

[RxSwift] HTTP通信部分にRxSwiftを使ってみる

本記事は Apple からベータ版として公開されているドキュメントを情報源としています。 そのため、正式版と異なる情報になる可能性があります。ご留意の上、お読みください。

検証環境

  • macOS Mojave 10.14.5
  • Xcode Version 11.0 beta (11M336w)

Combine Framework

UIイベント、ネットワーク通信などが非同期に提供するデータを処理するためのFrameworkです。以下のSDKで使用できます。

  • iOS 13.0+
  • macOS 10.15+
  • tvOS 13.0+
  • watchOS 6.0+

Combine Frameworkを構成する主な要素は以下の通りです。

  • Publisher: データを生成する
  • Subscriber: データを受け取る
  • Operator: データの合成や加工を行う
  • Subscription: PublisherとSubscriberのコネクションを表す

SubscriberはPublisherを購読してデータを受け取ります。データの合成や加工を行いたい場合はOperatorを使用します。

また、購読をやめたい場合はSubscriptionを使って解除します。

Publisherを作る

まずは、データを生成するPublisherを作ります。

ここではPublisherプロトコルに適合したstructRequestPublisherを定義して使用します。

(1) Output, Failureの定義

処理成功時に生成する値の型をData、失敗時に生成する値の型をRequestErrorと定義します。

(2) receive(subscriber:)の実装

Publisherプロトコルの必須メソッドreceive(subscriber:)を実装します。このメソッドはSubscriberによって購読されたときに呼ばれます。

このメソッドの中で通信処理と通信結果のハンドリングを処理を行います。

(3) 通信処理

URLSessionのメソッドを使って通信を行います。

(4)〜(6) 通信結果のハンドリング

通信結果のハンドリングを行います。

(4)の場合はAPIから取得した値をSubscriberに渡し、処理完了を伝えます。

(5)と(6)の場合は処理失敗をSubscriberに伝えます。

(7) 購読完了の通知

購読が完了したことをSubscriberに伝えます。

(8) RequestPublisherの提供

RequestPublisherを提供するメソッドをURLSessionに追加します。(「Subscribeする」で使用します)

struct RequestPublisher: Publisher {
    // (1) Output, Failureの定義
    typealias Output = Data
    typealias Failure = RequestError

    let session: URLSession
    let request: URLRequest

    // (2) `receive(subscriber:)`の実装
    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        // (3) 通信処理
        let task = session.dataTask(with: request) { data, response, error in
            DispatchQueue.main.async {
                let httpReponse = response as? HTTPURLResponse
                if let data = data, let httpReponse = httpReponse, 200..<300 ~= httpReponse.statusCode {
                    // (4) 通信結果のハンドリング(処理成功)
                    _ = subscriber.receive(data)
                    subscriber.receive(completion: .finished)
                } else if let httpReponse = httpReponse {
                    // (5) 通信結果のハンドリング(処理失敗、HTTPエラー)
                    let error = RequestError.http(code: httpReponse.statusCode, error: error)
                    subscriber.receive(completion: .failure(error))
                } else {
                    // (6) 通信結果のハンドリング(処理失敗、その他のエラー)
                    let error = RequestError.other
                    subscriber.receive(completion: .failure(error))
                }
            }
        }

        // (7) 購読完了の通知
        let subscription = RequestSubscription(combineIdentifier: CombineIdentifier(), task: task)
        subscriber.receive(subscription: subscription)

        task.resume()
    }
}

extension URLSession {
    // (8) `RequestPublisher`の提供
    func publisher(for request: URLRequest) -> RequestPublisher {
        return RequestPublisher(session: self, request: request)
    }
}

Subscribeする

次にSubscribeする処理を見ていきます。

(1) publisher(for:)の呼び出し

データを要求したいタイミングで、「Publisherを作る」で定義したメソッドpublisher(for:)を呼びます。

(2) デコード

decode(type:decoder:)を使用してJSONをデコードします。

(3) Subscribe実行

sink(receiveCompletion:receiveValue:)を使用してSubscribeします。

receiveCompletion引数のクロージャは処理完了時に呼ばれます。(成功時と失敗時のどちらでも呼ばれます)

receiveValue引数のクロージャは値を受け取った場合に呼ばれます。

func request() {
    let request = URLRequest(url: URL(string: "https://api.github.com/search/repositories?q=swift+combine")!)

    // (1) `publisher(for:)`の呼び出し
    requestCancellable = URLSession.shared.publisher(for: request)
        // (2) デコード
        .decode(type: Repositories.self, decoder: decorder)
        // (3) Subscribe実行
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("finished")
            case .failure(let error):
                print("error:\(error.localizedDescription)")
            }
        }, receiveValue: { repositories in
            print("repositories:\(repositories)")
        })
}

通信処理をキャンセルできるようにする

最後に通信処理をキャンセルできるようにするための実装を見ていきます。

(1) Subscriptionの定義

URLSessionTaskを保持するSubscriptionを定義し、cancel()などを実装します。

(2) Subscriptionの提供

RequestPublisherreceive(subscriber:)内でSubscriberにSubscriptionを提供します。

(3) Cancellableの保持

sink(receiveCompletion:receiveValue:)を呼ぶとCancellableが返るので保持しておきます。

(4) キャンセル

キャンセルしたいタイミングでcancel()を呼びます。

// (1) Subscriptionの定義
struct RequestSubscription: Subscription {
    let combineIdentifier: CombineIdentifier
    let task: URLSessionTask
    
    func request(_ demand: Subscribers.Demand) {}
    
    func cancel() {
        task.cancel()
    }
}

struct RequestPublisher: Publisher {
    // ...

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        // ...

        // (2) Subscriptionの提供
        let subscription = RequestSubscription(combineIdentifier: CombineIdentifier(), task: task)
        subscriber.receive(subscription: subscription)

        // ...
    }
}

class ViewController: UIViewController {
    // ...

    var requestCancellable: Cancellable?
    
    deinit {
        // (4) キャンセル
        requestCancellable?.cancel()
    }
    
    // ...

    func request() {
        // ...

        // (3) Cancellableの保持
        requestCancellable = URLSession.shared.publisher(for: request)
            .decode(type: Repositories.self, decoder: decorder)
            .sink(receiveCompletion: { completion in
                // ...
            }, receiveValue: { repositories in
                // ...
            })
    }
}

さいごに

本記事ではCombineを使用してHTTP通信を行う実装を紹介しました。

紹介したサンプルコードは単体の非同期処理なので、必要以上に大がかりな感じになってしまいましたが、UIイベントと組み合わせたり、複数の通信を束ねたりするようになれば旨味が出てきそうだなと思いました。

サンプルコードは以下のgistで公開していますので参考にしてみてください!

参考

Apple Developer

その他