Migliora le prestazioni dell'app con le coroutine Kotlin

Le coroutine Kotlin consentono di scrivere codice asincrono pulito e semplificato che mantiene la tua app reattiva e gestisce al contempo attività a lunga esecuzione come chiamate di rete o operazioni sul disco.

Questo argomento offre un'analisi dettagliata delle coroutine su Android. Se non conosci le coroutine, assicurati di leggere Kotlin coroutine su Android prima di leggere questo argomento.

Gestisci attività di lunga durata

Le coroutine si basano su funzioni regolari aggiungendo due operazioni per gestire attività di lunga durata. Oltre a invoke (o call) e return, le coroutine aggiungono suspend e resume:

  • suspend mette in pausa l'esecuzione della coroutine corrente, salvando tutte le variabili locali.
  • resume continua l'esecuzione di una coroutine sospesa dal luogo in cui è stata sospesa.

Puoi chiamare le funzioni suspend solo da altre funzioni suspend o utilizzando un generatore di coroutine come launch per avviare una nuova coroutine.

L'esempio seguente mostra una semplice implementazione di coroutine per un'ipotetica attività a lunga esecuzione:

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) { /* ... */ }

In questo esempio, get() continua a essere eseguito sul thread principale, ma sospende la coroutine prima di avviare la richiesta di rete. Quando la richiesta di rete viene completata, get ripristina la coroutine sospesa anziché utilizzare un callback per inviare una notifica al thread principale.

Kotlin utilizza un frame in stack per gestire la funzione in esecuzione insieme a qualsiasi variabile locale. Quando sospendi una coroutine, lo stack frame corrente viene copiato e salvato per un secondo momento. Al ripristino, lo stack frame viene copiato dalla posizione in cui era stato salvato e la funzione viene nuovamente eseguita. Anche se il codice potrebbe sembrare una normale richiesta di blocco sequenziale, la coroutine assicura che la richiesta di rete eviti di bloccare il thread principale.

Usa le coroutine per la sicurezza principale

Le coroutine Kotlin utilizzano i corrieri per determinare quali thread vengono utilizzati per l'esecuzione delle coroutine. Per eseguire il codice al di fuori del thread principale, puoi chiedere a Kotlin coroutine di eseguire il lavoro sul supervisore predefinito o IO. In Kotlin, tutte le coroutine devono essere eseguite in un supervisore, anche quando sono in esecuzione sul thread principale. Le coroutine possono sospendersi da sole e il supervisore sarà responsabile di ripristinarle.

Per specificare dove devono essere eseguite le coroutine, Kotlin offre tre supervisori che puoi utilizzare:

  • Dispatchers.Main: utilizza questo supervisore per eseguire una coroutine sul thread Android principale. Deve essere utilizzata solo per interagire con l'interfaccia utente ed eseguire operazioni rapide. Gli esempi includono la chiamata alle funzioni suspend, l'esecuzione di operazioni del framework dell'interfaccia utente di Android e l'aggiornamento degli oggetti LiveData.
  • Dispatchers.IO: questo supervisore è ottimizzato per eseguire I/O su disco o rete al di fuori del thread principale. Alcuni esempi sono l'utilizzo del componente Room, la lettura o la scrittura su file e l'esecuzione di qualsiasi operazione di rete.
  • Dispatchers.Default: questo supervisore è ottimizzato per eseguire il lavoro ad alta intensità di CPU al di fuori del thread principale. Esempi di casi d'uso includono l'ordinamento di un elenco e l'analisi del codice JSON.

Continuando con l'esempio precedente, puoi utilizzare i supervisori per ridefinire la funzione get. All'interno del corpo di get, chiama withContext(Dispatchers.IO) per creare un blocco che viene eseguito sul pool di thread di I/O. Qualsiasi codice inserito nel blocco viene sempre eseguito tramite il supervisore IO. Poiché withContext è a sua volta una funzione di sospensione, anche la funzione get è una funzione di sospensione.

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
}

Con le coroutine, puoi inviare thread con un controllo granulare. Poiché withContext() ti consente di controllare il pool di thread di qualsiasi riga di codice senza introdurre callback, puoi applicarlo a funzioni molto piccole come la lettura da un database o l'esecuzione di una richiesta di rete. È buona norma usare withContext() per assicurarti che ogni funzione sia main-safe, il che significa che puoi chiamare la funzione dal thread principale. In questo modo il chiamante non deve mai pensare a quale thread deve essere utilizzato per eseguire la funzione.

Nell'esempio precedente, fetchDocs() viene eseguito sul thread principale. Tuttavia, può chiamare in sicurezza get, che esegue una richiesta di rete in background. Poiché le coroutine supportano suspend e resume, la coroutine nel thread principale viene ripresa con il risultato get non appena viene completato il blocco withContext.

Rendimento di withContext()

withContext() non comporta un overhead aggiuntivo rispetto a un'implementazione equivalente basata su callback. Inoltre, in alcune situazioni è possibile ottimizzare le chiamate withContext() al di là di un'implementazione equivalente basata su callback. Ad esempio, se una funzione effettua dieci chiamate a una rete, puoi dire a Kotlin di cambiare thread solo una volta utilizzando un withContext() esterno. Quindi, anche se la libreria di rete utilizza withContext() più volte, rimane sullo stesso mittente ed evita di cambiare thread. Inoltre, Kotlin ottimizza il passaggio tra Dispatchers.Default e Dispatchers.IO per evitare il cambio dei thread quando possibile.

Avvia una coroutine

Puoi avviare una coroutine in due modi:

  • launch avvia una nuova coroutine e non restituisce il risultato al chiamante. Qualsiasi opera considerata "fuoco e dimentica" può essere avviata utilizzando launch.
  • async avvia una nuova coroutine e ti consente di restituire un risultato con una funzione di sospensione chiamata await.

In genere, dovresti launch una nuova coroutine da una funzione regolare, poiché una funzione regolare non può chiamare await. Utilizza async solo quando all'interno di un'altra coroutine o all'interno di una funzione di sospensione ed esegui la decomposizione parallela.

Decomposizione parallela

Tutte le coroutine avviate all'interno di una funzione suspend devono essere interrotte quando viene restituita la funzione, quindi è probabile che tu debba garantire che le coroutine terminino prima di tornare. Con la contemporaneità strutturata in Kotlin, puoi definire un coroutineScope che avvia una o più coroutine. Quindi, utilizzando await() (per una singola coroutine) o awaitAll() (per più coroutine), puoi garantire che queste coroutine terminino prima di tornare dalla funzione.

Ad esempio, definiamo un valore coroutineScope che recupera due documenti in modo asincrono. Richiamando await() su ogni riferimento differito, garantiamo il completamento di entrambe le operazioni di async prima di restituire un valore:

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

Puoi anche usare awaitAll() nelle raccolte, come mostrato nell'esempio seguente:

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
    }

Anche se fetchTwoDocs() lancia nuove coroutine con async, la funzione utilizza awaitAll() per attendere il termine di quelle avviate prima di tornare. Tuttavia, tieni presente che, anche se non avessimo chiamato awaitAll(), lo strumento per la creazione di coroutineScope non riprende la coroutine che ha chiamato fetchTwoDocs fino al completamento di tutte le nuove coroutine.

Inoltre, coroutineScope individua eventuali eccezioni generate dalle coroutine e le reindirizza al chiamante.

Per ulteriori informazioni sulla decomposizione parallela, consulta Composizione di funzioni di sospensione.

Concetti sulle coroutine

CoroutineScope

Un CoroutineScope tiene traccia di qualsiasi coroutine creata utilizzando launch o async. Il lavoro in corso (ovvero le coroutine in corso) può essere annullato chiamando scope.cancel() in qualsiasi momento. In Android, alcune librerie KTX forniscono il proprio CoroutineScope per determinate classi del ciclo di vita. Ad esempio, ViewModel ha viewModelScope e Lifecycle ha lifecycleScope. A differenza di un supervisore, tuttavia, un CoroutineScope non esegue le coroutine.

viewModelScope è utilizzato anche negli esempi che si trovano in Threading in background su Android con Coroutine. Tuttavia, se devi creare un CoroutineScope personalizzato per controllare il ciclo di vita delle coroutine in un determinato livello dell'app, puoi crearne uno nel seguente modo:

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()
    }
}

Un ambito annullato non può creare altre coroutine. Pertanto, devi chiamare scope.cancel() solo quando viene eliminata la classe che controlla il suo ciclo di vita. Quando utilizzi viewModelScope, la classe ViewModel annulla automaticamente l'ambito nel metodo onCleared() di ViewModel.

Job

Un Job è l'handle di una coroutine. Ogni coroutine creata con launch o async restituisce un'istanza Job che identifica in modo univoco la coroutine e ne gestisce il ciclo di vita. Puoi anche passare un Job a un CoroutineScope per gestirne ulteriormente il ciclo di vita, come illustrato nell'esempio seguente:

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

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

CoroutineContext

Un elemento CoroutineContext definisce il comportamento di una coroutina utilizzando il seguente insieme di elementi:

Per le nuove coroutine create all'interno di un ambito, viene assegnata una nuova istanza Job alla nuova coroutine, mentre gli altri elementi CoroutineContext vengono ereditati dall'ambito contenitore. Puoi eseguire l'override degli elementi ereditati passando un nuovo valore CoroutineContext alla funzione launch o async. Tieni presente che il passaggio di Job a launch o async non ha alcun effetto, poiché una nuova istanza di Job viene sempre assegnata a una nuova coroutine.

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

Risorse aggiuntive sulle coroutine

Per altre risorse sulle coroutine, consulta i seguenti link: