Lineamientos de almacenamiento en caché del cliente de la API de Android

Por lo general, las llamadas a la API de Android implican una latencia y un procesamiento significativos por invocación. Por lo tanto, el almacenamiento en caché del cliente es una consideración importante en el diseño de APIs útiles, correctas y con buen rendimiento.

Motivación

Las APIs expuestas a los desarrolladores de apps en el SDK de Android suelen implementarse como código de cliente en el framework de Android que realiza una llamada de IPC de Binder a un servicio del sistema en un proceso de plataforma, cuyo trabajo es realizar algún procesamiento y mostrar un resultado al cliente. Por lo general, la latencia de esta operación está dominada por tres factores:

  • Sobrecarga de IPC: Una llamada de IPC básica suele ser 10,000 veces la latencia de una llamada de método básica en el proceso.
  • Contención del servidor: Es posible que el trabajo realizado en el servicio del sistema en respuesta a la solicitud del cliente no comience de inmediato, por ejemplo, si un subproceso del servidor está ocupado controlando otras solicitudes que llegaron antes.
  • Procesamiento del servidor: El trabajo en sí para controlar la solicitud en el servidor podría requerir un trabajo significativo.

Puedes eliminar estos tres factores de latencia si implementas una caché en el cliente, siempre que la caché sea lo siguiente:

  • Correcta: La caché del cliente nunca muestra resultados diferentes de los que habría mostrado el servidor.
  • Eficaz: Las solicitudes del cliente suelen publicarse desde la caché, por ejemplo, la caché tiene una tasa de aciertos alta.
  • Eficiente: La caché del cliente hace un uso eficiente de los recursos del cliente, por ejemplo, representa los datos almacenados en caché de forma compacta y no almacena demasiados resultados almacenados en caché ni datos obsoletos en la memoria del cliente.

Considera almacenar en caché los resultados del servidor en el cliente

Si los clientes suelen realizar la misma solicitud varias veces y el valor que se muestra no cambia con el tiempo, debes implementar una caché en la biblioteca cliente con clave en los parámetros de la solicitud.

Considera usar IpcDataCache en tu implementación:

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);
    }
}

Para obtener un ejemplo completo, consulta android.app.admin.DevicePolicyManager.

IpcDataCache está disponible para todo el código del sistema, incluidos los módulos de la línea principal. También existe PropertyInvalidatedCache, que es casi idéntico, pero solo es visible para el framework. Prefiere IpcDataCache cuando sea posible.

Invalida las cachés en los cambios del servidor

Si el valor que se muestra desde el servidor puede cambiar con el tiempo, implementa una devolución de llamada para observar los cambios y registra una devolución de llamada para que puedas invalidar la caché del cliente en consecuencia.

Invalida las cachés entre los casos de prueba de unidades

En un paquete de pruebas, puedes probar el código del cliente con un doble de prueba en lugar del servidor real. Si es así, asegúrate de borrar las cachés del cliente entre los casos de prueba. Esto es para mantener los casos de prueba mutuamente herméticos y evitar que un caso de prueba interfiera con otro.

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

    @Before
    public void setUp() {
        BirthdayManager.clearCache();
    }

    @After
    public void tearDown() {
        BirthdayManager.clearCache();
    }

    ...
}

Cuando se escriben pruebas de CTS que ejercen un cliente de API que usa el almacenamiento en caché de forma interna, la caché es un detalle de implementación que no se expone al autor de la API. Por lo tanto, las pruebas de CTS no deben requerir ningún conocimiento especial del almacenamiento en caché que se usa en el código del cliente.

Estudia los aciertos y errores de caché

IpcDataCache y PropertyInvalidatedCache pueden imprimir estadísticas en vivo:

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

Campos

Aciertos:

  • Definición: Es la cantidad de veces que se encontró correctamente un fragmento de datos solicitado dentro de la caché.
  • Importancia: Indica una recuperación de datos eficiente y rápida, lo que reduce la recuperación de datos innecesaria.
  • Por lo general, las cantidades más altas son mejores.

Borra:

  • Definición: Es la cantidad de veces que se borró la caché debido a la invalidación.
  • Motivos para borrar:
    • Invalidación: Datos obsoletos del servidor
    • Administración del espacio: Liberar espacio para datos nuevos cuando la caché está llena
  • Las cantidades altas podrían indicar datos que cambian con frecuencia y una posible ineficiencia.

Errores:

  • Definición: Es la cantidad de veces que la caché no pudo proporcionar los datos solicitados.
  • Causas:
    • Almacenamiento en caché ineficiente: La caché es demasiado pequeña o no almacena los datos correctos.
    • Datos que cambian con frecuencia
    • Solicitudes por primera vez
  • Las cantidades altas sugieren posibles problemas de almacenamiento en caché.

Omisiones:

  • Definición: Son las instancias en las que no se usó la caché, aunque podría haberse usado.
  • Motivos para omitir:
    • Corking: Específico para las actualizaciones de Android Package Manager, que desactiva deliberadamente el almacenamiento en caché debido a un gran volumen de llamadas durante el arranque.
    • Sin configurar: La caché existe, pero no se inicializó. El nonce no se configuró, lo que significa que la caché nunca se invalidó.
    • Omisión: Decisión intencional de omitir la caché
  • Las cantidades altas indican posibles ineficiencias en el uso de la caché.

Invalida:

  • Definición: Es el proceso de marcar los datos almacenados en caché como obsoletos.
  • Importancia: Proporciona una señal de que el sistema funciona con los datos más actualizados, lo que evita errores e incoherencias.
  • Por lo general, se activa en el servidor que posee los datos.

Tamaño actual:

  • Definición: Es la cantidad actual de elementos en la caché.
  • Importancia: Indica el uso de recursos de la caché y el posible impacto en el rendimiento del sistema.
  • Por lo general, los valores más altos significan que la caché usa más memoria.

Tamaño máximo:

  • Definición: Es la cantidad máxima de espacio asignado para la caché.
  • Importancia: Determina la capacidad de la caché y su capacidad para almacenar datos.
  • Establecer un tamaño máximo adecuado ayuda a equilibrar la eficacia de la caché con el uso de memoria. Una vez que se alcanza el tamaño máximo, se agrega un elemento nuevo expulsando el elemento usado más recientemente, lo que puede indicar ineficiencia.

Marca de agua alta:

  • Definición: Es el tamaño máximo que alcanzó la caché desde su creación.
  • Importancia: Proporciona estadísticas sobre el uso máximo de la caché y la posible presión de la memoria.
  • Supervisar la marca de agua alta puede ayudar a identificar posibles cuellos de botella o áreas de optimización.

Desbordamientos:

  • Definición: Es la cantidad de veces que la caché superó su tamaño máximo y tuvo que expulsar datos para liberar espacio para entradas nuevas.
  • Importancia: Indica la presión de la caché y la posible degradación del rendimiento debido a la expulsión de datos.
  • Las cantidades altas de desbordamiento sugieren que es posible que se deba ajustar el tamaño de la caché o volver a evaluar la estrategia de almacenamiento en caché.

Las mismas estadísticas también se pueden encontrar en un informe de errores.

Ajusta el tamaño de la caché

Las cachés tienen un tamaño máximo. Cuando se supera el tamaño máximo de la caché, las entradas se expulsan en orden LRU.

  • Almacenar en caché muy pocas entradas podría afectar negativamente la tasa de aciertos de caché.
  • Almacenar en caché demasiadas entradas aumenta el uso de memoria de la caché.

Encuentra el equilibrio adecuado para tu caso de uso.

Elimina las llamadas de cliente redundantes

Los clientes pueden realizar la misma consulta al servidor varias veces en un período corto:

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();
  }
}

Considera reutilizar los resultados de las llamadas anteriores:

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();
  }
}

Considera la memoización del cliente de las respuestas recientes del servidor

Las apps cliente pueden consultar la API a una velocidad más rápida que la que el servidor de la API puede producir respuestas nuevas significativas. En este caso, un enfoque eficaz es memoizar la última respuesta del servidor vista en el cliente junto con una marca de tiempo y mostrar el resultado memoizado sin consultar al servidor si el resultado memoizado es lo suficientemente reciente. El autor del cliente de API puede determinar la duración de la memoización.

Por ejemplo, una app puede mostrar estadísticas de tráfico de red al usuario consultando las estadísticas en cada fotograma dibujado:

@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()));
}

La app puede dibujar fotogramas a 60 Hz. Sin embargo, hipotéticamente, el código del cliente en TrafficStats puede optar por consultar al servidor para obtener estadísticas como máximo una vez por segundo y, si se consulta dentro de un segundo de una consulta anterior, mostrar el último valor visto. Esto se permite, ya que la documentación de la API no proporciona ningún contrato sobre la actualidad de los resultados que se muestran.

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

Considera la generación de código del cliente en lugar de las consultas del servidor

Si el servidor puede conocer los resultados de la consulta en el tiempo de compilación, considera si el cliente también puede conocerlos en el tiempo de compilación y si la API se podría implementar por completo del lado del cliente.

Considera el siguiente código de la app que verifica si el dispositivo es un reloj (es decir, si el dispositivo ejecuta Wear OS):

public boolean isWatch(Context ctx) {
    PackageManager pm = ctx.getPackageManager();
    return pm.hasSystemFeature(PackageManager.FEATURE_WATCH);
}

Esta propiedad del dispositivo se conoce en el tiempo de compilación, específicamente en el momento en que se compiló el framework para la imagen de arranque de este dispositivo. El código del cliente para hasSystemFeature podría mostrar un resultado conocido de inmediato, en lugar de consultar el servicio del sistema PackageManager remoto.

Elimina las devoluciones de llamada del servidor duplicadas en el cliente

Por último, el cliente de API puede registrar devoluciones de llamada con el servidor de la API para recibir notificaciones de eventos.

Es habitual que las apps registren varias devoluciones de llamada para la misma información subyacente. En lugar de que el servidor notifique al cliente una vez por devolución de llamada registrada con IPC, la biblioteca cliente debe tener una devolución de llamada registrada con IPC con el servidor y, luego, notificar cada devolución de llamada registrada en la app.

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