Wytyczne dotyczące buforowania po stronie klienta w interfejsie API Androida

Wywołania interfejsu API Androida zwykle wiążą się z znaczną latencją i przetwarzaniem na wywołanie. Buforowanie po stronie klienta jest więc ważnym elementem projektowania interfejsów API, które są przydatne, poprawne i wydajne.

Motywacja

Interfejsy API udostępniane deweloperom aplikacji w pakiecie Android SDK są często implementowane jako kod klienta w ramach Android Framework, który wywołuje wywołanie Binder IPC do usługi systemowej w ramach procesu platformy. Zadaniem tej usługi jest wykonanie pewnych obliczeń i zwrócenie wyniku do klienta. Czas oczekiwania na wykonanie tej operacji zależy zwykle od 3 czynników:

  • Nadmiar IPC: podstawowe wywołanie IPC ma zwykle o 10 000 razy dłuższy czas oczekiwania niż podstawowe wywołanie metody w procesie.
  • Spór po stronie serwera: praca wykonana w ramach usługi systemowej w odpowiedzi na żądanie klienta może nie rozpocząć się natychmiast, na przykład jeśli wątek serwera jest zajęty obsługą innych żądań, które dotarły wcześniej.
  • Obliczenia po stronie serwera: przetwarzanie żądania na serwerze może wymagać znacznego nakładu pracy.

Możesz wyeliminować wszystkie te czynniki opóźnienia, stosując pamięć podręczną po stronie klienta, pod warunkiem że:

  • Prawidłowo: pamięć podręczna po stronie klienta nigdy nie zwraca wyników, które różniłyby się od tych zwracanych przez serwer.
  • Skuteczne: żądania klienta są często obsługiwane z poziomu pamięci podręcznej, np. pamięć podręczna ma wysoki współczynnik trafień.
  • Wydajność: pamięć podręczna po stronie klienta efektywnie wykorzystuje zasoby po stronie klienta, np. przedstawiając dane w pamięci podręcznej w sposób zwięzły i nie przechowując zbyt wielu zasobów w pamięci podręcznej lub nieaktualnych danych.

Zastosowanie pamięci podręcznej wyników serwera w kliencie

Jeśli klienci często wysyłają dokładnie to samo żądanie wielokrotnie, a zwracana wartość nie zmienia się z czasem, należy zaimplementować w bibliotece klienta pamięć podręczną z kluczami opartymi na parametrach żądania.

W swojej implementacji możesz użyć 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);
    }
}

Pełny przykład znajdziesz w sekcji android.app.admin.DevicePolicyManager.

IpcDataCache jest dostępny dla całego kodu systemowego, w tym modułów głównych. Jest też PropertyInvalidatedCache, która jest prawie identyczna, ale widoczna tylko dla frameworka. Jeśli to możliwe, używaj IpcDataCache.

Unieważnianie pamięci podręcznej po zmianach po stronie serwera

Jeśli wartość zwracana przez serwer może się zmienić w czasie, wprowadź wywołanie zwrotne do obserwowania zmian i zarejestruj wywołanie zwrotne, aby odpowiednio unieważnić pamięć podręczną po stronie klienta.

Unieważnianie pamięci podręcznej między przypadkami testów jednostkowych

W zestawie testów jednostkowych możesz testować kod klienta na podwójnym serwerze testowym, a nie na prawdziwym serwerze. Jeśli tak, pamiętaj, aby wykasować pamięć podręczną po stronie klienta między testami. Dzięki temu przypadki testowe są hermetyczne i nie wpływają na siebie nawzajem.

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

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

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

    ...
}

Podczas pisania testów CTS, które sprawdzają klienta interfejsu API, który korzysta z pamięci podręcznej wewnętrznie, pamięć podręczna jest szczegółem implementacji, który nie jest widoczny dla autora interfejsu API. Dlatego testy CTS nie wymagają żadnej szczególnej wiedzy o pamięci podręcznej używanej w kodzie klienta.

Badanie trafień i błędów pamięci podręcznej

IpcDataCache i PropertyInvalidatedCache mogą drukować statystyki na żywo:

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

Fieldsem

Wyniki:

  • Definicja: liczba przypadków, w których żądane dane zostały znalezione w pamięci podręcznej.
  • Znaczenie: wskazuje na wydajne i szybkie pobieranie danych, co ogranicza niepotrzebne pobieranie danych.
  • Im większa liczba, tym lepiej.

Wyczyszcza:

  • Definicja: liczba przypadków wyczyszczenia pamięci podręcznej z powodu unieważnienia.
  • Przyczyny usunięcia:
    • Nieważność: nieaktualne dane z serwera.
    • Zarządzanie miejscem: tworzenie miejsca na nowe dane, gdy pamięć podręczna jest pełna.
  • Wysokie wartości mogą wskazywać na często zmieniające się dane i potencjalną nieefektywność.

Błędy:

  • Definicja: liczba przypadków, w których pamięć podręczna nie dostarczyła żądanych danych.
  • Przyczyny:
    • Nieefektywne buforowanie: bufor jest za mały lub nie przechowuje odpowiednich danych.
    • dane, które często się zmieniają;
    • Prośby o dołączenie po raz pierwszy.
  • Duża liczba może wskazywać na potencjalne problemy z buforowaniem.

Pominięcia:

  • Definicja: przypadki, w których pamięć podręczna nie była w ogóle używana, mimo że można było z niej korzystać.
  • Przyczyny pominięcia:
    • Corking: dotyczy aktualizacji Menedżera pakietów Androida, celowe wyłączenie buforowania z powodu dużej liczby wywołań podczas uruchamiania.
    • Nie ustawione: pamięć podręczna istnieje, ale nie została zainicjowana. Wartość nonce nie została ustawiona, co oznacza, że pamięć podręczna nigdy nie została unieważniona.
    • Pomiń: celowe pominięcie pamięci podręcznej.
  • Wysokie wartości wskazują na potencjalne nieefektywne wykorzystanie pamięci podręcznej.

Uniemożliwia:

  • Definicja: proces oznaczania danych w pamięci podręcznej jako nieaktualnych lub nieużytecznych.
  • Znaczenie: sygnał, że system działa z najnowszymi danymi, co zapobiega błędom i niespójnościom.
  • Zwykle jest wywoływany przez serwer, który jest właścicielem danych.

Aktualny rozmiar:

  • Definicja: bieżąca liczba elementów w pamięci podręcznej.
  • Znaczenie: wskazuje wykorzystanie zasobów przez pamięć podręczną i potencjalny wpływ na wydajność systemu.
  • Wyższe wartości oznaczają, że pamięć podręczna wykorzystuje więcej pamięci.

Maksymalny rozmiar:

  • Definicja: maksymalna ilość miejsca przydzielona do pamięci podręcznej.
  • Znaczenie: określa pojemność pamięci podręcznej i jej zdolność do przechowywania danych.
  • Ustawienie odpowiedniego maksymalnego rozmiaru pomaga zrównoważyć skuteczność pamięci podręcznej z zużyciem pamięci. Gdy osiągnięta zostanie maksymalna wielkość, nowy element jest dodawany przez usunięcie elementu używanego najrzadziej, co może wskazywać na nieefektywność.

Wysoki stan wody:

  • Definicja: maksymalny rozmiar osiągnięty przez pamięć podręczną od momentu jej utworzenia.
  • Znaczenie: dostarcza informacji o szczytowym wykorzystaniu pamięci podręcznej i potencjalnym wykorzystaniu pamięci.
  • Monitorowanie poziomu szczytowego może pomóc w identyfikowaniu potencjalnych wąskich gardeł lub obszarów, które można zoptymalizować.

Przepełnienia:

  • Definicja: liczba przypadków, w których pamięć podręczna przekroczyła swój maksymalny rozmiar i musiała usunąć dane, aby zrobić miejsce na nowe wpisy.
  • Znaczenie: wskazuje na obciążenie pamięci podręcznej i potencjalne pogorszenie wydajności spowodowane wyrzucaniem danych.
  • Wysokie wartości przepełnienia wskazują, że może być konieczne dostosowanie rozmiaru pamięci podręcznej lub ponowne przeanalizowanie strategii buforowania.

Te same statystyki możesz też znaleźć w raporcie o błędach.

Dostosowywanie rozmiaru pamięci podręcznej

Pamięć podręczna ma ograniczony rozmiar. Gdy przekroczysz maksymalny rozmiar pamięci podręcznej, wpisy są usuwane zgodnie z zasadą LRU.

  • Buforowanie zbyt małej liczby wpisów może negatywnie wpłynąć na współczynnik trafień do pamięci podręcznej.
  • Przechowywanie w pamięci podręcznej zbyt wielu wpisów zwiększa wykorzystanie pamięci.

Znajdź odpowiednią równowagę dla swojego przypadku użycia.

Usuwanie zbędnych wywołań klienta

Klienci mogą wysyłać to samo zapytanie do serwera wielokrotnie w krótkim czasie:

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

Możesz użyć wyników z poprzednich wywołań:

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

Rozważ zapamiętanie ostatnich odpowiedzi serwera po stronie klienta

Aplikacje klienckie mogą wysyłać zapytania do interfejsu API szybciej niż serwer interfejsu API może generować nowe odpowiedzi. W takiej sytuacji skutecznym podejściem jest zapamiętanie ostatniej odpowiedzi serwera po stronie klienta wraz z stemplą czasową i zwrócenie zapamiętanego wyniku bez wysyłania zapytania do serwera, jeśli jest on wystarczająco aktualny. Autor klienta interfejsu API może określić czas trwania zapamiętywania.

Aplikacja może na przykład wyświetlać użytkownikowi statystyki ruchu w sieci, wysyłając zapytanie o statystyki w każdej generowanej klatce:

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

Aplikacja może rysować klatki z częstotliwością 60 Hz. Jednak hipotetycznie kod klienta w TrafficStats może wysłać do serwera zapytanie o statystyki najwyżej raz na sekundę. Jeśli zapytanie zostanie wysłane w ciągu sekundy od poprzedniego zapytania, zostanie zwrócona ostatnia wartość. Jest to dozwolone, ponieważ dokumentacja interfejsu API nie zawiera żadnych informacji dotyczących aktualności zwracanych wyników.

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

Zamiast zapytań serwera rozważ użycie generowania kodu po stronie klienta

Jeśli wyniki zapytań są znane serwerowi w momencie kompilacji, zastanów się, czy są one również znane klientowi w momencie kompilacji, i zastosuj interfejs API całkowicie po stronie klienta.

Rozważ ten kod aplikacji, który sprawdza, czy urządzenie jest zegarkiem (czyli czy działa na Wear OS):

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

Ta właściwość urządzenia jest znana w momencie kompilacji, a precyzyjniej w momencie kompilacji ramy dla obrazu rozruchowego urządzenia. Kod po stronie klientahasSystemFeature może zwracać znany wynik natychmiast, zamiast wysyłać zapytanie do zdalnej usługi systemu PackageManager.

usuwanie duplikatów wywołań zwrotnych serwera w kliencie.

Na koniec klient interfejsu API może zarejestrować na serwerze interfejsu API wywołania zwrotne, aby otrzymywać powiadomienia o zdarzeniach.

Aplikacje często rejestrują wiele wywołań zwrotnych dla tej samej informacji. Zamiast powiadamiania klienta raz na zarejestrowany wywołanie zwrotne za pomocą IPC, biblioteka klienta powinna mieć jedno zarejestrowane wywołanie zwrotne za pomocą IPC z serwerem, a następnie powiadamiać o każdym zarejestrowanym wywołaniu zwrotnym w aplikacji.

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