AsyncTask: Tareas Asíncronas en Android

Este artículo ha sido creado con el fin de presentarte los beneficios del uso de hilos en tus aplicaciones Android. La idea es evaluar entre varias opciones la mejor alternativa para implementar concurrencia entre las tareas que se ejecutan.

Esta valoración mantendrá la sensación multitarea, evitará bloqueos y optimizará el hilo principal de tus proyectos.

El Manejo de Hilos en Android

Un hilo es una unidad de ejecución asociada a una aplicación. Es la estructura de la programación concurrente, la cual tiene como objetivo dar la percepción al usuario que el sistema que ejecuta realiza múltiples tareas a la vez.

Aunque los hilos se benefician de las tecnologías multinucleos y multiprocesamiento, no significa que una arquitectura simplista no se beneficie de la creación de hilos.

Cuando se construye una aplicación Android, todos los componentes y tareas son introducidos en el hilo principal o hilo de UI (UI Thread). Hasta el momento hemos trabajado de esta forma, ya que las operaciones que hemos realizado toman poco tiempo y no muestran problemas significativos de rendimiento visual en nuestros ejemplos.

Pero en tus proyectos reales no puedes pretender que todas las acciones que lleva a cabo tu aplicación sean simples y concisas.

En ocasiones hay instrucciones que toman unos segundos en terminarse, esta es una de las mayores causas de la terminación abrupta de las aplicaciones. Android está diseñado para ofrecerle al usuario la opción de terminar aquellas aplicaciones que demoran más de 5 segundos en responder a los eventos del usuario.

Cuando esto pasa, podemos ver el famoso Diálogo ANR (Application not respond).
Diálogo ANR de Android
¿Qué pasaría si intentas cargar una imagen jpg de 3MB desde un servidor externo vía HTTP en tu aplicación?, si la conexión es rápida, tal vez nada. Pero para conexiones lentas esto tomara algunos segundos. ¿Crees que se vería muy bien, que tu aplicación se dedique a cargar primero la imagen y luego actualice la interfaz de usuario?

¡En lo absoluto!, esto arruina la fluidez visual y estropea la estadía de nuestros usuarios, lo que en la mayoría de casos termina en la eliminación de tu aplicación. A nivel computacional este caso podría apreciarse de la siguiente forma:
Ejecución de tareas sin el uso de hilos en Android
La imagen ilustra la transición de las tareas que ocurren en el hilo principal de la aplicación. Si tu tarea toma algunos segundos se arruinaría la capacidad de respuesta, ya las tareas están en serie, es decir, hasta que una no acabe la otra no puede iniciar.

El camino correcto es renderizar la interfaz de la aplicación y al mismo tiempo ejecutar en segundo plano la otra actividad para continuar con la armonía de la aplicación y evitar paradas inesperadas. Es aquí donde entran los hilos, porque son los únicos que tienen la habilidad especial de permitir al programador generar concurrencia en sus aplicaciones y la sensación de multitareas ante el usuario.

Esta técnica es mostrada en el siguiente gráfico:
Uso de Hilos en Android
Se ha creado un nuevo hilo donde se ejecuta la tarea en el mismo intervalo de tiempo [t1, t2], pero esta vez el tiempo de ejecución de la tercera tarea UI se extendió debido a que se realizarán pequeños incrementos entre al segundo y tercera tarea. Aunque el tiempo empleado es el mismo, la respuesta ante el usuario simula una aplicación limpia.

Recuerda que existen dos tipos de procesamientos de tareas, Concurrencia y Paralelismo. La concurrencia se refiere a la existencia de múltiples tareas que se realizan simultáneamente compartiendo recursos de procesamiento.

El paralelismo es la ejecución de varias tareas al tiempo en distintas unidades de procesamiento, por lo que es mucho más rápido que la concurrencia.

Ejemplo de Hilos: Ordenar Números Con El Algoritmo Burbuja

Este tutorial no tendría gran valor si no encontrases un buen ejemplo explicativo. Por esta razón verás la construcción de una aplicación llamada AsyncLab.

Dicha aplicación tiene como fin mostrar algunos experimentos de ejecución del Algoritmo de Ordenamiento Burbuja Simple, con 4 diferentes caminos y así evaluar la mejor opción.

Para desbloquear el link de descarga del código completo, sigue estas instrucciones:

En cuanto a diseño, AsyncLab consiste en una actividad Main que despliega un menú construido a través de una lista. Cada uno de los ítems representa un experimento aislado que muestra al usuario el comportamiento que se produce. Precisamente ese comportamiento es mostrado en una segunda actividad hija llamada ABTest, donde existe el botón sortButton para iniciar el ordenamiento de los números y cancelButton para cancelar la operación.

Experimento #1: Ordenar números sin Hilos en Java

Para este experimento se debe aclarar que cada ítem de la actividad Main es dirigido a la misma actividad ABTest, es decir, no se creó una actividad o fragmento para cada uno. Lo que se hizo fue establecer una condición dentro del método onClick() del botón “Ordenar”.

Veamos:

public void onClickSort(View v) {

        switch (position){
            case 0:
                // Experimento #1
                break;
            case 1:
                // Experimento #2
                break;
            case 2:
                // Experimento #3
                break;
            case 3:
                // Experimento #4
                break;

        }

    }

Un programador que desconozca la existencia de la programación concurrente y el uso de hilos en Java, abordaría una solución simplista para ordenar los números. Se le ocurría crear un procedimiento para representar el algoritmo burbuja y lo usaría directamente cuando el botón de ordenamiento sea pulsado. Luego mostraría un Toast para indicarle al usuario que la tarea se ha llevado a cabo.

 public void onClickSort(View v) {

        switch (position){
            case 0:
                bubbleSort(numbers);
       Toast.makeText(
                        getBaseContext(), 
                        "¡Números Ordenados!", 
                        Toast.LENGTH_LONG).show();
                break;
    ...
}

private void bubbleSort(int[] numbers) {

    int aux;

    for (int i = 0; i < numbers.length - 1; i++) {
        for (int j = 0; j < numbers.length -1; j++) {
            if (numbers[j] > numbers[j+1])
            {
                aux          = numbers[j];
                numbers[j]   = numbers[j+1];
                numbers[j+1] = aux;
            }
        }
    }

}

Este enfoque es completamente válido y funcional hasta cierto punto. Recuerda que el algoritmo de ordenamiento burbuja puede llegar a tener un orden de complejidad de O(N2) dependiendo de la dispersión de los números.

Esto significa que si en algún momento la cantidad de números requiere una cantidad de segundos considerable, el usuario debe esperar a que se termine la ejecución del algoritmo antes de poder interactuar de nuevo con la aplicación.
Bloqueo del hilo principal de una aplicación Android
Al ejecutar este experimento el botón “Ordenar” queda seleccionado y la interfaz se congela. Si das taps prolongadamente, para intentar que la aplicación responda, obtendrás un diálogo ANR.

Experimento #2: Usar Hilos en Java para ordenar los números

Si recuerdas la época en que veías tus clases de Java en el instituto, se usaba el paquete java.util.concurrent para acceder a la clase Thread que es la que representa un hilo. También has de recordar que un hilo ejecuta las instrucciones que se implementan el método run() de la interfaz Runnable. Y que para activar estas sentencias se ha de invocar al método start() para iniciar la ejecución.

Aplicando esta definición puedes crear un método que construya un hilo para añadir la ejecución del método bubbleSort():

public void onClickSort(View v) {

        switch (position){
          ...
            case 1:
                execWithThread();
                break;
    ...
}

public void execWithThread(){

    new Thread(
            new Runnable() {
                @Override
                public void run() {
                    bubbleSort(numbers);                    
                    Toast.makeText(
                        getBaseContext(), 
                        "¡Números Ordenados!", 
                        Toast.LENGTH_LONG).show();
                }
            }
    ).start();

}

Aunque el código anterior parece correcto, al iniciar este experimento obtendrás un error debido a que no es aceptada la creación de instancias de la clase Toast dentro de un Hilo.

La documentación de Android recomienda no acceder directamente a los objetos del hilo de UI desde los hilos creados manualmente. Advierten que pueden llegar a producirse anomalías debido a la ausencia de sincronización.

Para evitar acceder directamente sobre los elementos de la UI, puedes usar algunos de los siguientes métodos: Activity.runOnUiThread(), View.post(Runnable) y View.postDelayed(Runnable, long). Estos nos permitirán presentar la información necesaria que se ha procesada en UI Thread.

runOnUiThread() es ideal para presentar resultados en el UI Thread cuando se ejecutan sentencias generales asociadas a una actividad. El método post() se usa para relacionar un hilo al contenido de un View específico. postDelayed() realiza exactamente lo mismo que post(), solo que retrasa n milisegundos el inicio del hilo.
El ejemplo anterior quedaría asegurado con la siguiente definición:

public void execWithThread(){

    new Thread(
            new Runnable() {
                @Override
                public void run() {
                    bubbleSort(numbers);
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            Toast.makeText(
                                getBaseContext(), 
                                "¡Números Ordenados!", 
                                Toast.LENGTH_LONG).show();
                        }
                    });
                }
            }
    ).start();


}

Hacemos exactamente lo mismo pero esta vez asignamos ejecutamos el método makeText() del de runOnUiThread() junto a una nueva instancia Runnable que comunique las acciones al hilo principal. Internamente esta operación entra a un proceso de cola de peticiones, donde el main Thread gestionará el momento adecuado para iniciarla.

Experimento #3: Usar AsyncTasks en Android

Existen casos en los que se ejecutan varias instrucciones que deben presentar cambios en el hilo principal. Si se aplica el enfoque visto en la sección anterior el código para el envío de las ejecuciones tiende a ser muy largo, confuso y poco maleable a la hora de mantenimiento.

Por esta razón ha sido creada la interfaz AsyncTask, cuyo objetivo es liberar al programador del uso de hilos, la sincronización entre ellos y la presentación de resultados en el hilo primario. Esta clase unifica los aspectos relacionados que se realizarán en segundo plano y además gestiona de forma asíncrona la ejecución de las tareas.

Para implementarla debes extender una nueva clase con las características de AsyncTask e implementar los métodos correspondientes para la ejecución en segundo plano y la publicación de resultados en el UI Thread. Adaptemos el ejemplo anterior a esta filosofía:

private class SimpleTask extends AsyncTask<Void, Integer, Void> {

  /*
  Se hace visible el botón "Cancelar" y se desactiva
  el botón "Ordenar"
   */
  @Override
  protected void onPreExecute() {
      cancelButton.setVisibility(View.VISIBLE);
      sortButton.setEnabled(false);
  }

  /*
  Ejecución del ordenamiento y transmision de progreso
   */
  @Override
  protected Void doInBackground(Void... params) {
      int aux;

      for (int i = 0; i < numbers.length - 1; i++) {
          for (int j = 0; j < numbers.length -1; j++) {
              if (numbers[j] > numbers[j+1])
              {
                  aux          = numbers[j];
                  numbers[j]   = numbers[j+1];
                  numbers[j+1] = aux;
              }
          }
          // Notifica a onProgressUpdate() del progreso actual
          if(!isCancelled())
              publishProgress((int)(((i+1)/(float)(numbers.length-1))*100));
          else break;
      }
      return null;
  }

  /*
   Se informa en progressLabel que se canceló la tarea y
   se hace invisile el botón "Cancelar"
    */
  @Override
  protected void onCancelled() {
      super.onCancelled();
      progressLabel.setText("En la Espera");
      cancelButton.setVisibility(View.INVISIBLE);
      sortButton.setEnabled(true);
  }

  /*
  Impresión del progreso en tiempo real
    */
  @Override
  protected void onProgressUpdate(Integer... values) {
      super.onProgressUpdate(values);
      progressLabel.setText(values[0] + "%");
  }

  /*
  Se notifica que se completó el ordenamiento y se habilita
  de nuevo el botón "Ordenar"
   */
  @Override
  protected void onPostExecute(Void result) {
      super.onPostExecute(result);
      progressLabel.setText("Completado");
      sortButton.setEnabled(true);
  }

}

SimpleTask se extiende de AsyncTask que además de ser abstracta es genérica. Las tres variables de entrada que posee se refieren a los Parámetros, Unidades de Progreso y Resultados respectivamente.

La clase AsyncTask posee métodos te permitirán coordinar la ejecución de las tareas que deseas ubicar en segundo plano. Estos métodos tienen los siguientes propósitos:

  • onPreExecute(): En este método van todas aquellas instrucciones que se ejecutarán antes de iniciar la tarea en segundo plano. Normalmente es la inicialización de variables, objetos y la preparación de componentes de la interfaz.
  • doInBackground(Parámetros…): Recibe los parámetros de entrada para ejecutar las instrucciones especificas que irán en segundo plano, luego de que ha terminado onPreExecute(). Dentro de él podemos invocar un método auxiliar llamado publishProgress(), el cual transmitirá unidades de progreso al hilo principal. Estas unidades miden cuanto tiempo falta para terminar la tarea, de acuerdo a la velocidad y prioridad que se está ejecutando.
  • onProgressUpdate(Progreso…): Este método se ejecuta en el hilo de UI luego de que publishProgress() ha sido llamado. Su ejecución se prolongará lo necesario hasta que la tarea en segundo plano haya sido terminada. Recibe las unidades de progreso, así que podemos usar algún View para mostrarlas al usuario para que este sea consciente de la cantidad de tiempo que debe esperar.
  • onPostExecute(Resultados…): Aquí puedes publicar todos los resultados retornados por doInBackground() hacia el hilo principal.
  • onCancelled(): Ejecuta las instrucciones que desees que se realicen al cancelar la tarea asíncrona.

Comprendiendo estas propiedades, la clase SimpleTask queda fácil de asimilar. Si observas onPreExecute(), se comienza por hacer visible el botón cancelButton ( ya que solo aparece cuando la tarea asíncrona está en ejecución) y luego se desactiva el botón sortButton para evitar la ejecución la actividad un sinnúmero de ocasiones.

@Override
protected void onPreExecute() {
    cancelButton.setVisibility(View.VISIBLE);
    sortButton.setEnabled(false);
}

En el caso de doInBackground() se han puesto parámetros de tipo Void, ya que no se recibe valores de entrada y solo se ejecutarán las instrucciones del ordenamiento burbuja. Si eres buen observador, el método publishProgress() aparece al finalizar el primer bucle for. Esto con el fin de obtener una medida relativa en tiempo real del progreso actual. Matemáticas básicas!

El tipo de dato para las unidades de progreso es Integer, así se obtienen números enteros que muestren un porcentaje entre el intervalo [0, 100]. Dicha medida se muestra en un TextView llamado progressLabel.

@Override
protected void onProgressUpdate(Integer... values) {
    super.onProgressUpdate(values);
    progressLabel.setText(values[0] + "%");
}

En onPostExecute() se recibe un tipo Void como resultado, debido a que no se recibe retorno de doInBackground(). Aquí aprovecharás para restablecer sortButton y cancelButton a su estado inicial. También puedes avisar a través de progressLabel que se ha completado el trabajo.

@Override
protected void onPostExecute(Void result) {
    super.onPostExecute(result);
    progressLabel.setText("Completado");
    cancelButton.setVisibility(View.INVISIBLE);
    sortButton.setEnabled(true);
}

Ahora solo queda usar el método execute() para comenzar a ejecutar la tarea asíncrona en el método onClick():

public void onClickSort(View v) {
        switch (position){
             ...
            case 2:
                execWithAsyncTask();
                break;
                ...
}

public void execWithAsyncTask(){
        simpleTask= new SimpleTask();
        simpleTask.execute();
}

Las Tareas Asíncronas deben ser creadas, cargadas y ejecutadas dentro del UI Thread para su correcto funcionamiento.
Progreso de una Tarea Asíncrona en Android

Cancelar una tarea Asíncrona

Puedes detener la ejecución de una tarea asíncrona usando el método cancel(). Este invoca el método onCancelled(), en vez de doInBackground(), por lo que se descartarán los resultados que estén por entregarse al hilo principal. Si necesitas saber el momento exacto en que terminó la tarea, puedes comprobar el valor arrojado por el método isCancelled()(que retorna en true si ya se ha cancelado):

En AsyncLab la tarea asíncrona se cancela en el método onClick() con cancelButton.

public void onClickCancel(View v){
    simpleTask.cancel(true);
}

Como viste en la definición de SimpleTask, onCancelled() se sobrescribe para que ya no muestre unidades de progreso en progressLabel y para restablecer los estados de los botones:

@Override
protected void onCancelled() {
    super.onCancelled();
    progressLabel.setText("En la Espera");
    cancelButton.setVisibility(View.INVISIBLE);
    sortButton.setEnabled(true);
}

Complementariamente se coordina la detención del ordenamiento burbuja con un break si en algún momento la actividad se ha cancelado:

if(!isCancelled())
    publishProgress((int)(((i+1)/(float)(numbers.length-1))*100));
else break;

Experimento #4: Retener una AsyncTask ante la Rotación de la Pantalla

Hasta el momento nuestra tarea asíncrona ejecuta el ordenamiento de los números de forma perfecta, pero… ¿Qué pasará si rotas la pantalla mientras se está ordenando?

¿Lo has probado?… desafortunadamente la tarea asíncrona es alterada y la ejecución de onProgressUpdate() es anulada. Esto se debe a que cuando hay un cambio de configuración en tus aplicaciones (rotación de la pantalla, cambio de idioma, cambio de teclado) las actividades llaman a su método onDestroy() y luego a su método onCreate() para actualizar su layout y recursos.

Lo que quiere decir que comienza un nuevo ciclo de vida para la actividad y por ende el hilo principal toma otro rumbo.

Lee también Ciclo de Vida de una Actividad en Android

Para solucionar este pequeño inconveniente se usará el método setRetainInstace(), el cual permite retener las características de un fragmento ante un cambio de configuración. En consecuencia crearemos un fragmento personalizado, donde se añada una instancia de la tarea asíncrona ProgressBarTask con el fin de que el fragmento la proteja ante el cambio de configuración.

Lee También Fragmentos en una Aplicación Android

Cabe añadir que para el progreso de la tarea usaremos una ProgressBar en vez de progressLabel, pero básicamente el comportamiento es igual a SimpleTask.

public class HiddenFragment extends Fragment {

    /*
    Interfaz para la comunicación con la actividad ABTest.
     */
    static interface TaskCallbacks {
        void onPreExecute();
        void onProgressUpdate(int progress);
        void onCancelled();
        void onPostExecute();
    }

    private TaskCallbacks mCallbacks;
    ProgressBarTask progressBarTask;

    public HiddenFragment() {}

    @Override
    public void onAttach(Activity activity){
        super.onAttach(activity);
        //Obtener la instancia de ABTest
        mCallbacks = (TaskCallbacks) activity;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        //Retener el fragmento creado
        setRetainInstance(true);

        //Una vez creado el fragmento se inicia la tarea asincrona
        progressBarTask = new ProgressBarTask();
        progressBarTask.execute();
    }

    @Override
    public void onDetach(){
        super.onDetach();
        mCallbacks = null;
    }


    public class ProgressBarTask extends AsyncTask<Void, Integer, Long> {

        @Override
        protected void onPreExecute() {
            if (mCallbacks != null) {
                mCallbacks.onPreExecute();
            }
        }

        @Override
        protected Long doInBackground(Void... params) {
            long t0 = System.currentTimeMillis();

            int aux;
            int numbers[] = ABTest.numbers;

            for (int i = 0; i < numbers.length - 1; i++) {
                for (int j = 0; j < numbers.length -1; j++) {
                    if (numbers[j] > numbers[j+1])
                    {
                        aux          = numbers[j];
                        numbers[j]   = numbers[j+1];
                        numbers[j+1] = aux;
                    }
                }
                if(!isCancelled())
                    publishProgress((int)(((i+1)/(float)(numbers.length-1))*100));
                else break;

            }

            return t0;
        }

        @Override
        protected void onProgressUpdate(Integer... values) {

            if (mCallbacks != null) {
                mCallbacks.onProgressUpdate(values[0]);
            }
        }

        @Override
        protected void onPostExecute(Long aLong) {
            if (mCallbacks != null) {
                mCallbacks.onPostExecute();
            }
        }


    }

}

Se le ha llamado HiddenFragment porque no posee interfaz de usuario (por eso su método onCreateView() no está sobrescrito). Su estructura está formada por una interfaz llamada TaskCallbacks que le permitirá comunicarse con la actividad ABTest, donde se han añadido 4 métodos de comunicación para sobrescribir respectivamente los métodos callback de la clase ProgressBarTask.

Recuerda que para que la comunicación se dé, es necesario obtener la instancia de la actividad ABTest en el método onAttach(). Una vez realizado esto, es posible comenzar a llamar todas las implementaciones de los métodos de la interfaz que han sido definidos en la actividad. Es de vital importancia que ejecutes la tarea en el método onCreate() del fragmento para arraigarla con setRetainInstance().

Al implementar la interfaz TaskCallbacks en ABTest es necesario sobrescribir los métodos de la siguiente forma:

@Override
public void onPreExecute() {
    progressBar.setVisibility(View.VISIBLE);
    cancelButton.setVisibility(View.VISIBLE);
    sortButton.setEnabled(false);
}

@Override
public void onProgressUpdate(int progress) {
    progressBar.setProgress(progress);
    progressLabel.setText(progress+"%");
}

@Override
public void onCancelled() {
    progressBar.setVisibility(View.INVISIBLE);
    cancelButton.setVisibility(View.INVISIBLE);
    progressLabel.setText("En la Espera");
    sortButton.setEnabled(true);
}

@Override
public void onPostExecute() {
    progressBar.setVisibility(View.INVISIBLE);
    cancelButton.setVisibility(View.INVISIBLE);
    progressLabel.setText("Completado");
    sortButton.setEnabled(true);

}

Básicamente se oculta la ProgressBar al igual que se hacía con la progressLabel y luego configuras los botones, para que aparezcan en la situación adecuada. También se usa el método setProgress() para actualizar el estado de realización de la tarea.

A continuación crea el fragmento justo cuando sea presionado sortButton para que la tarea se inicie.

private void execWithProgresBar() {
        FragmentManager fg = getFragmentManager();
        fragment = new HiddenFragment();
        FragmentTransaction transaction = fg.beginTransaction();
        transaction.add(fragment, HIDDEN_FRAGMENT_TAG);
        transaction.commit();
    }

Finalmente asegura que los botones y la barra se restablezcan correctamente en el método onCreate() de ABTest cuando surja la rotación de la pantalla:

fragment = (HiddenFragment)getFragmentManager().
                findFragmentByTag(HIDDEN_FRAGMENT_TAG);

if(position==3 && fragment!=null) {
    if(<strong>fragment.progressBarTask.getStatus()== AsyncTask.Status.RUNNING</strong>){
        progressBar.setVisibility(View.VISIBLE);
        cancelButton.setVisibility(View.VISIBLE);
        sortButton.setEnabled(false);
    }
}

En castellano, el código anterior se traduce a “Si se ha elegido la opción 3 y el fragmento ya ha sido creado, entonces compruebe si la tarea está en ejecución. Si es así, entonces mantener visible la barra de progreso, mantener visible el botón de cancelar y además conservar el estado de inactividad de sortButton”.

Supongo que ya deduces que getStatus() obtiene el estado actual de la tarea y que RUNNING es un tipo enumerado que representa el estado de ejecución.

Ahora prueba el experimento y verás como al cambiar a landscape, la ProgressBar y la tarea asíncrona siguen intactas.
Aplicación Android con una ProgressBar en Landscape

Conclusiones

No hace falta justificar ampliamente el porqué es mejor usar la clase AsyncTask para gestionar hilos y trabajos en segundo plano. Este mecanismo permite optimizar tanto el flujo de tus aplicaciones como el orden de codificación de las Actividades.

Así que la próxima vez que debas poner en ejecución una operación que requiere algunos segundos considerables, no dudes en acudir a las tareas asíncronas.

Icono de la Aplicación AsyncLab cortesía de IconFinder