Android の非同期 API と非ブロッキング API のガイドライン

非ブロッキング API は、処理の実行をリクエストしてから、呼び出しスレッドに制御を戻します。これにより、リクエストされたオペレーションの完了前に他の処理を実行できます。これらの API は、リクエストされた作業が進行中である場合や、作業を進める前に I/O や IPC の完了、競合の激しいシステム リソースの可用性、ユーザー入力の待機が必要な場合に便利です。特に、適切に設計された API は、進行中のオペレーションをキャンセルし、元の呼び出し元に代わって作業が実行されないようにする方法を提供します。これにより、オペレーションが不要になったときに、システムの健全性とバッテリー寿命を維持できます。

非同期 API は、非ブロッキング動作を実現する方法の 1 つです。非同期 API は、オペレーションの完了時やオペレーションの進行中の他のイベント時に通知される継続またはコールバックの形式を受け入れます。

非同期 API を作成する主な動機は次の 2 つです。

  • 複数のオペレーションを同時に実行します。N 番目のオペレーションは、N-1 番目のオペレーションが完了する前に開始する必要があります。
  • オペレーションが完了するまで呼び出し元のスレッドをブロックしないようにします。

Kotlin は、構造化された並行処理を強く推奨しています。これは、コードの同期実行と非同期実行をスレッド ブロック動作から切り離す suspend 関数に基づいて構築された一連の原則と API です。suspend 関数は非ブロッキングかつ同期です。

中断関数:

  • 呼び出しスレッドをブロックせず、代わりに、別の場所で実行されているオペレーションの結果を待機している間、実装の詳細として実行スレッドを生成します。
  • 同期的に実行し、非ブロッキング API の呼び出し元が、API 呼び出しによって開始された非ブロッキング作業と並行して実行を継続する必要がない。

このページでは、非ブロッキング API と非同期 API を使用する際にデベロッパーが安全に保持できる最小限のベースラインについて詳しく説明します。続いて、Android プラットフォームまたは Jetpack ライブラリで、Kotlin または Java 言語でこれらの期待を満たす API を作成するためのレシピをいくつか紹介します。不明な場合は、デベロッパーの期待を新しい API サーフェスの要件と見なしてください。

非同期 API に関するデベロッパーの期待

特に明記されていない限り、以下の期待値は一時停止しない API の観点から記述されています。

コールバックを受け入れる API は通常非同期です

API が、インプレース(API 呼び出し自体が戻る前に呼び出しスレッドによってのみ呼び出される)でのみ呼び出されることがドキュメント化されていないコールバックを受け入れる場合、API は非同期であると想定され、その API は次のセクションで説明する他のすべての要件を満たす必要があります。

インプレースでのみ呼び出されるコールバックの例としては、コレクション内の各項目でマッパーまたは述語を呼び出してから返す高階マップ関数やフィルタ関数があります。

非同期 API はできるだけ早く返す必要があります

デベロッパーは、非同期 API が非ブロッキングであり、オペレーションのリクエストを開始した後にすぐに戻ることを期待しています。非同期 API はいつでも安全に呼び出すことができ、非同期 API の呼び出しによってフレームのジャンプや ANR が発生することはありません。

多くのオペレーションとライフサイクル シグナルは、プラットフォームまたはライブラリによってオンデマンドでトリガーできます。コードのすべての潜在的な呼び出しサイトに関するグローバルな知識をデベロッパーが保持することを期待するのは持続不可能です。たとえば、アプリのコンテンツを RecyclerView などの利用可能なスペースに埋める必要がある場合、View の測定とレイアウトに応じて、同期トランザクションで FragmentManagerFragment を追加できます。このフラグメントの onStart ライフサイクル コールバックに応答する LifecycleObserver は、ここで 1 回限りの起動オペレーションを合理的に実行できます。これは、ジャンクのないアニメーションのフレームを生成するための重要なコードパス上にある可能性があります。デベロッパーは、こうしたライフサイクル コールバックに応答して任意の非同期 API を呼び出しても、フレームがジャンクになることはないと常に確信できる必要があります。

これは、非同期 API が戻る前に実行する作業は非常に軽量でなければならないことを意味します。リクエストと関連するコールバックのレコードを作成し、その作業を実行する実行エンジンに登録する程度です。非同期オペレーションの登録に IPC が必要な場合、API の実装では、デベロッパーの期待に応えるために必要な対策を講じる必要があります。これには、次の 1 つ以上が含まれる場合があります。

  • 基盤となる IPC を oneway バインダ呼び出しとして実装する
  • 登録の完了に競合の激しいロックを必要としないシステム サーバーへの双方向バインダー呼び出しを行う
  • アプリ プロセスのワーカー スレッドにリクエストを投稿して、IPC 経由でブロッキング登録を実行する

非同期 API は void を返し、無効な引数に対してのみ例外をスローするべきです

非同期 API は、リクエストされたオペレーションのすべての結果を指定されたコールバックに報告する必要があります。これにより、デベロッパーは成功とエラー処理の単一のコードパスを実装できます。

非同期 API は、引数が null かどうかをチェックして NullPointerException をスローしたり、指定された引数が有効な範囲内にあるかどうかをチェックして IllegalArgumentException をスローしたりする場合があります。たとえば、01f の範囲の float を受け取る関数では、パラメータがこの範囲内にあることを確認し、範囲外の場合は IllegalArgumentException をスローすることがあります。また、String の短い文字列が、英数字のみなどの有効な形式に準拠していることを確認することもあります。(システム サーバーはアプリ プロセスを信頼してはならないことを覚えておいてください。システム サービスは、システム サービス自体でこれらのチェックを複製する必要があります)。

その他のすべてのエラーは、指定されたコールバックに報告する必要があります。これには以下のようなものが含まれますが、これらに限定されません。

  • リクエストされたオペレーションのターミナル エラー
  • 操作の完了に必要な認可または権限がない場合のセキュリティ例外
  • オペレーションの実行の割り当てを超過しました
  • アプリ プロセスがオペレーションを実行するのに十分な「フォアグラウンド」ではない
  • 必要なハードウェアが接続解除された
  • ネットワーク障害
  • タイムアウト
  • バインダの終了またはリモート プロセスの利用不可

非同期 API はキャンセル メカニズムを提供すべきです

非同期 API は、呼び出し元が結果を必要としなくなったことを実行中のオペレーションに伝える方法を提供する必要があります。このキャンセル オペレーションは、次の 2 つのシグナルを送信します。

呼び出し元が提供するコールバックへのハード参照を解放する

非同期 API に提供されるコールバックには、大きなオブジェクト グラフへのハード参照が含まれている場合があります。そのコールバックへのハード参照を保持している進行中の作業があると、それらのオブジェクト グラフがガベージ コレクションされない可能性があります。キャンセル時にこれらのコールバック参照を解放することで、これらのオブジェクト グラフは、作業が完了まで実行される場合よりもはるかに早くガベージ コレクションの対象になる可能性があります。

呼び出し元のために作業を行う実行エンジンが、その作業を停止する可能性があります。

非同期 API 呼び出しによって開始された処理は、電力消費量やその他のシステム リソースのコストが高くなる可能性があります。呼び出し元がこの作業が不要になったときにシグナルを送ることができる API を使用すると、システム リソースがさらに消費される前に作業を停止できます。

キャッシュに保存されたアプリまたは凍結されたアプリに関する特別な考慮事項

コールバックがシステム プロセスで発生し、アプリに配信される非同期 API を設計する場合は、次の点を考慮してください。

  1. プロセスとアプリのライフサイクル: 受信側のアプリプロセスがキャッシュに保存された状態になっている可能性があります。
  2. キャッシュ保存済みアプリ フリーザー: 受信側アプリのプロセスが凍結される可能性があります。

アプリのプロセスがキャッシュされた状態になると、アクティビティやサービスなどのユーザーに表示されるコンポーネントをアクティブにホストしていないことを意味します。アプリは、ユーザーに再び表示される場合に備えてメモリに保持されますが、その間は動作しないようにする必要があります。ほとんどの場合、アプリがキャッシュに保存された状態になったらアプリのコールバックのディスパッチを一時停止し、アプリがキャッシュに保存された状態から出たら再開して、キャッシュに保存されたアプリ プロセスで処理が発生しないようにする必要があります。

キャッシュに保存されたアプリがフリーズすることもあります。アプリが凍結されると、CPU 時間がゼロになり、まったく動作しなくなります。そのアプリの登録済みコールバックへの呼び出しはすべてバッファリングされ、アプリがフリーズ解除されたときに配信されます。

アプリが凍結解除されてバッファリングされたトランザクションを処理するまでに、アプリのコールバックが古くなる可能性があります。バッファは有限であり、オーバーフローすると受信側アプリがクラッシュします。アプリが古いイベントでいっぱいになったり、バッファがオーバーフローしたりするのを防ぐため、アプリのプロセスがフリーズしている間はアプリのコールバックをディスパッチしないでください。

審査中:

  • アプリのプロセスがキャッシュに保存されている間、アプリのコールバックのディスパッチを一時停止することを検討する必要があります。
  • アプリのプロセスがフリーズしている間は、アプリのコールバックのディスパッチを一時停止しなければなりません。

状態のトラッキング

アプリがキャッシュ状態になったり、キャッシュ状態から抜けたりするタイミングをトラッキングするには:

mActivityManager.addOnUidImportanceListener(
    new UidImportanceListener() { ... },
    IMPORTANCE_CACHED);

アプリがフリーズまたはフリーズ解除されたタイミングを追跡するには:

IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);

アプリのコールバックのディスパッチを再開するための戦略

アプリがキャッシュに保存された状態またはフリーズ状態になったときにアプリのコールバックのディスパッチを一時停止するかどうかに関係なく、アプリがそれぞれの状態を終了したときに、アプリがコールバックの登録を解除するかアプリのプロセスが終了するまで、アプリがそれぞれの状態を終了した時点でアプリの登録済みコールバックのディスパッチを再開する必要があります。

次に例を示します。

IBinder binder = <...>;
bool shouldSendCallbacks = true;
binder.addFrozenStateChangeCallback(executor, (who, state) -> {
    if (state == IBinder.FrozenStateChangeCallback.STATE_FROZEN) {
        shouldSendCallbacks = false;
    } else if (state == IBinder.FrozenStateChangeCallback.STATE_UNFROZEN) {
        shouldSendCallbacks = true;
    }
});

また、RemoteCallbackList を使用することもできます。この場合、ターゲット プロセスがフリーズしているときにコールバックが配信されないように処理されます。

次に例を示します。

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_DROP)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.foo(bar));

callback.foo() は、プロセスがフリーズしていない場合にのみ呼び出されます。

アプリは、コールバックを使用して受け取った更新を最新の状態のスナップショットとして保存することがよくあります。アプリがバッテリー残量をモニタリングするための架空の API を考えてみましょう。

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

アプリがフリーズしたときに複数の状態変更イベントが発生するシナリオを考えてみましょう。アプリがフリーズ解除されたら、最新の状態のみをアプリに配信し、他の古い状態の変化は破棄する必要があります。この配信は、アプリがフリーズ解除された直後に行われるため、アプリは「追いつく」ことができます。これは次のように実現できます。

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_ENQUEUE_MOST_RECENT)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.onBatteryPercentageChanged(value));

アプリに配信された最後の値をトラッキングして、フリーズが解除されたときに同じ値がアプリに通知されないようにすることがあります。

状態はより複雑なデータとして表現される場合があります。アプリにネットワーク インターフェースを通知する架空の API を考えてみましょう。

interface NetworkListener {
    void onAvailable(Network network);
    void onLost(Network network);
    void onChanged(Network network);
}

アプリへの通知を一時停止するときは、アプリが最後に確認したネットワークと状態のセットを覚えておく必要があります。再開時に、失われた古いネットワーク、利用可能になった新しいネットワーク、状態が変化した既存のネットワークをこの順序でアプリに通知することが推奨されます。

コールバックが一時停止している間に利用可能になった後で利用できなくなったネットワークをアプリに通知しない。アプリは、フリーズ中に発生したイベントの完全なアカウントを受け取るべきではありません。また、API ドキュメントでは、明示的なライフサイクル状態以外でイベント ストリームを中断なく配信することを約束すべきではありません。この例では、アプリがネットワークの可用性を継続的にモニタリングする必要がある場合、キャッシュに保存されたりフリーズしたりしないライフサイクル状態を維持する必要があります。

レビューでは、通知の一時停止後、再開前に発生したイベントを統合し、登録されたアプリのコールバックに最新の状態を簡潔に配信する必要があります。

デベロッパー向けドキュメントに関する考慮事項

非同期イベントの配信は、前のセクションで説明したように送信側が一定期間配信を一時停止したか、受信側アプリがイベントをタイムリーに処理するのに十分なデバイスリソースを受け取っていないため、遅延する可能性があります。

アプリがイベントの通知を受け取ってから、実際にイベントが発生するまでの時間を想定しないようデベロッパーに促します。

API の一時停止に関するデベロッパーの期待

Kotlin の構造化された同時実行に精通しているデベロッパーは、一時停止 API に次の動作を期待します。

一時停止関数は、戻るか例外をスローする前に、関連するすべての作業を完了する必要があります

非ブロッキング オペレーションの結果は通常の関数の戻り値として返され、エラーは例外をスローすることで報告されます。(多くの場合、コールバック パラメータは不要です)。

suspend 関数はコールバック パラメータをインプレースで呼び出すだけにする

suspend 関数は、戻る前に常にすべての関連する作業を完了する必要があります。そのため、suspend 関数が戻った後に、提供されたコールバックや他の関数パラメータを呼び出したり、それらへの参照を保持したりしてはなりません。

コールバック パラメータを受け取る suspend 関数は、特に記載がない限り、コンテキストを保持する必要があります

suspend 関数で関数を呼び出すと、呼び出し元の CoroutineContext で実行されます。一時停止関数は、戻るか例外をスローする前に、関連するすべての作業を完了し、コールバック パラメータをインプレースでのみ呼び出す必要があるため、デフォルトでは、そのようなコールバックは、関連するディスパッチャーを使用して呼び出し元の CoroutineContext で実行されることが想定されています。API の目的が呼び出し元の CoroutineContext の外部でコールバックを実行することである場合は、この動作を明確に文書化する必要があります。

suspend 関数は kotlinx.coroutines ジョブのキャンセルをサポートする

提供される suspend 関数は、kotlinx.coroutines で定義されているジョブのキャンセルと連携する必要があります。進行中のオペレーションの呼び出しジョブがキャンセルされた場合、呼び出し元ができるだけ早くクリーンアップして続行できるように、関数はできるだけ早く CancellationException で再開する必要があります。これは、suspendCancellableCoroutinekotlinx.coroutines が提供する他の一時停止 API によって自動的に処理されます。ライブラリの実装では、通常、suspendCoroutine を直接使用すべきではありません。このキャンセル動作はデフォルトでサポートされていません。

バックグラウンド(メインスレッドまたは UI スレッド以外)でブロッキング処理を行う suspend 関数は、使用するディスパッチャーを構成する方法を提供する必要があります

スレッドを切り替えるために、ブロッキング関数を完全に一時停止することはおすすめしません

suspend 関数を呼び出すと、デベロッパーがその作業を行うための独自のスレッドまたはスレッド プールを提供することを許可することなく、追加のスレッドが作成されるべきではありません。たとえば、コンストラクタは、クラスのメソッドのバックグラウンド作業を実行するために使用される CoroutineContext を受け取ることがあります。

オプションの CoroutineContext パラメータまたは Dispatcher パラメータを受け取り、そのディスパッチャーに切り替えてブロッキング作業を行うだけの関数は、基盤となるブロッキング関数を公開し、呼び出し元のデベロッパーが withContext への独自の呼び出しを使用して、選択したディスパッチャーに作業を転送することを推奨する必要があります。

コルーチンを起動するクラス

コルーチンを起動するクラスには、起動オペレーションを実行するための CoroutineScope が必要です。構造化された同時実行の原則を尊重することは、そのスコープを取得して管理するための次の構造パターンを意味します。

別のスコープで同時実行タスクを起動するクラスを記述する前に、代替パターンを検討してください。

class MyClass {
    private val requests = Channel<MyRequest>(Channel.UNLIMITED)

    suspend fun handleRequests() {
        coroutineScope {
            for (request in requests) {
                // Allow requests to be processed concurrently;
                // alternatively, omit the [launch] and outer [coroutineScope]
                // to process requests serially
                launch {
                    processRequest(request)
                }
            }
        }
    }

    fun submitRequest(request: MyRequest) {
        requests.trySend(request).getOrThrow()
    }
}

同時作業を行うために suspend fun を公開すると、呼び出し元は独自のコンテキストでオペレーションを呼び出すことができ、MyClassCoroutineScope を管理する必要がなくなります。リクエストの処理のシリアル化が簡素化され、状態は、追加の同期が必要になるクラス プロパティではなく、handleRequests のローカル変数として存在することが多くなります。

コルーチンを管理するクラスは close メソッドと cancel メソッドを公開すべきです

実装の詳細としてコルーチンを起動するクラスは、進行中の同時実行タスクをクリーンにシャットダウンして、制御されていない同時実行作業が親スコープにリークしないようにする方法を提供する必要があります。通常、これは指定された CoroutineContext の子 Job を作成する形式になります。

private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)

fun cancel() {
    myJob.cancel()
}

join() メソッドも提供される場合があります。このメソッドを使用すると、オブジェクトによって実行されている未完了の同時実行作業が完了するまで、ユーザーコードを待機させることができます。(これには、オペレーションのキャンセルによって実行されるクリーンアップ作業が含まれる場合があります)。

suspend fun join() {
    myJob.join()
}

ターミナル オペレーションの命名

まだ進行中のオブジェクトが所有する同時実行タスクをクリーンにシャットダウンするメソッドに使用する名前は、シャットダウンの動作契約を反映する必要があります。

進行中のオペレーションは完了する可能性があるが、close() の呼び出しが戻った後に新しいオペレーションを開始できない場合は、close() を使用します。

進行中のオペレーションが完了前にキャンセルされる可能性がある場合は、cancel() を使用します。cancel() の呼び出しが戻った後、新しいオペレーションを開始することはできません。

クラス コンストラクタは CoroutineScope ではなく CoroutineContext を受け入れる

オブジェクトが指定された親スコープに直接起動することを禁止されている場合、CoroutineScope のコンストラクタ パラメータとしての適合性が損なわれます。

// Don't do this
class MyClass(scope: CoroutineScope) {
    private val myJob = Job(parent = scope.`CoroutineContext`[Job])
    private val myScope = CoroutineScope(scope.`CoroutineContext` + myJob)

    // ... the [scope] constructor parameter is never used again
}

CoroutineScope は不要で誤解を招くラッパーになります。一部のユースケースでは、コンストラクタ パラメータとして渡すためだけに構築され、その後破棄される可能性があります。

// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))

CoroutineContext パラメータのデフォルトは EmptyCoroutineContext

API サーフェスにオプションの CoroutineContext パラメータが表示される場合、デフォルト値は Empty`CoroutineContext` センチネルにする必要があります。これにより、呼び出し元からの Empty`CoroutineContext` 値がデフォルトの受け入れと同じように扱われるため、API 動作の構成が改善されます。

class MyOuterClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val innerObject = MyInnerClass(`CoroutineContext`)

    // ...
}

class MyInnerClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val job = Job(parent = `CoroutineContext`[Job])
    private val scope = CoroutineScope(`CoroutineContext` + job)

    // ...
}