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

Współprogramy 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 szczegółowo omawiamy współprogramy 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

Współprogramy rozszerzają zwykłe funkcje, dodając 2 operacje do obsługi długotrwałych zadań. Oprócz invoke (lub call) i return współprogramy dodają suspend i resume:

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

Funkcje suspend możesz wywoływać tylko z innych funkcji suspend lub za pomocą narzędzia do tworzenia współprogramów, takiego jak 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 funkcja get() nadal działa w wątku głównym, ale wstrzymuje współprogram przed rozpoczęciem żądania sieciowego. Gdy żądanie sieciowe zostanie zrealizowane, funkcja get wznawia wstrzymany współprogram zamiast używać wywołania zwrotnego do powiadomienia wątku głównego.

Kotlin używa ramki stosu do zarządzania tym, która funkcja jest wykonywana, oraz wszystkimi zmiennymi lokalnymi. Podczas wstrzymywania współprogramu bieżąca ramka stosu jest kopiowana i zapisywana na później. Podczas wznawiania ramka stosu jest kopiowana z powrotem z miejsca, w którym została zapisana, a funkcja zaczyna działać ponownie. 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 współprogramów do zapewnienia bezpieczeństwa w wątku głównym

Współprogramy Kotlin używają dyspozytorów 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ć współprogramy Kotlin, aby wykonywały pracę w dyspozytorze Default lub IO. W Kotlinie wszystkie współprogramy muszą działać w dyspozytorze, nawet jeśli działają w wątku głównym. Współprogramy mogą się wstrzymywać, a dyspozytor jest odpowiedzialny za ich wznawianie.

Aby określić, gdzie mają działać współprogramy, Kotlin udostępnia 3 dyspozytory, których możesz używać:

  • Dispatchers.Main – użyj tego dyspozytora, aby uruchomić współprogram w głównym wątku Androida. Powinien być używany tylko do interakcji z interfejsem i wykonywania szybkich zadań. Przykłady obejmują wywoływanie suspend funkcji, wykonywanie operacji w ramach Android UI framework i aktualizowanie LiveData obiektów.
  • 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. Przykłady obejmują używanie komponentu Room, odczytywanie i zapisywanie plików oraz wykonywanie operacji sieciowych.
  • Dispatchers.Default – ten dyspozytor jest zoptymalizowany pod kątem wykonywania zadań wymagających dużej mocy obliczeniowej poza wątkiem głównym. Przykłady obejmują sortowanie listy i analizowanie kodu JSON.

Kontynuując poprzedni przykład, możesz użyć dyspozytorów, aby ponownie zdefiniować funkcję get. W treści funkcji get wywołaj withContext(Dispatchers.IO), aby utworzyć blok, który działa w puli wątków wejścia-wyjścia. Każdy kod umieszczony w tym bloku jest zawsze wykonywany za pomocą dyspozytora IO. Ponieważ withContext jest funkcją wstrzymującą, funkcja get jest również funkcją wstrzymują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

Dzięki współprogramom możesz wysyłać wątki z precyzyjną kontrolą. Ponieważ withContext() umożliwia kontrolowanie puli wątków dowolnej linii kodu bez wprowadzania wywołań zwrotnych, możesz zastosować ją do bardzo małych funkcji, takich jak odczytywanie z bazy danych lub wykonywanie żądania sieciowego. Dobrym rozwiązaniem jest używanie withContext() w celu zapewnienia, że każda funkcja jest bezpieczna w wątku głównym, co oznacza, że możesz wywołać ją z wątku głównego. W ten sposób element wywołujący 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 wątku głównym jest wznawiany z wynikiem get zaraz po zakończeniu bloku withContext.

Wydajność funkcji withContext()

withContext() nie dodaje dodatkowego obciążenia w porównaniu z równoważną implementacją opartą na wywołaniach zwrotnych. Ponadto w niektórych sytuacjach można zoptymalizować wywołania withContext() poza równoważną implementacją opartą na wywołaniach zwrotnych. Jeśli na przykład funkcja wykonuje 10 wywołań sieciowych, możesz poinformować Kotlin, aby przełączał wątki tylko raz, używając zewnętrznej funkcji withContext(). Wtedy, nawet jeśli biblioteka sieciowa używa withContext() wiele razy, pozostaje w tym samym dyspozytorze i unika przełączania wątków. Ponadto 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

Współprogramy możesz uruchamiać na 2 sposoby:

  • launch uruchamia nowy współprogram i nie zwraca wyniku do elementu wywołującego. Każde zadanie, które jest uważane za „uruchom i zapomnij”, można uruchomić za pomocą launch.
  • async uruchamia nowy współprogram i umożliwia zwrócenie wyniku za pomocą funkcji wstrzymującej o nazwie await.

Zwykle należy launch nowy współprogram z normalnej funkcji, ponieważ normalna funkcja nie może wywołać await. Używaj async tylko w innym współprogramie lub w funkcji wstrzymującej i podczas wykonywania dekompozycji równoległej.

Dekompozycja równoległa

Wszystkie współprogramy uruchomione w funkcji suspend muszą zostać zatrzymane, gdy ta funkcja zwróci wartość, dlatego prawdopodobnie musisz zagwarantować, że te współprogramy zakończą się przed zwróceniem wartości. Dzięki strukturalnej współbieżności w Kotlinie możesz zdefiniować coroutineScope, który uruchamia co najmniej 1 współprogram. Następnie, używając await() (w przypadku pojedynczego współprogramu) lub awaitAll() (w przypadku wielu współprogramów), możesz zagwarantować, że te współprogramy zakończą się przed zwróceniem wartości z funkcji.

Na przykład zdefiniujmy coroutineScope, który asynchronicznie pobiera 2 dokumenty. Wywołując await() na każdym odroczonym odwołaniu, 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żyć 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
    }

Chociaż fetchTwoDocs() uruchamia nowe współprogramy za pomocą async, funkcja używa awaitAll() do czekania na zakończenie tych uruchomionych współprogramów przed zwróceniem wartości. Pamiętaj jednak, że nawet jeśli nie wywołaliśmy awaitAll(), narzędzie do tworzenia coroutineScope nie wznawia współprogramu, który wywołał fetchTwoDocs, dopóki nie zakończą się wszystkie nowe współprogramy.

Ponadto coroutineScope przechwytuje wszystkie wyjątki zgłaszane przez współprogramy i przekazuje je z powrotem do elementu wywołującego.

Więcej informacji o dekompozycji równoległej znajdziesz w artykule Tworzenie funkcji wstrzymujących.

Pojęcia związane ze współprogramami

CoroutineScope

A CoroutineScope śledzi wszystkie współprogramy, które tworzy za pomocą launch lub async. Trwające zadanie (czyli działające współprogramy) można anulować w dowolnym momencie, wywołując scope.cancel(). W Androidzie niektóre biblioteki KTX udostępniają własny 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 współprogramów.

viewModelScope jest też używany w przykładach w artykule Wątki w tle na Androidzie za pomocą współprogramów. Jeśli jednak musisz utworzyć własny CoroutineScope, aby kontrolować cykl życia współprogramów 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 współprogramów. Dlatego scope.cancel() należy wywoływać tylko wtedy, gdy klasa, która kontroluje jego cykl życia, jest niszczona. Gdy używasz viewModelScope, klasa ViewModel automatycznie anuluje zakres w metodzie onCleared() ViewModel.

Zadanie

Job to uchwyt do współprogramu. Każdy współprogram utworzony 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 dodatkowo 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

A 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 CoroutineContext do funkcji launch lub async. Pamiętaj, że przekazanie Job do launch lub async nie ma żadnego efektu, ponieważ do nowego współprogramu 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 współprogramach

Więcej materiałów o współprogramach znajdziesz pod tymi linkami: