Android API 客户端缓存准则

Android API 调用通常会导致每次调用都存在较长的延迟时间和较多的计算。因此,在设计实用、正确且高性能的 API 时,客户端缓存是一项重要考虑因素。

设计初衷

Android SDK 中向应用开发者公开的 API 通常在 Android 框架中实现为客户端代码,该代码会向平台进程中的系统服务发出 Binder IPC 调用,系统服务的任务是执行一些计算并将结果返回给客户端。此操作的延迟时间通常取决于三个因素:

  • IPC 开销:基本 IPC 调用的延迟时间通常是基本进程内方法调用的 1 万倍。
  • 服务器端争用:系统服务在响应客户端请求时执行的工作可能不会立即开始,例如,如果服务器线程正忙于处理先前到达的其他请求。
  • 服务器端计算:在服务器中处理请求的工作本身可能需要大量工作。

您可以在客户端实现缓存,从而消除这三个延迟时间因素,前提是缓存:

  • 正确答案:客户端缓存绝不会返回与服务器返回的结果不同的结果。
  • 有效:客户端请求通常从缓存中提供,例如缓存命中率较高。
  • 高效:客户端缓存可高效使用客户端资源,例如以紧凑的方式表示缓存数据,以及不会在客户端内存中存储过多缓存结果或过时数据。

考虑在客户端缓存服务器结果

如果客户端经常多次发出完全相同的请求,并且返回的值不会随时间而变化,则您应在客户端库中实现一个按请求参数键控的缓存。

在实现中考虑使用 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 软件包管理器更新,由于启动期间的调用量较大,因此刻意关闭缓存。
    • 未设置:缓存存在,但未初始化。未设置 Nonce,这意味着缓存从未失效。
    • 绕过:有意决定跳过缓存。
  • 计数较高表示缓存用量可能存在低效问题。

失效

  • 定义:将缓存数据标记为过时或过期的流程。
  • 重要性:提供一个信号,表明系统使用的是最新数据,从而防止出现错误和不一致的情况。
  • 通常由拥有数据的服务器触发。

当前大小

  • 定义:缓存中当前的元素数量。
  • 重要性:指示缓存的资源利用率以及对系统性能的潜在影响。
  • 值越高,通常表示缓存使用的内存越多。

大小上限

  • 定义:为缓存分配的空间上限。
  • 重要性:确定缓存的容量及其存储数据的能力。
  • 设置适当的最大大小有助于平衡缓存效率与内存用量。达到上限后,系统会通过驱逐最近使用次数最少的元素来添加新元素,这可能会导致效率低下。

高水位

  • 定义:缓存自创建以来达到的最大大小。
  • 重要性:提供有关峰值缓存用量和潜在内存压力的数据洞见。
  • 监控高水位有助于发现潜在的瓶颈或优化方面。

溢出

  • 定义:缓存超出其大小上限并必须逐出数据以便为新条目腾出空间的次数。
  • 重要性:表示缓存压力和因数据驱逐而导致的潜在性能下降。
  • 如果溢出次数较多,则表示可能需要调整缓存大小或重新评估缓存策略。

您还可以在 bug 报告中找到相同的统计信息。

调整缓存大小

缓存有大小上限。超出缓存大小上限时,系统会按 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 中的客户端代码可以选择每秒最多查询一次服务器的统计信息,如果在之前的查询后一秒内进行查询,则返回上次看到的值。这是允许的,因为 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 的注册回调,而不是让服务器针对每个注册回调使用 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"];
  }
}