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
  ...

字段

命中

  • 定义:在缓存中成功找到所请求的数据块的次数。
  • 重要性:表示高效快速地检索数据,减少不必要的数据检索。
  • 计数越高通常越好。

清除

  • 定义:因失效而清除缓存的次数。
  • 清除原因:
    • 失效:服务器中的过时数据。
    • 空间管理:在缓存已满时为新数据腾出空间。
  • 计数过高可能表示数据频繁更改,并且可能效率低下。

未命中

  • 定义:缓存未能提供所请求数据的次数。
  • 原因:
    • 缓存效率低下:缓存太小或未存储正确的数据。
    • 数据频繁更改。
    • 首次请求。
  • 计数过高表示可能存在缓存问题。

跳过

  • 定义:即使可以使用缓存,但缓存根本未使用的实例。
  • 跳过原因:
    • 软塞:特定于 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 客户端作者可以确定记忆化持续时间。

例如,应用可以通过在绘制的每个帧中查询统计信息,向用户显示网络流量统计信息:

@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

考虑使用客户端代码生成,而不是服务器查询

如果服务器在构建时知道查询结果,请考虑客户端在构建时是否也知道这些结果,并考虑是否可以在客户端完全实现 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"];
  }
}