iOSのAPIリクエストのCombine実装

みなさんこんにちは。ラクマでiOSエンジニアをしているdarquroです。

ラクマは去年12月のリリースでサポートOSをiOS13以上にしました。 それに伴い、Combine Frameworkの利用もプロダクションコードに本格導入しました。 ラクマiOSアプリのアーキテクチャはMVP+Clean Architectureを採用しており、RxSwiftの置き換えではなく、現在はUIのバリデーションの一部やAPIリクエストの一部で使用している形になります。

今回の投稿では、Combine Frameworkの各プロトコルやイテレータなどのおさらいをして、APIリクエストの部分の実装、UnitTestでハマったポイントなどをご紹介したいと思います。

環境

  • Xcode: 12.3
  • Swift: 5

それじゃいくで!!

まずはCombineについておさらい

Publisherは 値を渡す完了を呼ぶエラーを返す の3つや!

struct MyPublisher: Publisher {

    typealias Output = String
    typealias Failure = MyError
    struct MyError: Error {}

    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
        // なんやかんやして結果を出力
        // recieveでOutPutを何回でも渡すことができる
        subscriber.receive("A")

        // finishとfailureはどちらか1回呼べます
        subscriber.receive(completion: .finished)
//      subscriber.receive(completion: .failure(.init()))
    }
}

sinkをすると結果を受け取れるで!

MyPublisher()
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("finish")
        case .failure(let error):
            print(error)
        }
    }, receiveValue: { value in
        print(value)
    })

// -> A
// -> finish

自分でPublisherを独自実装しなくても、便利クラスも用意されてるで!

  • Future 1つの値と、完了か失敗を返せる
  • Just 1回だけ値を返して、完了
  • Deferred Futureはインスタンス作成した時点で、クロージャー内の実行が行われるのに対し、Deferredはsinkされたタイミングで実行
  • Empty 何も値は返さない。完了だけ呼ばれる
  • Fail failureだけ呼ばれる
  • Record 複数値を返して、完了か失敗を返せる
let publisher = Future<Int, MyError> { promise in
    // successで値を渡す&完了
    promise(.success(1))
    // failure
//  promise(.failure(.init()))
}
let publisher = Record<Int, MyError> { promise in
    promise.receive(1)
    promise.receive(2)
    promise.receive(completion: .finished)
//  promise.receive(completion: .failure(.init()))
}

.eraseToAnyPublisher() でAnyPublisher<Outpub, Failure> に変換するんや!

Publisherを購読する側はsinkできることだけ分かればいいので、特定の型に依存させない。

let publisher = Future<Int, MyError> { promise in
    // successで値を渡す&完了
    promise(.success(1))
    // failure
//  promise(.failure(.init()))
}.eraseToAnyPublisher() // publisherはAnyPublisher<Int, MyError> 型になる

sinkしたらstoreするで!

var cancellables: Set<AnyCancellable> = []

MyPublisher()
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("finish")
        case .failure(let error):
            print(error)
        }
    }, receiveValue: { value in
        print(value)
    })
    .store(in: &cancellables)
  • sinkの返り値はAnyCancellable
  • AnyCancellableはcancel()がある

storeの他にもassignで値を突っ込める。

class MyClass {
    var value = ""
}
let myclass = MyClass()

Just("a")
    .assign(to: \.value, on: myclass)

print(myclass.value)

sibsribe(on:)とreceive(on:)でスレッド指定できるで!

MyPublisher()
     // subscribeすると、それより前の処理のスレッドを指定
    .subscribe(on: DispatchQueue.global())
    // receiveするすると、それ以降のスレッドを指定
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("finish")
        case .failure(let error):
            print(error)
        }
    }, receiveValue: { value in
        print(value)
    })
    .store(in: &cancellables)

map使って違う値に変換できるで!

Just(1)
    .map({ "10倍にしたよ:\($0 * 10)" })
    .sink(receiveValue: { print($0) })
// -> 10倍にしたよ:10

SubjectはCurrentValueSubjectとPassthroughSubjectの2種類!

https://developer.apple.com/documentation/combine/subject

CurrentValueSubjectは初期値を持ってるのでsinkすると、最初の値が取れる。 PassthroughSubjectは初期値なし。

let subject = PassthroughSubject<String, Error>()
subject
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("finishd")
        case .failure(let error):
            print(error)
        }
    }, receiveValue: { value in
        print(value)
    })
    .store(in: &cancellables)

subject.send("a")
subject.send("b")
subject.send(completion: .finished)

subjectをクラスの外に公開するときに、外から値を更新する必要なければ、publisherにするとええで!

class MyClass {

    var publisher: AnyPublisher<String, Error> { subject.eraseToAnyPublisher() }
    private let subject = PassthroughSubject<String, Error>()
}

APIリクエストのPublisherを作るで!

ここで、少しラクマの事情になりますが、APIリクエストは、HTTPリクエストヘッダーの追加など全てのリクエストに共通する処理をまとめています。 以下のコードのAPICoreClientは"リクエストの各パラメータを渡すとURLSessionTaskを返してくれるクラス"と想定して頂ければと思います。

struct FetchItemPublisher: Publisher {

    typealias Output = FetchItemResponse
    typealias Failure = ErrorResponse

    private let coreClient: APICoreClientable
    private let itemID: Int

    init(coreClient: APICoreClientable = APICoreClient.shared,
         itemID: Int) {
        self.coreClient = coreClient
        self.itemID = itemID
    }

    func receive<S>(subscriber: S) where S: Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
        let task = coreClient.dataTask(
            httpMethod: "GET",
            path: "/api/items",
            parameters: [
                "item_id": itemID
            ],
            success: { _, responseObject in
                do {
                    let jsonData = try JSONSerialization.data(withJSONObject: responseObject)
                    let response = try JSONDecoder().decode(FetchItemResponse.self, from: jsonData)
                    _ = subscriber.receive(response)
                } catch {
                    subscriber.receive(completion: .failure(.parseError))
                }
                subscriber.receive(completion: .finished)
            }, failure: { _, error in
                let response = coreClient.parse(error)
                subscriber.receive(completion: .failure(response))
            })
        // Subscriptionを作って登録する
        let subscription = FetchItemSubscription(combineIdentifier: .init(), task: task)
        subscriber.receive(subscription: subscription)
        task.resume()
    }
}

struct FetchItemSubscription: Subscription {

    let combineIdentifier: CombineIdentifier
    let task: URLSessionTask

    func request(_ demand: Subscribers.Demand) {}

    func cancel() {
        task.cancel()
    }
}

struct FetchItemResponse: Decodable { ... }

struct ErrorResponse: Error {
    ...
    static var parseError: Self { ... }
}

protocol APICoreClientable {
    func dataTask(httpMethod: String, path: String, parameters: [AnyHashable: Any]?, success: (URLSessionTask, Any) -> Void, failure: (URLSessionTask, Error) -> Void) -> URLSessionTask
    func parse(_ error: Error) -> ErrorResponse
}

struct APICoreClient: APICoreClientable {
    static let shared = APICoreClient()
    func dataTask(httpMethod: String, path: String, parameters: [AnyHashable : Any]?, success: (URLSessionTask, Any) -> Void, failure: (URLSessionTask, Error) -> Void) -> URLSessionTask {
        return ...
    }
    func parse(_ error: Error) -> ErrorResponse {
        return ...
    }
}
FetchItemPublisher(itemID: 100)
     // APIのリクエストは別スレッドで
    .subscribe(on: DispatchQueue.global())
    // 結果の受け取りはメインスレッドで
    .receive(on: DispatchQueue.main)
    // タイムアウトを指定する
    .timeout(.seconds(20), scheduler: DispatchQueue.main, customError: { .timeout })
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished: break;
        case .failure(let error):
            //エラー処理
            print(error)
        }
    }, receiveValue: { value in
        // 値を受け取った後の処理
    })
    .store(in: &cancellables)

ポイントとしては、以下の3つになると思います。

  • Subscription を作ってCancel出来るようにする
  • subscribe(on:)receive(on:) でスレッド指定をする
  • timeout(_:scheduler:options:customError:) を使ってタイムアウトを設定する

URLSession dataTaskPublisher(for:)を使うともっとシンプルに書けると思うのですが、ラクマの既存コードから部分的にCombineを導入するにはPublisherから作ってあげる必要があり、少しコード量が増えてしまいました。 将来的にリファクタリングをしていきたいと思います。

UnitTestは罠だらけや!

標準のAPIを使用すると、exceptionを作成し、fulfillとwaitを使って、非同期処理を待ち受けるようになります。

var cancellables = Set<AnyCancellable>()
let stub = APICoreClientStub()
let exp = expectation(description: "get API response")
FetchItemPublisher(core: stub, itemID: 100)
    .sink(receiveCompletion: { _ in
        exp.fulfill()
    }, receiveValue: { value in
       XCTAssertNotNil(response)
    })
    .store(in: &cancellables)
wait(for: [exp], timeout: 3)

上記のようにPublisherだけをテストする場合は、問題はないのですが、Publisherを作成し、.subscribe(on: DispatchQueue.global()).receive(on: DispatchQueue.main)を挟み、sinkをしているメソッドをテストしようとすると、sink以降が呼ばれたり呼ばれなかったりと、テストが不安定になりました。

また、StubやMockの側クラスでダミーのデータを作って、テストコードでも同じ値を参照しようとすると、sinkが呼ばれなくなるケースもあり、なるべくスコープ内でインスタンスの参照が閉じている方が安定するようでした。 このあたりは、おそらくXcodeのユニットテストの実行プロセスに依存しており、並列処理がされていることが影響しているのではないかと想定しているのですが、詳しいところはよくわかっておりません・・・。

なので、ユニットテストではPublisherのテストまではなるべく書き、スレッドを跨いでsinkを書いているところは動作確認やQAでカバーすることにしました。

また、ラクマでは導入していませんが、RxSwiftのTestSchedulerのように書けるOSSライブラリも良さそうです。

github.com

さいごに

今回はCombine Frameworkについてご紹介しました。 ラクマでは新しいSwiftのAPIを積極的に使いながら、サービス改善を行っています。 Swiftの新しい機能に関心が高く、一緒にアプリ開発をしてくれるメンバーを募集しています。 www.wantedly.com カジュアル面談で、私達のチームについて紹介させて頂きます。