こんにちは。iOSアプリを開発している吉岡(rikusouda)です。
業務で開発している「auサービスToday」というアプリでSwift Concurrencyを導入中なので、今回はその中でも「async/awaitを使うまで」にやったことを紹介します。
背景
auサービスTodayは2021年4月にリリースしたサービスで、SwiftUIを部分導入したり、非同期な処理の結果を受け取るためにCombineを使うなどして開発してきました。
それから時は進み、Xcode 13.2以降であればアプリのターゲットOSがiOS 13以上でSwift Concurrencyが利用できるようになりました。当初は色々と不具合があったようですがXcode 13.3.1以降で落ち着いたように見受けられ、実際の開発に利用できそうでした。
Combineでも非同期的に結果を受け取る処理を実現可能ですが、Swift Concurrencyのasync/awaitを用いることでさらにシンプルに記述ができると考え、導入を進めてきました。
iOSアプリ開発でSwift Concurrencyを使う目的の一つと考えられる「ひとまずasync/awaitを使いたい」を叶えるまでにやったことを紹介します。
この記事はXcode 14.1で動作確認した結果に基づいて記載しております。
Swift Concurrency周りでやったこと
- 影響の範囲の小さなAPIアクセスをasync/await対応
- async/awaitを使う基本方針の策定 ←この記事ではここまで解説します
- UseCase以降のActor化してみる
- 得られた知見から、基本方針を見直し
- 全画面、全モジュールへ適用 ←いまここ
auサービスTodayでは、UseCase以降をActor化して別スレッドで実行したりスレッドセーフにする取り組みもしているのですが、こちらは注意点が多くそれだけで1記事が書けるくらいのボリュームになってしまいます。そのため今回は「2. async/awaitを使う基本方針の策定」までを紹介します。
影響の小さなAPIアクセスをasync/await対応
まずは利用者が少ない画面の中で、その画面でのみ利用されているサーバーAPI呼び出し部分をasync/awaitで実現することとしました。
- URLSession通信のasync版を用意
- APIアクセス共通処理のasync版を作成
- APIアクセス処理のawait対応
- Swift 6相当のチェックをする
URLSession通信のasync版を用意
URLSessionによる通信を行うメソッドですが、iOS 15以上だとasync版のメソッド URLSession.data(from:) async throws が公式で用意されています。しかしauサービスTodayはiOS 14未満もサポートしているアプリなのでこのメソッドが利用できません。
そこで、Studyplusさんのブログで紹介されているような方法で、上記メソッドと同等の働きをするメソッドを作りました。
APIリクエスト共通処理のasync版を作成
APIリクエスト部分は、もともとCombineで下記のような処理でAPIリクエストを実現していました。
URLSession.dataTaskPublisher(for:)を使って通信結果をCombineのオブジェクトで受け取り、Publisher.decode(type:decoder:)にてデコード(JSONのパース)をしたデータを返すような実装です。
internal final class APIGateway { internal func send<T: APIRequestable>(_ request: T) -> AnyPublisher<T.Response, GatewayError> { // APIRequestable側で実際のURLRequestを生成する guard let urlRequest = request.buildURLRequest() else { return Fail(error: GatewayError.unknownError).eraseToAnyPublisher() } // JSONデコード用のデコーダを先に用意 let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase jsonDecoder.dateDecodingStrategy = request.dateDecodingStrategy // リクエストを投げる return session.dataTaskPublisher(for: urlRequest) .tryMap { [weak self] data, response -> Data in // レスポンスコードをみてエラー処理 guard let self = self else { throw GatewayError.unknownError } return try self.validateStatusCode(data: data, response: response) } .retry(1) .decode(type: T.Response.self, decoder: jsonDecoder) .mapError { // .decode で発生したエラーをGatewayError.parseに変換 if let gatewayError = $0 as? GatewayError { return gatewayError } else { return GatewayError.parse($0) } } .eraseToAnyPublisher() } }
下記のようなasync版を別に実装しました。
先程実装したURLSession.data(from:)でレスポンスとデータを受け取り、 JSONDecoder.decode(_:from:) にてデコード(JSONのパース)をしたデータを返すような実装です。
internal final class APIGateway { internal func asyncSend<T: APIRequestable>(_ request: T) async throws -> T.Response { // APIRequestable側で実際のURLRequestを生成する guard let urlRequest = request.buildURLRequest() else { throw GatewayError.unknownError } // リクエスト(2回までやり直す) var data: Data? var requestError: GatewayError? for _ in 0..<1 { do { // リクエストを投げる let dataResponse = try await session.data(for: urlRequest) // レスポンスコードをみてエラー処理 data = try validateStatusCode(data: dataResponse.data, response: dataResponse.response) requestError = nil break } catch { requestError = error as? GatewayError continue } } if let requestError = requestError { throw requestError } guard let data = data else { throw GatewayError.unknownError } // JSONのパースとエラー処理 do { let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase jsonDecoder.dateDecodingStrategy = request.dateDecodingStrategy return try jsonDecoder.decode(T.Response.self, from: data) } catch { throw GatewayError.parse(error) } } }
エラー処理周りがシンプルになったので、リクエストのエラーとJSONパースのエラーそれぞれの扱いが読みやすくなったと思います。また、Combineで必要だった[weak self]
とguard let self = self
のような、本筋とは関係ない処理が削減できたことも見通しを良くしています。
ちなみに、今回は APIGateway.asyncSend(:) を新規で実装しましたが元のメソッド APIGateway.send(:) をラップしてwithCheckedThrowingContinuation を使う方法でもasync版を作成できます。しかし、元のメソッドは将来的に消すことを前提としており、そのときに新規実装すると再テストが必要となってしまいます。それを避けるために最初からasync版を別に実装することとしました。この部分はほとんど変更した実績もなく、一時的な二重メンテによるデメリットも限定的と判断しました。
APIアクセス処理の await 対応
APIアクセス時はViewModelからUseCaseのメソッドを呼び出し、そこからRepository経由で先程紹介したAPIGateway.send(_:)を呼び出す方式をとっていました。
最終的に呼び出すメソッドをAPIGateway.asyncSend(_:)にすることでasync/await化を実現します。
HogeRepository
internal func fetchHoge(param1: Int, param2: Int) async throws -> Hoge { let hogeRequest = HogeRequest(param1: param1, param2: param2) try await apiGateway.asyncSend(request) }
FugaUseCase
internal func fetchHoge(param1: Int, param2: Int) async throws -> Hoge { try await hogeRepository.asyncSend(param1: param1, param2: param2) }
FugaViewModel
@MainActor internal final class FugaViewModel { private let useCase: FugaUseCase private let router: FugaRouter // 画面遷移に使う // これをViewControllerで監視しています private let hogeSubject = CurrentValueSubject<Hoge?, Never>(nil) // 略 internal func fetchHoge(param1: Int, param2: Int) { Task { do { let hoge = try await useCase.asyncSend(param1: param1, param2: param2) hogeSubject.send(hoge) } catch { // エラー表示など } } } }
こんな感じで先程のAPIを呼び出し、エラー処理もシンプルに記述することが出来ました。
ここでの注意はViewModelクラス全体に対して@MainActor
をつけることです(詳細は後述)。
小ネタ: 既存のCombineで実装されたメソッドをawaitしたいとき
async/awaitの利用を拡大していく中で、多くの画面で利用されているAPIなどで「現時点ではasync化するのはつらいけど使う側ではawaitしたい」という場面が発生しました。Repositoryを先程のようにasync化してしまうと、いくつもの画面の実装を同時に変更する必要が発生してしまい、一つの変更が巨大になってしまいます。それを避けて、一つの画面内で完結する変更でasync/awaitを利用したいと思いました。
その要望を下記のように実現しました。
internal protocol PublisherAwaitable: AnyObject { var subscriptions: Set<AnyCancellable> { get set } } extension PublisherAwaitable { @MainActor internal func asyncFirst<T>(from publisher: AnyPublisher<T, Never>) async -> T { await withCheckedContinuation { continuation in publisher .first() .sink { output in continuation.resume(returning: output) } .store(in: &subscriptions) } } @MainActor internal func asyncThrowingFirst<T, ErrorType: Error>(from publisher: AnyPublisher<T, ErrorType>) async throws -> T { try await withCheckedThrowingContinuation { continuation in publisher .first() .sink( receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): continuation.resume(with: .failure(error)) } }, receiveValue: { output in continuation.resume(with: .success(output)) } ) .store(in: &subscriptions) } } }
内部でAnyPublisherを.first()付きで購読し、結果をasyncで返すようにしています。
使う側のコード
final class HogeUseCase { func fetchData() -> AnyPublisher<Data, GatewayError> { repository.fetchData() } } @MainActor final class HogeViewModel: PublisherAwaitable { private let useCase: HogeUseCase internal var subscriptions = Set<AnyCancellable>() func fetchData() { do { let data = try await asyncThrowingFirst(from: useCase.fetchData()) self.dataSubjerct.send(data) } catch { // エラー表示など } } }
このようにAnyPublisherをasync化してawaitすることができるようになりました。
この方法の注意点として、行動するAnyPublisherは必ず結果が帰ってくることが保証されていなければなりません。結果が帰ってこない場合、awaitから返ってこなくなりTaskの処理が止まります。その結果、selfがキャプチャされたまま開放されなくなりメモリリークの原因にもなります。
Swift 6相当のチェックをする
プロジェクトのBuild Settings - Other Swift Flagsに -Xfrontend -warn-concurrency
を追加することでSwift 6でエラーとなる書き方に警告を発生させることができます。ここで警告される内容はバグを生み出す書き方な可能性もありますし、知らずに対応を進めてしまうと後々対応することが困難となってしまうため、所々でチェックするようにしました。
その結果「EntityがSendableではないのに非同期処理で利用されている」という問題が発覚しました。
auサービスTodayではEntity(APIから返ってきた値などを格納するstruct)を別のFrameworkに分けています。別Framework以外であればSendableな構造体は自動的にSendable扱いとなるのですが、別Frameworkだと明示的にSendableの指定が必要ということがわかりました。 そのため、すべてのEntityに明示的にSendableを指定することとしました。
async/awaitを使う最低限の基本方針の策定
ここまでの経験で下記のような方針を作りました
* Swift Concurrencyを使うViewModelとRouterにはクラス定義全体に`@MainActor`を必ずつける * Task内での意図せぬデータ競合や、Main Thread以外からのUI操作を防止する目的 * API通信のような「一度だけ結果が返る処理」にはCombineではなくasync(またはasync throws)として実装する * イベント監視のような何度も結果が返ってくるような処理はひとまずCombineで実現 * CurrentValueSubjectのような動作はおそらく[AsyncSequence](https://developer.apple.com/documentation/swift/asyncsequence)での実現は難しいと考えた(未検証) * (外部Frameworkに定義している)Entityには必ずSendableをつける
ここまでで、「async/awaitを使いたい」という当初の目的は達成です!
UseCase以降の処理はもともとMain Threadで行っていたため、今回の変更でUseCase以降の処理に対する悪影響もないはずです。
async/awaitを既存プロジェクトでスモールスタートするにはこれくらいの規模から導入していくのが良いと考えています。
注意ポイント
今回紹介した実装を行う中で、注意しないとバグを生み出すかもしれないポイントが有りましたので紹介します。
async/await、Taskを使う場合には必ずMainActorやActorを使う
ActorやMainActorの指定がない処理の中で単純にTask.initを書いたときにはその処理はどのスレッドで実行されるかは保証されないようです。またasyncメソッドの実装によってはawaitを抜けたあとの処理は別のスレッドで以降の処理が行われます。そのためTask内の処理が再入可能になっていない場合、データ競合が発生するリスクがあると考えました。
final class ViewModel { func fetchData() { Task { isLoading = true do { let data = try await.useCase.fetchData() isLoading = false self.dataSubject.send(data) } catch { isLoading = false // エラー表示処理 } } } }
例えば、上記のTask内の処理はどのスレッドで実行されるのかわかりませんしawait以降の処理は別のスレッドで実行される可能性もあり、安全かの判断が難しいです。
データ競合を防ぐためにはTask { @MainActor in }
のようにするか、ViewModelをActorにする方法も考えられますが既存処理ではUseCase以降もメインスレッドで実行すること前提に実装されているため、現時点でViewModelをactorにすると意図せぬ不具合が発生する可能性が高いです。またViewModelは全体的にViewControllerと密接に連携してUI周りの処理をするという性質もありますので、クラス全体に@MainActor
を指定するのが良いと判断しました。
@MainActor final class ViewModel { func fetchData() { Task { isLoading = true do { let data = try await.useCase.fetchData() isLoading = false self.dataSubject.send(data) } catch { isLoading = false // エラー表示処理 } } } }
このようにすることで、Task内の処理がすべてMainThread(MainActor)で実行されるようになり、安全性に関しての配慮が容易となりました。
CombineのAnyPublisherなどを監視したときの処理のスレッドは保証されない
@MainActor
をつけるとすべての処理がMain Threadで処理されることが保証されたように思えますが実際にはそうではないようです。
例えばAnyPublisherの値をsinkで監視したときのハンドラは実行されるスレッドが保証されていないようです。
@MainActor final class HogeViewModel { let router: HogeRouter func subscribeTappedButton() { viewControllerEvent.tappedOkButton .sink { [weak self] in self?.router.showNextScreen() } .store(in: &subscriptions) } }
上記の処理だとself?.router.showNextScreen()
がMainThreadで実行されることは保証されないようです。動作確認したところイベント送信元のスレッドで実行されていました。その状態でありながら、MainActor外からrouterにアクセスしているということを示す警告やエラーも発生しません。
@MainActor final class HogeViewModel { let router: HogeRouter func subscribeTappedButton() { viewControllerEvent.tappedOkButton .sink { [weak self] in guard let self = self else { return } Task { self.handleTappedOkButton() } } .store(in: &subscriptions) } private func handleTappedOkButton() { router.showNextScreen() } }
上記のようにsink内でTaskを作り、その中で処理をすると安全に期待通りのスレッドで実行できると考えます。
まとめ
async/awaitを使うには、比較的簡単に、しかも部分的に書いていくことができるということを紹介しました。 しかしいくつかの注意点があり、なにも気にせずに書くとバグを生み出す恐れもあるものでしたのでそのポイントや対応策を紹介しました。
MainThreadでいろいろな処理をすることが前提になりやすいiOSアプリ開発において、 Actorを使うには慎重さ(影響範囲全部が別スレッドになっても問題ないような対応)が求められますが@MainActorやasync/awaitは部分的に導入していきやすいと思います。