Android API クライアントサイド キャッシュのガイドライン

Android API 呼び出しでは、通常、呼び出しごとにレイテンシと計算が大幅に増加します。したがって、有用で正確かつ高パフォーマンスな API を設計する際には、クライアントサイド キャッシュを重要な考慮事項として検討する必要があります。

目的

Android SDK でアプリ デベロッパーに公開される API は、多くの場合、Android フレームワークのクライアント コードとして実装されます。このコードは、プラットフォーム プロセス内のシステム サービスに対してバインダー IPC 呼び出しを行います。このシステム サービスは、計算を実行して結果をクライアントに返す役割を担います。このオペレーションのレイテンシは通常、次の 3 つの要因によって決まります。

  • IPC オーバーヘッド: 通常、基本的な IPC 呼び出しは、基本的なインプロセス メソッド呼び出しのレイテンシの 10,000 倍です。
  • サーバーサイドの競合: クライアントのリクエストに応答してシステム サービスで実行される処理がすぐに開始されない場合があります。たとえば、サーバー スレッドが、先に届いた他のリクエストの処理でビジー状態になっている場合などです。
  • サーバーサイド計算: サーバー内でリクエストを処理する作業自体に、かなりの作業が必要になる場合があります。

これらの 3 つのレイテンシ要因はすべて、クライアント側にキャッシュを実装することで排除できます。ただし、キャッシュは次の条件を満たしている必要があります。

  • 正しい。クライアントサイド キャッシュは、サーバーが返す結果とは異なる結果を返すことはありません。
  • 有効: キャッシュのヒット率が高いなど、クライアント リクエストがキャッシュから提供されることが多い。
  • 効率的: クライアントサイド キャッシュは、キャッシュに保存されたデータをコンパクトに表現したり、キャッシュに保存された結果や古いデータをクライアントのメモリに過度に保存しないようにしたりすることで、クライアントサイド リソースを効率的に使用します。

クライアントでサーバーの結果をキャッシュに保存することを検討する

クライアントがまったく同じリクエストを何度も行い、返される値が時間の経過とともに変化しない場合は、リクエスト パラメータでキー設定されたキャッシュをクライアント ライブラリに実装する必要があります。

実装で IpcDataCache を使用することを検討してください。

public class BirthdayManager {
    private final IpcDataCache.QueryHandler<User, Birthday> mBirthdayQuery =
            new IpcDataCache.QueryHandler<User, Birthday>() {
                @Override
                public Birthday apply(User user) {
                    return mService.getBirthday(user);
                }
            };
    private static final int BDAY_CACHE_MAX = 8;  // Maximum birthdays to cache
    private static final String BDAY_API = "getUserBirthday";
    private final IpcDataCache<User, Birthday> mCache
            new IpcDataCache<User, Birthday>(
                BDAY_CACHE_MAX, MODULE_SYSTEM, BDAY_API,  BDAY_API, mBirthdayQuery);

    /** @hide **/
    @VisibleForTesting
    public static void clearCache() {
        IpcDataCache.invalidateCache(MODULE_SYSTEM, BDAY_API);
    }

    public Birthday getBirthday(User user) {
        return mCache.query(user);
    }
}

完全な例については、android.app.admin.DevicePolicyManager をご覧ください。

IpcDataCache は、Mainline モジュールを含むすべてのシステムコードで使用できます。PropertyInvalidatedCache もあります。これはほぼ同じですが、フレームワークにのみ表示されます。可能であれば IpcDataCache を使用することをおすすめします。

サーバーサイドの変更でキャッシュを無効にする

サーバーから返される値が時間の経過とともに変化する可能性がある場合は、変更を検出するコールバックを実装し、コールバックを登録して、必要に応じてクライアントサイド キャッシュを無効にできるようにします。

単体テストケース間でキャッシュを無効にする

単体テストスイートでは、実際のサーバーではなく、テストダブルに対してクライアント コードをテストできます。その場合は、テストケース間でクライアントサイド キャッシュを必ず消去してください。これは、テストケースを相互に密閉し、あるテストケースが別のテストケースに干渉するのを防ぐためです。

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

    @Before
    public void setUp() {
        BirthdayManager.clearCache();
    }

    @After
    public void tearDown() {
        BirthdayManager.clearCache();
    }

    ...
}

内部でキャッシュを使用する API クライアントを実行する CTS テストを作成する場合は、キャッシュは API 作成者に公開されない実装の詳細であるため、CTS テストでクライアント コードで使用されるキャッシュに関する特別な知識は必要ありません。

キャッシュ ヒットとキャッシュミスを調べる

IpcDataCachePropertyInvalidatedCache は、ライブ統計情報を出力できます。

adb shell dumpsys cacheinfo
  ...
  Cache Name: cache_key.is_compat_change_enabled
    Property: cache_key.is_compat_change_enabled
    Hits: 1301458, Misses: 21387, Skips: 0, Clears: 39
    Skip-corked: 0, Skip-unset: 0, Skip-bypass: 0, Skip-other: 0
    Nonce: 0x856e911694198091, Invalidates: 72, CorkedInvalidates: 0
    Current Size: 1254, Max Size: 2048, HW Mark: 2049, Overflows: 310
    Enabled: true
  ...

フィールド

ヒット数:

  • 定義: リクエストされたデータがキャッシュ内で正常に検出された回数。
  • 重要度: データの効率的で高速な取得を示し、不要なデータの取得を減らします。
  • 一般的に、数値が大きいほど良いと判断されます。

消去:

  • 定義: 無効化が原因でキャッシュが削除された回数。
  • 削除の理由:
    • 無効化: サーバーからの古いデータ。
    • スペース管理: キャッシュがいっぱいになったときに新しいデータ用のスペースを確保します。
  • カウントが多い場合は、データが頻繁に変更され、非効率になる可能性があることを示しています。

ミス:

  • 定義: キャッシュがリクエストされたデータを提供できなかった回数。
  • 原因:
    • キャッシュの効率が悪い: キャッシュが小さすぎるか、適切なデータを保存していない。
    • 頻繁に変化するデータ。
    • 初回のリクエスト。
  • 数が多い場合は、キャッシュに問題がある可能性があります。

スキップ:

  • 定義: キャッシュを使用できるにもかかわらず、キャッシュがまったく使用されなかったインスタンス。
  • スキップする理由:
    • コルクリング: Android パッケージ マネージャーのアップデートに固有の機能で、起動時の呼び出しが多いため、キャッシュを意図的にオフにします。
    • 未設定: キャッシュは存在しますが、初期化されていません。ノンスが未設定です。つまり、キャッシュが無効化されたことはありません。
    • バイパス: キャッシュをスキップすることを意図的に決定する。
  • カウントが多い場合は、キャッシュ使用の非効率性が疑われます。

無効にする:

  • 定義: キャッシュに保存されたデータを古いデータとしてマークするプロセス。
  • 重要性: システムが最新のデータで動作していることを示すシグナルを提供し、エラーや不整合を防ぎます。
  • 通常は、データを所有するサーバーがトリガーします。

現在のサイズ:

  • 定義: キャッシュ内の現在の要素数。
  • 重要度: キャッシュのリソース使用率とシステム パフォーマンスへの潜在的な影響を示します。
  • 値が大きいほど、キャッシュによって使用されるメモリが多くなります。

最大サイズ:

  • 定義: キャッシュに割り当てられる最大容量。
  • 重要度: キャッシュの容量とデータの保存能力を決定します。
  • 適切な最大サイズを設定すると、キャッシュの有効性とメモリ使用量のバランスを取ることができます。最大サイズに達すると、直近で最も使用されていない要素が強制排除されて新しい要素が追加されます。これは非効率的である可能性があります。

ハイ ウォーターマーク:

  • 定義: キャッシュの作成後に達した最大サイズ。
  • 重要度: ピーク時のキャッシュ使用量と潜在的なメモリ負荷に関する分析情報を提供します。
  • ハイウォーターマークをモニタリングすると、潜在的なボトルネックや最適化の対象となる領域を特定できます。

オーバーフロー:

  • 定義: キャッシュの最大サイズを超えて、新しいエントリ用のスペースを確保するためにデータを強制排除した回数。
  • 重要度: データの強制排除によるキャッシュ圧力とパフォーマンス低下の可能性を示します。
  • オーバーフロー数が多すぎる場合は、キャッシュサイズの調整やキャッシュ戦略の再評価が必要になる可能性があります。

同じ統計情報はバグレポートでも確認できます。

キャッシュのサイズを調整する

キャッシュには最大サイズがあります。キャッシュの最大サイズを超えると、エントリは LRU 順に強制排除されます。

  • キャッシュに保存するエントリ数が少すぎると、キャッシュ ヒット率に悪影響を及ぼす可能性があります。
  • キャッシュにエントリをキャッシュに保存しすぎると、キャッシュのメモリ使用量が増加します。

ユースケースに適したバランスを見つけます。

重複するクライアント呼び出しを排除する

クライアントは、短い時間内に同じクエリをサーバーに複数回送信することがあります。

public void executeAll(List<Operation> operations) throws SecurityException {
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionChecker.checkPermission(permission, ...)) {
                throw new SecurityException("Missing permission " + permission);
            }
        }
        op.execute();
  }
}

以前の呼び出しの結果を再利用することを検討してください。

public void executeAll(List<Operation> operations) throws SecurityException {
    Set<Permission> permissionsChecked = new HashSet<>();
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionsChecked.add(permission)) {
                if (!permissionChecker.checkPermission(permission, ...)) {
                    throw new SecurityException(
                            "Missing permission " + permission);
                }
            }
        }
        op.execute();
  }
}

最近のサーバー レスポンスをクライアントサイドでメモ化する

クライアント アプリは、API のサーバーが有意な新しいレスポンスを生成できるよりも速いレートで API にクエリを実行する場合があります。この場合、効果的なアプローチは、最後に確認したサーバー レスポンスをタイムスタンプとともにクライアント側でメモ化し、メモ化された結果が十分に新しい場合は、サーバーをクエリせずにメモ化された結果を返すことです。API クライアントの作成者は、メモ化の期間を決定できます。

たとえば、アプリが描画されるフレームごとに統計情報をクエリして、ネットワーク トラフィックの統計情報をユーザーに表示する場合があります。

@UiThread
private void setStats() {
    mobileRxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileRxBytes()));
    mobileRxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileRxPackages()));
    mobileTxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileTxBytes()));
    mobileTxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileTxPackages()));
}

アプリは 60 Hz でフレームを描画できますが、TrafficStats のクライアント コードでは、サーバーに統計情報を 1 秒間に最大 1 回クエリし、前のクエリから 1 秒以内にクエリされた場合は、最後に確認された値を返すように選択できます。これは、API ドキュメントに返される結果の鮮度に関する契約がないため許可されます。

participant App code as app
participant Client library as clib
participant Server as server

app->clib: request @ T=100ms
clib->server: request
server->clib: response 1
clib->app: response 1

app->clib: request @ T=200ms
clib->app: response 1

app->clib: request @ T=300ms
clib->app: response 1

app->clib: request @ T=2000ms
clib->server: request
server->clib: response 2
clib->app: response 2

サーバー クエリではなくクライアントサイドの codegen を検討する

クエリ結果がビルド時にサーバーで判明する場合は、ビルド時にクライアントでも判明するかどうかを検討し、API をクライアント側で完全に実装できるかどうかを検討します。

デバイスがスマートウォッチ(Wear OS を搭載しているデバイス)かどうかを確認する次のアプリコードについて考えてみましょう。

public boolean isWatch(Context ctx) {
    PackageManager pm = ctx.getPackageManager();
    return pm.hasSystemFeature(PackageManager.FEATURE_WATCH);
}

デバイスのこのプロパティは、ビルド時に、具体的には、このデバイスのブートイメージ用にフレームワークがビルドされたときに判明します。hasSystemFeature のクライアントサイド コードは、リモートの PackageManager システム サービスをクエリするのではなく、既知の結果をすぐに返すことができます。

クライアントでサーバー コールバックの重複を排除する

最後に、API クライアントは API サーバーにコールバックを登録して、イベントの通知を受け取ることができます。

アプリでは、同じ基盤となる情報に対して複数のコールバックを登録するのが一般的です。サーバーが IPC を使用して登録されたコールバックごとに 1 回クライアントに通知するのではなく、クライアント ライブラリにサーバーと IPC を使用して登録されたコールバックを 1 つ用意し、アプリで登録された各コールバックに通知する必要があります。

digraph d_front_back {
  rankdir=RL;
  node [style=filled, shape="rectangle", fontcolor="white" fontname="Roboto"]
  server->clib
  clib->c1;
  clib->c2;
  clib->c3;

  subgraph cluster_client {
    graph [style="dashed", label="Client app process"];
    c1 [label="my.app.FirstCallback" color="#4285F4"];
    c2 [label="my.app.SecondCallback" color="#4285F4"];
    c3 [label="my.app.ThirdCallback" color="#4285F4"];
    clib [label="android.app.FooManager" color="#F4B400"];
  }

  subgraph cluster_server {
    graph [style="dashed", label="Server process"];
    server [label="com.android.server.FooManagerService" color="#0F9D58"];
  }
}