Zwiększanie wydajności aplikacji przy użyciu współprogramów Kotlin

Korutyny Kotlin umożliwiają pisanie przejrzystego, uproszczonego kodu asynchronicznego, który zapewnia responsywność aplikacji podczas zarządzania długotrwałymi zadaniami, takimi jak wywołania sieciowe czy operacje na dysku.

W tym artykule znajdziesz szczegółowe informacje o korutynach na Androidzie. Jeśli nie znasz współprogramów, przed przeczytaniem tego artykułu zapoznaj się z artykułem Współprogramy Kotlin na Androidzie.

Zarządzanie długotrwałymi zadaniami

Korutyny są rozszerzeniem zwykłych funkcji, które dodają 2 operacje do obsługi długotrwałych zadań. Oprócz invoke (lub call) i return korutyny dodają suspend i resume:

  • suspend wstrzymuje wykonywanie bieżącego współprogramu, zapisując wszystkie zmienne lokalne.
  • resume wznawia wykonywanie zawieszonej korutyny od miejsca, w którym została zawieszona.

Funkcje suspend można wywoływać tylko z innych funkcji suspend lub za pomocą konstruktora współprogramów, np. launch, aby uruchomić nowy współprogram.

Poniższy przykład pokazuje prostą implementację współprogramu dla hipotetycznego długotrwałego zadania:

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) {
    /* ... */
}

W tym przykładzie get() nadal działa w głównym wątku, ale zawiesza współprogram przed rozpoczęciem żądania sieciowego. Gdy żądanie sieciowe zostanie zrealizowane, get wznawia zawieszony współprogram zamiast używać wywołania zwrotnego do powiadamiania wątku głównego.

Kotlin używa ramki stosu do zarządzania tym, która funkcja jest uruchomiona, oraz zmiennymi lokalnymi. Podczas wstrzymywania korutyny bieżąca ramka stosu jest kopiowana i zapisywana na później. Po wznowieniu ramka stosu jest kopiowana z powrotem z miejsca, w którym została zapisana, a funkcja zaczyna ponownie działać. Chociaż kod może wyglądać jak zwykłe sekwencyjne żądanie blokujące, współprogram zapewnia, że żądanie sieciowe nie blokuje wątku głównego.

Używanie korutyn w celu zapewnienia bezpieczeństwa wątku głównego

Korutyny Kotlin używają mechanizmów dispatcher do określania, które wątki są używane do wykonywania współprogramów. Aby uruchomić kod poza wątkiem głównym, możesz poinformować korutyny Kotlin, aby wykonywały pracę na dyspozytorze Default lub IO. W Kotlinie wszystkie współprogramy muszą działać w dispatcherze, nawet jeśli są uruchamiane w wątku głównym. Korutyny mogą się zawieszać, a za ich wznowienie odpowiada dyspozytor.

Aby określić, gdzie mają być uruchamiane współprogramy, Kotlin udostępnia 3 dyspozytorów:

  • Dispatchers.Main – użyj tego dyspozytora, aby uruchomić współprogram w głównym wątku Androida. Należy go używać tylko do interakcji z interfejsem i wykonywania szybkich zadań. Może to być na przykład wywoływanie funkcji suspend, wykonywanie operacji w ramach platformy interfejsu Androida czy aktualizowanie obiektów LiveData.
  • Dispatchers.IO – ten dyspozytor jest zoptymalizowany pod kątem wykonywania operacji wejścia-wyjścia na dysku lub w sieci poza wątkiem głównym. Może to być na przykład używanie komponentu Room, odczytywanie lub zapisywanie plików oraz wykonywanie operacji sieciowych.
  • Dispatchers.Default – ten dyspozytor jest zoptymalizowany pod kątem wykonywania zadań wymagających dużej mocy obliczeniowej procesora poza wątkiem głównym. Przykładowe przypadki użycia to sortowanie listy i analizowanie formatu JSON.

Kontynuując poprzedni przykład, możesz użyć dyspozytorów, aby ponownie zdefiniować funkcję get. W treści funkcji get wywołaj funkcję withContext(Dispatchers.IO), aby utworzyć blok, który będzie działać w puli wątków wejścia/wyjścia. Każdy kod umieszczony w tym bloku jest zawsze wykonywany przez dyspozytora IO. Ponieważ withContext jest funkcją zawieszającą, funkcja get również jest funkcją zawieszającą.

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("developer.android.com") // Dispatchers.Main
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = // Dispatchers.Main
    withContext(Dispatchers.IO) { // Dispatchers.IO (main-safety block)
        /* perform network IO here */ // Dispatchers.IO (main-safety block)
    } // Dispatchers.Main

Za pomocą korutyn możesz wysyłać wątki z dokładną kontrolą. Ponieważ withContext() umożliwia kontrolowanie puli wątków dowolnego wiersza kodu bez wprowadzania wywołań zwrotnych, możesz zastosować ją do bardzo małych funkcji, takich jak odczytywanie danych z bazy danych lub wykonywanie żądania sieciowego. Zalecamy używanie withContext(), aby mieć pewność, że każda funkcja jest bezpieczna dla wątku głównego, co oznacza, że możesz wywoływać ją z wątku głównego. Dzięki temu osoba wywołująca nigdy nie musi się zastanawiać, którego wątku należy użyć do wykonania funkcji.

W poprzednim przykładzie funkcja fetchDocs() jest wykonywana w wątku głównym, ale może bezpiecznie wywoływać funkcję get, która wykonuje żądanie sieciowe w tle. Ponieważ współprogramy obsługują suspend i resume, współprogram w głównym wątku jest wznawiany z wynikiem get, gdy tylko blok withContext zostanie wykonany.

Wydajność funkcji withContext()

withContext() nie powoduje dodatkowego obciążenia w porównaniu z odpowiednią implementacją opartą na wywołaniach zwrotnych. W niektórych sytuacjach można też zoptymalizować połączenia withContext() w sposób wykraczający poza równoważną implementację opartą na wywołaniu zwrotnym. Jeśli na przykład funkcja wykonuje 10 wywołań sieciowych, możesz polecić Kotlinowi przełączanie wątków tylko raz, używając zewnętrznego withContext(). Dzięki temu, nawet jeśli biblioteka sieciowa używa withContext() wiele razy, pozostaje w tym samym dyspozytorze i nie przełącza wątków. Dodatkowo Kotlin optymalizuje przełączanie między Dispatchers.Default a Dispatchers.IO, aby w miarę możliwości unikać przełączania wątków.

Uruchamianie współprogramu

Korutyny można uruchamiać na 2 sposoby:

  • launch – uruchamia nowy współprogram i nie zwraca wyniku do elementu wywołującego. Każde zadanie, które można uznać za „zrobione i zapomniane”, można rozpocząć za pomocą launch.
  • async rozpoczyna nowy współprogram i umożliwia zwrócenie wyniku za pomocą funkcji zawieszania o nazwie await.

Zwykle należy launch nowy współprogram z funkcji regularnej, ponieważ funkcja regularna nie może wywoływać funkcji await. Używaj funkcji async tylko w innej korutynie lub w funkcji zawieszającej, gdy wykonujesz dekompozycję równoległą.

Równoległa dekompozycja

Wszystkie korutyny uruchomione w funkcji suspend muszą zostać zatrzymane, gdy ta funkcja zwróci wartość, więc prawdopodobnie musisz zagwarantować, że te korutyny zakończą się przed zwróceniem wartości. Dzięki strukturalnemu współbieżnemu wykonywaniu w Kotlinie możesz zdefiniować coroutineScope, które uruchamia co najmniej 1 korutynę. Następnie za pomocą funkcji await() (w przypadku jednego współprogramu) lub awaitAll() (w przypadku wielu współprogramów) możesz zagwarantować, że te współprogramy zakończą się przed powrotem z funkcji.

Zdefiniujmy na przykład element coroutineScope, który asynchronicznie pobiera 2 dokumenty. Wywołując await() w przypadku każdego odroczonego odwołania, gwarantujemy, że obie operacje async zakończą się przed zwróceniem wartości:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

Możesz też używać awaitAll() w kolekcjach, jak pokazano w tym przykładzie:

suspend fun fetchTwoDocs() = // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf( // fetch two docs at the same time
            async { fetchDoc(1) }, // async returns a result for the first doc
            async { fetchDoc(2) } // async returns a result for the second doc
        )
        deferreds.awaitAll() // use awaitAll to wait for both network requests
    }

Mimo że funkcja fetchTwoDocs() uruchamia nowe korutyny za pomocą async, używa awaitAll(), aby poczekać na zakończenie tych uruchomionych korutyn przed zwróceniem wartości. Pamiętaj jednak, że nawet gdybyśmy nie wywołali funkcji awaitAll(), narzędzie do tworzenia coroutineScope nie wznowi korutyny, która wywołała funkcję fetchTwoDocs, dopóki wszystkie nowe korutyny nie zostaną ukończone.

Dodatkowo coroutineScope przechwytuje wszystkie wyjątki zgłaszane przez korutyny i przekazuje je z powrotem do elementu wywołującego.

Więcej informacji o równoległym rozkładzie znajdziesz w artykule Tworzenie funkcji zawieszających.

Pojęcia związane z współprogramami

CoroutineScope

CoroutineScope śledzi wszystkie utworzone przez siebie współprogramy za pomocą funkcji launch lub async. Trwającą pracę (czyli działające korutyny) można anulować w dowolnym momencie, wywołując scope.cancel(). W Androidzie niektóre biblioteki KTX udostępniają własny poziom CoroutineScope dla niektórych klas cyklu życia. Na przykład ViewModel ma viewModelScope, a Lifecycle ma lifecycleScope. W przeciwieństwie do dyspozytora CoroutineScope nie uruchamia jednak korutyn.

Symbol viewModelScope jest też używany w przykładach w artykule Wątki w tle na Androidzie z użyciem współprogramów. Jeśli jednak chcesz utworzyć własny CoroutineScope, aby kontrolować cykl życia korutyn w określonej warstwie aplikacji, możesz to zrobić w ten sposób:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Anulowany zakres nie może tworzyć więcej korutyn. Dlatego wywołuj funkcję scope.cancel() tylko wtedy, gdy klasa, która kontroluje jej cykl życia, jest niszczona. Gdy używasz viewModelScope, klasa ViewModel automatycznie anuluje zakres w metodzie onCleared() klasy ViewModel.

Zadanie

Job to uchwyt do współprogramu. Każdy współprogram, który utworzysz za pomocą launch lub async, zwraca instancję Job, która jednoznacznie identyfikuje współprogram i zarządza jego cyklem życia. Możesz też przekazać Job do CoroutineScope, aby dalej zarządzać jego cyklem życia, jak pokazano w tym przykładzie:

class ExampleClass {
    // ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (condition) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext określa zachowanie współprogramu za pomocą tego zestawu elementów:

W przypadku nowych współprogramów utworzonych w zakresie do nowego współprogramu przypisywana jest nowa instancja Job, a pozostałe elementy CoroutineContext są dziedziczone z zakresu zawierającego. Możesz zastąpić odziedziczone elementy, przekazując nowy element CoroutineContext do funkcji launch lub async. Pamiętaj, że przekazanie Job do launch lub async nie ma wpływu, ponieważ do nowej korutyny zawsze przypisywana jest nowa instancja Job.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Dodatkowe materiały o korutynach

Więcej informacji o korutynach znajdziesz w tych artykułach: