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

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

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

非同期 API を作成する主な動機は 2 つあります。

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

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

suspend 関数:

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

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

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

特に明記されていない限り、以下の期待は非 suspend API の観点から記述されています。

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

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

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

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

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

多くのオペレーションとライフサイクル シグナルは、プラットフォームまたはライブラリによってオンデマンドでトリガーできます。デベロッパーがコードの潜在的な呼び出しサイトをすべて把握することはできません。たとえば、Fragment を同期トランザクションでFragmentManagerに追加できます。これは、アプリのコンテンツを使用可能なスペース(RecyclerViewなど)に設定する必要がある場合に、Viewの測定とレイアウトに応答します。このフラグメントの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 ドキュメントでは、明示的なライフサイクル状態以外でイベント ストリームを中断せずに配信することを保証しないでください。この例では、アプリがネットワークの可用性を継続的にモニタリングする必要がある場合は、キャッシュまたは凍結されないライフサイクル状態を維持する必要があります。

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

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

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

デベロッパーが、アプリにイベントが通知された時刻とイベントが実際に発生した時刻の間の時間を想定しないようにしてください。

suspend API に対するデベロッパーの期待

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

suspend 関数は、返されるかスローされる前に、関連するすべての処理を完了する必要がある

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

suspend 関数は、インプレースでのみコールバック パラメータを呼び出す必要がある

suspend 関数は、返される前に、関連するすべての処理を完了する必要があります。そのため、suspend 関数が返された後に、提供されたコールバックや他の関数パラメータを呼び出したり、その参照を保持したりしないでください。

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

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

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

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

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

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

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

ブロッキング処理を実行するためにディスパッチャーに切り替えるために、オプションの CoroutineContext または Dispatcher パラメータを受け入れる suspend 関数は、代わりに基盤となるブロッキング関数を公開し、呼び出し元のデベロッパーが独自の 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` センチネルにする必要があります。これにより、API 動作の構成が改善されます。呼び出し元からの Empty`CoroutineContext` 値は、デフォルトを受け入れる場合と同じように扱われます。

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)

    // ...
}