こんにちわ。 iOSエンジニアの ohayoukenchan です。
前編 でURLRequest
を作成するところまでお伝えできたので、今回は実際にURLSession
を前回の変更方針にあった非同期処理を適切に処理したいを解決する内容となっています。
URLSessionとは?
URLSession
はURLで示されるエンドポイントからデータをダウンロードしたり、エンドポイントにデータをアップロードしたりするためのAPIを提供します。
アプリは、このAPIを使用して、アプリが実行されていないときや、iOSではアプリがサスペンドされている間に、バックグラウンドでダウンロードを実行することもできます。
単純なHTTPの非同期通信を行うにはdataTask(with:completionHandler:)を用いればいいので、dataTask(with:completionHandler:)
で書くとこのような感じになります。completionHandler:のclosureで処理されるdata
, response
, error
はすべてoptionalな値なので実際に利用するときはunwrapする必要があります。
let task = session.dataTask(with: url!) { data, response, error in if let error = error { // エラーが返って来た場合エラーハンドリング return } guard let data = data, let response = response as? HTTPURLResponse else { // dataが取得出来ない場合のハンドリング return } if response.statusCode == 200 { do { let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments) } catch { // デコードに失敗した場合のエラーハンドリング処理 } // 処理... } } task.resume() // taskをsuspended状態からrunning状態にする
dataTaskPublisherでcombine publisherを操作していく
今回はURLリクエストの返り値としてAnyPublisher<Request.Response, APIServiceError>
のようなcombine publisherが欲しいのですが、
UrlSession
にはdataTaskPublisher(for:)
があり、combine publisherを返してくれるので返り値の理想に近いこちらを使用していきます。
詳細はこちらをご確認ください。
Request処理とその結果を返すクラスを作っていきます。まずは、Protocolの定義をしていきます。今回はApiService
としました。
where
を使ってRequestに型制約をもたせておくと予期しないRequest
型で実装してしまった場合、コンパイルエラーで気づくことができます。
protocol ApiService { func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType }
ApiServivceクラスはレスポンスエラーとデコードに失敗したときのパースエラーがあることを明示しておきます。
enum APIServiceError: Error { case responseError(Error) case parseError(Error) }
次にApiServiceに準拠したApiServiceクラスを実装していきます。
継承されないようにfinal
修飾子をつけておくとどこかで継承して利用される心配がなく安心かと思います。
final class ApiService: ApiService { func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType { let urlRequest: URLRequest = request.buildRequest() // (1) let decorder = JSONDecoder() decorder.dateDecodingStrategy = .iso8601 decorder.keyDecodingStrategy = .convertFromSnakeCase // (2) return URLSession.shared.dataTaskPublisher(for: urlRequest) .tryMap { (data, response) -> Data in if let urlResponse = response as? HTTPURLResponse { switch urlResponse.statusCode { case 200..<300: return data default: throw try decorder.decode(ApiErrorResponse.self, from: data) } } return data } // (3) .mapError { error in APIServiceError.responseError(error) } // (4) .flatMap { Just($0) .decode(type: Request.Response.self, decoder: decorder) .mapError { error in return APIServiceError.parseError(error) } } // (5) .receive(on: DispatchQueue.main) // (6) .eraseToAnyPublisher() } }
(1) let urlRequest: URLRequest = request.buildRequest()
のところで前回作成したAPIRequestType
に準拠したStruct型のリクエスト(前回SampleRequestとしたもの)のbuildRequest()
を実行してURLRequest
をつくります。
(2) dataTaskPublisher(for: urlRequest)
の返り値data
をデコードする際の指定で、Date型にするときiso8601
形式に、JSON keyがsnakeCaseの場合、structとのmapping ができるようになります。
(3) dataTaskPublisher
はURL session data task
をwrapしたpublisherを返すので.map()
オペレータを使うことができます。publisherの中身は(data, response)
のタプルです。レスポンス結果に応じて処理を振り分けたいので.map()
ではなくtryMap()
を使用しています。この場合、HttpStatusCodeが200から300までの値の場合リクエストは正常なレスポンスを返したとして後続の処理にdata
を返します
その他の場合はthrow
キーワードでpublisherの処理を失敗させます
(4) mapError
はtryMap()
で処理が失敗した際にthrow
キーワードで投げたエラーを別の型に変換します。ここではAPIServiceError.responseError(error)
に変換していますが、画面側でAPIを叩いたときに、
URLリクエストの返り値としてAnyPublisher<Request.Response, APIServiceError>
のようなcombine publisherを返して欲しいためです。
(5) .flatMap
のjust($0)
ですがこちらは(data, response)
のタプルのうちdata
のみを扱うpublisherにしたいのでこのようにしています。そしてCodable(decodable)を使用してデコードしています。デコードに失敗した場合はレスポンスエラーのときと同じようにmapError
を使ってAPIServiceError.parseError(error)
に変換しています。
(6) URL sessionはバックグラウンドで処理されますが、UIの更新はメインスレッドで行いたいため、receive(on: DispatchQueue.main)
でメインスレッドで返すように指定しています。
これでAPIServiceを実装が完了したので、いよいよUI側の処理を書いていくことが出来ます。
リクエストをcombine publisherで受け取る
UI側の処理はこのように簡潔に書けます。
let apiService = ApiService() apiService.call( from: SampleRequest.List(query: "foo") ) .sink( receiveCompletion: { [weak self] completion in switch completion { case .finished: break case .failure(let error): self?.errorSubject.send(error) } }, receiveValue: { [weak self] result in self?.fooSubject.send(result.bar) } ) .store(in: &cancellables)}
ApiService
に実装したcall(from:)
メソッドにリクエストを渡し、.sink(receiveCompletion:, receiveValue:)
することでAnyPublisher<Request.Response, APIServiceError>
の値を流します。receiveCompletion:
ではcompletionが.failure
だった場合APIServiceError
を流してUI側の制御をします。errorSubject
をUI側で購読できるようにして値が流れてきたらUIAlert
をだすなどの処理につなげます。値を受け取った場合、receiveValue:
で受け取った値をUIに反映させていきます。
Swift5.3からMultiple Trailing Closures
が使えるのでもう少しシュッと書くことができます。
let apiService = ApiService() apiService.call( from: SampleRequest.List(query: "foo") ) .sink { [weak self] completion in switch completion { case .finished: break case .failure(let error): self?.errorSubject.send(error) } } receiveValue: { [weak self] result in self?.fooSubject.send(result.bar) } .store(in: &cancellables)}
インデントが浅くなって可読性があがりました!
これでURLSessionを利用してレスポンスをCombineのAnyPublisher
で受け取ることができるようになりました。
しかし、APIが正しく動作するのかのテストが書けていません。
ママリではViewModelの初期化時にApiServiceをDIすることでテストを書けるようにしています。 長くなってしまったのでテストについては後編でお伝えできればと思います。( ^ θ ^ )
余談
WWDC2021のMeet async/await in Swift - WWDC21 - Videos - Apple Developerにもありますが、非同期処理をasync/awaitキーワードを使って自然にかけるようになりましたね。
関数にasync throws
つけて、非同期処理が行われる処理にtry await
をつけることで非同期処理を安全に処理できるようになります。
ママリにも早く適応させて行きたいず!
struct UserRequest { var session = URLSession.shared func load(from url: URL) async throws -> [User] { let (data, response) = try await session.data(from: url) // レスポンスエラーなど何らかの失敗処理 let decoder = JSONDecoder() return try decoder.decode([User].self, from: data) } }