Interfejsy API nieblokujące wysyłają żądanie wykonania pracy, a potem przekazują kontrolę z powrotem do wątku wywołującego, aby mógł on wykonać inne zadania 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ć żywotność baterii, gdy operacja nie jest już potrzebna.
Interfejsy API asynchroniczne 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 o zakończeniu operacji lub o innych zdarzeniach podczas jej wykonywania.
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 zestaw zasad i interfejsów API opartych na funkcjach zawieszających, które oddzielają synchroniczne i asynchroniczne wykonywanie kodu od blokowania wątków. Funkcje zawieszania są nieblokujące i synchroniczne.
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 innym miejscu.
- wykonywać się synchronicznie i nie wymagać, aby wywołujący nieblokujący interfejs API kontynuował wykonywanie równolegle z nieblokującą pracą 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 traktuj oczekiwania deweloperów jako wymagania dotyczące każdej nowej platformy interfejsu API.
Oczekiwania deweloperów dotyczące interfejsów Async API
Poniższe oczekiwania są napisane z perspektywy interfejsów API, które nie zawieszają konta, 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 map lub filter 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ływanie 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 do elementu FragmentManager
w transakcji synchronicznej można dodać element Fragment
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ć krytyczną ścieżką 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 niestabilności klatki.
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 interfejsu
- Nawiązywanie dwukierunkowego połączenia z serwerem systemowym, w przypadku którego ukończenie rejestracji nie wymaga uzyskania blokady o wysokim poziomie rywalizacji.
- Wysłanie żądania do wątku roboczego 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 zaimplementować 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 akceptuje wartość float
w 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
, jeśli jest poza zakresem. Może też sprawdzać, czy krótki ciąg znaków 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 kontrole 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
- 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 usunąć 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 tych wywołań zwrotnych mogą uniemożliwiać odzyskiwanie pamięci przez te wykresy obiektów. Zwalniając te odwołania do wywołania zwrotnego po anulowaniu, wykresy 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:
- Procesy i cykl życia aplikacji: proces aplikacji odbierającej może być w stanie buforowania.
- 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 działania 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 jej odblokowaniu.
Zbuforowane transakcje do wywołań zwrotnych aplikacji mogą być nieaktualne, gdy aplikacja zostanie odmrożona 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.
- Podczas gdy proces aplikacji jest zamrożony, MUSISZ wstrzymać wywoływanie zwrotne aplikacji wysyłającej.
Ś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 wyrejestruje ona wywołania zwrotnego lub nie zakończy się proces aplikacji.
Na 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ć funkcji RemoteCallbackList
, która zapobiega dostarczaniu wywołań zwrotnych do procesu docelowego, gdy jest on zamrożony.
Na 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 sytuację, w której podczas zamrożenia aplikacji występuje wiele zdarzeń zmiany stanu. Po odmrożeniu aplikacji należy przekazać jej 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 osiągnąć 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 dostępnych sieciach i 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 stały się niedostępne, gdy wywołania zwrotne były wstrzymane. Aplikacje nie powinny otrzymywać pełnego raportu o zdarzeniach, które miały miejsce, gdy były zamrożone, a dokumentacja interfejsu API nie powinna obiecywać dostarczania strumieni zdarzeń bez przerw poza wyraźnymi 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 łączyć zdarzenia, które wystąpiły po wstrzymaniu i przed wznowieniem powiadomień, i zwięźle przekazywać 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, więc 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 celem interfejsu API jest uruchomienie wywołania zwrotnego poza wywołującym CoroutineContext
, to zachowanie powinno być wyraźnie udokumentowane.
Funkcje zawieszania powinny obsługiwać anulowanie zadania kotlinx.coroutines
Każda oferowana funkcja wstrzymania powinna współpracować z anulowaniem zadania zgodnie z definicją w 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 wyczyścić dane i kontynuować działanie. Jest to obsługiwane automatycznie przez suspendCancellableCoroutine
i inne interfejsy API zawieszania oferowane przez kotlinx.coroutines
. Implementacje bibliotek nie powinny zwykle 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 interfejsu), muszą umożliwiać skonfigurowanie używanego dyspozytora.
Nie zalecamy, 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 dyspozytor w celu wykonania blokującej pracy, powinny zamiast tego udostępniać podstawową funkcję blokującą i zalecać, aby programiści wywołujący używali własnego wywołania z withContext, aby kierować pracę do wybranego dyspozytora.
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 tego zakresu i zarządzania nim.
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 operację 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 równoczesnej pracy do zakresu nadrzędnego. Zwykle polega to na utworzeniu elementu podrzędnego Job
pod podanym elementem CoroutineContext
:
private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)
fun cancel() {
myJob.cancel()
}
Może też być udostępniona 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 zamykają równoczesne zadania należące do obiektu, a które są nadal w toku, powinna odzwierciedlać umowę dotyczącą sposobu zamykania:
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 przyjmują CoroutineContext, a nie CoroutineScope
Jeśli obiektom nie wolno uruchamiać się bezpośrednio w podanym zakresie nadrzędnym, parametr konstruktora CoroutineScope
nie jest odpowiedni:
// 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.
Jeśli w interfejsie API występuje opcjonalny parametr CoroutineContext
, wartością domyślną musi być wartość 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)
// ...
}