Linee guida per le API asincrone e non bloccanti di Android

Le API non bloccanti richiedono l'esecuzione di un'attività e poi restituiscono il controllo al thread chiamante in modo che possa eseguire altre attività prima del completamento dell'operazione richiesta. Queste API sono utili nei casi in cui l'attività richiesta potrebbe essere in corso o potrebbe richiedere l'attesa del completamento di I/O o IPC, la disponibilità di risorse di sistema molto contese o l'input dell'utente prima che l'attività possa procedere. Le API particolarmente ben progettate forniscono un modo per annullare l'operazione in corso e impedire che l'attività venga eseguita per conto del chiamante originale, preservando l'integrità del sistema e la durata della batteria quando l'operazione non è più necessaria.

Le API asincrone sono un modo per ottenere un comportamento non bloccante. Le API asincrone accettano una forma di continuazione o callback che viene notificata al completamento dell'operazione o di altri eventi durante l'avanzamento dell'operazione.

Esistono due motivazioni principali per scrivere un'API asincrona:

  • Eseguire più operazioni contemporaneamente, dove è necessario avviare un'operazione N prima del completamento dell'operazione N-1.
  • Evitare di bloccare un thread chiamante fino al completamento di un'operazione.

Kotlin promuove fortemente la concorrenza strutturata, una serie di principi e API basati su funzioni di sospensione che disaccoppiano l'esecuzione sincrona e asincrona del codice dal comportamento di blocco dei thread. Le funzioni di sospensione sono non bloccanti e sincrone.

Funzioni di sospensione:

  • Non bloccano il thread chiamante e, invece, restituiscono il thread di esecuzione come dettaglio di implementazione in attesa dei risultati delle operazioni eseguite altrove.
  • Vengono eseguite in modo sincrono e non richiedono al chiamante di un'API non bloccante di continuare l'esecuzione contemporaneamente al lavoro non bloccante avviato dalla chiamata API.

Questa pagina descrive una base minima di aspettative che gli sviluppatori possono avere in sicurezza quando lavorano con API non bloccanti e asincrone, seguita da una serie di ricette per la creazione di API che soddisfano queste aspettative in Kotlin o in Java, nella piattaforma Android o nelle librerie Jetpack. In caso di dubbi, considera le aspettative degli sviluppatori come requisiti per qualsiasi nuova superficie API.

Aspettative degli sviluppatori per le API asincrone

Le seguenti aspettative sono scritte dal punto di vista delle API non di sospensione, se non diversamente specificato.

Le API che accettano callback sono in genere asincrone

Se un'API accetta un callback che non è documentato per essere chiamato in-place (ovvero chiamato solo dal thread chiamante prima che la chiamata API stessa restituisca un valore), si presume che l'API sia asincrona e che soddisfi tutte le altre aspettative documentate nelle sezioni seguenti.

Un esempio di callback chiamato solo in-place è una funzione di mappa o filtro di ordine superiore che richiama un mapper o un predicato su ogni elemento di una raccolta prima di restituire un valore.

Le API asincrone devono restituire un valore il più rapidamente possibile

Gli sviluppatori si aspettano che le API asincrone siano non bloccanti e restituiscano un valore rapidamente dopo aver avviato la richiesta dell'operazione. Dovrebbe essere sempre sicuro chiamare un'API asincrona in qualsiasi momento e la chiamata di un'API asincrona non dovrebbe mai causare frame instabili o ANR.

Molte operazioni e segnali del ciclo di vita possono essere attivati dalla piattaforma o dalle librerie on demand e non è sostenibile aspettarsi che uno sviluppatore abbia una conoscenza globale di tutti i potenziali siti di chiamata per il proprio codice. Ad esempio, un Fragment può essere aggiunto al FragmentManager in una transazione sincrona in risposta alla misurazione e al layout di View quando i contenuti dell'app devono essere inseriti per riempire lo spazio disponibile (ad esempio RecyclerView). Un LifecycleObserver che risponde al callback del ciclo di vita onStart di questo frammento può ragionevolmente eseguire operazioni di avvio una tantum qui e questo potrebbe essere su un percorso del codice critico per produrre un frame di animazione senza jank. Uno sviluppatore dovrebbe sempre essere certo che la chiamata di qualsiasi API asincrona in risposta a questi tipi di callback del ciclo di vita non sia la causa di un frame instabile.

Ciò implica che l'attività eseguita da un'API asincrona prima di restituire un valore deve essere molto leggera: al massimo, creare un record della richiesta e del callback associato e registrarlo con il motore di esecuzione che esegue l'attività. Se la registrazione per un'operazione asincrona richiede IPC, l'implementazione dell'API deve adottare tutte le misure necessarie per soddisfare questa aspettativa dello sviluppatore. Potrebbe includere uno o più dei seguenti elementi:

  • Implementare un IPC sottostante come chiamata di binder unidirezionale
  • Effettuare una chiamata di binder bidirezionale al server di sistema in cui il completamento della registrazione non richiede l'acquisizione di un blocco molto conteso
  • Pubblicare la richiesta in un thread di lavoro nel processo dell'app per eseguire una registrazione bloccante tramite IPC

Le API asincrone devono restituire un valore vuoto e generare un'eccezione solo per gli argomenti non validi

Le API asincrone devono segnalare tutti i risultati dell'operazione richiesta al callback fornito. In questo modo, lo sviluppatore può implementare un singolo percorso del codice per la gestione di successo e la gestione degli errori.

Le API asincrone possono controllare se gli argomenti sono nulli e generare NullPointerException oppure verificare che gli argomenti forniti rientrino in un intervallo valido e generare IllegalArgumentException. Ad esempio, per una funzione che accetta un float nell'intervallo da 0 a 1f, la funzione può verificare che il parametro rientri in questo intervallo e generare IllegalArgumentException se è fuori intervallo oppure una String breve può essere verificata per la conformità a un formato valido, ad esempio solo alfanumerico. Ricorda che il server di sistema non deve mai considerare attendibile il processo dell'app. Qualsiasi servizio di sistema deve duplicare questi controlli nel servizio di sistema stesso.

Tutti gli altri errori devono essere segnalati al callback fornito. Ciò include, a titolo esemplificativo e non esaustivo:

  • Errore terminale dell'operazione richiesta
  • Eccezioni di sicurezza per autorizzazione o autorizzazioni mancanti necessarie per completare l'operazione
  • Quota superata per l'esecuzione dell'operazione
  • Il processo dell'app non è sufficientemente "in primo piano" per eseguire l'operazione
  • L'hardware richiesto è stato disconnesso
  • Errori di rete
  • Timeout
  • Processo remoto non disponibile o interruzione del binder

Le API asincrone devono fornire un meccanismo di annullamento

Le API asincrone devono fornire un modo per indicare a un'operazione in esecuzione che il chiamante non è più interessato al risultato. Questa operazione di annullamento deve segnalare due cose:

I riferimenti hard ai callback forniti dal chiamante devono essere rilasciati

I callback forniti alle API asincrone possono contenere riferimenti hard a grafici di oggetti di grandi dimensioni e il lavoro in corso che contiene un riferimento hard a quel callback può impedire che questi grafici di oggetti vengano sottoposti a garbage collection. Se questi riferimenti ai callback vengono rilasciati all'annullamento, questi grafici di oggetti potrebbero diventare idonei per la garbage collection molto prima rispetto a se il lavoro potesse essere completato.

Il motore di esecuzione che esegue il lavoro per il chiamante può interrompere l'attività

Il lavoro avviato dalle chiamate API asincrone può comportare un costo elevato in termini di consumo energetico o altre risorse di sistema. Le API che consentono ai chiamanti di segnalare quando questo lavoro non è più necessario consentono di interromperlo prima che possa consumare ulteriori risorse di sistema.

Considerazioni speciali per le app memorizzate nella cache o bloccate

Quando progetti API asincrone in cui i callback hanno origine in un processo di sistema e vengono forniti alle app, tieni presente quanto segue:

  1. Processi e ciclo di vita dell'app: il processo dell'app destinataria potrebbe essere nello stato memorizzato nella cache.
  2. Blocco delle app memorizzate nella cache: il processo dell'app destinataria potrebbe essere bloccato.

Quando il processo di un'app entra nello stato memorizzato nella cache, significa che non ospita attivamente componenti visibili all'utente, come attività e servizi. L'app viene mantenuta in memoria nel caso in cui diventi di nuovo visibile all'utente, ma nel frattempo non deve eseguire alcuna attività. Nella maggior parte dei casi, devi mettere in pausa l'invio dei callback dell'app quando l'app entra nello stato memorizzato nella cache e riprendere quando l'app esce dallo stato memorizzato nella cache, in modo da non indurre attività nei processi dell'app memorizzati nella cache.

Un'app memorizzata nella cache può anche essere bloccata. Quando un'app è bloccata, riceve zero tempo CPU e non è in grado di eseguire alcuna attività. Tutte le chiamate ai callback registrati dell'app vengono memorizzate nel buffer e consegnate quando l'app viene sbloccata.

Le transazioni memorizzate nel buffer per i callback dell'app potrebbero essere obsolete quando l'app viene sbloccata ed elaborate. Il buffer è finito e, se viene superato, l'app destinataria andrà in crash. Per evitare di sovraccaricare le app con eventi obsoleti o di superare i buffer, non inviare i callback dell'app mentre il processo è bloccato.

In revisione:

  • Devi valutare la possibilità di mettere in pausa l'invio dei callback dell'app mentre il processo dell'app è memorizzato nella cache.
  • DEVI mettere in pausa l'invio dei callback dell'app mentre il processo dell'app è bloccato.

Monitoraggio dello stato

Per monitorare quando le app entrano o escono dallo stato memorizzato nella cache:

mActivityManager.addOnUidImportanceListener(
    new UidImportanceListener() { ... },
    IMPORTANCE_CACHED);

Per monitorare quando le app vengono bloccate o sbloccate:

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

Strategie per riprendere l'invio dei callback dell'app

Se metti in pausa l'invio dei callback dell'app quando l'app entra nello stato memorizzato nella cache o nello stato bloccato, quando l'app esce dallo stato rispettivamente, devi riprendere l'invio dei callback registrati dell'app una volta che l'app esce dallo stato rispettivamente fino a quando l'app non ha annullato la registrazione del callback o il processo dell'app non viene interrotto.

Ad esempio:

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

In alternativa, puoi utilizzare RemoteCallbackList, che si occupa di non inviare i callback al processo di destinazione quando è bloccato.

Ad esempio:

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() viene richiamato solo se il processo non è bloccato.

Spesso le app salvano gli aggiornamenti ricevuti utilizzando i callback come snapshot dell'ultimo stato. Considera un'API ipotetica per le app per monitorare la percentuale di batteria rimanente:

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

Considera lo scenario in cui si verificano più eventi di modifica dello stato quando un'app è bloccata. Quando l'app viene sbloccata, devi fornire all'app solo lo stato più recente ed eliminare le altre modifiche dello stato obsolete. Questa consegna deve avvenire immediatamente quando l'app viene sbloccata in modo che l'app possa "recuperare". Questo può essere ottenuto come segue:

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

In alcuni casi, puoi monitorare l'ultimo valore fornito all'app in modo che l'app non debba ricevere una notifica dello stesso valore una volta sbloccata.

Lo stato può essere espresso come dati più complessi. Considera un'API ipotetica per le app per ricevere una notifica delle interfacce di rete:

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

Quando metti in pausa le notifiche a un'app, devi ricordare l'insieme di reti e stati che l'app ha visto l'ultima volta. Quando riprendi, ti consigliamo di inviare una notifica all'app delle vecchie reti che sono state perse, delle nuove reti che sono diventate disponibili e delle reti esistenti il cui stato è cambiato, in questo ordine.

Non inviare una notifica all'app delle reti che sono state rese disponibili e poi perse mentre i callback erano in pausa. Le app non devono ricevere un resoconto completo degli eventi che si sono verificati mentre erano bloccate e la documentazione dell'API non deve promettere di fornire flussi di eventi ininterrotti al di fuori degli stati del ciclo di vita espliciti. In questo esempio, se l'app deve monitorare continuamente la disponibilità della rete, deve rimanere in uno stato del ciclo di vita che impedisca che venga memorizzata nella cache o bloccata.

In revisione, devi unire gli eventi che si sono verificati dopo la messa in pausa e prima della ripresa delle notifiche e fornire in modo conciso lo stato più recente ai callback dell'app registrati.

Considerazioni per la documentazione per gli sviluppatori

La consegna degli eventi asincroni potrebbe essere ritardata, perché il mittente ha messo in pausa la consegna per un periodo di tempo, come mostrato nella sezione precedente, o perché l'app destinataria non ha ricevuto risorse del dispositivo sufficienti per elaborare l'evento in modo tempestivo.

Sconsiglia agli sviluppatori di fare ipotesi sul tempo che intercorre tra il momento in cui l'app riceve una notifica di un evento e il momento in cui l'evento si è effettivamente verificato.

Aspettative degli sviluppatori per le API di sospensione

Gli sviluppatori che conoscono la concorrenza strutturata di Kotlin si aspettano i seguenti comportamenti da qualsiasi API di sospensione:

Le funzioni di sospensione devono completare tutte le attività associate prima di restituire un valore o generare un'eccezione

I risultati delle operazioni non bloccanti vengono restituiti come valori di ritorno delle funzioni normali e gli errori vengono segnalati generando eccezioni. (Spesso questo significa che i parametri di callback non sono necessari.)

Le funzioni di sospensione devono richiamare i parametri di callback solo in-place

Le funzioni di sospensione devono sempre completare tutte le attività associate prima di restituire un valore, quindi non devono mai richiamare un callback fornito o un altro parametro di funzione né conservare un riferimento ad esso dopo che la funzione di sospensione ha restituito un valore.

Le funzioni di sospensione che accettano parametri di callback devono conservare il contesto, se non diversamente documentato

La chiamata di una funzione in una funzione di sospensione fa sì che venga eseguita in CoroutineContext del chiamante. Poiché le funzioni di sospensione devono completare tutte le attività associate prima di restituire un valore o generare un'eccezione e devono richiamare i parametri di callback solo in-place, l'aspettativa predefinita è che anche questi callback vengano eseguiti su CoroutineContext chiamante utilizzando il dispatcher associato.CoroutineContext Se lo scopo dell'API è eseguire un callback al di fuori di CoroutineContext chiamante, questo comportamento deve essere documentato chiaramente.

Le funzioni di sospensione devono supportare l'annullamento del job kotlinx.coroutines

Qualsiasi funzione di sospensione offerta deve collaborare con l'annullamento del job come definito da kotlinx.coroutines. Se il job chiamante di un'operazione in corso viene annullato, la funzione deve riprendere con un CancellationException il prima possibile in modo che il chiamante possa eseguire la pulizia e continuare il prima possibile. Questa operazione viene gestita automaticamente da suspendCancellableCoroutine e da altre API di sospensione offerte da kotlinx.coroutines. In genere, le implementazioni delle librerie non devono utilizzare suspendCoroutine direttamente, poiché non supporta questo comportamento di annullamento per impostazione predefinita.

Le funzioni di sospensione che eseguono attività di blocco su un thread in background (non principale o thread dell'interfaccia utente) devono fornire un modo per configurare il dispatcher utilizzato

Non è consigliabile rendere una funzione bloccante di sospensione interamente per cambiare thread.

La chiamata di una funzione di sospensione non deve comportare la creazione di thread aggiuntivi senza consentire allo sviluppatore di fornire il proprio thread o pool di thread per eseguire l'attività. Ad esempio, un costruttore può accettare un CoroutineContext utilizzato per eseguire attività in background per i metodi della classe.

Le funzioni di sospensione che accettano un parametro CoroutineContext o Dispatcher facoltativo solo per passare a quel dispatcher per eseguire attività di blocco devono invece esporre la funzione di blocco sottostante e consigliare agli sviluppatori chiamanti di utilizzare la propria chiamata a withContext per indirizzare l'attività a un dispatcher scelto.

Classi che avviano coroutine

Le classi che avviano le coroutine devono avere un CoroutineScope per eseguire queste operazioni di avvio. Il rispetto dei principi di concorrenza strutturata implica i seguenti pattern strutturali per ottenere e gestire questo ambito.

Prima di scrivere una classe che avvia attività simultanee in un altro ambito, valuta i pattern alternativi:

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

L'esposizione di un suspend fun per eseguire attività simultanee consente al chiamante di richiamare l'operazione nel proprio contesto, eliminando la necessità che MyClass gestisca un CoroutineScope. La serializzazione dell'elaborazione delle richieste diventa più semplice e lo stato può spesso esistere come variabili locali di handleRequests anziché come proprietà di classe che altrimenti richiederebbero una sincronizzazione aggiuntiva.

Le classi che gestiscono le coroutine devono esporre i metodi close e cancel

Le classi che avviano le coroutine come dettagli di implementazione devono offrire un modo per arrestare correttamente queste attività simultanee in corso in modo che non perdano il lavoro simultaneo non controllato in un ambito principale. In genere, questa operazione assume la forma di creazione di un Job secondario di un CoroutineContext fornito:

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

fun cancel() {
    myJob.cancel()
}

È possibile fornire anche un metodo join() per consentire al codice utente di attendere il completamento di qualsiasi attività simultanea in sospeso eseguita dall'oggetto. (Potrebbe includere attività di pulizia eseguite annullando un'operazione.)

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

Denominazione dell'operazione terminale

Il nome utilizzato per i metodi che arrestano correttamente le attività simultanee di proprietà di un oggetto ancora in corso deve riflettere il contratto comportamentale di come avviene l'arresto:

Utilizza close() quando le operazioni in corso possono essere completate, ma non è possibile avviare nuove operazioni dopo che la chiamata a close() restituisce un valore.

Utilizza cancel() quando le operazioni in corso possono essere annullate prima del completamento. Non è possibile avviare nuove operazioni dopo che la chiamata a cancel() restituisce un valore.

I costruttori di classe accettano CoroutineContext, non CoroutineScope

Quando agli oggetti è vietato l'avvio diretto in un ambito principale fornito, l'idoneità di CoroutineScope come parametro del costruttore si interrompe:

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

CoroutineScope diventa un wrapper non necessario e fuorviante che in alcuni casi d'uso può essere costruito solo per essere passato come parametro del costruttore, solo per essere eliminato:

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

I parametri CoroutineContext hanno come valore predefinito EmptyCoroutineContext

Quando una superficie API contiene un parametro facoltativo CoroutineContext, il valore predefinito deve essere il sentinel Empty`CoroutineContext`. Ciò consente una migliore composizione dei comportamenti delle API, poiché un valore Empty`CoroutineContext` da un chiamante viene trattato allo stesso modo dell'accettazione del valore predefinito:

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)

    // ...
}