Android API 呼び出しでは、通常、呼び出しごとに大きなレイテンシと計算が発生します。したがって、クライアントサイド キャッシュ保存は、有用で正確かつパフォーマンスの高い API を設計するうえで重要な考慮事項となります。
目的
Android SDK でアプリ デベロッパーに公開される API は、多くの場合、プラットフォーム プロセスのシステム サービスにバインダ IPC 呼び出しを行う Android フレームワークのクライアント コードとして実装されます。このシステム サービスの役割は、計算を実行して結果をクライアントに返すことです。このオペレーションのレイテンシは、通常、次の 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 パッケージ マネージャーのアップデートに固有のものです。起動中の呼び出しが多いため、キャッシュを意図的にオフにします。
- 未設定: キャッシュは存在するが初期化されていない。nonce が設定されていません。つまり、キャッシュは無効化されていません。
- バイパス: キャッシュをスキップする意図的な決定。
- カウントが多い場合は、キャッシュの使用に非効率性がある可能性があります。
無効化:
- 定義: キャッシュに保存されたデータを古いまたは最新でないとマークするプロセス。
- 重要度: システムが最新のデータで動作していることを示すシグナルを提供し、エラーや不整合を防ぎます。
- 通常は、データを所有するサーバーによってトリガーされます。
現在のサイズ:
- 定義: キャッシュ内の要素の現在の量。
- 重要度: キャッシュのリソース使用率と、システム パフォーマンスに与える影響の可能性を示します。
- 一般に、値が大きいほど、キャッシュで使用されるメモリが多くなります。
最大サイズ:
- 定義: キャッシュに割り当てられる最大容量。
- 重要度: キャッシュの容量とデータを保存する能力を決定します。
- 適切な最大サイズを設定すると、キャッシュの有効性とメモリ使用量のバランスを取ることができます。最大サイズに達すると、最近使用されていない要素を削除して新しい要素が追加されます。これは非効率性を示す可能性があります。
ハイ ウォーターマーク:
- 定義: キャッシュの作成以降に達した最大サイズ。
- 重要度: ピーク時のキャッシュ使用量とメモリ不足の可能性に関する分析情報を提供します。
- 高水位標識をモニタリングすると、潜在的なボトルネックや最適化の対象となる領域を特定できます。
オーバーフロー:
- 定義: キャッシュが最大サイズを超え、新しいエントリのスペースを確保するためにデータを削除する必要があった回数。
- 重要度: キャッシュの圧迫と、データの削除によるパフォーマンス低下の可能性を示します。
- オーバーフロー数が多い場合は、キャッシュ サイズの調整またはキャッシュ保存戦略の再評価が必要になる可能性があります。
同じ統計情報はバグレポートでも確認できます。
キャッシュのサイズを調整する
キャッシュには最大サイズがあります。最大キャッシュサイズを超えると、エントリは 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);
}
デバイスのこのプロパティは、ビルド時、具体的にはこのデバイスのブートイメージ用に Framework がビルドされた時点で認識されます。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"];
}
}