Richtlinien für das clientseitige Caching der Android API

Android API-Aufrufe sind in der Regel mit erheblicher Latenz und Rechenleistung pro Aufruf verbunden. Clientseitiges Caching ist daher ein wichtiger Aspekt bei der Entwicklung von APIs, die hilfreich, korrekt und leistungsstark sind.

Motivation

APIs, die App-Entwicklern im Android SDK zur Verfügung gestellt werden, werden häufig als Clientcode im Android Framework implementiert. Dieser Code führt einen Binder IPC-Aufruf an einen Systemdienst in einem Plattformprozess aus. Dieser Dienst führt eine Berechnung durch und gibt ein Ergebnis an den Client zurück. Die Latenz dieser Operation wird in der Regel von drei Faktoren bestimmt:

  • IPC-Overhead: Ein einfacher IPC-Aufruf ist in der Regel 10.000 Mal so langsam wie ein einfacher Methodenaufruf im Prozess.
  • Serverseitige Konflikte: Die Arbeit, die im Systemdienst als Reaktion auf die Anfrage des Clients ausgeführt wird, kann nicht sofort beginnen. Das ist beispielsweise der Fall, wenn ein Serverthread mit der Bearbeitung anderer Anfragen beschäftigt ist, die früher eingegangen sind.
  • Serverseitige Berechnung: Die Arbeit selbst, um die Anfrage auf dem Server zu bearbeiten, kann erheblich sein.

Sie können alle drei Latenzfaktoren eliminieren, indem Sie einen Cache auf der Clientseite implementieren. Voraussetzung dafür ist, dass der Cache:

  • Korrekt ist: Der clientseitige Cache gibt niemals Ergebnisse zurück, die sich von den Ergebnissen unterscheiden, die der Server zurückgegeben hätte.
  • Effektiv ist: Clientanfragen werden häufig aus dem Cache bedient. Der Cache hat beispielsweise eine hohe Trefferquote.
  • Effizient ist: Der clientseitige Cache nutzt clientseitige Ressourcen effizient, z. B. durch eine kompakte Darstellung der im Cache gespeicherten Daten und durch die Speicherung einer angemessenen Anzahl von im Cache gespeicherten Ergebnissen oder veralteten Daten im Arbeitsspeicher des Clients.

Serverseitige Ergebnisse im Client cachen

Wenn Clients dieselbe Anfrage häufig mehrmals senden und sich der zurückgegebene Wert im Laufe der Zeit nicht ändert, sollten Sie in der Clientbibliothek einen Cache implementieren, der anhand der Anfrageparameter verschlüsselt wird.

Verwenden Sie in Ihrer Implementierung 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);
    }
}

Ein vollständiges Beispiel finden Sie unter android.app.admin.DevicePolicyManager.

IpcDataCache ist für den gesamten Systemcode verfügbar, einschließlich Mainline-Module. Es gibt auch PropertyInvalidatedCache, der fast identisch ist, aber nur für das Framework sichtbar ist. Verwenden Sie nach Möglichkeit IpcDataCache.

Caches bei serverseitigen Änderungen entwerten

Wenn sich der vom Server zurückgegebene Wert im Laufe der Zeit ändern kann, implementieren Sie einen Callback zur Beobachtung von Änderungen und registrieren Sie einen Callback, damit Sie den clientseitigen Cache entsprechend entwerten können.

Caches zwischen Unit-Testfällen entwerten

In einer Unit-Testsuite können Sie den Clientcode mit einem Test-Double anstelle des echten Servers testen. Wenn das der Fall ist, müssen Sie alle clientseitigen Caches zwischen den Testfällen leeren. So bleiben die Testfälle voneinander isoliert und ein Testfall kann einen anderen nicht beeinträchtigen.

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

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

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

    ...
}

Wenn Sie CTS-Tests schreiben, die einen API-Client verwenden, der intern Caching nutzt, ist der Cache ein Implementierungsdetail, das dem API-Autor nicht zur Verfügung steht. Daher sollten CTS-Tests keine besonderen Kenntnisse über das Caching erfordern, das im Clientcode verwendet wird.

Cache-Treffer und Cache-Fehler untersuchen

IpcDataCache und PropertyInvalidatedCache können Live-Statistiken ausgeben:

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

Felder

Treffer :

  • Definition: Die Anzahl der Fälle, in denen ein angeforderter Datenteil erfolgreich im Cache gefunden wurde.
  • Bedeutung: Gibt einen effizienten und schnellen Abruf von Daten an, wodurch unnötige Datenabrufe reduziert werden.
  • Höhere Werte sind im Allgemeinen besser.

Löschungen :

  • Definition: Die Anzahl der Fälle, in denen der Cache aufgrund einer Entwertung geleert wurde.
  • Gründe für das Leeren:
    • Entwertung: Veraltete Daten vom Server.
    • Speicherverwaltung: Platz für neue Daten schaffen, wenn der Cache voll ist.
  • Hohe Werte können auf häufig wechselnde Daten und potenzielle Ineffizienzen hinweisen.

Fehler :

  • Definition: Die Anzahl der Fälle, in denen der Cache die angeforderten Daten nicht bereitstellen konnte.
  • Ursachen:
    • Ineffizientes Caching: Cache zu klein oder falsche Daten werden gespeichert.
    • Häufig wechselnde Daten.
    • Erstanfragen.
  • Hohe Werte deuten auf potenzielle Caching-Probleme hin.

Übersprungen :

  • Definition: Fälle, in denen der Cache überhaupt nicht verwendet wurde, obwohl er hätte verwendet werden können.
  • Gründe für das Überspringen:
    • Corking: Spezifisch für Android Package Manager-Updates. Caching wird bewusst deaktiviert, da während des Bootvorgangs eine hohe Anzahl von Aufrufen erfolgt.
    • Nicht festgelegt: Cache vorhanden, aber nicht initialisiert. Die Nonce wurde nicht festgelegt, was bedeutet, dass der Cache noch nie entwertet wurde.
    • Umgehen: Bewusste Entscheidung, den Cache zu überspringen.
  • Hohe Werte deuten auf potenzielle Ineffizienzen bei der Cache-Nutzung hin.

Entwertungen :

  • Definition: Der Prozess, bei dem im Cache gespeicherte Daten als veraltet oder verbraucht markiert werden.
  • Bedeutung: Gibt an, dass das System mit den aktuellsten Daten arbeitet, wodurch Fehler und Inkonsistenzen vermieden werden.
  • Wird in der Regel vom Server ausgelöst, der Eigentümer der Daten ist.

Aktuelle Größe :

  • Definition: Die aktuelle Anzahl der Elemente im Cache.
  • Bedeutung: Gibt die Ressourcennutzung des Caches und die potenziellen Auswirkungen auf die Systemleistung an.
  • Höhere Werte bedeuten in der Regel, dass mehr Arbeitsspeicher vom Cache verwendet wird.

Maximale Größe :

  • Definition: Die maximale Größe des für den Cache zugewiesenen Speicherplatzes.
  • Bedeutung: Bestimmt die Kapazität des Caches und seine Fähigkeit, Daten zu speichern.
  • Wenn Sie eine angemessene maximale Größe festlegen, können Sie die Cache-Effizienz mit der Arbeitsspeichernutzung in Einklang bringen. Sobald die maximale Größe erreicht ist, wird ein neues Element hinzugefügt, indem das zuletzt verwendete Element entfernt wird. Dies kann auf Ineffizienz hindeuten.

Höchststand :

  • Definition: Die maximale Größe, die der Cache seit seiner Erstellung erreicht hat.
  • Bedeutung: Bietet Einblicke in die maximale Cache-Nutzung und den potenziellen Arbeitsspeicherdruck.
  • Durch die Überwachung des Höchststands können potenzielle Engpässe oder Bereiche für die Optimierung ermittelt werden.

Überläufe :

  • Definition: Die Anzahl der Fälle, in denen der Cache seine maximale Größe überschritten hat und Daten entfernen musste, um Platz für neue Einträge zu schaffen.
  • Bedeutung: Gibt den Cache-Druck und die potenzielle Leistungsbeeinträchtigung aufgrund des Entfernens von Daten an.
  • Hohe Überlaufwerte deuten darauf hin, dass die Cache-Größe angepasst oder die Caching-Strategie neu bewertet werden muss.

Dieselben Statistiken finden Sie auch in einem Fehlerbericht.

Cache-Größe anpassen

Caches haben eine maximale Größe. Wenn die maximale Cache-Größe überschritten wird, werden Einträge in LRU-Reihenfolge entfernt.

  • Wenn zu wenige Einträge im Cache gespeichert werden, kann sich das negativ auf die Cache-Trefferquote auswirken.
  • Wenn zu viele Einträge im Cache gespeichert werden, erhöht sich die Arbeitsspeichernutzung des Caches.

Finden Sie die richtige Balance für Ihren Anwendungsfall.

Redundante Clientaufrufe eliminieren

Clients können dieselbe Abfrage innerhalb kurzer Zeit mehrmals an den Server senden:

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

Ergebnisse aus vorherigen Aufrufen wiederverwenden:

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

Clientseitige Memoization der letzten Serverantworten in Betracht ziehen

Client-Apps fragen die API möglicherweise schneller ab, als der Server der API sinnvolle neue Antworten liefern kann. In diesem Fall ist es sinnvoll, die letzte Serverantwort zusammen mit einem Zeitstempel auf der Clientseite zu speichern und das gespeicherte Ergebnis zurückzugeben, ohne den Server abzufragen, wenn das gespeicherte Ergebnis aktuell genug ist. Der Autor des API-Clients kann die Memoization-Dauer festlegen.

Eine App kann dem Nutzer beispielsweise Statistiken zum Netzwerk-Traffic anzeigen, indem sie in jedem Frame nach den Statistiken fragt:

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

Die App kann Frames mit 60 Hz zeichnen. Hypothetisch kann der Clientcode in TrafficStats den Server jedoch höchstens einmal pro Sekunde nach Statistiken fragen. Wenn innerhalb einer Sekunde nach einer vorherigen Abfrage eine weitere Abfrage erfolgt, wird der zuletzt gesehene Wert zurückgegeben. Das ist zulässig, da die API-Dokumentation keine Vereinbarung bezüglich der Aktualität der zurückgegebenen Ergebnisse enthält.

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

Clientseitige Codegenerierung anstelle von Serverabfragen in Betracht ziehen

Wenn die Abfrageergebnisse dem Server zur Build-Zeit bekannt sind, sollten Sie prüfen, ob sie auch dem Client zur Build-Zeit bekannt sind, und überlegen, ob die API vollständig auf der Clientseite implementiert werden kann.

Betrachten Sie den folgenden App-Code, der prüft, ob das Gerät eine Smartwatch ist (d. h., ob auf dem Gerät Wear OS ausgeführt wird):

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

Diese Eigenschaft des Geräts ist zur Build-Zeit bekannt, insbesondere zu dem Zeitpunkt, zu dem das Framework für das Boot-Image dieses Geräts erstellt wurde. Der clientseitige Code für hasSystemFeature könnte sofort ein bekanntes Ergebnis zurückgeben, anstatt den Remote-Systemdienst PackageManager abzufragen.

Serverseitige Callbacks im Client deduplizieren

Schließlich kann der API-Client Callbacks beim API-Server registrieren, um über Ereignisse benachrichtigt zu werden.

In der Regel registrieren Apps mehrere Callbacks für dieselben zugrunde liegenden Informationen. Anstatt den Client einmal pro registriertem Callback über IPC zu benachrichtigen, sollte die Clientbibliothek einen registrierten Callback über IPC mit dem Server haben und dann jeden registrierten Callback in der App benachrichtigen.

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