Migliori prestazioni tramite l'organizzazione in thread

Un uso esperto dei thread su Android può aiutarti a migliorare le prestazioni della tua app. Questa pagina illustra diversi aspetti dell'utilizzo dei thread: l'utilizzo dell'interfaccia utente, o del thread principale, la relazione tra ciclo di vita dell'app e priorità dei thread e i metodi forniti dalla piattaforma per gestire la complessità dei thread. In ognuna di queste aree, questa pagina descrive potenziali errori e strategie per evitarli.

Thread principale

Quando l'utente avvia l'app, Android crea un nuovo processo Linux con un thread di esecuzione. Questo thread principale,noto anche come thread dell'interfaccia utente, è responsabile di tutto ciò che accade sullo schermo. Capire come funziona può aiutarti a progettare la tua app in modo che utilizzi il thread principale per ottenere le migliori prestazioni possibili.

Interni

Il thread principale ha un design molto semplice: il suo unico compito è prendere ed eseguire blocchi di lavoro da una coda di lavoro sicura per i thread fino al termine dell'app. Il framework genera alcuni di questi blocchi di lavoro da una varietà di posizioni. tra cui callback associati alle informazioni sul ciclo di vita, eventi utente come input o eventi provenienti da altre app e altri processi. Inoltre, l'app può accodare esplicitamente i blocchi in modo autonomo, senza utilizzare il framework.

Quasi qualsiasi blocco di codice eseguito dall'app è legato a un callback di evento, ad esempio input, inflazione del layout o disegno. Quando qualcosa attiva un evento, il thread in cui si è verificato l'evento sposta l'evento fuori da se stesso e nella coda dei messaggi del thread principale. Il thread principale può quindi gestire l'evento.

Durante l'aggiornamento di un'animazione o di una schermata, il sistema prova a eseguire un blocco (responsabile del disegno dello schermo) ogni 16 ms circa, al fine di ottenere un rendering uniforme a 60 frame al secondo. Affinché il sistema raggiunga questo obiettivo, la gerarchia UI/Visualizzazioni deve essere aggiornata nel thread principale. Tuttavia, quando la coda di messaggi del thread principale contiene attività troppo numerose o troppo lunghe affinché il thread principale completi l'aggiornamento abbastanza velocemente, l'app dovrebbe spostare questo lavoro in un thread di lavoro. Se il thread principale non riesce a completare l'esecuzione dei blocchi di lavoro entro 16 ms, l'utente potrebbe notare un attacco, un ritardo o una mancanza di reattività dell'interfaccia utente all'input. Se il thread principale si blocca per circa cinque secondi, il sistema mostra la finestra di dialogo L'applicazione non risponde (ANR), che consente all'utente di chiudere direttamente l'app.

Spostare numerose o attività lunghe dal thread principale, in modo che non interferiscano con il rendering fluido e la reattività rapida all'input dell'utente, è il motivo principale per adottare il threading nella tua app.

Thread e riferimenti agli oggetti UI

Per impostazione predefinita, gli oggetti Android View non sono compatibili con i thread. Un'app deve creare, usare ed eliminare gli oggetti UI, il tutto nel thread principale. Se provi a modificare o persino a fare riferimento a un oggetto UI in un thread diverso dal thread principale, il risultato potrebbe essere un'eccezione, errori silenziosi, arresti anomali e altri comportamenti non definiti.

I problemi con i riferimenti rientrano in due categorie distinte: riferimenti espliciti e riferimenti impliciti.

Riferimenti espliciti

Molte attività nei thread non principali hanno l'obiettivo finale di aggiornare gli oggetti dell'interfaccia utente. Tuttavia, se uno di questi thread accede a un oggetto nella gerarchia delle visualizzazioni, può verificarsi l'instabilità dell'applicazione: se un thread di lavoro cambia le proprietà dell'oggetto nello stesso momento in cui qualsiasi altro thread fa riferimento all'oggetto, i risultati non sono definiti.

Prendiamo ad esempio un'app che contiene un riferimento diretto a un oggetto UI in un thread di lavoro. L'oggetto nel thread worker potrebbe contenere un riferimento a View; ma prima del completamento del lavoro, View viene rimosso dalla gerarchia delle visualizzazioni. Quando queste due azioni si verificano contemporaneamente, il riferimento mantiene l'oggetto View in memoria e ne imposta le proprietà. Tuttavia, l'utente non vede mai questo oggetto e l'app elimina l'oggetto una volta rimosso il riferimento.

In un altro esempio, gli oggetti View contengono riferimenti all'attività di loro proprietà. Se l'attività viene eliminata, ma rimane un blocco di lavoro con conversazioni in thread che fa riferimento direttamente o indirettamente, il garbage collector non raccoglierà l'attività fino al termine dell'esecuzione del blocco di lavoro.

Questo scenario può causare un problema in situazioni in cui il lavoro con conversazioni in thread può essere in esecuzione mentre si verifica un evento del ciclo di vita di un'attività, ad esempio una rotazione dello schermo. Il sistema non sarebbe in grado di eseguire la garbage collection fino al completamento delle operazioni in corso. Di conseguenza, potrebbero esserci due oggetti Activity in memoria fino all'esecuzione della garbage collection.

In scenari come questi, suggeriamo di non includere riferimenti espliciti agli oggetti UI nelle attività di lavoro organizzate in thread. Se evita questi riferimenti, eviti anche questi tipi di perdite di memoria e, allo stesso tempo, evita i conflitti di thread.

In tutti i casi, l'app deve aggiornare solo gli oggetti UI nel thread principale. Ciò significa che devi creare un criterio di negoziazione che consenta a più thread di comunicare il lavoro al thread principale, in modo da gestire l'attività o il frammento di primo livello con il lavoro di aggiornamento dell'oggetto dell'interfaccia utente effettivo.

Riferimenti impliciti

Un comune difetto di progettazione del codice negli oggetti con conversazioni in thread può essere notato nello snippet di codice riportato di seguito:

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

Il problema in questo snippet è che il codice dichiara l'oggetto threading MyAsyncTask come una classe interna non statica di alcune attività (o una classe interna in Kotlin). Questa dichiarazione crea un riferimento implicito all'istanza Activity che include. Di conseguenza, l'oggetto contiene un riferimento all'attività fino al completamento del lavoro in thread, causando un ritardo nell'eliminazione dell'attività di riferimento. Questo ritardo, a sua volta, mette maggiormente a disposizione la memoria.

Una soluzione diretta a questo problema sarebbe definire le istanze di classe sovraccaricate come classi statiche o nei propri file, rimuovendo così il riferimento implicito.

Un'altra soluzione potrebbe essere annullare sempre le attività in background ed eseguire la pulizia nel callback Activity del ciclo di vita appropriato, ad esempio onDestroy. Questo approccio, tuttavia, può essere noioso e soggetto a errori. Come regola generale, non inserire logica complessa non UI direttamente nelle attività. Inoltre, l'API AsyncTask è ora deprecata e non ne è consigliato l'utilizzo nel nuovo codice. Consulta Threading su Android per ulteriori dettagli sulle primitive di contemporaneità disponibili.

Thread e cicli di vita delle attività app

Il ciclo di vita dell'app può influire sul funzionamento del threading nell'applicazione. Potresti dover decidere se un thread deve o meno rimanere in vigore dopo l'eliminazione di un'attività. Dovresti anche conoscere la relazione tra l'assegnazione della priorità dei thread e se un'attività è in esecuzione in primo piano o in background.

Thread persistenti

I fili persistono oltre le attività che li hanno generati. I thread continuano a essere eseguiti senza interruzioni, indipendentemente dalla creazione o dall'eliminazione delle attività, anche se vengono terminati insieme al processo di applicazione quando non ci sono più componenti dell'applicazione attivi. In alcuni casi, questa persistenza è desiderabile.

Considera un caso in cui un'attività genera una serie di blocchi di lavoro in thread e viene poi eliminata prima che un thread di lavoro possa eseguire i blocchi. Cosa dovrebbe fare l'app con i blocchi in volo?

Se i blocchi dovevano aggiornare una UI che non esiste più, non c'è motivo di continuare il lavoro. Ad esempio, se il lavoro consiste nel caricare le informazioni degli utenti da un database e quindi nell'aggiornare le visualizzazioni, il thread non è più necessario.

Al contrario, i pacchetti di lavoro potrebbero avere qualche vantaggio non interamente correlato all'interfaccia utente. In questo caso, devi rendere il thread permanente. Ad esempio, i pacchetti potrebbero essere in attesa di scaricare un'immagine, memorizzarla nella cache su disco e aggiornare l'oggetto View associato. Anche se l'oggetto non esiste più, il download e la memorizzazione dell'immagine nella cache potrebbero essere comunque utili nel caso in cui l'utente ritorni all'attività eliminata.

La gestione manuale delle risposte del ciclo di vita per tutti gli oggetti in thread può diventare estremamente complessa. Se non li gestisci correttamente, la tua app potrebbe presentare problemi di prestazioni e contesa di memoria. La combinazione di ViewModel con LiveData ti consente di caricare i dati e di ricevere notifiche quando vengono modificati senza doverti preoccupare del ciclo di vita. ViewModel sono una soluzione a questo problema. I modelli ViewModel vengono conservati durante le modifiche alla configurazione, consentendo un modo semplice per mantenere i dati delle viste. Per ulteriori informazioni su ViewModels, consulta la guida ViewModel e per ulteriori informazioni su LiveData, consulta la guida LiveData. Per ulteriori informazioni sull'architettura delle applicazioni, leggi la Guida all'architettura delle app.

Priorità dei thread

Come descritto in Processi e ciclo di vita dell'applicazione, la priorità ricevuta dai thread dell'app dipende in parte da dove si trova l'app nel suo ciclo di vita. Quando crei e gestisci i thread nella tua applicazione, è importante impostarne la priorità in modo che i thread giusti ricevano le priorità giuste al momento giusto. Se il valore impostato è troppo elevato, il thread potrebbe interrompere il thread dell'interfaccia utente e il RenderThread, causando la perdita di frame da parte dell'app. Se il valore impostato è troppo basso, le attività asincrone (ad esempio il caricamento delle immagini) possono essere più lente del dovuto.

Ogni volta che crei un thread devi chiamare setThreadPriority(). Lo strumento di pianificazione dei thread del sistema dà la preferenza ai thread con priorità elevate, bilanciando queste priorità con la necessità di portare a termine tutto il lavoro alla fine. In genere, i thread nel gruppo in primo piano ricevono circa il 95% del tempo di esecuzione totale dal dispositivo, mentre il gruppo in background raggiunge circa il 5%.

Il sistema assegna inoltre a ogni thread un proprio valore di priorità, utilizzando la classe Process.

Per impostazione predefinita, il sistema imposta la priorità di un thread sulla stessa priorità e gli stessi abbonamenti del thread di generazione. Tuttavia, l'applicazione può regolare esplicitamente la priorità dei thread utilizzando setThreadPriority().

La classe Process aiuta a ridurre la complessità nell'assegnazione dei valori di priorità fornendo un insieme di costanti che la tua app può utilizzare per impostare le priorità dei thread. Ad esempio, THREAD_PRIORITY_DEFAULT rappresenta il valore predefinito per un thread. L'app deve impostare la priorità dei thread su THREAD_PRIORITY_BACKGROUND per i thread che eseguono lavori meno urgenti.

La tua app può utilizzare le costanti THREAD_PRIORITY_LESS_FAVORABLE e THREAD_PRIORITY_MORE_FAVORABLE come incrementatori per impostare priorità relative. Per un elenco delle priorità dei thread, consulta le costanti THREAD_PRIORITY nella classe Process.

Per ulteriori informazioni sulla gestione dei thread, consulta la documentazione di riferimento sulle classi Thread e Process.

Classi di supporto per i thread

Per gli sviluppatori che utilizzano Kotlin come lingua principale, consigliamo l'uso delle coroutine. Le coroutine offrono una serie di vantaggi, tra cui la scrittura di codice asincrono senza callback e la contemporaneità strutturata per la definizione dell'ambito, l'annullamento e la gestione degli errori.

Il framework fornisce inoltre le stesse classi e primitive Java per facilitare il threading, come le classi Thread, Runnable e Executors, oltre a quelle aggiuntive come HandlerThread. Per ulteriori informazioni, consulta la sezione Threading su Android.

La classe HandlerThread

Un thread gestore è di fatto un thread a lunga esecuzione che recupera il lavoro da una coda e vi opera.

Considera una difficoltà comune nel recuperare frame di anteprima dall'oggetto Camera. Quando ti registri per i frame di anteprima della fotocamera, li ricevi nel callback onPreviewFrame(), che viene richiamato nel thread di eventi da cui è stato chiamato. Se questo callback venisse richiamato nel thread dell'interfaccia utente, l'attività di gestione degli array di pixel di grandi dimensioni interferisce con le operazioni di rendering e di elaborazione degli eventi.

In questo esempio, quando la tua app delega il comando Camera.open() a un blocco di lavoro sul thread del gestore, il callback onPreviewFrame() associato viene indirizzato al thread del gestore anziché al thread dell'interfaccia utente. Questa potrebbe essere la soluzione migliore se hai intenzione di lavorare a lungo sui pixel.

Quando la tua app crea un thread utilizzando HandlerThread, non dimenticare di impostare la priorità del thread in base al tipo di lavoro che svolge. Ricorda che le CPU possono gestire solo un numero limitato di thread in parallelo. L'impostazione della priorità consente al sistema di conoscere i modi corretti per programmare questa operazione quando tutti gli altri thread fanno fatica a richiamare l'attenzione.

La classe ThreadPoolExecutor

Esistono alcuni tipi di lavoro che possono essere ridotti ad attività molto parallele e distribuite. Un'attività di questo tipo, ad esempio, è calcolare un filtro per ogni blocco 8 x 8 di un'immagine da 8 megapixel. Con l'enorme volume di pacchetti di lavoro creati, HandlerThread non è la classe appropriata da utilizzare.

ThreadPoolExecutor è una classe di supporto per semplificare questo processo. Questo corso gestisce la creazione di un gruppo di thread, imposta le priorità e gestisce il modo in cui il lavoro viene distribuito tra i thread. Con l'aumento o la diminuzione del carico di lavoro, la classe avvia o elimina altri thread per adattarsi al carico di lavoro.

Questa lezione consente inoltre alla tua app di generare un numero ottimale di thread. Quando crea un oggetto ThreadPoolExecutor, l'app imposta un numero minimo e massimo di thread. All'aumentare del carico di lavoro assegnato a ThreadPoolExecutor, la classe prende in considerazione il numero minimo e massimo di thread inizializzati e prende in considerazione la quantità di lavoro in attesa da fare. In base a questi fattori, ThreadPoolExecutor decide quanti thread devono essere attivi in un determinato momento.

Quanti thread dovresti creare?

Anche se a livello di software, il codice ha la capacità di creare centinaia di thread, questo può creare problemi di prestazioni. La tua app condivide risorse della CPU limitate con servizi in background, renderer, motore audio, networking e altro ancora. In realtà, le CPU sono in grado di gestire solo un piccolo numero di thread in parallelo; tutto ciò che precede riscontra un problema di priorità e pianificazione. Di conseguenza, è importante creare solo il numero di thread necessario al carico di lavoro.

In pratica, ci sono diverse variabili responsabili di questo comportamento, ma scegliere un valore (ad esempio 4, per i comandi iniziali) e testarlo con Systrace è una strategia solida come qualsiasi altra strategia. Puoi utilizzare prove ed errori per scoprire il numero minimo di thread che puoi utilizzare senza riscontrare problemi.

Un altro aspetto da considerare per la scelta del numero di thread è che i thread non sono senza costi: occupano tutta la memoria. Ogni thread costa almeno 64.000 di memoria. Questa somma si somma rapidamente per tutte le app installate su un dispositivo, soprattutto nelle situazioni in cui lo stack di chiamate cresce in modo significativo.

Molti processi di sistema e librerie di terze parti spesso avviano i propri pool di thread. Se la tua app può riutilizzare un pool di thread esistente, questo riutilizzo può migliorare le prestazioni riducendo i conflitti per la memoria e le risorse di elaborazione.