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 可供所有系统代码(包括 Mainline 模块)使用。还有一个几乎完全相同的 PropertyInvalidatedCache,但它仅对框架可见。尽可能使用 IpcDataCache

在服务器端更改时使缓存失效

如果从服务器返回的值可能会随时间变化,请实现一个用于观察变化的 callback,并注册一个 callback,以便您可以相应地使客户端缓存失效。

使单元测试用例之间的缓存失效

在单元测试套件中,您可能会针对测试替身(而非真实服务器)测试客户端代码。如果是,请务必在测试用例之间清除所有客户端缓存。这是为了保持测试用例之间的相互封闭性,防止一个测试用例干扰另一个测试用例。

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

使以下内容失效

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

当前大小

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

大小上限

  • 定义:为缓存分配的空间上限。
  • 意义:决定了缓存的容量及其存储数据的能力。
  • 设置适当的最大大小有助于平衡缓存效果与内存使用情况。达到最大尺寸后,系统会通过逐出最近最少使用的元素来添加新元素,这可能表明效率低下。

High Water Mark

  • 定义:自创建以来,缓存达到的最大大小。
  • 意义:可深入了解缓存使用峰值和潜在的内存压力。
  • 监控最高水位有助于发现潜在的瓶颈或可优化的方面。

溢出

  • 定义:缓存超出其最大大小并不得不逐出数据以腾出空间来容纳新条目的次数。
  • 重要性:表示缓存压力以及因数据逐出而导致的潜在性能下降。
  • 溢出次数过高表明可能需要调整缓存大小或重新评估缓存策略。

您还可以在 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"];
  }
}