Android API 用戶端快取指南

Android API 呼叫通常會在每次叫用時產生大量的延遲和運算。因此,在設計有用、正確且效能良好的 API 時,請務必考量用戶端快取功能。

動機

在 Android SDK 中向應用程式開發人員公開的 API,通常會在 Android 架構中實作為用戶端程式碼,用於在平台程序中對系統服務發出 Binder IPC 呼叫,執行某些運算並將結果傳回給用戶端。這項作業的延遲時間通常受三個因素主導:

  • IPC 額外負擔:基本 IPC 呼叫的延遲時間通常是基本程序內方法呼叫的 10,000 倍。
  • 伺服器端爭用:系統服務為了回應用戶端的要求而執行的工作可能不會立即開始,例如,如果伺服器執行緒正忙於處理先前傳入的其他要求。
  • 伺服器端運算:處理伺服器中要求的工作本身可能需要大量工作。

您可以在用戶端上實作快取,藉此消除這三個延遲因素,前提是快取必須符合以下條件:

  • 正確:用戶端快取一律不會傳回與伺服器傳回結果不同的結果。
  • 有效:用戶端要求通常會從快取中提供,例如快取的命中率很高。
  • 效率:用戶端快取可有效運用用戶端資源,例如以精簡的方式呈現快取資料,並避免在用戶端記憶體中儲存過多快取結果或過時資料。

考慮在用戶端快取伺服器結果

如果用戶端經常重複提出相同要求,且傳回的值不會隨時間變更,則應在用戶端程式庫中實作快取,並以要求參數做為索引。

建議您在實作中使用 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();
    }

    ...
}

編寫 CTS 測試時,如果要測試內部使用快取的 API 用戶端,快取是不會向 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
  ...

欄位

命中:

  • 定義:快取中成功找到要求的資料次數。
  • 重要性:表示資料的擷取方式高效快速,可減少不必要的資料擷取。
  • 數量越多,效果通常越好。

清除:

  • 定義:因無效而清除快取的次數。
  • 清除原因:
    • 無效:來自伺服器的過時資料。
    • 空間管理:在快取已滿時為新資料釋出空間。
  • 數量高可能表示資料經常變更,且可能效率不彰。

未接通:

  • 定義:快取無法提供要求資料的次數。
  • 原因:
    • 快取效率不佳:快取空間太小或未儲存正確的資料。
    • 資料變更頻繁。
    • 首次要求。
  • 數量高表示可能有快取問題。

略過次數:

  • 定義:即使可以使用快取,但例項完全未使用快取。
  • 略過的原因:
    • Corking:針對 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 用戶端作者可以決定 memoization 的時間長度。

舉例來說,應用程式可以透過查詢每個繪製影格中的統計資料,向使用者顯示網路流量統計資料:

@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 秒內進行查詢,則會傳回上次查詢的值。這是因為 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);
}

裝置的這項屬性會在建構期間知曉,特別是在為這部裝置的啟動映像檔建構 Framework 時。hasSystemFeature 的用戶端程式碼可以立即傳回已知結果,而不需要查詢遠端 PackageManager 系統服務。

在用戶端中避免重複的伺服器回呼

最後,API 用戶端可以向 API 伺服器註冊回呼,以便收到事件通知。

應用程式通常會針對相同的基礎資訊註冊多個回呼。與其讓伺服器使用 IPC 向用戶端通知每個已註冊的回呼一次,不如讓用戶端程式庫使用 IPC 與伺服器建立一個已註冊的回呼,然後在應用程式中通知每個已註冊的回呼。

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"];
  }
}