Lavoro asincrono con i thread Java

Tutte le app per Android utilizzano un thread principale per gestire le operazioni dell'interfaccia utente. La chiamata di operazioni a lunga esecuzione da questo thread principale può causare blocchi e mancata risposta. Ad esempio, se la tua app effettua una richiesta di rete dal thread principale, la UI dell'app sarà bloccata finché non riceve la risposta di rete. Se utilizzi Java, puoi creare thread in background aggiuntivi per gestire le operazioni a lunga esecuzione mentre il thread principale continua a gestire gli aggiornamenti dell'interfaccia utente.

Questa guida mostra come gli sviluppatori che utilizzano il linguaggio di programmazione Java possono utilizzare un pool di thread per configurare e utilizzare più thread in un'app per Android. Inoltre, mostra come definire il codice da eseguire su un thread e come comunicare tra uno di questi thread e il thread principale.

Librerie di contemporaneità

È importante comprendere le nozioni di base dell'organizzazione in thread e dei meccanismi sottostanti. Tuttavia, esistono molte librerie note che offrono astrazioni di livello superiore rispetto a questi concetti e utilità pronte all'uso per il passaggio di dati tra i thread. Queste librerie includono Guava e RxJava per gli utenti del linguaggio di programmazione Java e Coroutines, che consigliamo per gli utenti di Kotlin.

In pratica, dovresti scegliere quella più adatta alla tua app e al tuo team di sviluppo, anche se le regole di organizzazione in thread rimangono le stesse.

Panoramica degli esempi

In base alla Guida all'architettura delle app, gli esempi in questo argomento effettuano una richiesta di rete e restituiscono il risultato al thread principale, dove l'app potrebbe quindi visualizzare il risultato sullo schermo.

In particolare, ViewModel chiama il livello dati sul thread principale per attivare la richiesta di rete. Il livello dati ha il compito di spostare l'esecuzione della richiesta di rete dal thread principale e di pubblicare il risultato nel thread principale utilizzando un callback.

Per spostare l'esecuzione della richiesta di rete dal thread principale, dobbiamo creare altri thread nella nostra app.

Crea più thread

Un pool di thread è una raccolta gestita di thread che esegue attività in parallelo da una coda. Vengono eseguite nuove attività su thread esistenti quando questi thread diventano inattivi. Per inviare un'attività a un pool di thread, utilizza l'interfaccia ExecutorService. Tieni presente che ExecutorService non ha nulla a che fare con Services, il componente delle applicazioni Android.

La creazione di thread è costosa, quindi devi creare un pool di thread solo una volta durante l'inizializzazione dell'app. Assicurati di salvare l'istanza di ExecutorService nella classe Application o in un container di inserimento delle dipendenze. L'esempio seguente crea un pool di thread di quattro thread che possiamo utilizzare per eseguire attività in background.

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
}

Esistono altri modi per configurare un pool di thread in base al carico di lavoro previsto. Per saperne di più, consulta Configurazione di un pool di thread.

Esegui in un thread in background

Se effettui una richiesta di rete nel thread principale, il thread attende, o blocca, finché non riceve una risposta. Poiché il thread è bloccato, il sistema operativo non può chiamare onDraw() e l'app si blocca, visualizzando potenzialmente una finestra di dialogo L'applicazione non risponde (ANR). Eseguiamo invece questa operazione su un thread in background.

Effettua la richiesta

Per prima cosa, diamo un'occhiata alla classe LoginRepository e vediamo come esegue la richiesta di rete:

// Result.java
public abstract class Result<T> {
    private Result() {}

    public static final class Success<T> extends Result<T> {
        public T data;

        public Success(T data) {
            this.data = data;
        }
    }

    public static final class Error<T> extends Result<T> {
        public Exception exception;

        public Error(Exception exception) {
            this.exception = exception;
        }
    }
}

// LoginRepository.java
public class LoginRepository {

    private final String loginUrl = "https://example.com/login";
    private final LoginResponseParser responseParser;

    public LoginRepository(LoginResponseParser responseParser) {
        this.responseParser = responseParser;
    }

    public Result<LoginResponse> makeLoginRequest(String jsonBody) {
        try {
            URL url = new URL(loginUrl);
            HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
            httpConnection.setRequestMethod("POST");
            httpConnection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
            httpConnection.setRequestProperty("Accept", "application/json");
            httpConnection.setDoOutput(true);
            httpConnection.getOutputStream().write(jsonBody.getBytes("utf-8"));

            LoginResponse loginResponse = responseParser.parse(httpConnection.getInputStream());
            return new Result.Success<LoginResponse>(loginResponse);
        } catch (Exception e) {
            return new Result.Error<LoginResponse>(e);
        }
    }
}

makeLoginRequest() è sincrono e blocca il thread chiamante. Per modellare la risposta alla richiesta di rete, disponiamo della nostra classe Result.

Attiva la richiesta

ViewModel attiva la richiesta di rete quando l'utente tocca, ad esempio, un pulsante:

public class LoginViewModel {

    private final LoginRepository loginRepository;

    public LoginViewModel(LoginRepository loginRepository) {
        this.loginRepository = loginRepository;
    }

    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody);
    }
}

Con il codice precedente, LoginViewModel blocca il thread principale quando effettua la richiesta di rete. Possiamo utilizzare il pool di thread di cui abbiamo creato un'istanza per spostare l'esecuzione in un thread in background.

Gestire l'inserimento delle dipendenze

Innanzitutto, seguendo i principi dell'inserimento delle dipendenze, LoginRepository acquisisce un'istanza di Executor anziché ExecutorService perché esegue codice e non gestisce i thread:

public class LoginRepository {
    ...
    private final Executor executor;

    public LoginRepository(LoginResponseParser responseParser, Executor executor) {
        this.responseParser = responseParser;
        this.executor = executor;
    }
    ...
}

Il metodo execute() dell'esecutore utilizza un valore Runnable. Runnable è un'interfaccia SAM (Single Abstract Method) con un metodo run() che viene eseguito in un thread quando viene richiamato.

Esegui in background

Creiamo un'altra funzione denominata makeLoginRequest() che sposta l'esecuzione nel thread in background e ignora la risposta per il momento:

public class LoginRepository {
    ...
    public void makeLoginRequest(final String jsonBody) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                Result<LoginResponse> ignoredResponse = makeSynchronousLoginRequest(jsonBody);
            }
        });
    }

    public Result<LoginResponse> makeSynchronousLoginRequest(String jsonBody) {
        ... // HttpURLConnection logic
    }
    ...
}

All'interno del metodo execute(), creiamo un nuovo Runnable con il blocco di codice che vogliamo eseguire nel thread in background, nel nostro caso il metodo di richiesta di rete sincrono. Internamente, l'oggetto ExecutorService gestisce Runnable ed e lo esegue in un thread disponibile.

considerazioni

Qualsiasi thread nella tua app può essere eseguito in parallelo ad altri thread, incluso il thread principale, quindi devi assicurarti che il codice sia a misura di thread. Nota che nel nostro esempio evitiamo di scrivere in variabili condivise tra thread, passando invece dati immutabili. Questa è una buona pratica, perché ogni thread funziona con la propria istanza di dati ed evitiamo la complessità della sincronizzazione.

Se hai bisogno di condividere lo stato tra i thread, devi prestare attenzione a gestire l'accesso dai thread utilizzando meccanismi di sincronizzazione come i blocchi. Questo non rientra nell'ambito di questa guida. In generale, evita di condividere uno stato modificabile tra i thread, se possibile.

Comunicare con il thread principale

Nel passaggio precedente abbiamo ignorato la risposta alla richiesta di rete. Per visualizzare il risultato sullo schermo, LoginViewModel deve essere a conoscenza dell'elemento. A tal fine, possiamo utilizzare i callback.

La funzione makeLoginRequest() deve utilizzare un callback come parametro in modo da poter restituire un valore in modo asincrono. Il callback con il risultato viene chiamato ogni volta che la richiesta di rete viene completata o quando si verifica un errore. In Kotlin, possiamo utilizzare una funzione di ordine superiore. Tuttavia, in Java, dobbiamo creare una nuova interfaccia di callback per avere la stessa funzionalità:

interface RepositoryCallback<T> {
    void onComplete(Result<T> result);
}

public class LoginRepository {
    ...
    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    callback.onComplete(result);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    callback.onComplete(errorResult);
                }
            }
        });
    }
  ...
}

L'ViewModel deve implementare il callback ora. Può eseguire logiche diverse a seconda del risultato:

public class LoginViewModel {
    ...
    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody, new RepositoryCallback<LoginResponse>() {
            @Override
            public void onComplete(Result<LoginResponse> result) {
                if (result instanceof Result.Success) {
                    // Happy path
                } else {
                    // Show error in UI
                }
            }
        });
    }
}

In questo esempio, il callback viene eseguito nel thread della chiamata, ovvero un thread in background. Ciò significa che non puoi modificare né comunicare direttamente con il livello UI finché non torni al thread principale.

Utilizzare i gestori

Puoi utilizzare un gestore per accodare un'azione da eseguire su un altro thread. Per specificare il thread su cui eseguire l'azione, crea il Handler utilizzando un looer per il thread. Un Looper è un oggetto che esegue il loop di messaggi per un thread associato. Una volta creato un Handler, puoi utilizzare il metodo post(Runnable) per eseguire un blocco di codice nel thread corrispondente.

Looper include una funzione helper, getMainLooper(), che recupera l'elemento Looper del thread principale. Puoi eseguire il codice nel thread principale utilizzando questo Looper per creare un Handler. Poiché si tratta di un'operazione che potresti svolgere spesso, puoi anche salvare un'istanza di Handler nello stesso punto in cui hai salvato ExecutorService:

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
    Handler mainThreadHandler = HandlerCompat.createAsync(Looper.getMainLooper());
}

È buona norma inserire il gestore nel repository, dato che ti offre maggiore flessibilità. Ad esempio, in futuro potresti voler passare un Handler diverso per pianificare attività in un thread separato. Se comunichi sempre allo stesso thread, puoi passare Handler nel costruttore del repository, come mostrato nell'esempio seguente.

public class LoginRepository {
    ...
    private final Handler resultHandler;

    public LoginRepository(LoginResponseParser responseParser, Executor executor,
            Handler resultHandler) {
        this.responseParser = responseParser;
        this.executor = executor;
        this.resultHandler = resultHandler;
    }

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
    ...
}

In alternativa, se vuoi maggiore flessibilità, puoi inviare un Handler a ogni funzione:

public class LoginRepository {
    ...

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler,
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback, resultHandler);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback, resultHandler);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
}

In questo esempio, il callback passato nella chiamata makeLoginRequest del repository viene eseguito nel thread principale. Ciò significa che puoi modificare l'interfaccia utente direttamente dal callback o usare LiveData.setValue() per comunicare con l'interfaccia.

Configura un pool di thread

Puoi creare un pool di thread utilizzando una delle funzioni helper Executor con impostazioni predefinite, come mostrato nel codice di esempio precedente. In alternativa, se vuoi personalizzare i dettagli del pool di thread, puoi creare un'istanza utilizzando direttamente ThreadPoolExecutor. Puoi configurare i seguenti dettagli:

  • Dimensione iniziale e massima del pool.
  • Tempo attivo e unità di tempo. Il tempo di conservazione è la durata massima per cui un thread può rimanere inattivo prima dell'arresto.
  • Una coda di input che contiene Runnable attività. Questa coda deve implementare l'interfaccia BlockQueue. Per soddisfare i requisiti della tua app, puoi scegliere tra le implementazioni delle code disponibili. Per saperne di più, consulta la panoramica della classe ThreadPoolExecutor.

Ecco un esempio che specifica la dimensione del pool di thread in base al numero totale di core del processore, a un tempo di keep-alive di un secondo e a una coda di input.

public class MyApplication extends Application {
    /*
     * Gets the number of available cores
     * (not always the same as the maximum number of cores)
     */
    private static int NUMBER_OF_CORES = Runtime.getRuntime().availableProcessors();

    // Instantiates the queue of Runnables as a LinkedBlockingQueue
    private final BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<Runnable>();

    // Sets the amount of time an idle thread waits before terminating
    private static final int KEEP_ALIVE_TIME = 1;
    // Sets the Time Unit to seconds
    private static final TimeUnit KEEP_ALIVE_TIME_UNIT = TimeUnit.SECONDS;

    // Creates a thread pool manager
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            NUMBER_OF_CORES,       // Initial pool size
            NUMBER_OF_CORES,       // Max pool size
            KEEP_ALIVE_TIME,
            KEEP_ALIVE_TIME_UNIT,
            workQueue
    );
    ...
}