Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Studyplus iOSアプリにasync/awaitを導入してみた

こんにちは、Studyplus事業部モバイルクライアントグループの上原です。 中途入社でiOSエンジニアとして入社して、StudyplusのiOSアプリの開発を主にしています。 また、最近はiOS以外にもFlutterを触り新機能を開発したりしています。

趣味の方では、Apex Legendsを数年やっているのですが、最近愛用していた武器が弱体化&武器生成必須になりモチベーションがどんどん低くなっています。新しい複数人でやれて人口の多いゲームの発売を切実に期待しています。

さて、今回は、Swift 5.5から導入されたasync/awaitをStudyplusのiOSアプリに一部導入したことについて書きます。

docs.swift.org

async/await導入以前の非同期コード

iOSでは、ネットワーク処理などの時間がかかる処理で、非同期的に動作させるためにクロージャーを呼び出す必要がありました。 下記のようにサーバーから画像を取得し、取得後にcompletionを呼んで後処理をする形が、アプリの様々な所で利用されています。

func fetchImage(url: String, completion: @escaping (UIImage?) -> Void) {
    let request = sampleImageRequest(url: url)
    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
        completion(convertImage(data: data))
    }
    task.resume()
}

しかしこの書き方では、クロージャーの内部で別の非同期処理を呼ぶなどの実装をした時に、コードの見通しが悪くなります。 見通しが悪いコードは、クロージャーの呼び忘れをするなどのミスを起こしやすくなります。 async/awaitを利用するとこの問題が解消されます。

async/await導入後のコード

async/awaitを利用したコードは、下記になります。 async/await導入以前は、クロージャーで処理が終わった際に値を渡していましたが導入後はメソッドが値を返すようになります。 これによってクロージャーの呼び忘れがなくなり、値が返却されない場合は型チェックが失敗するためコンパイルエラーになるのでより安全にコードが書けるようになります。 また、他の非同期コードもtry awaitで順番に実行させることもできネストが深くなりにくくなります。

func fetchImage() async throws -> UIImage {
    let request = sampleImageRequest(url: url)
    let (data, _) = try await URLSession.data(for: request)
    return convertImage(data: data)
}

もっと知りたい方は、WWDC2021のMeet async/await in Swiftをご覧ください。

developer.apple.com

どの部分に導入したのか

async/awaitを導入していく上で、既存の非同期コードを置き換えていくので影響箇所が非常に大きくなります。 大体のアプリでは、ネットワーク処理を様々な箇所で呼んでおりその部分を全体的に変えなければいけません。 そのためStudyplusのアプリでは、初めに通信処理の基盤となっているクラスやロジックをasync/awaitに対応させ、その後、各機能を少しづつasync/awaitに対応させていく方針をとっています。

まず最初に、アプリのメインターゲットに影響しない部分でWidgetを利用している機能にasync/awaitを導入していきました。 ただ、Widget部分はまだIntentTimelineProviderがasync/awaitに対応していません。 そのため、準拠するメソッド自体はクロージャーを利用することになり、完全にasync/awaitには対応できませんでした。 しかし、ネットワーク処理周りやエラー回りの分岐などをスッキリでき可読性が向上しました。

導入していく上での注意点

iOS 15未満の端末でのクラッシュが起きる可能性がある

iOS 14端末でasync/awaitに置き換えたコードを実行したところクラッシュが発生しました。(実行環境 Xcode 13.2.1)

原因を調査していたのですが、async/awaitの置き換えを間違えたなどは特になく、async/awaitを利用するためのTask {}が呼ばれた段階でクラッシュします。 対応が難しいため、Studyplus iOSではXcodeのアップデートで修正されるのを待っています。 Swiftのforumsにも同じような現象の報告がされており回避策や根本への対応なども行われているので気長に待とうと思います。

forums.swift.org

iOS 15未満への後方互換が不足

async/awaitは本来iOS 15からの機能を想定して作られているので、iOS 15未満でのコードが用意されていない場合があります。 具体的に、導入していく上で用意されておらず困ったのは、URLSessionのdataメソッドです。 利用したい場合は、自前でasync/await用のメソッドを作る必要があります。 Studyplusでは、iOS 15以上はURLSessionのdataメソッドを利用することにしました。 iOS 15未満ではwithCheckedThrowingContinuationを利用して、async/awaitを利用するコードをextensionに定義し、利用しています。

    @available(iOS, introduced: 13.0, deprecated: 15.0, message: "iOS 14のサポート終了次第削除してください")
    func data(from request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (data: Data?, response: URLResponse?) {
        if #available(iOS 15.0, *) {
            return try await base.data(for: request)
        } else {
            return try await withCheckedThrowingContinuation { continuation in
                let task = base.dataTask(with: request) { data, response, error in
                    if let error = error {
                        continuation.resume(throwing: error)
                    } else {
                        continuation.resume(returning: (data, response))
                    }
                }
                task.resume()
            }
        }
    }

withCheckedContinuationとwithCheckedThrowingContinuation

先ほどiOS 15未満で利用していると紹介したwithCheckedThrowingContinuationは何なのかを説明します。 多くの会社では、最低iOSバージョンがiOS 13か14になっており、async/awaitを利用したコードを書いていく際に既存コードを大きく書き換えることになります。 しかし影響が大きく一部分だけを変えていきたい際や暫定的にasync/awaitに対応したい時などに利用できるのがwithCheckedContinuationwithCheckedThrowingContinuationです。

fetchImageをasync/awaitを利用したものにすることを想定した際、以下の手順で実装します。

  • async/await用のUIImageを返すメソッドを作成
  • return try withCheckedThrowingContinuation内でクロージャーを利用したfetchImageを呼ぶ
  • fetchImageのcompletion内でcontinuation.resumeにデータを渡す

これを利用することによってfetchImageの内部コードを変更せずasync/awaitを利用する箇所で動かせます。

func fetchImage(url: String, completion: @escaping (UIImage?) -> Void) {
    let request = sampleImageRequest(url: url)
    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
        completion(convertImage(data: data))
    }
    task.resume()
}

func fetchImage(url: String) -> async throws UIImage? {
    return try await withCheckedThrowingContinuation { continuation in
        fetchImage(url: url) { image in
            // エラーの処理は continuation.resume(throwing: error)
            continuation.resume(returning: image)
        }
    }
}

上記例では、エラーの処理を説明するために、withCheckedThrowingContinuationを利用していますが、withCheckedContinuationを利用するとエラー処理が必要無くシンプルです。

まとめ

async/awaitを利用すると可読性もあがり既存のような処理の呼び忘れが無くなりコードをよくできます。 しかし、async/awaitの処理は奥が深くデータの競合やTaskのキャンセルなど非同期処理特有の問題などは良く考える必要があります。 まだ複雑な処理の場所には導入できていないので、導入の際には嵌ることもあるかと思いますが、その際にはブログに書いて知見の共有をしていきます。 async/awaitで良いSwiftライフを送りましょう!

追記

withCheckedContinuationとwithCheckedThrowingContinuationでご案内したasyncなfetchImageメソッドの返り値がOptionalなUIImageになっていませんでした。

ご指摘の通り、Optionalなため修正いたしました。ご指摘ありがとうございました!!