通常、Android API 呼び出しでは、呼び出しごとに大きなレイテンシと計算が発生します。そのため、有用で正確かつ高性能な API を設計するうえで、クライアントサイド キャッシュは重要な考慮事項となります。
目的
Android SDK でアプリ デベロッパーに公開される API は、多くの場合、Android フレームワークのクライアント コードとして実装されます。このコードは、プラットフォーム プロセスのシステム サービスに対して Binder 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 は、メインライン モジュールを含むすべてのシステム コードで使用できます。
ほぼ同じ PropertyInvalidatedCache もありますが、フレームワークでのみ使用できます。可能な場合は IpcDataCache を使用してください。
サーバーサイドの変更時にキャッシュを無効にする
サーバーから返される値が時間とともに変化する可能性がある場合は、変更を監視するためのコールバックを実装し、クライアントサイド キャッシュを無効にできるようにコールバックを登録します。
単体テストケース間でキャッシュを無効にする
単体テストスイートでは、実際のサーバーではなくテストダブルに対してクライアント コードをテストすることがあります。その場合は、テストケース間でクライアントサイド キャッシュを必ずクリアしてください。これは、テストケースを相互に密閉し、あるテストケースが別のテストケースに干渉しないようにするためです。
@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {
@Before
public void setUp() {
BirthdayManager.clearCache();
}
@After
public void tearDown() {
BirthdayManager.clearCache();
}
...
}
内部でキャッシュを使用する API クライアントを操作する CTS テストを作成する場合、キャッシュは API 作成者に公開されない実装の詳細であるため、CTS テストではクライアント コードで使用されるキャッシュに関する特別な知識は必要ありません。
キャッシュ ヒットとキャッシュミスを調べる
IpcDataCache と PropertyInvalidatedCache は、ライブ統計情報を出力できます。
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 Package Manager のアップデートに固有のものです。起動時の呼び出し数が多いため、キャッシュを意図的にオフにしています。
- 未設定: キャッシュは存在するが初期化されていない。ノンスが設定されていないため、キャッシュは無効になっていません。
- バイパス: キャッシュをスキップする意図的な決定。
- カウントが多い場合は、キャッシュの使用に非効率性がある可能性があります。
無効化:
- 定義: キャッシュされたデータを古いデータまたは古いデータとしてマークするプロセス。
- 重要度: システムが最新のデータで動作していることを示すシグナルを提供し、エラーや不整合を防ぎます。
- 通常、データを所有するサーバーによってトリガーされます。
現在のサイズ:
- 定義: キャッシュ内の要素の現在の量。
- 重要度: キャッシュのリソース使用率と、システム パフォーマンスへの潜在的な影響を示します。
- 一般的に、値が大きいほど、キャッシュで使用されるメモリが多くなります。
最大サイズ:
- 定義: キャッシュに割り当てられる最大容量。
- 重要度: キャッシュの容量とデータを保存する能力を決定します。
- 適切な最大サイズを設定すると、キャッシュの有効性とメモリ使用量のバランスを取ることができます。最大サイズに達すると、最近使用されていない要素を削除して新しい要素が追加されます。これは非効率性を示す可能性があります。
ハイ ウォーターマーク:
- 定義: キャッシュの作成以降に達した最大サイズ。
- 重要度: キャッシュの使用量のピークと、メモリ不足の可能性に関する分析情報を提供します。
- ハイ ウォーターマークをモニタリングすると、ボトルネックや最適化の可能性がある領域を特定できます。
オーバーフロー:
- 定義: キャッシュが最大サイズを超え、新しいエントリのスペースを確保するためにデータを削除する必要があった回数。
- 重要度: キャッシュの負荷と、データの削除によるパフォーマンスの低下の可能性を示します。
- オーバーフローのカウントが多い場合は、キャッシュサイズの調整またはキャッシュ戦略の再評価が必要になることがあります。
同じ統計情報はバグレポートにも記載されています。
キャッシュのサイズを調整する
キャッシュには最大サイズがあります。キャッシュの最大サイズを超えると、エントリは 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
サーバークエリの代わりにクライアントサイドのコード生成を検討する
クエリ結果がビルド時にサーバーで判明している場合は、ビルド時にクライアントで判明しているかどうかを検討し、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"];
}
}