Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Swift Concurrencyを利用した表示再開するUIViewControllerの実装

こんにちは、クライアントエンジニアの竹澤(@to4iki)です。

iOSチームでは下記記事にある通り、チーム内でSwift Concurrencyに関してキャッチアップと知見の共有会を行っています。 本ブログでは、勉強会を通し既存実装をSwift Concurrencyを利用することで安全に簡潔に書き換えることができそうな箇所があったので、その実装例と、並行処理を表す Task に関して興味深い点を紹介します。

tech.mirrativ.stream tech.mirrativ.stream

置き換え対象の実装

任意の1トリガーに対して、複数の画面を表示するケースを考えてみます。

Mirrativアプリの場合、配信者が視聴者から受け取るギフトに応じて複数のランキングが上昇し、演出用の画面を表示するといった仕様があります。 以下コードのように、上から順に画面を表示する必要があるかを検証し、表示したら return 。画面を閉じるタイミングで演出用の画面を表示するメソッドを再実行する再起的な呼び出しを行っています。

// trigger
func presentRankupViewControllersIfNeeded(gift: Gift) {
    if Aランキングのランクアップ {
        let rankingRankupVC_1 = RankingRankupViewController(gift: gift)
        rankingRankupVC_1.delegate = self
        present(rankingRankupVC_1, animated: true)
        return
    }
    if Bランキングのランクアップ {
        let rankingRankupVC_2 = RankingRankupViewController(gift: gift)
        rankingRankupVC_2.delegate = self
        present(rankingRankupVC_2, animated: true)
        return
    }
}

// deleagteメソッド
func didDismiss(_ viewController: RankingRankupViewController, gift: Gift) {
    presentRankupViewControllersIfNeeded(gift: gift)
}

従来実装の課題

従来の実装だと、画面が「閉じられた」というdeleagteメソッドから表示用のメソッド呼び出しを強制出来ず、漏れが発生する可能性があります。 また、手続的なコードとなっており物理的な実装との距離が離れ処理の流れを追うのが難しいという欠点もあります。

仮に、キューイング相当の表示制御のViewModelやStoreクラスを用意しても、現在開いているかどうかの状態を意識する必要があり煩雑な実装となりそうです。

置き換え後の実装

従来実装の課題であった処理の流れを追いづらい点と後続処理の呼び出し漏れを解消するため、Swift Concurrencyを利用し、画面が表示されている間は後続の処理を中断するような実装をステップ毎に紹介します。

1. 画面が閉じられるまで待機する画面を示すprotocolの定義と実装

まずは中断可能な画面を示すprotocolを定義し、didDisappearHandler プロパティでdelegateメソッドの実装者(呼び出し側)が画面非表示のタイミングに介入できるようにします。

@MainActor
protocol SuspendableViewControllerProtocol: AnyObject {
    var didDisappearHandler: (() -> Void)? { get set }
}

typealias SuspendableViewController = UIViewController & SuspendableViewControllerProtocol
final class RankingRankupViewController: UIHostingController<CoverView>, SuspendableViewControllerProtocol {
    var didDisappearHandler: (() -> Void)?

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        didDisappearHandler?()
    }
}

2. 中断を実現する並行処理の実装

画面表示~画面非表示の間の待機状態のContinuation(継続)を表現したasyncメソッドを定義します。 withCheckedContinuation を利用し既存のdelegateメソッドを並行処理に置き換えています。

ここでは、present(_:animated:completion) の後続の行のawaitで処理が中断され、画面が閉じられるタイミング didDisappearHandlercontinuation.resume を呼び出すことで処理を再開させます。

extension UIViewController {
    func presentAsync(_ viewController: SuspendableViewController, animated: Bool, completion: (() -> Void)? = nil) async {
        if Task.isCancelled { // キャンセルされていた場合は後続処理を行わない
            return
        }
        present(viewController, animated: animated, completion: completion)
        await withCheckedContinuation { continuation in
            viewController.didDisappearHandler = {
                continuation.resume()
            }
        }
    }
}

グローバルな通信エラーが発生した場合を考慮し後続の画面表示のキャンセル対応を考えてみます。

自前のasyncメソッドは Task の生存期間の管理を自動で行う TaskGroup を利用せず、Task.init を介しsyncコードからasyncメソッドを呼び出すことになります。 その場合、キャンセルのハンドリングは Task の実行側と呼び出されるasyncメソッドの両方で行う必要があります。*1

今回の実装例では、呼び出し側で task.cancel() を実行するケースを想定し、asyncメソッド側では Task.isCancelled の状態をチェックし、並行処理の呼び出しがキャンセル済みの場合は後続の処理を無視するようにします。*2

3. 呼び出し側の実装

前述の通りsyncコードからasyncメソッドを呼び出すには Task.init を利用します。

また、キャンセル時の考慮のため Task インスタンスを保持し、複数画面表示のメソッド内では順に presentAsync(:animated:completion) メソッドを呼び出します。 これで、1つ目の画面が表示されている間は後続処理が中断状態となり画面が閉じられたタイミングで処理が再開されます。

従来実装の課題であった処理の流れが追いづらさが軽減され、同期コードのように直列に呼び出す直感的な実装となりました。

private var task: Task<Void, Never>?

override func viewDidLoad() {
    super.viewDidLoad()
    ...
    // グローバルなエラーが発生したら現在実行中のTaskをキャンセルする
    task?.cancel()
}

func presentRankupViewControllersIfNeeded(gift: Gift) {
    self.task = Task {
        if Aランキングのランクアップ {
            let rankingRankupVC_1 = RankingRankupViewController(gift: Gift)
            await presentAsync(rankingRankupVC_1, animated: true)
        }
        if Bランキングのランクアップ {
            let rankingRankupVC_2 = RankingRankupViewController(gift: Gift)
            await presentAsync(rankingRankupVC_2, animated: true)
        }
    }
}

Task.init のクロージャー引数内のself参照に関して

自前のasyncメソッドの呼び出しに利用する Task.init に関して深掘りします。

参照型のクラスのプロパティにクロージャーを割り当てる場合やクロージャー引数を受け取るメソッドで、クロージャの内部で self を呼び出すと強参照のサイクルが発生します。 メモリリークが起きないよう = 参照カウンタが0になるよう、キャプチャリストを使って self への参照を弱参照にする場合があると思いますが、非同期処理のワーカーでもある Task.init には [weak self] のような弱参照の設定は必要なのでしょうか。

Task.init の実装を見てみるとクロージャー引数に @_implicitSelfCapture というが属性が付与されています。

https://github.com/apple/swift/blob/main/stdlib/public/Concurrency/Task.swift#L489-L494

ドキュメントによると、@_implicitselfcapture を付与することで self が参照型であっても明示的にキャプチャすることなく、クロージャ内の self にアクセスできるとの事です。

https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_implicitselfcapture

class C {
  func f() {}
  func g(_: @escaping () -> Void) {
    g({ f() }) // error: call to method 'f' in closure requires explicit use of 'self'
  }
  func h(@_implicitSelfCapture _: @escaping () -> Void) {
    h({ f() }) // ok
  }
}

また、プロポーザルを見ると implicit "self" と命名をしており、Task に渡されたクロージャの場合は処理はすぐに実行され、selfの参照も本文内で完結するため self は不要と説明がされています。つまり Task.init はメモリリークを起こさないということが約束されています。

swift-evolution/0304-structured-concurrency.md at main · apple/swift-evolution · GitHub

注意点として、詳細は脚注のリンクを参照頂きたいのですが、Task.init 自体はメモリリークを起こさないが内部の実装によってメモリリークにつながるケースがあるようです。*3

まとめ

既存のdelegateベースの処理フローをSwift Concurrencyを利用し同期コードのように上から順にプログラマが処理を読めるよう簡潔に記述することが出来ました。

クロージャーベースのコールバックな非同期処理やdelegateを介した処理をasyncメソッドに変換し、Task.init を利用した呼び出しを行う場合は、中断処理とエラーハンドリングに関してプロダクト要件に従い適切な手段を随時判断していく必要があると思います。

最後になりますが、本ブログの補足として、スライドとサンプルコードを見てもらえると嬉しいです!

speakerdeck.com github.com

参考資料

We are hiring!

ミラティブでは一緒にアプリを作ってくれる iOS エンジニアを募集しています!
少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。 www.mirrativ.co.jp

エンジニア向け会社紹介資料

speakerdeck.com