Linee guida per le API asincrone e non bloccanti di Android

Le API non bloccanti richiedono l'esecuzione di un'operazione e poi restituiscono il controllo al thread chiamante in modo che possa eseguire altri compiti prima del completamento dell'operazione richiesta. Queste API sono utili nei casi in cui il lavoro richiesto possa essere in corso o possa richiedere l'attesa del completamento di I/O o IPC, la disponibilità di risorse di sistema altamente contese o l'input dell'utente prima che il lavoro possa procedere. Le API particolarmente ben progettate offrono un modo per annullare l'operazione in corso e interrompere l'esecuzione del lavoro per conto dell'utente chiamante originale, preservando lo stato di salute 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 un'operazione N deve essere avviata prima del completamento dell'operazione N-1.
  • Evitare di bloccare un thread di chiamata 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 del thread. Le funzioni di sospensione sono non bloccanti e sincrone.

Sospendi funzioni:

  • Non bloccare il thread di chiamata, ma restituisci il thread di esecuzione come dettaglio di implementazione in attesa dei risultati delle operazioni in esecuzione altrove.
  • Esegui in modo sincrono e non richiedere all'autore della chiamata di un'API non bloccante di continuare a eseguire contemporaneamente il lavoro non bloccante avviato dalla chiamata all'API.

Questa pagina descrive in dettaglio un livello minimo di aspettative che gli sviluppatori possono mantenere al sicuro quando lavorano con API asincrone e non bloccanti, seguita da una serie di ricette per la creazione di API che soddisfano queste aspettative nei linguaggi Kotlin o Java, nella piattaforma Android o nelle librerie Jetpack. In caso di dubbi, considera le aspettative degli sviluppatori come requisiti per qualsiasi nuova piattaforma API.

Aspettative degli sviluppatori per le API asincrone

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

Le API che accettano i callback sono in genere asincrone

Se un'API accetta un callback che non è documentato come da chiamare solo in-place (ovvero chiamato solo dal thread chiamante prima che la chiamata all'API si interrompa), si presume che l'API sia asincrona e debba soddisfare tutte le altre aspettative documentate nelle sezioni seguenti.

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

Le API asincrone devono restituire i risultati il più rapidamente possibile

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

Molti indicatori di operazioni e ciclo di vita possono essere attivati dalla piattaforma o dalle librerie on demand ed è insostenibile 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 compilati per riempire lo spazio disponibile (ad esempio RecyclerView). Un LifecycleObserver che risponde al callback del ciclo di vita onStart di questo frammento potrebbe ragionevolmente eseguire operazioni di startup una tantum qui e potrebbe trovarsi in un percorso di codice critico per la produzione di un frame di animazione senza scatti. Uno sviluppatore deve sempre essere certo che chiamare qualsiasi API asincrona in risposta a questi tipi di callback del ciclo di vita non causerà un frame instabile.

Ciò implica che il lavoro svolto da un'API asincrona prima del ritorno deve essere molto leggero: creare un record della richiesta e della chiamata associata e registrarlo al massimo con il motore di esecuzione che esegue il lavoro. Se la registrazione per un'operazione asincrona richiede l'IPC, l'implementazione dell'API deve adottare tutte le misure necessarie per soddisfare questa aspettativa dello sviluppatore. Potrebbero essere inclusi uno o più dei seguenti elementi:

  • Implementazione di un IPC sottostante come chiamata del binder unidirezionale
  • Eseguire una chiamata di binder bidirezionale al server di sistema in cui il completamento della registrazione non richiede l'acquisizione di un blocco altamente conteso
  • Pubblicazione della richiesta in un thread di lavoro nel processo dell'app per eseguire una registrazione bloccante tramite IPC

Le API asincrone devono restituire void e generare un'eccezione solo per 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 unico percorso di codice per la gestione degli errori e del buon esito.

Le API asincrone possono verificare se gli argomenti sono null e lanciare NullPointerException oppure verificare che gli argomenti forniti rientrino in un intervallo valido e lanciare 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 non rientra nell'intervallo oppure un String breve può essere verificato per verificare la conformità a un formato valido come solo alfanumerico. Ricorda che il server di sistema non deve mai considerare attendibile il processo dell'app. Qualsiasi servizio di sistema dovrebbe duplicare questi controlli nel servizio di sistema stesso.

Tutti gli altri errori devono essere segnalati al callback fornito. Sono inclusi, a titolo esemplificativo:

  • Errore terminale dell'operazione richiesta
  • Eccezioni di sicurezza per autorizzazioni 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 scollegato
  • Errori di rete
  • Timeout
  • Binder non attivo o processo remoto non disponibile

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 dovrebbe segnalare due cose:

I riferimenti rigidi ai callback forniti dal chiamante devono essere rilasciati

I callout forniti alle API asincrone possono contenere riferimenti diretti a grafici di oggetti di grandi dimensioni e il lavoro in corso che detiene un riferimento diretto a quel callout può impedire la raccolta dei rifiuti di questi grafici di oggetti. Se rilasci questi riferimenti ai callback al momento dell'annullamento, questi grafici di oggetti potrebbero diventare idonei per la raccolta del garbage molto prima che se il lavoro fosse stato eseguito fino al completamento.

Il motore di esecuzione che esegue il lavoro per il chiamante potrebbe interrompere il lavoro

Il lavoro avviato da chiamate API asincrone può comportare un costo elevato in termini di consumo energetico o di 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 inviati alle app, tieni presente quanto segue:

  1. Processi e ciclo di vita dell'app: il processo dell'app di destinazione potrebbe essere nello stato nella cache.
  2. App in cache bloccate: il processo dell'app di destinazione potrebbe essere bloccato.

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

Un'app memorizzata nella cache potrebbe anche essere bloccata. Quando un'app è bloccata, non riceve tempo di CPU e non è in grado di eseguire alcun lavoro. Eventuali chiamate ai callback registrati dell'app vengono messe in coda e inviate quando l'app viene scongelata.

Le transazioni in buffer per i callback dell'app potrebbero non essere aggiornate al momento in cui l'app viene scongelata ed elaborata. Il buffer è limitato e, se viene superato, provoca un arresto anomalo dell'app di destinazione. Per evitare di sovraccaricare le app con eventi obsoleti o di far traboccare i relativi buffer, non inviare i callback dell'app mentre il processo è bloccato.

In corso di revisione:

  • Ti consigliamo di valutare la possibilità di mettere in pausa l'invio dei callback dell'app mentre il processo dell'app viene 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

Indipendentemente dal fatto che tu metta 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 dal rispettivo stato devi riprendere l'invio dei callback registrati dell'app fino a quando l'app non ha annullato la registrazione del callback o il processo dell'app non si arresta.

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 eseguire callback al processo target 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 invocato solo se il processo non è bloccato.

Spesso le app salvano gli aggiornamenti ricevuti utilizzando i callback come snapshot dell'ultimo stato. Prendiamo in considerazione 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 scongelata, devi inviare solo lo stato più recente all'app e ignorare le altre modifiche dello stato non aggiornate. Questo invio dovrebbe avvenire immediatamente quando l'app viene scongelata in modo che possa "aggiornarsi". Per farlo, procedi nel seguente modo:

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 inviato all'app in modo che non debba essere inviata una notifica dello stesso valore una volta sbloccata.

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

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

Quando metti in pausa le notifiche di un'app, devi ricordare l'insieme di emittenti e stati che l'app aveva visto per l'ultima volta. Al momento della ripresa, ti consigliamo di informare l'app delle vecchie reti perse, delle nuove reti diventate disponibili e delle reti esistenti il cui stato è cambiato, in questo ordine.

Non avvisare l'app delle reti 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 durante il blocco e la documentazione dell'API non deve promettere di fornire stream 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 ne impedisca la memorizzazione nella cache o il blocco.

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

Considerazioni per la documentazione per gli sviluppatori

L'invio di eventi asincroni potrebbe essere ritardato perché il mittente ha messo in pausa l'invio per un determinato periodo di tempo, come mostrato nella sezione precedente, o perché l'app di destinazione non ha ricevuto risorse del dispositivo sufficienti per elaborare l'evento in modo tempestivo.

Scoraggiare gli sviluppatori dal fare supposizioni sul tempo che intercorre tra il momento in cui la loro app viene informata di un evento e il momento in cui l'evento si è effettivamente verificato.

Aspettative degli sviluppatori per la sospensione delle API

Gli sviluppatori che hanno dimestichezza con la concorrenza strutturata di Kotlin si aspettano i seguenti comportamenti da qualsiasi API in sospensione:

Le funzioni di sospensione devono completare tutto il lavoro associato prima di restituire o lanciare un errore

I risultati delle operazioni non bloccanti vengono restituiti come normali valori restituiti delle funzioni e gli errori vengono segnalati generando eccezioni. Ciò significa spesso 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 tutto il lavoro associato prima di restituire un valore, quindi non devono mai richiamare un callback o un altro parametro di funzione fornito o conservarne un riferimento dopo il ritorno della funzione di sospensione.

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

La chiamata di una funzione in una funzione di sospensione ne causa l'esecuzione nel CoroutineContext dell'autore della chiamata. Poiché le funzioni di sospensione devono completare tutto il lavoro associato prima di restituire o generare un errore e devono invocare solo i parametri di callback in-place, per impostazione predefinita si presume che questi callback vengano eseguiti anche nell'CoroutineContext chiamante utilizzando il relativo dispatcher associato. Se scopo dell'API è eseguire un callback al di fuori della chiamataCoroutineContext, questo comportamento deve essere documentato chiaramente.

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

Qualsiasi funzione di sospensione offerta deve essere compatibile con l'annullamento dei job come definito da kotlinx.coroutines. Se il job di chiamata 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 con sospensione offerte da kotlinx.coroutines. In genere, le implementazioni delle librerie non devono utilizzare direttamente suspendCoroutine, in quanto questo comportamento di annullamento non è supportato per impostazione predefinita.

Le funzioni di sospensione che eseguono operazioni di blocco in un contesto in background (thread non principale o dell'interfaccia utente) devono fornire un modo per configurare il gestore delle richieste utilizzato

Non è consigliabile sospendere completamente una funzione bloccante 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 il lavoro. Ad esempio, un costruttore può accettare un CoroutineContext utilizzato per eseguire operazioni in background per i metodi del corso.

Le funzioni di sospensione che accetterebbero un parametro facoltativo CoroutineContext o Dispatcher solo per passare a quel gestore per eseguire il lavoro di blocco devono invece esporre la funzione di blocco sottostante e consigliare agli sviluppatori che fanno chiamate di utilizzare la propria chiamata a withContext per indirizzare il lavoro a un gestore scelto.

Classi che lanciano coroutine

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

Prima di scrivere un corso che avvia attività concorrenti in un altro ambito, prendi in considerazione 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 un lavoro concorrente consente all'utente chiamante di invocare l'operazione nel proprio contesto, eliminando la necessità di avere MyClass che gestisca un CoroutineScope. La serializzazione dell'elaborazione delle richieste diventa più semplice e spesso lo stato può 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 lanciano coroutine come dettagli di implementazione devono offrire un modo per interrompere in modo pulito le attività concorrenti in corso in modo che non lascino tracce di lavoro concorrente non controllato in un ambito principale. In genere, si tratta di creare 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 eventuali attività concorrenti in sospeso eseguite dall'oggetto. Potrebbero essere inclusi i lavori di pulizia eseguiti annullando un'operazione.

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

Denominazione delle operazioni del terminale

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

Utilizza close() quando le operazioni in corso potrebbero essere completate, ma non possono essere avviate nuove operazioni dopo il ritorno della chiamata a close().

Utilizza cancel() quando le operazioni in corso possono essere annullate prima del completamento. Non è possibile avviare nuove operazioni dopo il ritorno della chiamata a cancel().

I costruttori di classi accettano CoroutineContext, non CoroutineScope

Quando è vietato avviare oggetti direttamente in un ambito principale fornito, l'idoneità di CoroutineScope come parametro del costruttore non è più valida:

// 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 scenari di utilizzo può essere costruito esclusivamente per essere passato come parametro del costruttore, per poi essere ignorato:

// 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 in un'interfaccia API viene visualizzato un parametro CoroutineContext facoltativo, il valore predefinito deve essere il valore sentinella Empty`CoroutineContext`. In questo modo è possibile migliorare la composizione dei comportamenti dell'API, poiché un valore Empty`CoroutineContext` di un chiamante viene trattato nello 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)

    // ...
}