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