Linee guida per la memorizzazione nella cache lato client dell'API Android

Le chiamate all'API Android in genere comportano latenza e calcoli significativi per ogni chiamata. La memorizzazione nella cache lato client è quindi un aspetto importante da tenere presente per progettare API utili, corrette e performanti.

Motivazione

Le API esposte agli sviluppatori di app nell'SDK Android sono spesso implementate come codice client nel framework Android che esegue una chiamata Binder IPC a un servizio di sistema in un processo della piattaforma, il cui compito è eseguire alcuni calcoli e restituire un risultato al client. La latenza di questa operazione è in genere dominata da tre fattori:

  • Overhead IPC: una chiamata IPC di base ha in genere una latenza 10.000 volte superiore a quella di una chiamata di metodo in-process di base.
  • Concorrenza lato server: il lavoro svolto nel servizio di sistema in risposta alla richiesta del client potrebbe non iniziare immediatamente, ad esempio se un thread del server è occupato a gestire altre richieste arrivate in precedenza.
  • Calcolo lato server: il lavoro necessario per gestire la richiesta sul server potrebbe richiedere un impegno significativo.

Puoi eliminare tutti e tre questi fattori di latenza implementando una cache lato client, a condizione che la cache sia:

  • Giusto: la cache lato client non restituisce mai risultati diversi da quelli che avrebbe restituito il server.
  • Efficace: le richieste del client vengono spesso pubblicate dalla cache, ad esempio la cache ha un tasso di hit elevato.
  • Efficace: la cache lato client utilizza in modo efficiente le risorse lato client, ad esempio rappresentando i dati memorizzati nella cache in modo compatto e non memorizzando troppi risultati memorizzati nella cache o dati non aggiornati nella memoria del client.

Valuta la possibilità di memorizzare nella cache i risultati del server nel client

Se i client inviano spesso la stessa richiesta più volte e il valore restituito non cambia nel tempo, devi implementare una cache nella libreria client basata sui parametri di richiesta.

Valuta la possibilità di utilizzare IpcDataCache nella tua implementazione:

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

Per un esempio completo, vedi android.app.admin.DevicePolicyManager.

IpcDataCache è disponibile per tutto il codice di sistema, inclusi i moduli principali. Esiste anche PropertyInvalidatedCache, che è quasi identico, ma è visibile solo al framework. Se possibile, preferisci IpcDataCache.

Invalidare le cache in caso di modifiche lato server

Se il valore restituito dal server può cambiare nel tempo, implementa un callback per osservare le modifiche e registra un callback in modo da poter invalidare la cache lato client di conseguenza.

Annullare la validità delle cache tra gli scenari di test delle unità

In una suite di test di unità, puoi testare il codice client su un doppio test piuttosto che sul server reale. In questo caso, assicurati di svuotare le cache lato client tra i casi di test. Questo serve a mantenere gli scenari di test mutuamente ermetici e a impedire che uno scenario di test interferisca con un altro.

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

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

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

    ...
}

Quando scrivi test CTS che eseguono un client API che utilizza la memorizzazione nella cache internamente, la cache è un dettaglio di implementazione non esposto all'autore dell'API, quindi i test CTS non dovrebbero richiedere alcuna conoscenza specifica della memorizzazione nella cache utilizzata nel codice client.

Studia le hit e le mancate hit della cache

IpcDataCache e PropertyInvalidatedCache possono stampare le statistiche in tempo reale:

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

Campi

Hit:

  • Definizione: il numero di volte in cui un dato richiesto è stato trovato correttamente nella cache.
  • Significato: indica un recupero rapido ed efficiente dei dati, riducendo il recupero di dati non necessari.
  • In genere, i conteggi più elevati sono migliori.

Cancellazioni:

  • Definizione: il numero di volte in cui la cache è stata svuotata a causa di un'invalidazione.
  • Motivi dell'approvazione:
    • Mancata convalida: dati obsoleti del server.
    • Gestione dello spazio: libera spazio per i nuovi dati quando la cache è piena.
  • Conteggi elevati potrebbero indicare dati in continua evoluzione e potenziale inefficienza.

Mancate corrispondenze:

  • Definizione: il numero di volte in cui la cache non è riuscita a fornire i dati richiesti.
  • Cause:
    • Memorizzazione nella cache inefficiente: cache troppo piccola o che non memorizza i dati corretti.
    • Dati che cambiano di frequente.
    • Richieste effettuate per la prima volta.
  • Conteggi elevati suggeriscono potenziali problemi di memorizzazione nella cache.

Salti:

  • Definizione: istanze in cui la cache non è stata utilizzata affatto, anche se poteva esserlo.
  • Motivi dell'interruzione:
    • Corking: specifico per gli aggiornamenti di Android Package Manager, disattiva deliberatamente la memorizzazione nella cache a causa di un volume elevato di chiamate durante l'avvio.
    • Non impostato: la cache esiste, ma non è stata inizializzata. Il nonce non è stato impostato, il che significa che la cache non è mai stata invalidata.
    • Ignora: decisione intenzionale di saltare la cache.
  • Conteggi elevati indicano potenziali inefficienze nell'utilizzo della cache.

Annullamento:

  • Definizione: il processo di indicazione dei dati memorizzati nella cache come obsoleti o non aggiornati.
  • Significato: indica che il sistema funziona con i dati più aggiornati, evitando errori e incoerenze.
  • In genere viene attivato dal server proprietario dei dati.

Dimensioni attuali:

  • Definizione: la quantità attuale di elementi in cache.
  • Significato: indica l'utilizzo delle risorse della cache e il potenziale impatto sulle prestazioni del sistema.
  • In genere, valori più elevati indicano che la cache utilizza più memoria.

Dimensione massima:

  • Definizione: lo spazio massimo allocato per la cache.
  • Significato: determina la capacità della cache e la sua capacità di archiviare dati.
  • L'impostazione di una dimensione massima appropriata consente di bilanciare l'efficacia della cache con l'utilizzo della memoria. Una volta raggiunta la dimensione massima, viene aggiunto un nuovo elemento eseguendo l'espulsione dell'elemento utilizzato meno di recente, il che può indicare un'inefficienza.

Livello massimo dell'acqua:

  • Definizione: le dimensioni massime raggiunte dalla cache dalla sua creazione.
  • Significato: fornisce informazioni sull'utilizzo massimo della cache e sulla potenziale pressione della memoria.
  • Il monitoraggio del picco può aiutarti a identificare potenziali colli di bottiglia o aree di ottimizzazione.

Overflow:

  • Definizione: il numero di volte in cui la cache ha superato la dimensione massima ed è stato necessario eliminare i dati per fare spazio a nuove voci.
  • Significato: indica la pressione sulla cache e il potenziale calo delle prestazioni dovuto all'espulsione dei dati.
  • Conteggi di overflow elevati suggeriscono che potrebbe essere necessario modificare le dimensioni della cache o rivalutare la strategia di memorizzazione nella cache.

Le stesse statistiche sono disponibili anche in una segnalazione di bug.

Ottimizzare le dimensioni della cache

Le cache hanno una dimensione massima. Quando viene superata la dimensione massima della cache, le voci vengono eliminate in ordine LRU.

  • La memorizzazione nella cache di un numero troppo ridotto di voci potrebbe influire negativamente sul tasso di hit della cache.
  • La memorizzazione nella cache di troppe voci aumenta l'utilizzo della memoria della cache.

Trova il giusto equilibrio per il tuo caso d'uso.

Elimina le chiamate client ridondanti

I client possono inviare la stessa query al server più volte in un breve periodo di tempo:

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

Valuta la possibilità di riutilizzare i risultati delle chiamate precedenti:

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

Valutare la memorizzazione lato client delle risposte recenti del server

Le app client potrebbero eseguire query sull'API a una velocità superiore a quella con cui il server dell'API può produrre risposte nuove e significative. In questo caso, un approccio efficace è memorizzare la risposta del server vista per ultima volta lato client insieme a un timestamp e restituire il risultato memorizzato nella cache senza eseguire query sul server se il risultato memorizzato nella cache è abbastanza recente. L'autore del client API può determinare la durata della memorizzazione nella cache.

Ad esempio, un'app potrebbe mostrare all'utente le statistiche sul traffico di rete tramite una query per le statistiche in ogni frame disegnato:

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

L'app potrebbe disegnare frame a 60 Hz. Tuttavia, in teoria, il codice client in TrafficStats potrebbe scegliere di eseguire query sul server per le statistiche al massimo una volta al secondo e, se viene eseguita una query entro un secondo da una query precedente, restituire l'ultimo valore visualizzato. Questo è consentito poiché la documentazione dell'API non fornisce alcun contratto in merito all'aggiornamento dei risultati restituiti.

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

Valuta la possibilità di utilizzare la generazione di codice lato client anziché le query del server

Se i risultati della query sono conoscibili al server in fase di compilazione, valuta se lo sono anche al client e se l'API può essere implementata interamente sul lato client.

Prendi in considerazione il seguente codice dell'app che controlla se il dispositivo è uno smartwatch (ovvero se è in esecuzione su Wear OS):

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

Questa proprietà del dispositivo è nota al momento della compilazione, in particolare al momento della compilazione del framework per l'immagine di avvio del dispositivo. Il codice lato client per hasSystemFeature potrebbe restituire immediatamente un risultato noto anziché eseguire una query sul servizio di sistema PackageManager remoto.

Deduplica i callback del server nel client

Infine, il client API può registrare i callback con il server API per ricevere notifiche degli eventi.

È normale che le app registrino più callback per le stesse informazioni di base. Anziché consentire al server di inviare una notifica al client una volta per ogni callback registrato utilizzando IPC, la libreria client deve avere un callback registrato utilizzando IPC con il server e poi inviare una notifica a ogni callback registrato nell'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"];
  }
}