Android クックパッドアプリの画面遷移実装

Androidエンジニアのこやまカニ大好きです。

10/19 に弊社で開催した After Party DroidKaigi 2022 というイベントで、クックパッドアプリの画面遷移について発表しました。 当日のセッションでは時間が限られていたりスライドでのコード表示の制約から実装面の説明をかなり省略していたので、この記事で補足しつつ説明しようと思います。

クックパッドアプリの構成

実装の内容に入る前に、前提としてクックパッドアプリのプロジェクト構造を説明していきます。

モジュール構成

これまでの技術ブログでも何度か説明してきた通り、クックパッドアプリは2018年ごろからマルチモジュールプロジェクトになっています。

  • 画面実装は View(Compose)と関連ロジックを Scene という単位で構成
  • 関連のある複数 Scene で機能モジュール(feature module) を構成
  • 共通ロジックやドメイン層は役割ごとにモジュール分割(library module)

全体的なモジュール構造は以下のようになっています。 feature module から library module への参照はありますが、feature module 同士に参照はありません。 (この図は過去の技術記事から持ってきたため、 legacy モジュールが重要そうな感じで見えていますが今回の内容には関係ありません)

画面遷移実装

クックパッドアプリでは以下の理由から Navigation Component を利用していません。

  • Navigation Component が出る前からマルチモジュール化されていて登場当時導入が難しかった
  • クックパッドアプリの画面遷移が複雑で Navigation Component を利用できる箇所が限られていた
  • 特にボトムタブに Multiple Back Stacks 相当の実装を自前で入れていたので乗り換えが難しい状況だった

今回紹介するような実装の工夫によってクックパッドアプリの画面遷移処理が簡単になったこと、Navigation Component 自体が進歩してきていることから今後はクックパッドアプリでもNavigation Component の採用を検討できる状態になってきましたが、今回の発表時点では Navigation Component 関連の内容はありません。

クックパッドアプリの画面遷移実装

モジュール間画面遷移の基本

例として、 A module の画面A から B module の画面Bに遷移する場合を考えます。 この時、素直に 画面A から 画面B の Fragment を生成しようとすると A module から B module を参照する形になります。 もし、画面Aと画面Bが相互に行き来できる場合はどうでしょうか。 A module と B module は相互参照になってしまうため、モジュール間の依存関係を設定できなくなります。

クックパッドアプリでは、別モジュールの画面を生成するために navigation module に AppDestinationFactory というインターフェイスを定義し、全ての画面は AppDestinationFactory を経由して遷移先の画面インスタンス(Fragment,Intent)を生成するようにしています。 この構造だと各 feature module は navigation module への参照を持つだけで画面遷移を実装できます。 AppDestinationFactory の実体は全てのモジュールへの参照を持つ、 application module で定義しています。

この構造はクックパッドアプリ特有というわけではなく、マルチモジュールプロジェクトで画面遷移を navigation component を使わずに実装しているアプリはほとんどが類似の方法で画面遷移を実装していると思います。

ボトムタブごとに画面遷移の履歴(Back Stacks) を切り替えられるようにする仕組み

クックパッドアプリはボトムタブで大まかな機能を切り替える設計になっています。 このボトムタブを切り替えた際、タブごとに画面遷移の履歴(Back Stacks)を保存して切り替える実装を入れています。 最近の FragmentManager 、 Navigation Component では Multiple Back Stacks という名前で類似の実装が含まれていますが、クックパッドアプリでは PrimaryNavigationFragment という仕組みを使い、数年前から独自に実装しています。

PrimaryNavigationFragment の説明はかなり難しいのですが、大体以下のような仕組みだと考えてください。

  • Activity と画面 Fragment の間に存在する FragmentManager 制御のための Fragment
    • NavHostFragment のようなもの…というか NavHostFragmentPrimaryNavigationFragment を使って実装されている
  • ActivitysupportFragmentManager を使う代わりに PrimaryNavigationFragmentchildFragmentManager を利用して画面遷移を実装することで back などの挙動はそのままに backstack の管理ができるレイヤーを作れる
  • タブ切り替え時に PrimaryNavigationFragment を attach/detach して切り替えることで PrimaryNavigationFragmentchildFragmentManager が持つ backstack ごとタブ表示を切り替えられる

全然わかりませんね。 ものすごく大雑把にいうと、 requireActivity().supportFragmentManager の代わりに requireActivity().supportFragmentManager.let { it.primaryNavigationFragment?.childFragmentManager ?: it } を使って画面遷移するといい感じにできるということです。 この実装を作った時に個人ブログに説明記事を書いたので、もし興味がある人がいれば参考にしてみてください。

画面遷移の処理で毎回 requireActivity().supportFragmentManager.let { it.primaryNavigationFragment?.childFragmentManager ?: it } という記述を書いていられないので、 FragmentManager を適切に選択してくれる NavigationController という仕組みを導入しました。

NavigationController はアプリ内の画面遷移における共通処理を吸収するレイヤーで、以下のような機能を持っています。

  • 処理対象の FragmentManager の選択
  • replace 先の containerViewId 指定
  • backstack の管理
class NavigationController internal constructor(  
    val context: Context,  
    val fragmentManager: FragmentManager,  
    val childFragmentManager: FragmentManager,  
    private val activityResultCaller: ActivityResultCaller,  
) : ActivityResultCaller by activityResultCaller,  
    FragmentResultOwner by fragmentManager {
    ...

    @JvmOverloads  
    fun navigateFragment(  
        fragment: Fragment,  
        fragmentTransition: Int = FragmentTransaction.TRANSIT_FRAGMENT_OPEN
    ) {  
        fragmentManager.commit {  
            replace(DEFAULT_CONTAINER_ID, fragment, null)  
            setTransition(fragmentTransition)  
            addToBackStack(null)  
        }  
    }

    ...
}

private val FragmentManager.primaryNavigationFragmentManager: FragmentManager  
    get() {  
        val fragment = primaryNavigationFragment  
        return when (fragment?.isAdded) {  
            true -> fragment.childFragmentManager  
            else -> this  
        }  
    }  
  
private val FragmentActivity.primaryNavigationFragmentManager: FragmentManager  
    get() = supportFragmentManager.primaryNavigationFragmentManager  
  
val Fragment.navigationController: NavigationController  
    get() {
        return NavigationController(  
            context = requireContext(),  
            fragmentManager = requireActivity().primaryNavigationFragmentManager,  
            childFragmentManager = childFragmentManager,  
            activityResultCaller = this,
        )  
    }  
val FragmentActivity.navigationController: NavigationController  
    get() {  
        return NavigationController(  
            context = this,  
            fragmentManager = primaryNavigationFragmentManager,  
            childFragmentManager = primaryNavigationFragmentManager,  
            activityResultCaller = this,
        )  
    }

NavigationController を利用する前の画面処理は大体以下のような実装でした。

val destinationFragment = destinationFactory.createFragment()
val fragmentManager = fragment.requireActivity().supportFragmentManager.let { it.primaryNavigationFragment?.childFragmentManager ?: it }  
fragmentManager.commit {  
    replace(containerId, fragment, null)  
}

NavigationController を利用することで以下のようにシンプルな処理にできます。

val destinationFragment = fragmentFactory.createFragment()
fragment.navigationController.navigateFragment(destinationFragment)

NavigationController により、画面遷移がよりシンプルに実装できるようになったことがわかると思います。

条件によって遷移先が Fragment だったり Activity だったりする画面遷移の実装

クックパッドアプリにはユーザー状態などによって遷移先が変わるアクションが存在します。 極端な例ではログイン済みの場合は目的の画面(Fragment)、未ログインの場合はログイン画面(Activity)という複雑なパターンもあります。 この遷移先切り替え問題の解決のため、 Destination という仕組みを導入しました。

Destination は遷移先の実体を隠蔽するための仕組みで、以下のような特徴を持ちます。

  • sealed interface である DestinationFragment, Intent をラップする実体クラスという構成
  • NavigationControllerDestination への 画面遷移をサポートすることで各画面では遷移先が Fragment なのか Activity なのか気にせず実装できる
    • Result API などで処理結果を受け取るケースでは考慮する必要がある

Destination の中身

sealed class Destination {  
    class FragmentDestination internal constructor(val fragment: Fragment) : Destination()  
    class DialogFragmentDestination internal constructor(val dialogFragment: DialogFragment) : Destination()  
    class ActivityDestination internal constructor(val intent: Intent) : Destination()  
}  
  
fun Fragment.toDestination(): Destination =  
    when (this) {  
        is DialogFragment -> Destination.DialogFragmentDestination(this)  
        else -> Destination.FragmentDestination(this)  
    }  
  
fun Intent.toDestination(): Destination = Destination.ActivityDestination(this)

NavigationController では Destination の実装クラスごとに navigation 処理の呼び分けをしているだけです。

class NavigationController internal constructor(
    ...
    @JvmOverloads  
    fun navigate(destination: Destination) {  
        when (destination) {  
            is Destination.FragmentDestination -> navigateFragment(destination.fragment)  
            is Destination.DialogFragmentDestination -> navigateDialogFragment(destination.dialogFragment)  
            is Destination.ActivityDestination -> navigateActivity(destination.intent)  
        }  
    }
    ...
}

このように、 NavigationControllerDestination の導入により各画面は遷移先の画面が Fragment なのか Activity なのか気にせず画面遷移処理を書けるように成りました。

URL から画面遷移する処理を実装する

URL から画面遷移する処理というのは、いわゆるディープリンク処理と呼ばれているものです。 URLによって遷移先は Fragment だったり Activity だったりする他、外部からのアプリ起動、WebView内での遷移、APIレスポンスからのアクションなど様々な箇所で同じ挙動を実現したいという要求があります。 この要求を解決するため、 DestinationParams という仕組みを導入しました。

  • DestinaionParamsDestination を生成するための情報をまとめた sealed interface
  • URLから遷移先(Destination)の種別と必要なパラメータを抽出し、パラメータとして保持する
  • Destination 実装は sealed interface なため、when による分岐処理でパターン網羅しやすい

DestinationParams の実装

DestinationParams の定義は基本となる DestinationParams sealed interface と、それを実装する各 data class 、 object から成ります。

sealed interface DestinationParams : Parcelable {
    @Parcelize  
    data class Web(val url: String) : DestinationParams
    
    @Parcelize  
    data class RecipeDetail(val recipeId: Long) : DestinationParams

    @Parcelize  
    object MyKitchen : DestinationParams

    ...(似た実装がいっぱいある)
}

DestinationParams の利用方法

Uri.toDestinationParams() という拡張関数を定義し、その中でURLを解析して適切な DestinationParams (または null)に変換する処理を書いています。 この仕組みにより、ほとんどのケースで以下のような実装だけでディープリンクが動作するようになりました。

// URI から DestinationParams を生成
val destinationParams = uri.toDestinationParams(serverSettings) ?: return
// DestinationParams から Destination を生成
val destination = appDestinationFactory.createDestination(context, destinationParams) ?: return
// 画面遷移処理
fragment.navigationController.navigate(destination)

URI -> DestinationParams の変換、 DestinationParams -> Destination の変換が別処理になっているため、それぞれのレイヤーで必要なテストが書けるようになっているのが特徴です。 この仕組みの導入により、クックパッドアプリでディープリンクとして扱うURLは URL 〜 遷移対象の画面の生成まですべてテストが書かれている状態にできました。

DestinationParams の機能ごとのタグづけ

Kotlin ではひとつのクラス/オブジェクトが複数の sealed interface を継承することができます。 この仕組みを使い、各 DestinationParams の実装クラスがサポートする機能に対応する sealed interface を作って継承するようにしています。

以下の例では、 RecipeDetailという DestinationParams が外部からのディープリンクによるアプリ起動とアプリ内でのディープリンクによる画面遷移、WebView内でのディープリンクによる画面遷移をサポートしていることがわかります。

このサブ sealed interface によるタグ付の導入により、アプリの起動処理やURIから DestinationParams の変換処理で何もしない when 分岐の数が減り、処理やテストが書きやすくなりました。

/**  
 * アプリ起動をサポートしている DestinationParams
 */
sealed interface AppLaunchSupportedDestinationParams : DestinationParams  
  
/**  
 * DeepLink からの変換をサポートしている DestinationParams
 */
sealed interface DeepLinkSupportedDestinationParams : DestinationParams  
 
/**  
 * WebView 内でのハンドリングをサポートしている DestinationParams
 */
sealed interface WebViewSupportedDestinationParams : DestinationParams  
  
sealed interface DestinationParams : Parcelable {
    @Parcelize  
    data class RecipeDetail(val recipeId: Long) :  
        DestinationParams,  
        AppLaunchSupportedDestinationParams,  
        DeepLinkSupportedDestinationParams,
        WebViewSupportedDestinationParams

...
}

DestinationParams による WebView の改善

2021年の DroidKaigi で発表した時点では DestinationParams は存在していませんでした。 そのため、 各WebView画面ではディープリンクからネイティブ画面の遷移は遷移先画面ごとに navigateXXX() のようなメソッドを個別に実装していました。 今年に入って DestinationParams が導入されたことにより、各 WebView 画面は WebViewSupportedDestinationParams を処理するメソッドだけ実装すれば良いようになりました。 以下の画像は WebView のディープリンク処理に WebViewSupportedDestinationParams を導入した時のPRの一部です。 大量のメソッドが消え、 WebViewSupportedDestinationParams を処理するメソッドに集約されているのがわかると思います。 WebViewSupportedDestinationParams も sealed interface なので、 when する処理を各 WebView 画面に書いておくだけで WebViewSupportedDestinationParams のパターンが増えた場合でも自動的にビルドエラーになってくれます。賢いですね。

まとめ

クックパッドアプリでは画面遷移処理を自作しており、画面遷移の高度な要求に対応するために様々な工夫を凝らしてきました。 最近の変更で画面遷移処理の抽象化が進み、アプリ起動時の処理やWebViewが圧倒的に開発しやすくなりました。 一方、今後の課題としては以下のような項目があると考えています。

  • Jetpack Navigation Component を利用していないこと
  • Navigation Component 事態は必須ではないと感じているが、長期的なメンテナンスコストを考えると公式が推している仕組みに近づけた方が良さそう
  • Navigation Component のマルチモジュールサポートはまだ改善が必要だと考えているので、しばらくは自作した仕組みを Navigation Component に寄せやすくする改良をしていくかも

クックパッドではユーザー体験を最高にするため、モバイルアプリの画面遷移を最高の状態にしていくために引き続き改善を続けていく予定です。 最高の画面遷移処理や最高の WebView に興味がある人はぜひお気軽にご連絡ください。

info.cookpad.com

Cookpad TechConf 2022 をパシフィコ横浜ノースで物理開催します!

こんにちは、CTO室の緑川です。 公式サイトでも連絡させていただきましたが、ちょうど1ヶ月後の11月25日(金)に技術カンファレンス『Cookpad TechConf 2022』をパシフィコ横浜ノースで開催します。カンファレンスではクックパッドのエンジニアやデザイナーをはじめ、多くの社員が日頃の業務と研究の末築き上げた技術的知見や経験を発表する予定です。このエントリーでは当日の見どころやトーク以外の企画について紹介いたします。

Cookpad TechConf 2022の概要

  • 名称:Cookpad TechConf 2022
  • 日時:2022年11月25日(金)13:00 - 18:00
  • 定員:200名(完全招待制)
  • 会場:パシフィコ横浜ノース 4F (〒220-0012 神奈川県横浜市西区みなとみらい1-1-2
  • トーク数:全10本(基調講演2本 + メイントーク8本)
  • 公式サイト:https://techconf.cookpad.com/2022/
  • ハッシュタグ:#CookpadTechConf

Cookpad TechConfとは

クックパッドでは「毎日の料理を楽しみにする」というミッションを掲げ、世界中における食と料理の課題をテクノロジーで解決するためにさまざまなプロジェクトに挑戦しています。 Cookpad TechConf はクックパッドの社員が課題に取り組む中で、どのようにサービスを生み出し、どのようにシステム開発をしているのか等、日頃の挑戦の中から得た技術的知見について発表します。

また、今回のCookpad TechConfでは新型コロナウイルス感染症への対策を万全にするために、完全招待制のカンファレンスとさせていただいています。

トークの紹介

トーク数はJapan CEOやCTOとデザイナー統括マネージャーによる基調講演2本とメイントーク8本の合計10本を予定しています。 レシピサービスの改善や機能の追加、新規事業クックパッドマートの取り組みや物流の最適化など、さまざまなトピックについて触れていきます。

また、今回のCookpad TechConfでは『ペア登壇』という仕組みを取り入れました。これはエンジニアとエンジニア、デザイナーとデザイナーというペアに加え、エンジニアとデザイナーといった職種を超えたペアも登壇予定です。1つのプロジェクトに対してエンジニアの視点とデザイナーの視点の両方から知見を得ることができます。

レシピサービスのプロダクト開発プロセスについて

登壇者:大石 英介 & 島 朋代
時間:13:30 - 13:50

巨大なレシピサービスのアーキテクチャを最高にしたい

登壇者:宮崎 広夢
時間:13:55 - 14:15

デザインシステムを使ってプロダクトのデザイン負債を解消する

登壇者:村山 賢太 & 見上 香桜里
時間:14:30 - 14:50

クックパッドマートが実現する新しい生鮮食品流通プラットフォーム

登壇者:勝間 亮 & 米田 哲丈
時間:14:55 - 15:15

生鮮食品をユーザーに届ける流通の仕組みと技術

登壇者:長 俊祐
時間:15:20 - 15:40

新規事業クックパッドマートを支える機械学習の技術

登壇者:深澤 祐援 & 山口 泰弘
時間:15:55 - 16:15

クックパッドが挑戦する「レシピ」と「かいもの」をつなぐ新しいサービス作り ~ 役割にとらわれず新しい価値に向き合い続けるチーム ~

登壇者:谷口 浩司 & 新妻 里夏
時間:16:20 - 16:40

Go Global Search 2

登壇者:Orgil Davaajargal
時間:16:45 - 17:05

その他の企画

Cookpad TechConf 2022ではメイントーク以外にもさまざまな企画を実施する予定です。簡単に紹介させていただきます。

Ask The Speaker

会場ではトーク後に登壇者へ質問できるAsk The Speakerを設けます。登壇者と物理開催ならではのコミュニケーションが行えますので、この機会に講演内容だけでなくさまざまなことをご質問ください。

ポスター展示

クックパッド内で使用している技術や事業に関するポスターを展示します。ポスター展示を行う社員からの概要としては以下の通りです。

  • 料理という事業ドメインならではの検索課題や、それらの課題を解決するシステムの全体像
  • Komerco のアーキテクチャや利用技術についての解説(Firebase の使い方など)
  • おりょうりえほんのサービス紹介ポスター
  • クックパッドマートで買える食材を、社内のメンバーがどのように楽しんでいるかを紹介します!
  • 意外と失敗する BtoBtoC プラットフォーム開発

LT(ライトニングトーク)

メイントークの後は約5分ほどのLTをメイン会場で行います。メイントークでは話せなかった奥が深い知見や特別な経験などLTならではの発表を行う予定です。

ライブコーディング

メイン会場とは別の会場で、エンジニアやデザイナーによるライブコーディングやライブデザインを行います。クックパッド社員による迫力満点の技術を体験できる貴重なイベントです。

懇親会

ご参加いただいた皆様と是非懇親会を行えればと思います。クロージングの基調講演終了後、会場からバスを手配し、野毛というエリアで懇親会を行う予定です。

Discordへの招待

11月に今回のイベントに向けたDiscordをオープンします。このDiscordでは社員とコミュニケーションができるほか、クックパッドが行う部活動の紹介や公開ミーティングなどのさまざまなイベントを行う予定です。クックパッドの文化や雰囲気を一緒に楽しんでいたければと思います。

まとめ

Cookpad TechConfは2019年以来の開催となります。交流会や勉強会など多くのイベントがオンライン開催となっている中での久しぶりの物理開催です。この2年半以上の時間で、クックパッドは組織全体で技術力の向上や新規プロジェクトへ果敢に取り組んできました。この成果をぜひ皆さまと共有できる場にできればと思います。


安全に開催できることを第一とし、その上で皆さまと楽しめるカンファレンスにできるよう万全な体制でお待ちしております!

最後に宣伝にはなりますが、クックパッドで働きたいエンジニアの方を募集しております。ぜひクックパッドのウェブサイトからご連絡ください!

info.cookpad.com

Swift Concurrencyでセマフォを作る

こんにちは、レシピサービス開発部と技術部兼務のヴァンサン(@vincentisambart)です。

Swift Concurrencyに関する中級の記事がまだ多くない気がしていたので、そういう記事を書くことにしました。

Swift Concurrencyの理解を深めたい人にはWWDC21の「Swift concurrency: Behind the scenes」がおすすめです。そのプレゼンの中でDispatchSemaphoreをSwift Concurrencyで使うべきではないと述べられました。

Preserving the runtime contract - Forward progress

Swift Concurrencyに提供されているツールを見ると、セマフォがありません。でも提供されたものでセマフォを作れないでしょうか?セマフォを使いたい場面が多いわけではありませんが、良い勉強になると思います。

どういうツールが標準で提供されているのでしょうか?safe(安全に使えるもの)はactorTaskTaskGroupasync letAsyncStreamCheckedContinuation、くらいですかね。

セマフォ自体の説明をすると長くなるので、下記はセマフォをある程度知っている前提で書いています。セマフォのメソッド名はセマフォの説明でたまに使われる分かりにくいPVではなく、DispatchSemaphoreも使っているwait()(待つ)とsignal()(合図を送る)を使います。

セマフォの値が0以下だったらwait()が次のsignal()まで待つ必要があるので、待たせる仕組み、もう待たなくて良いという合図を送る仕組み、が必要です。Swift Concurrencyのそれぞれのツールで実装できないか検討してみましょう。

actor

actor自体でできることを色々見ても、何かを待たせるすべはなさそうです(ビジーウェイトは論外)。

とはいえ、セマフォの状態を正しく保つには良いかもしれません。別のツールと合わせてなら役に立てるかもしれません。

TaskGroupasync let

async letのevolution proposalを見ると、TaskGroupと比較して紹介されていて、2つのユースケースが似ています:処理をいくつかの子タスクに分けて、最後に親タスクが結果をまとめます。async letは子タスクの数を動的に変えられないけどもっと使いやすい感じですかね。

特定のセマフォのwait()signal()を呼んでいるタスクが親子や兄弟であると限らないので、TaskGroupasync letをもっと詳しく調べなくても2つとも向いていなそうです。

AsyncStream

AsyncStreamの紹介事例は基本的に既存のSwift Concurrencyを使わないコードをSwift Concurrencyの世界に持ってきます。ゼロからSwift Concurrencyを使ってセマフォを作ろうとしているので、AsyncStreamは向いていないのでは?と思うかもしれませんが、もう少し見てみましょう。

  • AsyncStreamAsyncSequenceなので、非同期に値を順番に生み出します。次の値を入手するにはawaitを使う必要があるので、セマフォのwait()に近いかもしれません
  • AsyncStreamはクロージャーを渡して作成します:AsyncStream { continuation in ... }。クロージャーに渡されるAsyncStream.Continuationyield())がsignal()に少し似ているかもしれません

wait()signal()の実装に使えそうなものを見つけたので、本当に実装できるのかまずもう少しドキュメントを見てみましょう。

signal()に使えそうなAsyncSequence.Continution.yield()は並行で複数のタスクから呼んでも問題ないようです(この場合、値が取り出される順序が保証されませんが)。AsyncStream.init(_:bufferingPolicy:_:)のドキュメント)から抜粋:

The AsyncStream.Continuation received by the build closure is appropriate for use in concurrent contexts. It is thread safe to send and finish; all calls to the continuation are serialized. However, calling this from multiple concurrent contexts could result in out-of-order delivery.

ですが、残念ながら、wait()の実装はできないようです。並行で複数のタスクからAsyncStreamの次の値をawaitすることはできません。AsyncStream.Iteratorのドキュメントから抜粋:

This type doesn’t conform to Sendable. Don’t use it from multiple concurrent contexts. It is a programmer error to invoke next() from a concurrent context that constends with another such call, which results in a call to fatalError().

上記に書いてある時点で実際のコードで動いたとしても使うべきではありませんが、遊び感覚で試してみました。IteratorSendableでないためタスク間で共有できないが、並行で複数のタスクからvar iterator = stream.makeAsyncIterator(); await iterator.next()をしてみたら、記述の通りfatalError()が起きました。

_Concurrency/AsyncStreamBuffer.swift:253: Fatal error: attempt to await next() on more than one task

このため、今回のユースケースには向きません。とはいえ、AsyncStreamが既存のコードをSwift Concurrencyの世界に持っていくためだけのツールではないことを見られたと思います。

CheckedContinuation

CheckedContinuationとは

AsyncStream同様、CheckedContinuationは既存のコードをSwift Concurrencyの世界に持っていくためのツールとしてよく紹介されています。コールバックを使っているメソッドをawaitできるようにするためのツールですが、今回のユースケースで使えないでしょうか?

CheckedContinuationwithCheckedContinuation(function:_:))(エラーが発生することがあればwithCheckedThrowingContinuation(function:_:)))を使って作られます。

一番シンプルなユースケースは以下のようにhogeWithCallbackawaitできるようにすることです。

// `hogeWithCallback(_:)`を`hoge(_:)`と命名しても大丈夫です。
func hogeWithCallback(_ callback: () -> Void) { ... }
func hoge() async {
    await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
        hogeWithCallback {
            continuation.resume()
        }
    }
}

非同期のタスクがawait withCheckedContinuationに止まって、continuationを渡されたクロージャーが実行されて、hogeWithCallbackが呼ばれます。CheckedContinuationresumeメソッドが呼ばれたらawait withCheckedContinuationに止まっていたタスクが再開します。

セマフォはwait()側でawait withCheckedContinuationをして、signal()側でcontinuation.resume()を呼べたら実装できるかもしれません。同時に複数のタスクが待つ可能性があるので、1つのCheckedContinuationでは足りませんが、CheckedContinuationの配列を使えば良いでしょう。並行でさまざまなタスクからアクセスされても、セマフォの内部状態である配列と値の正当性を保つにはactorが向いていそうです。

セマフォの内部状態

では実装してみましょう。状態はとりあえず上記の説明にあった待機中のCheckedContinuationの配列とセマフォの値が必要です。

actor ContinuationSemaphore {
    // 待機中のタスクの`CheckedContinuation`です。
    // この`Void`はこの`CheckedContinuation`が値を返さないことを示します。
    // この`Never`はこの`CheckedContinuation`ではエラーが発生しないことを示します。
    private var waiters: [CheckedContinuation<Void, Never>] = []
    // セマフォの値です。
    // `value`が負であれば、`waiters.count == -value`が保証されます。
    private var value: Int

    init(value: Int) {
        // `DispatchSemaphore`同様、初期値が負であってはいけません。
        assert(value >= 0)
        self.value = value
    }

    // セマフォの正当性が保たれているのを確認するメソッドです。
    private func ensureValidState() {
        // セマフォの値が正であれば、待つタスクがあるべきではありません。
        // セマフォの値が負であれば、待つタスクの数が`-value`個であるべきです。
        assert((value >= 0 && waiters.isEmpty) || (waiters.count == -value))
    }

wait()

一番複雑なのがwait()の実装です。

コード自体が長いわけでもなく、シンプルにも見えますが、特別なことが起きています。

    func wait() async {
        value -= 1
        // 引き算後に値が0以上だったら待つ必要がありません。
        if value < 0 {
            await withCheckedContinuation { continuation in
                waiters.append(continuation)
                ensureValidState()
            }
        }
        ensureValidState()
    }

この実装は本当に大丈夫でしょうか?

awaitを使うたびに制御フローが別のタスクに移る可能性があります。wait()が呼ばれる時にvalue0で、waitersが空っぽだったのを仮定します。1を引かれたvalue-1になって、await withCheckedContinuationが呼ばれます。ここで制御フローが別のタスクに移るとしたら、別のタスクがセマフォを使えば、value-1なのにwaitersが空っぽなので不正状態ですよね…

また、withCheckedContinuationasync関数なのに、渡されるクロージャーが直接actorのプロパティwaitersを変更できているのは僕にとって少し不思議でした。

actor上でのクロージャーの制限を洗い出してみましょう。

actor上でのクロージャーの制限

特定のactorにとって、メソッドや関数はそのactorのisolatedな(孤立した)環境に属するかどうかで区別されます。このactorのisolatedな環境に属するメソッドや関数は以下の通りです:

  • actorの一部として実装されていて、nonisolatedでないもの
  actor MyActor {
    func anyMethod() {}
  }
  • actor外で実装されているが、actorを引数で受け取って、この引数にisolatedが明記されているもの
  actor MyActor {}
  func someFunction(myActor: isolated MyActor) {}

逆にactorのisolatedな環境に属さないメソッドや関数は以下の通りです:

  • actorの一部として実装されているがnonisolatedであるもの
  actor MyActor {
    nonisolated func anyMethod() {}
  }
  • actor外で実装されているが、このactorをisolated引数で受け取っていないもの
  actor MyActor {}
  func someFunction() {}

今回気になっているwithCheckedContinuationが特定のactorのisolatedな環境にも属しませんし、このwithCheckedContinuationがactorのisolatedな環境に属するメソッドから呼ばれてクロージャーを渡されるので、このユースケースに焦点を当てましょう。

現状SwiftのConcurrencyチェックがデフォルトで緩いので、Strict Concurrency Checkingを一番厳しい設定「Complete」にして色々試してみましょう。

Swift Compiler - Language - Strict Concurrency Checking

ひとまずは一番シンプルなケース、呼ばれる関数が普通である(asyncでない)場合を見てみましょう。

func normalFunctionTakingClosure(block: () -> Void) {}
actor MyActor {
    var value: Int = 0
    func anyMethod() async {
        normalFunctionTakingClosure { value = 1 }
    }
}

上記のコードでself.を明記せずにactorのプロパティをそのままアクセスしても何の警告が出ません。呼ばれる関数がasyncでないので、actorのisolatedな環境で実行されるので問題が起きる心配はありません。

async関数で試してみましょう。

func asyncFunctionTakingClosure(block: () -> Void) async {}
actor MyActor {
    var value: Int = 0
    func anyMethod() async {
        await asyncFunctionTakingClosure { value = 1 }
    }
}

上記のコードをビルドすると以下の警告が出ます。actorのisolatedな環境に属さないasyncメソッドや関数は別の環境で実行されます。() -> Voidは環境の境界線を渡れないと。(blockの型を() async -> Voidにしたとして同じです)

Non-sendable type () -> Void exiting actor-isolated context in call to non-isolated global function asyncFunctionTakingClosure(block:) cannot cross actor boundary

実行環境間、タスク間にクロージャーを送りたい場合、@Sendableをつける必要があるので試してみましょう。

func asyncFunctionTakingClosure(block: @Sendable () -> Void) async {}
actor MyActor {
    var value: Int = 0
    func anyMethod() async {
        await asyncFunctionTakingClosure { value = 1 }
    }
}

上記のコードをビルドしたら以下のエラーになってしまいました。

Actor-isolated property value can not be mutated from a Sendable closure

actorはisolatedな環境で実行されるものなので、プロパティを別の環境からアクセスできたらisolatedでなくなります。

withCheckedContinuationは上記のasyncFunctionTakingClosureに似ていそうなのに、警告なくクロージャーからactorのプロパティにアクセスできます。

withCheckedContinuationの定義を見てみると、クロージャーの型に@Sendableがついていませんし、不思議な@_unsafeInheritExecutorというのがついています。

この@_unsafeInheritExecutorの影響でwithCheckedContinuationが特別な振る舞いをします。async関数ではあるものの、actorから呼んでも実行環境(executor)が継承されます。渡されるクロージャーが@Sendableでもなく@escapingでもなく同じ実行環境のまま実行されます。もっと詳しい説明はこちらをご覧ください

注意:unsafeのついたものはリリースされるコードで使う場合、注意が必要です。UnsafeContinuationを利用することはできますが、安全なCheckedContinuationを使うべきです。@_unsafeInheritExecutorが分かりにくく、特別な振る舞いをするので一般的な開発において使う必要が出ることはないと思います。

好奇心を満たすためだけに試してみたら予想通り以下のコードで何の警告も出ません。

@_unsafeInheritExecutor
func asyncFunctionInheritExecutorTakingNonEscapingClosure(block: () -> Void) async {}
actor MyActor {
    var value: Int = 0
    func anyMethod() async {
        await asyncFunctionInheritExecutorTakingNonEscapingClosure { value = 1 }
    }
}

wait()再び

余談はここまでにして、ContinuationSemaphorewait()に戻りましょう。

    func wait() async {
        value -= 1
        // 引き算後に値が0以上だったら待つ必要がありません。
        if value < 0 {
            await withCheckedContinuation { continuation in
                waiters.append(continuation)
                ensureValidState()
            }
        }
        ensureValidState()
    }

「Strict Concurrency Checking」の厳しさを最大のCompleteにしても、上記のコードで警告が出ません。

(正確にはXcode 14.0.1でmacOS用にビルドすると出ますが、iOS用だと出ないし、Xcode 14.1だとmacOS用でも出ません。警告が出るのはバグで間違いなさそうです)。

改めてwait()が呼ばれる時にvalue0で、waitersが空っぽだったのを仮定して実行順を追ってみます。

  1. 実行環境がactorの環境に切り替わります。
  2. actorの実行環境上でvalue -= 1if value < 0 {が実行されます。
  3. withCheckedContinuationがactorに属さないとはいえ、@_unsafeInheritExecutorがついているので実行環境(executor)が継承され、withCheckedContinuationが実行されます。
  4. withCheckedContinuationの内部の動きが複雑ですが、簡単にまとめてみると、continuationが作成されて、actorの実行環境のままクロージャーが呼ばれるようです。
  5. value -= 1waiters.append(continuation)の間actorの実行環境を離れていないので、正当性が保たれるはずです。

signal()

signal()は割とシンプルです。待っているものがあれば、一番前から待っていたcontinuationを取り出してresume()を呼びます。

    func signal() {
        value += 1
        if value <= 0 {
            // 配列が空っぽの場合、`removeFirst()`によって異常終了されるが、
            // この`if`の条件が満たされていて`signal()`が呼ばれた時点ではvalueの値が-1以下のはずなので、
            // 待機中のタスクが1つ以上だったはずです。
            let waiter = waiters.removeFirst()
            // `waiters`から1つの`CheckedContinuation`を取り出して、正しい状態が保たれるはずです。
            ensureValidState()
            // 待っていたタスクが続行できます。
            waiter.resume()
        }
        ensureValidState()
    }
}

全体のコード

上記にContinuationSemaphoreの全てのコードを3つに分けて載せましたが、念のためまとめて載せます。意外と短いですね(正当性の確認を消すとなおさら)。

actor ContinuationSemaphore {
    private var waiters: [CheckedContinuation<Void, Never>] = []
    private var value: Int

    init(value: Int) {
        assert(value >= 0)
        self.value = value
    }

    private func ensureValidState() {
        assert((value >= 0 && waiters.isEmpty) || (waiters.count == -value))
    }

    func wait() async {
        value -= 1
        if value < 0 {
            await withCheckedContinuation { continuation in
                waiters.append(continuation)
                ensureValidState()
            }
        }
        ensureValidState()
    }

    func signal() {
        value += 1
        if value <= 0 {
            let waiter = waiters.removeFirst()
            ensureValidState()
            waiter.resume()
        }
        ensureValidState()
    }
}

注意事項

上記のコードは少し試したし、理解を深めるには良い例だと思いますが、そのままプロダクションで使うのをおすすめできません。あまりテストされていないことを除いても、以下の問題が未解決状態です。

  • ContinuationSemaphoreが解放される時、waitersが残っていれば何をすべきでしょうか?
    • DispatchSemaphoreは解放時のvalueが作成時に渡されたvalueより低かったら強制終了です。
  • タスクのキャンセルをどう扱うべきでしょうか?
  • 提供されている機能が限られています。wait()にタイムアウトを指定できるようにするとか、signal()に数字を渡せるようにするとか、多くのセマフォの実装に入っているけどここにはないさまざまな機能があります。

キャンセルを扱う場合、注意が必要です。wait()を呼んでいるコードがキャンセルを意識しなければ、キャンセルの影響でwait()が終わるとき、セマフォに守られているリソースが使えるのを勘違いしていれば困ります。Taskがキャンセルされるときにwait()throwするようにした方が良いかもしれません。

Task(ボーナスコンテンツ)

さまざまなツールを見てみましたが、Taskの話はまだしていませんでしたね。Taskは特定なタスクが終わるのを待つことはできます(await task.value)が、そのTask自体を止める方法がなさそうです(ビジーウェイトはもちろん論外)。

元々Taskに関してはこの数行だけで留まるつもりでしたが、Taskのドキュメントを改めて見てよく考えたら、ビジーウェイトには少し似ている邪道な方法を思いつきました。実行されたTaskをキャンセルできる機能とsleepさせる機能を利用すれば…

上記のContinuationSemaphoreCheckedContinuationの使い方が想定されたものだと思います。以下のTaskSemaphoreがそうでもありませんし、僕の気づいていない問題が隠れているかもしれません。こういう使い方をおすすめできないとはいえ、勉強になり得るので、とりあえず興味がある方のために載せてみます。全体の構成がCheckedContinuationを使ったバージョンに近いので、説明は少なめです。

actor TaskSemaphore {
    private var value: Int
    private var tasks: [Task<Void, Never>] = []

    init(value: Int) {
        assert(value >= 0)
        self.value = value
    }

    private func ensureValidState() {
        assert((value >= 0 && tasks.isEmpty) || (tasks.count == -value))
    }

    func wait() async {
        value -= 1
        if value < 0 {
            let task = Task {
                // キャンセルされない限り永遠にsleepさせます
                while !Task.isCancelled {
                    // タスクがキャンセルされたら、途中だったsleepがすぐ終わるはずです。
                    try? await Task.sleep(nanoseconds: 100_000_000_000 /* 適当に100秒 */)
                }
            }
            tasks.append(task)
            ensureValidState()
            await task.value
        }
    }

    func signal() {
        value += 1
        if value <= 0 {
            let task = tasks.removeFirst()
            ensureValidState()
            task.cancel() // 永遠にsleepさせているタスクをキャンセルすることで起こします。
        }
        ensureValidState()
    }
}

最後に

この記事からひとつだけ覚えるとしたら、既存のコードをSwift Concurrencyの世界から使えるためにあると紹介されているツールは他のユースケースでも利用できる場合があるということでしょう。CheckedContinuationAsyncStreamも最初からSwift Concurrencyを使っているコードでも役に立つ場面があります。

また、すべてのツールを洗い出して、セマフォを実装できましたが、現在存在する標準ライブラリのツールだけで実装できないものもあると思います。

この記事を読んでくれた皆さんがSwift Concurrencyに少しでも詳しくなっていたら嬉しく思います。