domingo, 14 de septiembre de 2014

Tareas y la pila de retroceso

Una aplicación suele estar formada por varias actividades. Cada actividad debe ser diseñada en torno a un tipo determinado de acción que el usuario puede realizar y que a su vez puede iniciar otras actividades. Por ejemplo, una aplicación de correo electrónico puede tener una actividad que muestre un listado con los mensajes entrantes y cuando el usuario selecciona uno, se inicia una nueva actividad para mostrar sus detalles.

Una actividad incluso puede iniciar actividades de otras aplicaciones. Por ejemplo, si queremos enviar un correo electrónico, podemos definir una intención para realizar una acción del tipo "send" y añadirle datos adicionales como la dirección del destinatario y el mensaje. Una actividad definida en alguna aplicación y que haya sido declarada para gestionar intenciones del tipo "send", será la encargada de enviar el correo (en el caso de que existan varias actividades en el sistema capaces de responder a la misma intención, el usuario podrá seleccionar qué actividad quiere usar). Una vez se ha enviado el correo, la actividad inicial se reanuda. Todo este proceso es transparente de cara al usuario que en todo momento cree estar en la misma aplicación. Para conseguir esto, Android gestiona ambas actividades dentro de la misma tarea.

Una tarea es una colección de actividades con las que el usuario interactúa con el único propósito de realizar una trabajo determinado. Las actividades se organizan en una pila (pila de retroceso) en función del orden en el que éstas se fueron iniciando.

La pantalla de inicio es el lugar de partida de la mayoría de las tareas. Cuando el usuario lanza una aplicación desde el lanzador de aplicaciones o desde la pantalla de inicio, la tarea de la aplicación pasa a un primer plano. Si no hay una tarea previa para la aplicación, es decir, ésta no se ha usado recientemente, se creará una nueva tarea y se abrirá su actividad principal que se será añadida a la pila como nodo raíz.

Cuando la actividad actual inicia otra actividad, ésta última pasa al tope de la pila y toma el foco.  La actividad previa se mantiene en la pila pero es detenida. Cuando una actividad se detiene, el sistema mantiene su estado y su interfaz de usuario. Cuando el usuario pulsa el botón de retroceso, la actividad actual se saca del tope de la pila (se destruye) y la actividad previa se reanuda restaurándose su estado (incluyendo su interfaz de usuario). El orden de las actividades en la pila atiende a las siglas LIFO (Last In First Out - Último Entra Primero Sale): la actividad entra en la pila cuando se inicia y se saca de la pila cuando el usuario la abandona pulsando el botón de retroceso. En la imagen 1 podemos observar este funcionamiento.

Funcionamiento de la pila de retroceso
Imagen 1. Las actividades nuevas se van a añadiendo a la pila.
Cuando el usuario pulsa el botón de retroceso, la actividad actual se destruye y la actividad previa se reanuda.

Cada vez que el usuario pulsa el botón de retroceso, la actividad actual se saca del tope de la pila y se recupera la actividad anterior. Esta operación se repite hasta que el usuario vuelve a la pantalla de inicio (o a la actividad que se estuviera ejecutando cuando la tarea fue creada). Cuando ya no quedan actividades en la pila, ésta desaparece.

Tarea A en primer plano y tarea B espera en segundo plano
Imagen 2. La tarea B está en primer plano
mientras la tarea A espera en segundo plano.

Las tareas pueden pasar a un segundo plano cuando el usuario comienza una nueva tarea o cuando éste navega hasta la pantalla de inicio pulsando el botón de inicio. Mientras una tarea se encuentra en segundo plano, todas sus actividades se encontrarán detenidas y la pila de retroceso permanecerá intacta. La tarea simplemente ha perdido el foco y otra tarea ha ocupado su lugar (ver imagen 2). Una tarea puede volver al primer plano, retomando el usuario el trabajo justo donde lo había dejado. Supongamos por ejemplo que la tarea actual (tarea A) tiene tres actividades en la pila (dos actividades previas a la actual). El usuario presiona el botón de inicio y ejecuta una nueva aplicación. Justo cuando aparece la pantalla de inicio, la tarea A pasa a un segundo plano. Cuando la nueva aplicación se inicia, el sistema crea una nueva tarea (tarea B) con su propia pila de actividades. Después de interactuar con la aplicación, el usuario vuelve a seleccionar la aplicación que dio lugar a la tarea A. Ahora, la tarea A pasa al primer plano, la tres actividades de su pila están intactas y la actividad del tope de la pila se reanuda. En este punto, el usuario podría volver a la tarea B a través de la pantalla de inicio (o seleccionando la tarea de la aplicación desde la pantalla de aplicaciones recientes). Este es un ejemplo típico en el que se observa cómo funciona la multitarea en Android.


Nota. Pueden existir varias tareas en segundo plano. Sin embargo, si el usuario ejecuta varias tareas en segundo plano al mismo tiempo, el sistema podría empezar a eliminar algunas actividades para recuperar memoria y éstas perderían sus estados.
La misma actividad instanciada dos veces
Imagen 3. La misma actividad
instanciada dos veces.

Debido a que el orden de las actividades en la pila permanece inalterable, si nuestra aplicación permite que el usuario inicie una misma actividad desde varias actividades, esto puede dar lugar a que acabemos teniendo múltiples instancias de esa actividad en la pila (ver imagen 3). Cada una de las instancias mantiene estados independientes y posiblemente distintos. Es decir, que el usuario podría llegar a usar estados distintos para de la misma actividad y esto podría llegar a ser confuso. Este comportamiento lo podremos evitar y lo veremos más adelante.

Como resumen, el funcionamiento por defecto de actividades y tareas:
  • Cuando una actividad A inicia una actividad B, la actividad A se detiene y el sistema mantiene su estado (como la posición de desplazamiento o el texto introducido en los campos de un formulario). Si el usuario pulsa el botón de retroceso mientras se encuentra en la actividad B, la actividad A se reanuda y se restaura su estado.
  • Cuando el usuario deja una tarea pulsando el botón de inicio, la actividad actual se detiene y su tarea pasa a un segundo plano. El sistema conserva el estado de todas las actividades de la tarea. Si el usuario decide volver más tarde a la tarea desde la pantalla de inicio, la tarea volverá al primer plano y la actividad del tope de la pila será reanudada.
  • Cada vez que el usuario presiona el botón de retroceso, la actividad actual se saca de la pila y se destruye. La actividad previa se reanuda. El sistema no guarda información de estado de actividades destruidas.
  • Podemos tener varias instancias de una actividad en la misma tarea o en varias.

Guardando el estado de la actividad.

Por defecto, cuando una actividad es detenida por el sistema, éste guardará su estado. Si el usuario decide volver a la actividad, su estado será restaurado y con él, su interfaz de usuario. Sin embargo, es recomendable gestionar el estado de una actividad de manera explícita para que se pueda recuperar incluso si el sistema la destruye.

Cuando el sistema detiene una actividad (porque se inicia otra actividad o porque la tarea pasa a un segundo plano), el sistema destruirá la actividad si necesita recuperar memoria. Cuando esto sucede, la información referente a su estado se pierde. Si esto pasa, el sistema aún sabe qué posición ocupa la actividad dentro de la pila y cuando ésta pasa al tope, se vuelve a crear restaurándose su estado antes de ser reanudada. Para evitar que el usuario pierda su trabajo, deberíamos implementar el método onSaveInstanceState() de nuestras actividades.

Gestión de tareas.

El funcionamiento por defecto con el que Android gestiona las tareas y la pila de retroceso es suficiente para la mayoría de las aplicaciones. No deberíamos preocuparnos de cómo se relacionan actividades y tareas o de cómo éstas coexisten en la pila de retroceso. Sin embargo, existen casos en los que necesitaremos cambiar este funcionamiento. Por ejemplo, podríamos necesitar que una actividad concreta diese lugar a una tarea nueva cada vez que se inicia en lugar de ser añadida a la tarea actual. O por ejemplo, cuando iniciamos una actividad, podríamos necesitar recuperar una instancia previa de ésta (en vez de crear una nueva instancia en el tope de la pila). O por ejemplo, podríamos necesitar vaciar la pila por completo, a excepción de su actividad raíz, cuando el usuario abandona la tarea.

Podremos gestionar estos comportamientos usando atributos específicos en un elemento <activity> dentro del archivo manifest, o bien, especificando unos indicadores (flags) especiales en la intención que inicia la actividad.

Los atributos relacionados con el elemento <activity> son:
  • taskAffinity
  • launchMode
  • allowTaskReparenting
  • clearTaskOnLaunch
  • alwaysRetainTaskState
  • finishOnTaskLaunch

En cuanto a los indicadores que podemos usar en la intención:
  • FLAG_ACTIVITY_NEW_TASK
  • FLAG_ACTIVITY_CLEAR_TOP
  • FLAG_ACTIVITY_SINGLE_TOP

Importante. Como norma no deberíamos cambiar el funcionamiento por defecto de actividades y tareas. Si nos viésemos obligados a ello, tendremos que probar concienzudamente el funcionamiento del botón de retroceso desde las diferentes actividades de nuestra aplicación. Ante todo, debemos asegurarnos de que la navegación a través de las actividades sea como el usuario espera que sea.

Definiendo modos de lanzamiento.

Los modos de lanzamiento nos permitirán definir la manera en la que se asocia una actividad nueva con respecto a la tarea actual. Los modos de lanzamiento pueden ser especificados de dos maneras:
  • Desde el archivo manifest. Mediante el uso de atributos especiales asociados con la actividad.
  • A través de indicadores especiales asociados a la intención.
Supongamos que una actividad A inicia otra actividad B. Que la actividad B define en el archivo manifest su modo de lanzamiento y que la actividad A también hace lo mismo a través de la intención. ¿Cómo solventa Android este conflicto? ¿Qué modo de lanzamiento usar? Android, en este caso, acaba dando prioridad al modo especificado por la actividad A en la intención.

Nota. Algunos modos de lanzamiento disponibles desde el archivo manifest, no estarán disponibles a través de la intención y viceversa.

Usando el archivo manifest.

Cuando declaramos una actividad en el archivo manifest, podemos especificar cómo será asociada la actividad con una tarea usando el atributo launchMode del elemento <activity>.

El atributo launchMode puede tomar uno de los siguientes valores:
standard
Es el valor por defecto. El sistema crea una nueva instancia de la actividad dentro de la tarea desde la que fue iniciada. Podremos tener varias instancias de la misma actividad y éstas pertenecer a una misma tarea o a tareas distintas.
singleTop
Si la actividad ya se encuentra en el tope de la pila, el sistema le entregará la intención a través de su método onNewIntent(). En este caso no se crea una nueva instancia de la actividad. En el resto de casos, podremos tener varias instancias de la misma actividad y éstas pertenecer a una misma tarea o a tareas distintas.

Por ejemplo, supongamos que la pila de una tarea tiene una actividad raíz A, con las actividades B, C y D en la parte superior (la pila quedaría como A-B-C-D, donde D sería el tope de la pila). Si D tiene el modo standard de lanzamiento y una nueva actividad D se inicia, se creará una nueva instancia para ella y ésta se añadirá a la pila, quedando: A-B-C-D-D. Sin embargo, si el modo de lanzamiento de la actividad es singleTop, la instancia previa de la actividad D recibirá la intención a través del método onNewIntent() y la pila quedaría tal cual: A-B-C-D. Por otro lado, si nos llega una nueva intención para una actividad B, se creará una nueva instancia y se añadirá al tope de la pila aunque el modo de lanzamiento para la actividad D sea singleTop.

Nota. Cuando se crea una nueva instancia de una actividad, el usuario podría presionar el botón de retroceso para volver a la actividad previa. Cuando la actividad ya existe en el tope de la pila y se le pasa la intención, el usuario no podrá volver al estado previo de la actividad hasta que la intención no llegue a su método onNewIntent().
singleTask
El sistema crea una nueva tarea a la que añade una nueva instancia de la actividad como nodo raíz. Si ya existía una instancia previa en otra tarea, el sistema le entregará la intención a través del método onNewIntent(), sin llegar a crearla. En este caso, a lo sumo tendremos una sola instancia de la actividad.

Nota. Aunque la actividad se inicia en una tarea nueva, el botón de retroceso aún nos seguirá devolviendo a la actividad previa.
singleInstance
Se comporta de la misma manera que si especificásemos singleTask pero en este caso, la tarea nueva solo tendrá una actividad, la actividad raíz. Cualquier actividad que se inicie a partir de la actividad raíz lo hará en una tarea distinta.

A modo de ejemplo, podemos fijarnos en el navegador de Android que declara sus actividades para que éstas siempre se abran desde su propia tarea (modo de lanzamiento singleTask). Esto significa que si nuestra aplicación solicita una intención para abrir el navegador, su actividad no será añadida a la tarea de nuestra aplicación, en su lugar, se crea una nueva tarea para el navegador o, si el navegador ya existe en una tarea previa en segundo plano, ésta pasa al primer plano y pasa a procesar la intención.

Independientemente de si una actividad se inicia en una tarea u otra, el botón de retroceso siempre llevará al usuario a la actividad previa. Sin embargo, si especificamos como modo de lanzamiento singleTask y la actividad ya existe en una tarea en segundo plano, todas las actividades de ésta pasarán a formar parte de la tarea que invocó dicha actividad. En este caso, si el usuario pulsa el botón de retroceso, navegará a través de actividades adicionales. La imagen 4 ilustra este último escenario.

Imagen 4. Iniciamos la actividad Y desde la tarea en primer plano que ya existe en otra tarea en segundo plano.
Todas las actividades de la tarea en segundo plano se añadirán a la tarea en primer plano.
Cuando el usuario pulsa el botón de retroceso varias veces, se respeta el orden de las nuevas actividades insertadas.

Usando los indicadores de una intención.

Cuando iniciamos una actividad, podemos modificar la manera en la que ésta se asocia con su tarea incluyendo una serie de indicadores a través de su intención. Los indicadores que podemos usar son los siguientes:
FLAG_ACTIVITY_NEW_TASK
Mismo funcionamiento que el modo de lanzamiento singleTask.
FLAG_ACTIVITY_SINGLE_TOP
Mismo funcionamiento que el modo de lanzamiento singleTop.
FLAG_ACTIVITY_CLEAR_TOP
Si la actividad que se inicia ya existe en la tarea actual, ésta pasará al tope de la pila, destruyéndose todas las actividades que puedan quedar por encima de ella. Una vez en el tope, la actividad se reanuda y se le pasará la intención a través del método onNewIntent().

No existe un modo de lanzamiento equivalente (launchMode).

LAUNCH_ACTIVITY_CLEAR_TOP se utiliza a menudo en conjunción con el valor FLAG_ACTIVITY_NEW_TASK. En este caso, el sistema intenta localizar la actividad en las tareas ya existentes: si la encuentra, pasa la actividad al tope de la pila y le entrega la intención.

Nota. Si el modo de lanzamiento de la actividad además es standard, ésta será eliminada de la pila y se creará una nueva instancia que será la encargada de gestionar la intención entrante. Siempre se creará una nueva instancia de la actividad cuando se reciba una intención y su modo de lanzamiento sea standard.

Trabajando con afinidades.

La afinidad hace referencia a qué tarea prefiere pertenecer una actividad. Por defecto, todas las actividades de una misma aplicación prefieren pertenecer a la misma tarea. Sin embargo, podremos modificar la afinidad por defecto de una actividad. De esta manera, actividades que pertenecen a aplicaciones diferentes pueden compartir afinidad, o por el contrario, actividades que pertenecen a la misma aplicación pueden tener una afinidad distinta.

Para cambiar la afinidad de una actividad, utilizaremos el atributo taskAffinity de su elemento <activity>. El atributo taskAffinity toma como valor una cadena que deberá ser única.

La afinidad entra en juego bajo las siguientes circunstancias:
  • Cuando se especifica el indicador FLAG_ACTIVITY_NEW_TASK en una intención.

    Una actividad, por defecto, se asocia a la tarea a la que pertenece la actividad que la invocó (método startActivity()). Sin embargo, si la intención lleva el indicador FLAG_ACTIVITY_NEW_TASK, el sistema buscará una tarea diferente para alojar la nueva actividad. A menudo, será una tarea nueva. Sin embargo no tiene por qué ser así. Si por ejemplo existe una tarea con la misma afinidad definida en la actividad, ésta se ejecutará dentro de esa tarea.

    Si este indicador da lugar a una nueva tarea y el usuario pulsa el botón de inicio para dejarla, tiene que haber alguna manera para que el usuario pueda volver a la tarea. Algunas entidades (tales como el gestor de notificaciones) siempre inician las actividades en una tarea externa usando el indicador FLAG_ACTIVITY_NEW_TASK en la intenciones que utilizan a la hora de llamar al método startActivity(). Si contamos con alguna actividad que pueda ser invocada por alguna entidad externa que use ese indicador, tendremos que tener cuidado si el usuario emprende un camino diferente para volver a la tarea previamente iniciada, como por ejemplo, a través del lanzador de aplicaciones.

  • Cuando una actividad tiene valor true para su atributo allowTaskReparenting.

    En este caso, la actividad puede viajar desde la tarea en la que se inició hasta la tarea con la que tiene afinidad, cuando ésta pasa a primer plano.

    Por ejemplo, supongamos que una actividad que nos da el parte meteorológico en unas determinadas ciudades está definida como parte de una aplicación de viajes. Ésta tendrá la misma afinidad que el resto de actividades en la misma aplicación (la afinidad por defecto de la aplicación) y además cuenta con el atributo allowTaskReparenting="true". Cuando una actividad inicia la actividad del parte meteorológico, ésta será iniciada en la misma tarea a la que pertenece la primera. Sin embargo, cuando la aplicación de viajes pasa a un primer plano, la actividad del parte meteorológico pasará a formar parte de esa tarea y se mostrará dentro de ella.

Truco. A veces, desde el punto de vista del usuario, en un mismo APK podríamos identificar una seria de aplicaciones virtuales. Cada una de éstas, vendrá determinada por un subconjunto de todas las actividades. En estos casos, podría ser interesante utilizar el atributo taskAffinity para asignar la misma afinidad a las actividades que pertenecen a una misma aplicación virtual.

Borrando la pila de retroceso.

Si el usuario abandona una tarea por un tiempo prolongado, el sistema acabará borrando todas las actividades de la tarea a excepción de su actividad raíz. Cuando el usuario vuelve a la tarea, sólo la actividad raíz será restaurada. El sistema actúa de esta manera porque pasado un tiempo considerable, los usuarios ya no deberían estar interesados en lo que estuvieron haciendo con anterioridad y si vuelven a la tarea, es para iniciar un nuevo trabajo.

Hay ciertos atributos de la actividad que podemos usar para modificar este comportamiento:
alwaysRetainTaskState
Si este atributo vale true en la actividad raíz de la tarea, el comportamiento descrito anteriormente no se produce. Es decir, la tarea conservará todas sus actividades en la pila independientemente del tiempo transcurrido.
clearTaskOnLaunch
Si este atributo vale true en la actividad raíz de la tarea, se borrarán todas las actividades de la pila a excepción de la actividad raíz cuando el usuario abandona la tarea, independientemente del tiempo transcurrido. Lo contrario al atributo anterior.
finishOnTaskLaunch
Este atributo se puede especificar para cualquier actividad. Cuando su valor es true, la actividad se mantendrá como parte de la tarea solo para la sesión actual. Es decir, si el usuario sale y entra de la tarea, la actividad ya no estará disponible.

Asignaremos el valor true a este atributo en aquellas actividades que no queremos que sigan disponibles después de haber sido abandonadas por el usario.

Iniciando una tarea.

Podemos configurar una actividad como punto de entrada de una tarea a través de un filtro de intenciones en el que especificaremos como acción android.intent.action.MAIN y como categoría android.intent.category.LAUNCHER:
<activity ... >
    <intent-filter ... >
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    ...
</activity>
Un filtro de intenciones de este tipo da como resultado un icono con el nombre de la actividad en el lanzador de aplicaciones, ofreciendo a los usuarios una manera de lanzar la actividad o de volver a la tarea creada después de haber sido lanzada.

Los usuario pueden abandonar la tarea y volver a ella desde el lanzador de aplicaciones. Por esta razón, los dos modos de lanzamiento para actividades: singleTask y singleInstance, los deberíamos usar cuando la actividad tiene definido el filtro anterior. ¿Qué sucedería si no existiese este filtro? Imaginemos que una intención lanza una actividad en modo singleTask creándose una nueva tarea. Transcurrido un tiempo interactuando con el usuario, éste decide pulsar el botón de inicio, con lo que la tarea se envía a un segundo plano y se oculta. A partir de ese momento, el usuario ya no podrá volver a la tarea desde el lanzador de aplicaciones puesto que no existirá el acceso directo necesario.

No hay comentarios:

Publicar un comentario