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

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

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

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

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

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

中断関数:

  • 呼び出し元のスレッドをブロックせず、別の場所で実行されているオペレーションの結果を待機しながら、実装の詳細として実行スレッドを yield します。
  • 同期的に実行し、非ブロッキング API の呼び出し元が、API 呼び出しによって開始された非ブロッキング処理と同時に実行を続行する必要はありません。

このページでは、非ブロッキング API と非同期 API を扱う際にデベロッパーが安全に期待できる最小限のベースラインについて説明します。次に、Kotlin または Java 言語、Android プラットフォーム、Jetpack ライブラリで、これらの期待に応える 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 をワンウェイ バインダー呼び出しとして実装する
  • 登録の完了に競合の激しいロックを取得する必要がないシステム サーバーに双方向バインダー呼び出しを行う
  • アプリプロセス内のワーカースレッドにリクエストを送信して、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 の構造化された同時実行に精通しているデベロッパーは、suspending 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

オプションの CoroutineContext パラメータが API サーフェスに表示される場合は、デフォルト値が 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)

    // ...
}