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

Interfejsy API bez blokowania proszą o wykonanie zadania, a potem oddają kontrolę z powrotem do wątku wywołującego, aby ten mógł wykonać inne zadania przed zakończeniem żądanej operacji. Te interfejsy API są przydatne w przypadkach, gdy wymagane działanie może być w trakcie wykonywania lub może wymagać oczekiwania na zakończenie operacji wejścia/wyjścia lub komunikacji między procesami, dostępności zasobów systemowych, z których korzysta wiele procesów, lub danych wejściowych użytkownika, zanim będzie można kontynuować. Szczególnie dobrze zaprojektowane interfejsy API umożliwiają anulowanie trwającej operacji i zatrzymanie wykonywania pracy w imieniu pierwotnego wywołującego, co pozwala zachować prawidłowe działanie systemu i czas pracy baterii, gdy operacja nie jest już potrzebna.

Interfejsy API asynchroniczne to jeden ze sposobów na uzyskanie zachowania bez blokowania. Interfejsy API asynchroniczne przyjmują pewną formę kontynuacji lub wywołania zwrotnego, które jest powiadamiane o zakończeniu operacji lub innych zdarzeniach w trakcie jej wykonywania.

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

  • jednoczesne wykonywanie wielu operacji, przy czym operacja N musi zostać zainicjowana przed zakończeniem operacji N-1;
  • unikanie blokowania wątku wywołania do czasu zakończenia operacji;

Kotlin zdecydowanie promuje strukturowaną współbieżność, czyli zbiór zasad i interfejsów API opartych na funkcjach zawieszania, które odłączają synchroniczne i asynchroniczne wykonywanie kodu od blokowania wątków. Funkcje zawieszania są nieblokującesynchroniczne.

Zawieszanie funkcji:

  • Nie blokuj wątku wywołania, a zamiast tego zrezygnuj z wykonywania wątku jako szczegółu implementacji podczas oczekiwania na wyniki operacji wykonywanych gdzie indziej.
  • Wykonywanie synchronicznie i nie wymaganie od wywołującego interfejsu API nieblokującego, aby kontynuował wykonywanie równolegle z nieblokującą pracą rozpoczętą przez wywołanie interfejsu API.

Na tej stronie znajdziesz informacje o minimalnych oczekiwaniach, jakie deweloperzy mogą bezpiecznie zgłaszać podczas pracy z nieblokującymi i asyncjonalnymi interfejsami API. Następnie znajdziesz serię metod tworzenia 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 weź pod uwagę oczekiwania deweloperów dotyczące nowych interfejsów API.

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

O ile nie zaznaczono inaczej, poniższe oczekiwania dotyczą interfejsów API, które nie powodują zawieszenia.

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 w miejscu (czyli wywoływane tylko przez wywołujący wątek przed powrotem wywołania interfejsu API), przyjmuje się, że jest on asynchroniczny i że powinien spełniać wszystkie inne wymagania opisane w następnych sekcjach.

Przykładem funkcji z wywołaniem zwrotnym, która jest zawsze wywoływana tylko w miejscu, jest funkcja mapowania lub filtrowania wyższego rzędu, która przed zwróceniem wartości wywołuje funkcję mapowania lub predykatu dla każdego elementu w zbiorze.

Interfejsy API asynchroniczne powinny zwracać dane jak najszybciej

Deweloperzy oczekują, że interfejsy API asynchroniczne będą nieblokujące i szybko zwracać dane po wysłaniu żądania operacji. Wywoływanie interfejsu API asynchronicznego powinno być zawsze bezpieczne, a wywoływanie interfejsu API asynchronicznego nie powinno powodować klatkowania ani ANR.

Wiele operacji i sygnałów cyklu życia może być wywoływanych przez platformę lub biblioteki na żądanie, a oczekywanie od dewelopera, że będzie on mieć ogólną wiedzę o wszystkich potencjalnych miejscach wywołania kodu, jest nierozsądne. Na przykład Fragment może zostać dodany do FragmentManager w ramach transakcji synchronicznej w odpowiedzi na pomiar i układ View, gdy treści aplikacji muszą wypełnić dostępne miejsce (np. RecyclerView). Element LifecycleObserver reagujący na wywołanie zwrotne cyklu życia onStart tego fragmentu może wykonywać jednorazowe operacje uruchamiania, które mogą znajdować się na ścieżce kodu krytycznej dla tworzenia klatek animacji bez zacięć. Deweloper powinien mieć zawsze pewność, że wywołanie dowolnego asynchronicznego interfejsu API w odpowiedzi na tego typu wywołania metody obsługi zdarzeń cyklu życia nie spowoduje niepłynnego wyświetlania obrazu.

Oznacza to, że zadanie wykonywane przez asynchroniczny interfejs API przed zwróceniem wyniku musi być bardzo lekkie. Może to być utworzenie rekordu żądania i powiązanego wywołania zwrotnego oraz zarejestrowanie go w silniku wykonawczym, który wykonuje zadanie. Jeśli rejestrowanie operacji asynchronicznej wymaga komunikacji między procesami, implementacja interfejsu API powinna podjąć wszelkie niezbędne działania, aby spełnić oczekiwania dewelopera. Może to obejmować co najmniej 1 z tych elementów:

  • Implementacja podstawowego interfejsu IPC jako wywołania bindera jednokierunkowego
  • Wykonywanie wywołania dwukierunkowego do serwera systemu, w którym do ukończenia rejestracji nie jest wymagane odblokowanie blokady o wysokim poziomie współzapisu
  • Przesyłanie żądania do wątku roboczego w procesie aplikacji w celu przeprowadzenia rejestracji bez blokowania przez IPC

Interfejsy API asynchroniczne powinny zwracać wartość void i wyjątek tylko w przypadku nieprawidłowych argumentów.

Interfejsy API asynchronicznych powinny przekazywać wszystkie wyniki żądanej operacji do podanego wywołania zwrotnego. Umożliwia to deweloperowi wdrożenie jednej ścieżki kodu do obsługi błędów i osiągnięcia sukcesu.

Interfejsy API asynchronicznych mogą sprawdzać argumenty pod kątem wartości null i wyrzucać błąd NullPointerException lub sprawdzać, czy podane argumenty mieszczą się w obowiązującym zakresie, i wyrzucać błąd IllegalArgumentException. Na przykład w przypadku funkcji, która przyjmuje parametr float w zakresie od 0 do 1f, funkcja może sprawdzić, czy parametr mieści się w tym zakresie, i wyrzucić błąd IllegalArgumentException, jeśli nie mieści się w tym zakresie. Może też sprawdzić, czy krótki parametr 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 tej usługi.

Wszystkie inne błędy należy zgłaszać podczas połączenia zwrotnego. Obejmuje to między innymi:

  • Nieudana operacja, której nie można powtórzyć
  • wyjątki dotyczące zabezpieczeń dotyczące brakujących autoryzacji lub uprawnień wymaganych do wykonania operacji;
  • przekroczono limit wykonywania operacji.
  • Proces aplikacji nie jest wystarczająco „na pierwszym planie”, aby wykonać operację
  • Wymagane urządzenie zostało odłączone
  • Błędy sieci
  • tymczasowe zawieszenia użytkowników
  • Śmierć Bindera lub niedostępny proces zdalny

Interfejsy API asynchroniczne powinny udostępniać mechanizm anulowania.

Interfejsy API asynchroniczne powinny zapewniać sposób na wskazanie uruchomionej operacji, że wywołujący nie jest już zainteresowany wynikiem. Anulowanie powinno sygnalizować 2 rzeczy:

Twarde odwołania do wywołań zwrotnych podanych przez wywołującego powinny zostać zwolnione

Zwróć uwagę, że wywołania przekazywane do interfejsów API asynchronicznych mogą zawierać stałe odwołania do dużych grafów obiektów, a ciągłe wykonywanie kodu, który zawiera stałe odwołanie do takiego wywołania, może uniemożliwić usunięcie tych grafów przez mechanizm garbage collection. Dzięki zwolnieniu tych odwołań do wywołania zwrotnego po anulowaniu grafy obiektów mogą kwalifikować się do usuwania elementów nieużywanych znacznie wcześniej niż wtedy, gdy zezwoli się na dokończenie pracy.

Silnik wykonawczy wykonujący zadania dla wywołującego może przerwać wykonywanie tych zadań.

Działania rozpoczęte przez asynchroniczne wywołania interfejsu API mogą wiązać się z wysokimi kosztami zużycia energii lub innych zasobów systemowych. Interfejsy API, które umożliwiają wywołującym sygnalizowanie, że nie jest już potrzebna praca, umożliwiają zatrzymanie tej pracy, zanim wykorzysta ona dodatkowe zasoby systemu.

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

Podczas projektowania interfejsów API asynchronicznych, 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 pamięci podręcznej.
  2. Zawieszanie aplikacji w pamięci podręcznej: proces aplikacji odbiorcy może zostać zawieszony.

Gdy proces aplikacji przechodzi w stan buforowania, oznacza to, że nie hostuje on aktywnie żadnych komponentów widocznych dla użytkownika, takich jak czynności i usługi. Aplikacja jest przechowywana w pamięci na wypadek, gdyby użytkownik znów ją wyświetlił, ale w międzyczasie nie powinna wykonywać żadnych czynności. W większości przypadków należy wstrzymać wysyłanie wywołań zwrotnych aplikacji, gdy aplikacja wejdzie w stan buforowania, i wznowić je, gdy aplikacja opuści stan buforowania, aby nie powodować pracy w procesach aplikacji buforowanej.

Aplikacja w pamięci podręcznej może też być zablokowana. Gdy aplikacja jest zawieszona, nie otrzymuje czasu procesora i nie może wykonywać żadnych działań. Wszystkie wywołania zarejestrowanych przez tę aplikację funkcji zwrotnych są buforowane i przesyłane, gdy aplikacja zostanie odmrożona.

Buforowane transakcje do wywołań zwrotnych aplikacji mogą być nieaktualne w momencie odblokowania aplikacji i przetworzenia tych transakcji. Bufor jest ograniczony, a jego przepełnienie spowoduje awarię aplikacji odbiorcy. Aby uniknąć przytłomienia 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 wysyłania wywołań zwrotnych aplikacji, gdy proces aplikacji jest przechowywany w pamięci podręcznej.
  • NALEŻY wstrzymać wysyłanie wywołań zwrotnych aplikacji, 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 i odmrażane:

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

Strategie wznawiania wysyłania wywołań zwrotnych aplikacji

Niezależnie od tego, czy wstrzymasz rozsyłanie wywołań zwrotnych aplikacji, gdy wejdzie ona w stan buforowania, czy zamrożenia, po wyjściu z odpowiedniego stanu powinnaś wznowić rozsyłanie zarejestrowanych wywołań zwrotnych aplikacji, dopóki nie zostanie ono usunięte lub proces aplikacji nie zostanie zakończony.

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 dba o to, aby nie wysyłać 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ływany tylko wtedy, gdy proces nie jest zamrożony.

Aplikacje często zapisują otrzymane aktualizacje za pomocą funkcji zwracającej wywołania zwrotne jako zrzut najnowszego stanu. Wyobraźmy sobie hipotetyczny interfejs API, który pozwala aplikacjom monitorować pozostały procent naładowania baterii:

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

Rozważmy scenariusz, w którym po zablokowaniu aplikacji występuje wiele zdarzeń zmiany stanu. Gdy aplikacja zostanie odmrożona, prześlij do niej tylko najnowszy stan, a inne nieaktualne zmiany stanu pomiń. Ta dostawa powinna nastąpić natychmiast po odblokowaniu aplikacji, aby mogła ona „nadrobić zaległości”. Można to zrobić na 2 sposoby:

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ść przesłaną do aplikacji, aby nie musiała ona otrzymywać powiadomień o tej samej wartości po odblokowaniu.

Stan może być wyrażony w bardziej złożonych danych. Rozważ hipotetyczny interfejs API, który umożliwia aplikacjom otrzymywanie powiadomień o interfejsach sieci:

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

Podczas wstrzymywania powiadomień aplikacji należy pamiętać o tym, jakie sieci i stany były ostatnio widoczne dla aplikacji. Po wznowieniu działania zalecamy powiadomienie aplikacji o starych sieciach, które zostały utracone, nowych sieciach, które stały się dostępne, oraz istniejących sieciach, których stan się zmienił. Właśnie w tej kolejności.

Nie informuj aplikacji o sieciach, które były dostępne, a potem utracone, gdy wywołania zwrotne były wstrzymane. Aplikacje nie powinny otrzymywać pełnego opisu zdarzeń, które miały miejsce, gdy były zamrożone, a dokumentacja interfejsu API nie powinna gwarantować ciągłości strumieni zdarzeń poza wyraźnymi stanami cyklu życia. W tym przykładzie, jeśli aplikacja musi stale monitorować dostępność sieci, musi pozostać w stanie cyklu życia, który uniemożliwia jej umieszczenie w pamięci podręcznej lub zamrożenie.

Podczas sprawdzania należy scalić zdarzenia, które wystąpiły po wstrzymaniu i przed wznowieniem powiadomień, oraz w zwięzły sposób przekazać najnowszy stan do zarejestrowanych wywołań zwrotnych aplikacji.

Informacje dotyczące dokumentacji dla deweloperów

Przesyłanie zdarzeń asynchronicznych może być opóźnione, ponieważ nadawca wstrzymał przesyłanie na czas podany w poprzedniej sekcji lub ponieważ aplikacja odbiorcy nie otrzymała wystarczającej ilości zasobów urządzenia, aby przetworzyć zdarzenie w odpowiedni sposób.

Odradzanie deweloperom tworzenia założeń dotyczących czasu między momentem, w którym ich aplikacja otrzymała powiadomienie o wydarzeniu, a czasem, w którym to zdarzenie rzeczywiście miało miejsce.

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

Deweloperzy, którzy znają strukturalną współbieżność w Kotlinie, oczekują od zawieszającego interfejsu API następujących zachowań:

Funkcje zawieszania powinny wykonać wszystkie powiązane zadania przed powrotem lub wyrzuceniem.

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

Funkcja zawieszania powinna wywoływać parametry wywołania zwrotnego tylko w miejscu.

Funkcje zawieszenia powinny zawsze wykonać wszystkie powiązane zadania przed powrotem, więc nie powinny nigdy wywoływać przekazanego wywołania zwrotnego ani innego parametru funkcji ani zachowywać odwołania do niego po powrocie funkcji zawieszenia.

Funkcja zawieszania, która przyjmuje parametry wywołania zwrotnego, powinna zachowywać kontekst, chyba że w dokumentacji podano inaczej.

Wywołanie funkcji w funkcji zawieszonej powoduje jej wykonanie w ramach CoroutineContext wywołującego. Funkcja zawieszania powinna wykonać wszystkie powiązane czynności przed zwróceniem lub wyrzuceniem wyjątku i powinna wywoływać parametry funkcji wywołania zwrotnego tylko w miejscu. Domyślnie zakłada się, że wszystkie takie funkcje wywołania zwrotnego są także wykonywane w funkcji wywołującej CoroutineContext przy użyciu powiązanego rozsyłarki. Jeśli celem interfejsu API jest wywołanie zwrotne spoza wywołującego CoroutineContext, należy wyraźnie to udokumentować.

Funkcje zawieszania powinny obsługiwać anulowanie zadań w kotlinx.coroutines

Każda oferowana funkcja zawieszania powinna współpracować z anulowaniem zadania zgodnie z definicją kotlinx.coroutines. Jeśli zadanie wywołujące w ramach trwającej operacji zostanie anulowane, funkcja powinna zostać wznowiona za pomocą CancellationException tak szybko, jak to możliwe, aby wywołujący mógł jak najszybciej oczyścić i kontynuować. Ta operacja jest wykonywana automatycznie przez interfejs suspendCancellableCoroutine i inne interfejsy API do zawieszania oferowane przez kotlinx.coroutines. Implementacji bibliotek nie należy używać bezpośrednio, ponieważ domyślnie nie obsługuje ona tego zachowania anulowania.suspendCoroutine

Funkcja zawieszania, która wykonuje blokowanie na tle (wątek inny niż główny lub wątek interfejsu użytkownika), musi umożliwiać skonfigurowanie używanego rozsyłarki.

Nie zalecamy korzystania z funkcji blokowania, która powoduje całkowite zawieszenie wątków.

Wywołanie funkcji zawieszania nie powinno prowadzić do tworzenia dodatkowych wątków bez zezwolenia dewelopera na dostarczenie własnego wątku lub puli wątków do wykonania tej pracy. Na przykład konstruktor może przyjmować parametr CoroutineContext, który służy do wykonywania pracy w tle w przypadku metod klasy.

Zawieszanie funkcji, które przyjmują opcjonalny parametr CoroutineContext lub Dispatcher tylko po to, aby przełączyć się na ten rozsyłający, aby wykonać blokowanie, powinno zamiast tego udostępniać podstawową funkcję blokowania i zalecać, aby wywołujący deweloperzy używali własnego wywołania withContext, aby kierować pracę do wybranego rozsyłającego.

Klasy uruchamiające coroutine

Klasy, które uruchamiają coroutines, muszą mieć CoroutineScope, aby wykonywać te operacje uruchamiania. Przestrzeganie zasad uporządkowanej równoległości wymaga stosowania następujących wzorów strukturalnych do uzyskiwania dostępu do zakresu i zarządzania nim.

Zanim napiszesz klasę, która uruchamia zadania równoległe 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()
    }
}

Wyeksponowanie suspend fun do wykonywania równoległej pracy pozwala wywołującemu wywoływać operację we własnym kontekście, eliminując potrzebę zarządzania MyClass przez CoroutineScope. Serializacja przetwarzania żądań staje się prostsza, a stan może często występować jako zmienna lokalna handleRequests zamiast właściwości klasy, które wymagałyby dodatkowej synchronizacji.

Klasy, które zarządzają coroutines, powinny udostępniać metody close i cancel

Klasy, które uruchamiają coroutines jako szczegóły implementacji, muszą oferować sposób na prawidłowe zamykanie tych trwających równoległych zadań, aby nie dochodziło do niekontrolowanego przenoszenia pracy równoległej do zakresu nadrzędnego. Zwykle polega to na utworzeniu elementu podrzędnego Job elementu CoroutineContext:

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

fun cancel() {
    myJob.cancel()
}

Możesz też udostępnić metodę join(), aby kod użytkownika mógł oczekiwać na zakończenie wszystkich pozostałych równoległych operacji wykonywanych przez obiekt. (może to obejmować działania związane z czyszczeniem, które są wykonywane po anulowaniu operacji).

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

Nazewi operacji terminala

Nazwa metody, która kończy działanie równoległych zadań należących do obiektu, które są jeszcze w toku, powinna odzwierciedlać sposób, w jaki odbywa się zamykanie:

Używaj funkcji close(), gdy operacje w toku mogą się zakończyć, ale po wywołaniu funkcji close() nie mogą się rozpocząć żadne nowe operacje.

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

Konstruktory klas akceptują CoroutineContext, a nie CoroutineScope

Jeśli obiekty nie mogą być uruchamiane bezpośrednio w ramach podanego zakresu nadrzędnego, nieodpowiednie jest użycie parametru konstruktora CoroutineScope:

// 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
}

Obiekt CoroutineScope staje się niepotrzebnym i mylącym opakowaniem, które w niektórych przypadkach może być tworzone tylko po to, aby przekazać je jako parametr konstruktora, a następnie odrzucić:

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

Parametry CoroutineContext są domyślnie ustawiane na EmptyCoroutineContext

Gdy w interfejsie API pojawia się opcjonalny parametr CoroutineContext, jego domyślną wartością musi być sentinel Empty`CoroutineContext`. Pozwala to na lepszą kompozycję zachowań interfejsu API, ponieważ wartość Empty`CoroutineContext` od wywołującego jest traktowana tak samo jak akceptacja domyślnej wartości:

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)

    // ...
}