Wytyczne dotyczące asynchronicznych i nieblokujących interfejsów API na Androidzie

Interfejsy API nieblokujące wysyłają żądanie wykonania działania, a następnie przekazują kontrolę z powrotem do wątku wywołującego, aby mógł on wykonać inne działania przed zakończeniem żądanej operacji. Te interfejsy API są przydatne w sytuacjach, gdy żądana praca może być w toku lub może wymagać oczekiwania na zakończenie operacji wejścia/wyjścia lub IPC, dostępność zasobów systemowych o dużej konkurencji lub danych wejściowych użytkownika, zanim będzie można kontynuować pracę. Dobrze zaprojektowane interfejsy API umożliwiają anulowanie trwającej operacji i zaprzestanie wykonywania pracy w imieniu pierwotnego wywołującego, co pozwala zachować sprawność systemu i wydłużyć czas pracy na baterii, gdy operacja nie jest już potrzebna.

Asynchroniczne interfejsy API to jeden ze sposobów na osiągnięcie zachowania nieblokującego. Interfejsy API asynchroniczne akceptują pewną formę kontynuacji lub wywołania zwrotnego, które jest powiadamiane, gdy operacja zostanie ukończona lub gdy podczas jej wykonywania wystąpią inne zdarzenia.

Istnieją 2 główne powody, dla których warto napisać asynchroniczny interfejs API:

  • Wykonywanie wielu operacji jednocześnie, przy czym N-ta operacja musi zostać zainicjowana przed zakończeniem N-1-szej operacji.
  • Unikanie blokowania wątku wywołującego do czasu zakończenia operacji.

Kotlin zdecydowanie promuje strukturalne współbieżne wykonywanie, czyli serię zasad i interfejsów API opartych na funkcjach zawieszania, które oddzielają synchroniczne i asynchroniczne wykonywanie kodu od blokowania wątków. Funkcje zawieszania są nieblokującesynchroniczne.

Zawieszanie funkcji:

  • Nie blokuj wątku wywołującego, ale zamiast tego przekaż wątek wykonania jako szczegół implementacji, oczekując na wyniki operacji wykonywanych w innych miejscach.
  • wykonywać się synchronicznie i nie wymagać, aby wywołujący interfejs API nieblokujący kontynuował wykonywanie równolegle z pracą nieblokującą zainicjowaną przez wywołanie interfejsu API.

Na tej stronie znajdziesz minimalne oczekiwania, które deweloperzy mogą bezpiecznie mieć podczas pracy z nieblokującymi i asynchronicznymi interfejsami API. Następnie znajdziesz serię przepisów na tworzenie interfejsów API, które spełniają te oczekiwania w językach Kotlin i Java, na platformie Android lub w bibliotekach Jetpack. W razie wątpliwości przyjmij oczekiwania deweloperów jako wymagania dotyczące każdego nowego interfejsu API.

Oczekiwania deweloperów dotyczące interfejsów Async API

Poniższe oczekiwania są napisane z perspektywy interfejsów API, które nie są zawieszane, chyba że zaznaczono inaczej.

Interfejsy API, które akceptują wywołania zwrotne, są zwykle asynchroniczne.

Jeśli interfejs API akceptuje wywołanie zwrotne, które nie jest udokumentowane jako wywoływane tylko w miejscu (czyli wywoływane tylko przez wątek wywołujący przed zwróceniem wywołania interfejsu API), zakłada się, że interfejs API jest asynchroniczny i powinien spełniać wszystkie inne oczekiwania opisane w kolejnych sekcjach.

Przykładem wywołania zwrotnego, które jest wywoływane tylko w miejscu, jest funkcja mapowania lub filtrowania wyższego rzędu, która wywołuje funkcję mapującą lub predykat dla każdego elementu w kolekcji przed zwróceniem wyniku.

Asynchroniczne interfejsy API powinny zwracać wyniki tak szybko, jak to możliwe.

Deweloperzy oczekują, że asynchroniczne interfejsy API będą nieblokujące i będą szybko zwracać wyniki po zainicjowaniu żądania operacji. Wywołanie asynchronicznego interfejsu API powinno być zawsze bezpieczne i nigdy nie powinno powodować niestabilnych klatek ani błędów ANR.

Wiele sygnałów operacyjnych i sygnałów cyklu życia może być wywoływanych na żądanie przez platformę lub biblioteki, a oczekiwanie, że programista będzie miał globalną wiedzę o wszystkich potencjalnych miejscach wywołań w swoim kodzie, jest nierealne. Na przykład element Fragment można dodać do elementu FragmentManager w transakcji synchronicznej w odpowiedzi na pomiar i układ elementu View, gdy treść aplikacji musi zostać wypełniona, aby wypełnić dostępną przestrzeń (np. RecyclerView). Element LifecycleObserver odpowiadający na wywołanie zwrotne cyklu życia onStart tego fragmentu może w tym miejscu wykonać jednorazowe operacje uruchamiania, co może być na krytycznej ścieżce kodu do wygenerowania klatki animacji bez zacięć. Deweloper powinien mieć pewność, że wywołanie dowolnego asynchronicznego interfejsu API w odpowiedzi na tego rodzaju wywołania zwrotne cyklu życia nie spowoduje zacinania się klatek.

Oznacza to, że praca wykonywana przez asynchroniczny interfejs API przed zwróceniem wyniku musi być bardzo lekka. Może polegać na utworzeniu rekordu żądania i powiązanego wywołania zwrotnego oraz zarejestrowaniu go w silniku wykonawczym, który wykonuje pracę. Jeśli rejestracja operacji asynchronicznej wymaga komunikacji międzyprocesowej, implementacja interfejsu API powinna podjąć wszelkie niezbędne środki, aby spełnić oczekiwania dewelopera. Może to obejmować co najmniej 1 z tych elementów:

  • Implementowanie bazowego IPC jako wywołania jednokierunkowego
  • Wykonanie dwukierunkowego wywołania bindera na serwerze systemowym, w przypadku którego ukończenie rejestracji nie wymaga uzyskania blokady o wysokim poziomie rywalizacji.
  • Wysłanie żądania do wątku instancji roboczej w procesie aplikacji w celu wykonania blokującej rejestracji za pomocą IPC.

Asynchroniczne interfejsy API powinny zwracać wartość void i zgłaszać wyjątki tylko w przypadku nieprawidłowych argumentów.

Asynchroniczne interfejsy API powinny przekazywać wszystkie wyniki żądanej operacji do podanego wywołania zwrotnego. Dzięki temu deweloper może wdrożyć jedną ścieżkę kodu do obsługi powodzenia i błędów.

Interfejsy API asynchroniczne mogą sprawdzać, czy argumenty nie mają wartości null, i zgłaszać wyjątek NullPointerException lub sprawdzać, czy podane argumenty mieszczą się w prawidłowym zakresie, i zgłaszać wyjątek IllegalArgumentException. Na przykład w przypadku funkcji, która przyjmuje wartość floatw zakresie od 0 do 1f, funkcja może sprawdzać, czy parametr mieści się w tym zakresie, i w razie potrzeby zgłaszać błąd IllegalArgumentException. Może też sprawdzać, czy krótki kod String jest zgodny z prawidłowym formatem, np. czy zawiera tylko znaki alfanumeryczne. (Pamiętaj, że serwer systemowy nigdy nie powinien ufać procesowi aplikacji. Każda usługa systemowa powinna powielać te sprawdzenia w ramach własnego działania).

Wszystkie inne błędy należy zgłaszać za pomocą podanego wywołania zwrotnego. Obejmuje to m.in.:

  • Nieudana operacja, której nie można powtórzyć
  • Wyjątki dotyczące bezpieczeństwa w przypadku braku autoryzacji lub uprawnień wymaganych do wykonania operacji
  • Przekroczono limit wykonywania operacji
  • Proces aplikacji nie jest wystarczająco „na pierwszym planie”, aby wykonać operację
  • Wymagany sprzęt został odłączony
  • Awaria sieci
  • tymczasowe zawieszenia użytkowników,
  • Błąd powiązania lub niedostępny proces zdalny

Asynchroniczne interfejsy API powinny udostępniać mechanizm anulowania

Asynchroniczne interfejsy API powinny umożliwiać wskazanie trwającemu działaniu, że wywołujący nie jest już zainteresowany wynikiem. Anulowanie powinno sygnalizować 2 rzeczy:

Należy zwolnić twarde odwołania do wywołań zwrotnych dostarczonych przez element wywołujący.

Wywołania zwrotne przekazywane do asynchronicznych interfejsów API mogą zawierać stałe odwołania do dużych wykresów obiektów, a trwające zadania zawierające stałe odwołania do tego wywołania zwrotnego mogą uniemożliwiać odzyskiwanie pamięci tych wykresów obiektów. Zwalniając te odwołania do wywołania zwrotnego po anulowaniu, te grafy obiektów mogą kwalifikować się do odzyskiwania pamięci znacznie wcześniej niż w przypadku, gdyby zadanie mogło zostać wykonane do końca.

Silnik wykonawczy, który wykonuje pracę na rzecz wywołującego, może ją przerwać.

Praca zainicjowana przez asynchroniczne wywołania interfejsu API może wiązać się z wysokim kosztem zużycia energii lub innych zasobów systemowych. Interfejsy API, które umożliwiają elementom wywołującym sygnalizowanie, kiedy ta praca nie jest już potrzebna, pozwalają na jej zatrzymanie, zanim zużyje ona więcej zasobów systemowych.

Specjalne uwagi dotyczące aplikacji z pamięci podręcznej lub zamrożonych

Podczas projektowania asynchronicznych interfejsów API, w których wywołania zwrotne pochodzą z procesu systemowego i są dostarczane do aplikacji, weź pod uwagę te kwestie:

  1. Procesy i cykl życia aplikacji: proces aplikacji odbiorcy może być w stanie buforowanym.
  2. Zamrażanie aplikacji w pamięci podręcznej: proces aplikacji odbiorcy może zostać zamrożony.

Gdy proces aplikacji przechodzi w stan buforowania, oznacza to, że nie hostuje aktywnie żadnych komponentów widocznych dla użytkownika, takich jak aktywności i usługi. Aplikacja jest przechowywana w pamięci na wypadek, gdyby ponownie stała się widoczna dla użytkownika, ale w międzyczasie nie powinna wykonywać żadnych działań. W większości przypadków należy wstrzymać wysyłanie wywołań zwrotnych aplikacji, gdy przechodzi ona w stan buforowania, i wznowić je, gdy z niego wychodzi, aby nie powodować pracy w procesach buforowanych aplikacji.

Aplikacja w pamięci podręcznej może być też zamrożona. Gdy aplikacja jest zamrożona, nie otrzymuje czasu procesora i nie może wykonywać żadnych działań. Wszelkie wywołania zarejestrowanych funkcji zwrotnych tej aplikacji są buforowane i dostarczane po odblokowaniu aplikacji.

Zbuforowane transakcje do wywołań zwrotnych aplikacji mogą być nieaktualne, gdy aplikacja zostanie odblokowana i je przetworzy. Bufor ma ograniczoną pojemność, a jeśli zostanie przepełniony, aplikacja odbiorcy ulegnie awarii. Aby uniknąć przeciążenia aplikacji nieaktualnymi zdarzeniami lub przepełnienia ich buforów, nie wysyłaj wywołań zwrotnych aplikacji, gdy ich proces jest zamrożony.

W trakcie sprawdzania:

  • Rozważ wstrzymanie wywoływania zwrotnego aplikacji wysyłającej, gdy proces aplikacji jest przechowywany w pamięci podręcznej.
  • MUSISZ wstrzymać wywoływanie zwrotne aplikacji wysyłającej, gdy proces aplikacji jest zamrożony.

Śledzenie stanu

Aby śledzić, kiedy aplikacje wchodzą w stan buforowania lub z niego wychodzą:

mActivityManager.addOnUidImportanceListener(
    new UidImportanceListener() { ... },
    IMPORTANCE_CACHED);

Aby śledzić, kiedy aplikacje są zamrażane lub odmrażane:

IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);

Strategie wznawiania wysyłania wywołań zwrotnych aplikacji

Niezależnie od tego, czy wstrzymasz wysyłanie wywołań zwrotnych aplikacji, gdy przejdzie ona w stan buforowania lub zamrożenia, po wyjściu z tego stanu musisz wznowić wysyłanie zarejestrowanych wywołań zwrotnych aplikacji, dopóki nie zostaną one wyrejestrowane lub proces aplikacji nie zostanie zakończony.

Przykład:

IBinder binder = <...>;
bool shouldSendCallbacks = true;
binder.addFrozenStateChangeCallback(executor, (who, state) -> {
    if (state == IBinder.FrozenStateChangeCallback.STATE_FROZEN) {
        shouldSendCallbacks = false;
    } else if (state == IBinder.FrozenStateChangeCallback.STATE_UNFROZEN) {
        shouldSendCallbacks = true;
    }
});

Możesz też użyć RemoteCallbackList, która zapobiega dostarczaniu wywołań zwrotnych do procesu docelowego, gdy jest on zamrożony.

Przykład:

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_DROP)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.foo(bar));

callback.foo() jest wywoływana tylko wtedy, gdy proces nie jest zamrożony.

Aplikacje często zapisują aktualizacje otrzymane za pomocą wywołań zwrotnych jako zrzut najnowszego stanu. Rozważmy hipotetyczny interfejs API, który umożliwia aplikacjom monitorowanie pozostałego poziomu baterii:

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

Rozważmy scenariusz, w którym podczas zamrożenia aplikacji występuje wiele zdarzeń zmiany stanu. Po odmrożeniu aplikacji należy dostarczyć do niej tylko najnowszy stan i odrzucić inne nieaktualne zmiany stanu. Dostarczenie powinno nastąpić natychmiast po odblokowaniu aplikacji, aby mogła ona „nadrobić zaległości”. Można to zrobić w ten sposób:

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_ENQUEUE_MOST_RECENT)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.onBatteryPercentageChanged(value));

W niektórych przypadkach możesz śledzić ostatnią wartość dostarczoną do aplikacji, aby nie trzeba było powiadamiać jej o tej samej wartości po odblokowaniu.

Stan może być wyrażony jako bardziej złożone dane. Rozważmy hipotetyczny interfejs API, który powiadamia aplikacje o interfejsach sieciowych:

interface NetworkListener {
    void onAvailable(Network network);
    void onLost(Network network);
    void onChanged(Network network);
}

Wstrzymując powiadomienia z aplikacji, zapamiętaj zestaw sieci i stanów, które były ostatnio widoczne dla aplikacji. Po wznowieniu zalecamy powiadomienie aplikacji o utraconych starych sieciach, nowych sieciach, które stały się dostępne, oraz o istniejących sieciach, których stan się zmienił – w tej kolejności.

Nie powiadamiaj aplikacji o sieciach, które były dostępne, a potem zostały utracone, gdy wywołania zwrotne były wstrzymane. Aplikacje nie powinny otrzymywać pełnego zestawu zdarzeń, które miały miejsce podczas ich zamrożenia, a dokumentacja API nie powinna obiecywać dostarczania strumieni zdarzeń bez przerw poza wyraźnie określonymi stanami cyklu życia. W tym przykładzie, jeśli aplikacja musi stale monitorować dostępność sieci, musi pozostawać w stanie cyklu życia, który uniemożliwia jej zapisanie w pamięci podręcznej lub zamrożenie.

Podczas sprawdzania należy połączyć zdarzenia, które miały miejsce po wstrzymaniu i przed wznowieniem powiadomień, i zwięźle przekazać najnowszy stan do zarejestrowanych wywołań zwrotnych aplikacji.

Uwagi dotyczące dokumentacji dla deweloperów

Dostarczanie zdarzeń asynchronicznych może być opóźnione, ponieważ nadawca wstrzymał dostarczanie na pewien czas (jak pokazano w poprzedniej sekcji) lub aplikacja odbiorcy nie otrzymała wystarczającej ilości zasobów urządzenia, aby przetworzyć zdarzenie w odpowiednim czasie.

Zniechęcanie deweloperów do przyjmowania założeń dotyczących czasu między powiadomieniem aplikacji o zdarzeniu a faktycznym wystąpieniem tego zdarzenia.

Oczekiwania deweloperów dotyczące zawieszania interfejsów API

Deweloperzy znający współbieżność strukturalną w języku Kotlin oczekują od każdego interfejsu API zawieszającego następujących zachowań:

Funkcje zawieszające powinny wykonać wszystkie powiązane zadania przed zwróceniem wartości lub zgłoszeniem wyjątku

Wyniki operacji nieblokujących są zwracane jako normalne wartości zwracane przez funkcję, a błędy są zgłaszane przez zgłaszanie wyjątków. (Często oznacza to, że parametry wywołania zwrotnego są niepotrzebne).

Funkcje zawieszania powinny wywoływać parametry wywołania zwrotnego tylko w miejscu ich występowania

Funkcje zawieszania powinny zawsze wykonywać wszystkie powiązane działania przed zwróceniem wartości, dlatego nigdy nie powinny wywoływać podanego wywołania zwrotnego ani innego parametru funkcji ani zachowywać do niego odwołania po zwróceniu wartości przez funkcję zawieszania.

Funkcje zawieszania, które akceptują parametry wywołania zwrotnego, powinny zachowywać kontekst, chyba że w dokumentacji podano inaczej

Wywołanie funkcji w funkcji zawieszającej powoduje jej uruchomienie w CoroutineContext wywołującego. Funkcje zawieszające powinny wykonać wszystkie powiązane zadania przed zwróceniem wartości lub zgłoszeniem wyjątku i powinny wywoływać parametry wywołania zwrotnego tylko w miejscu wywołania. Domyślnie oczekuje się, że wszystkie takie wywołania zwrotne są również wykonywane na wywołującym CoroutineContext przy użyciu powiązanego z nim dyspozytora. Jeśli interfejs API służy do uruchamiania wywołania zwrotnego poza wywołującym CoroutineContext, to zachowanie powinno być jasno udokumentowane.

Funkcje zawieszania powinny obsługiwać anulowanie zadania kotlinx.coroutines

Każda oferowana funkcja wstrzymania powinna współpracować z anulowaniem zadań zgodnie z definicją kotlinx.coroutines. Jeśli zadanie wywołujące operację w toku zostanie anulowane, funkcja powinna jak najszybciej wznowić działanie z wartością CancellationException, aby wywołujący mógł jak najszybciej zakończyć działanie i kontynuować. Tym zajmuje się automatycznie suspendCancellableCoroutine i inne interfejsy API do zawieszania oferowane przez kotlinx.coroutines. Implementacje bibliotek nie powinny na ogół używać bezpośrednio funkcji suspendCoroutine, ponieważ domyślnie nie obsługuje ona tego zachowania związanego z anulowaniem.

Funkcje zawieszania, które wykonują blokujące zadania w tle (wątek inny niż główny lub wątek UI), muszą umożliwiać skonfigurowanie używanego dyspozytora.

Nie zaleca się, aby funkcja blokująca zawieszała się całkowicie w celu przełączenia wątków.

Wywołanie funkcji zawieszania nie powinno powodować tworzenia dodatkowych wątków bez umożliwienia deweloperowi dostarczenia własnego wątku lub puli wątków do wykonania tej pracy. Na przykład konstruktor może akceptować obiekt CoroutineContext, który jest używany do wykonywania w tle pracy na potrzeby metod klasy.

Funkcje zawieszania, które akceptują opcjonalny parametr CoroutineContext lub Dispatcher tylko po to, aby przełączyć się na ten mechanizm dispatcher w celu wykonania blokowania, powinny zamiast tego udostępniać podstawową funkcję blokującą i zalecać deweloperom wywołującym używanie własnego wywołania z funkcją withContext, aby kierować pracę do wybranego mechanizmu dispatcher.

Klasy uruchamiające coroutines

Klasy, które uruchamiają współprogramy, muszą mieć CoroutineScope, aby wykonywać te operacje uruchamiania. Przestrzeganie zasad strukturalnego współbieżności oznacza stosowanie tych wzorców strukturalnych do uzyskiwania i zarządzania tym zakresem:

Zanim napiszesz klasę, która uruchamia współbieżne zadania w innym zakresie, rozważ alternatywne wzorce:

class MyClass {
    private val requests = Channel<MyRequest>(Channel.UNLIMITED)

    suspend fun handleRequests() {
        coroutineScope {
            for (request in requests) {
                // Allow requests to be processed concurrently;
                // alternatively, omit the [launch] and outer [coroutineScope]
                // to process requests serially
                launch {
                    processRequest(request)
                }
            }
        }
    }

    fun submitRequest(request: MyRequest) {
        requests.trySend(request).getOrThrow()
    }
}

Udostępnienie suspend fun do wykonywania równoczesnych działań umożliwia wywołującemu wywołanie operacji we własnym kontekście, co eliminuje potrzebę zarządzania CoroutineScope przez MyClass. Uporządkowanie przetwarzania żądań staje się prostsze, a stan może często istnieć jako zmienne lokalne funkcji handleRequests zamiast jako właściwości klasy, które w przeciwnym razie wymagałyby dodatkowej synchronizacji.

Klasy zarządzające korutynami powinny udostępniać metody zamykania i anulowania

Klasy, które uruchamiają korutyny jako szczegóły implementacji, muszą oferować sposób na czyste zamykanie tych trwających równoczesnych zadań, aby nie powodowały one wycieku niekontrolowanej pracy równoczesnej do zakresu nadrzędnego. Zwykle polega to na utworzeniu elementu podrzędnego Job podanego elementu CoroutineContext:

private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)

fun cancel() {
    myJob.cancel()
}

Może też być dostępna join() metoda, która umożliwia kodowi użytkownika oczekiwanie na zakończenie wszelkich zaległych zadań wykonywanych równolegle przez obiekt. (Może to obejmować czyszczenie przez anulowanie operacji).

suspend fun join() {
    myJob.join()
}

Nazewnictwo operacji terminala

Nazwa metod, które bezpiecznie wyłączają równoczesne zadania należące do obiektu, które są nadal w toku, powinna odzwierciedlać umowę dotyczącą sposobu wyłączania:

Używaj close(), gdy operacje w toku mogą zostać ukończone, ale po zwróceniu wywołania close() nie można rozpocząć nowych operacji.

Użyj cancel(), gdy operacje w toku mogą zostać anulowane przed zakończeniem. Po powrocie wywołania cancel() nie można rozpocząć nowych operacji.

Konstruktory klas akceptują CoroutineContext, a nie CoroutineScope

Gdy obiekty nie mogą być uruchamiane bezpośrednio w podanym zakresie nadrzędnym, przydatność CoroutineScope jako parametru konstruktora jest ograniczona:

// Don't do this
class MyClass(scope: CoroutineScope) {
    private val myJob = Job(parent = scope.`CoroutineContext`[Job])
    private val myScope = CoroutineScope(scope.`CoroutineContext` + myJob)

    // ... the [scope] constructor parameter is never used again
}

CoroutineScope staje się niepotrzebną i wprowadzającą w błąd otoczką, która w niektórych przypadkach może być tworzona wyłącznie w celu przekazania jako parametr konstruktora, a następnie odrzucana:

// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))

Parametry CoroutineContext mają domyślnie wartość EmptyCoroutineContext.

Gdy w interfejsie API występuje opcjonalny parametr CoroutineContext, wartością domyślną musi być wartość sentymentalna Empty`CoroutineContext`. Umożliwia to lepsze komponowanie zachowań interfejsu API, ponieważ wartość Empty`CoroutineContext` od wywołującego jest traktowana w taki sam sposób jak zaakceptowanie wartości domyślnej:

class MyOuterClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val innerObject = MyInnerClass(`CoroutineContext`)

    // ...
}

class MyInnerClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val job = Job(parent = `CoroutineContext`[Job])
    private val scope = CoroutineScope(`CoroutineContext` + job)

    // ...
}