この記事は STORES Advent Calendar 2022 の 14日目の記事です。
はじめに
STORES 予約 のモバイルアプリエンジニアnekoです。
先日、開発を担当している STORES 予約 iOSアプリでasync/awaitを導入しました。
まだリファクタリングすべき箇所は残っているものの、今回対応した変更範囲や、導入してみての感想などをまとめてみました。
なお、記事中のコードについては、サンプル用として書き直したもので、実際のプロダクトコードとは異なります。
OpenAPIファイルの置き換え
まず最初に、openapi-generator によって自動生成しているファイル群の置き換えを行いました。
従来はSwift Combineに対応したファイルを生成していましたが、下記のように、レスポンスの形式をオプションで指定することで、async/awaitに対応したファイルを生成できます。
openapi-generator generate -i openapi.yaml -g swift5 -o output --additional-properties=responseAs=AsyncAwait
自動生成されたAPIクラスは下記のようなものになります。
open class func session(sessionParams: SessionParams? = nil) async throws -> SessionToken { var requestTask: RequestTask? return try await withTaskCancellationHandler { try Task.checkCancellation() return try await withCheckedThrowingContinuation { continuation in guard !Task.isCancelled else { continuation.resume(throwing: CancellationError()) return } requestTask = sessionWithRequestBuilder(sessionParams: sessionParams).execute { result in switch result { case let .success(response): continuation.resume(returning: response.body) case let .failure(error): continuation.resume(throwing: error) } } } } onCancel: { [requestTask] in requestTask?.cancel() } }
STORES 予約 のiOSアプリは下記のようにレイヤードアーキテクチャを採用しており、上記で自動生成されたAPI定義をClientとし、これをRepository → UseCaseを経由してViewModelから利用するようにしています。
これら各レイヤーの書き換え例を順に見ていきます。
Repository
● Before
protocol LoginRepositoryProtocol: AnyObject { func session(param: String) -> AnyPublisher<SessionToken, Error> } class LoginRepository: LoginRepositoryProtocol { func session(param: String) -> AnyPublisher<SessionToken, Error> { API.post(param: String).eraseToAnyPublisher() } }
● After
protocol SessionRepositoryProtocol { func session(param: String) async throws -> SessionToken } class SessionRepository: SessionRepositoryProtocol { func session(param: String) async throws -> SessionToken { try await API.post(sessionParams: SessionParams(param: param) } }
RepositoryはAPIClientをラップし、インターフェースを公開しています。
従来はCombineのAnyPublisherを使っていたところを、async/awaitを利用する形に変更しています。
UseCase
● Before
protocol LoginUseCaseProtocol: AnyObject { func session(params: String) -> AnyPublisher<String?, Error> } class LoginUseCase: LoginUseCaseProtocol { private let repository: SessionRepositoryProtocol init(repository: SessionRepositoryProtocol) { self.repository = repository } func session(params: String) -> AnyPublisher<String?, Error> { repository.session(param: String).map(\.token) .eraseToAnyPublisher() } }
● After
protocol LoginUseCaseProtocol { func session(params: String) async throws -> String? } actor LoginUseCase: LoginUseCaseProtocol { private let repository: SessionRepositoryProtocol init(repository: SessionRepositoryProtocol) { self.repository = repository } func session(params: String) async throws -> String? { try await repository.session(param: String).token } }
実際のプロダクトコードでは、UseCaseは複数のRepositoryを持ち、ViewModelに対して1対1の依存性を持ちます。
またAPIのレスポンスを変換する処理も行なっています。
このレイヤーも従来はCombineのAnyPublisherを使っていたところを、async/awaitを利用する形に変更しています。
ViewModel
● Before
class LoginViewModel: ObservableObject { private func login() { useCase.session(param: String) .receive(on: DispatchQueue.main) .sink { result in switch result { case .finished: debugPrint("request finished") case let .failure(error): debugPrint("request failed : \(error)") } } receiveValue: { response in debugPrint("response \(response)") } .store(in: &cancellables) } }
● After
@MainActor class LoginViewModel: ObservableObject { private func basicLogin() async { do { let token = try await useCase.session(param: String) if let token = token { debugPrint("token \(token)") } else { debugPrint("token is empty") } } catch { debugPrint("request failed : \(error)") } }
UI周りはいわゆるMVVMの設計で作られており、ViewModelでUseCaseを介してAPIを扱います。
ViewModel自体は@MainActorで保護し、API呼び出しをCombineからasync/awaitを利用するように変えました。
API通信の並列処理と直接処理の書き換えについても例をあげておきます。
並列処理 / 直列処理
まず、asyc/awaitを利用することで、並列処理は下記のように書くことができます。
● Before
private func getData() { Publishers.Zip(useCase.getDataA(), useCase.getDataB()) .receive(on: DispatchQueue.main) .sink { [weak self] result in case let .failure(error): switch completion { case .finished: debugPrint("request finished") case let .failure(error): debugPrint("request failed : \(error)") } } receiveValue: { (responseA, responseB) in debugPrint("responseA : \(responseA)") debugPrint("responseB : \(responseB)") }.store(in: &cancellables) }
● After
private func getData() async { do { async let responseA = useCase.getDataA() async let responseB = useCase.getDataB() let dataA = try await responseA.data let dataB = try await responseB.data } catch { debugPrint("request failed : \(error)") } }
getDataA()が実行された後、その応答を待つことなくgetDataB()も並列で処理が行われます。
応答の待ち合わせはその下のtry await部分で行われることになります。
また、直列処理については下記のようになります。
● Before
private func getData() { useCase.getDataA() .compactMap { $0.data.id } .flatMap { id in self.getDataB(id: id) } .receive(on: DispatchQueue.main) .sink { [weak self] result in case let .failure(error): switch completion { case .finished: debugPrint("request finished") case let .failure(error): debugPrint("request failed : \(error)") } } receiveValue: { response in debugPrint("response : \(response)") }.store(in: &cancellables) }
● After
private func getData() async { do { let responseA = try await useCase.getDataA() let id = responseA.data.id let responseB = try await useCase.getDataB(id: id) debugPrint("responseB : \(responseB)") } catch { debugPrint("response : \(response)") } }
この場合、useCase.getDataA()の応答を待ってから、次のuseCase.getDataB()の処理が行われます。
Combineに慣れているとBeforeの書き方も違和感はないですが、実際のプロダクトコードではもう少し処理内容が複雑になっていて、サンプルで示しているよりも可読性向上の効果は大きかったと感じています。
まとめ
個人の経験としてはRxSwiftを長く扱ってきたこともあり、Combineを利用した書き方自体には違和感を持っていませんでした。
しかし、今回全体をasync/awaitに置き換えてみたところ、かなりスッキリした実感があります。
また、@MainActorなどとの組み合わせによって、「非同期処理、並行処理のコードを簡潔かつ安全に記述できる」というSwift Concurrencyの効果も実感できました。
しかし、冒頭で触れたとおり、アプリ全体のリファクタリングはまだまだやることが多くあり、今回紹介した内容もその一部にすぎませんので、今後も継続的に取り組みについて発信していけたらと思っています。
STORES のモバイルアプリグループでは、他にも色々な取り組みをしています。
もし少しでも興味を持っていただけましたら、ぜひ採用サイトをご覧ください。