Accueil > Code > Exécution de code en arrière-plan avec Android

Exécution de code en arrière-plan avec Android

mercredi 2 mars 2011, par Nicolas

Problématique

Pour différentes applications Android que je développe expérimentalement, j’avais besoin de pouvoir télécharger des données depuis un serveur, en arrière-plan et potentiellement en bloquant l’interface utilisateur via un ProgressDialog, mais en la laissant réactive.

En cherchant dans les différentes documentations et sur des forums etc., il y a plusieurs approches, chacune avec ses spécificités.

Principe général

Mon application se connecte à un serveur, et récupère un flux XML de données. Elle traite les données au fur et à mesure de leur arrivée, notant éventuellement le dernier élément traité pour recommencer après celui-ci en cas d’interruption du flux [1].

L’application utilise la date de dernière mise à jour pour savoir s’il faut ou non télécharger les données. L’utilisateur peut également forcer une mise à jour s’il le souhaite.

J’utilise les différentes classes liées au XML, comme DefaultHandler, XMLReader ou SAXParser.

Le volume de données n’est pas nécessairement extrêmement volumineux, et pourrait probablement se traiter d’un seul coup. Cependant un changement d’orientation n’est pas simple à gérer pour une tâche en arrière-plan, quelle que soit la durée de la tâche [2]. Et il faut également traiter le cas de la fermeture de l’application par l’utilisateur, qui tant qu’à faire ne devrait pas interrompre la mise à jour.

Première solution : utiliser AsyncTask

Ma première implémentation a été la plus simple, utiliser la classe AsyncTask pour exécuter du code en arrière-plan.

Rien de bien compliqué, juste surcharger les bonnes méthodes, et appeler execute() pour lancer le traitement.

Outre sa simplicité, l’avantage de cette méthode est qu’elle permet d’interagir simplement avec l’interface utilisateur lorsque le AsyncTask est inclus dans une activité, en appelant directement les méthodes dans la fonction onProgressUpdate.

Cependant, la limitation majeure est que, liée à l’Activity la contenant, une destruction de cette activité notamment lors d’un changement d’orientation rompt le lien avec l’interface, interdisant des notifications futures [3].

Seconde solution : utiliser IntentService

Ma seconde solution a été d’utiliser la classe IntentService, qui permet de créer simplement un service s’exécutant en arrière-plan sans avoir à gérer toutes les problématiques de lancement et d’arrêt.

La simple surcharge de la méthode onHandleIntent() résout de nombreux problèmes, et permet de télécharger en arrière-plan à moindres frais.

L’inconvénient majeur de cette méthode est la difficulté de communiquer en retour à l’application l’état du téléchargement. Il doit être possible d’utiliser des Intent via des BroadcastReceiver, mais je n’ai pas trouvé d’exemple significatif [4]. De plus, l’application à son lancement a des difficultés à vérifier si une mise à jour est en cours ou pas.

Troisième solution : utiliser Service

Ma dernière solution a été d’utiliser une classe dérivant directement de Service.

Cela a nécessité pas mal de briques, mais au final cela me semble la meilleure solution [5].

Les bases : une simple surcharge du onCreate du service, pour lancer l’exécution de la mise à jour :

  1.   public void onCreate() {
  2.     super.onCreate();
  3.     Log.i(Tag, "starting service");
  4.     launchTask(false);
  5.   }

La méthode launchTask() prend un boolean indiquant s’il faut forcer ou non la mise à jour. Le AsyncTask est stocké dans une variable membre _updater.

Puis l’équivalent dans le onDestroy() pour interrompre la tâche si elle est encore active.

  1.   public void onDestroy() {
  2.     Log.i(Tag, "destroying service");
  3.     if (_updater != null)
  4.       _updater.cancel(true);
  5.     super.onDestroy();
  6.   }

Communication avec l’appelant : le but étant que le service puisse informer les clients, il faut bien une méthode de transfert d’informations.

Le code qui suite est quasiment une copie de MessengerService.

Il y a besoin de suivre les activités connectées au service, et de pouvoir leur envoyer des messages.

  1.   /** Tous les clients connectés au service. */
  2.   private final ArrayList<Messenger> _clients = new ArrayList<Messenger>();
  3.   /** Traite les messages entrant des clients. */
  4.   private final Handler _handler = new Handler() {
  5.     /**
  6.      * {@inheritDoc}
  7.      */
  8.     @Override
  9.     public void handleMessage(Message msg) {
  10.       switch (msg.what) {
  11.         case MESSAGE_CLIENT_ADD:
  12.           Log.d(Tag, "adding client");
  13.           if (!_clients.contains(msg.replyTo)) {
  14.             _clients.add(msg.replyTo);
  15.             if (_updater != null && _informNewClients)
  16.               sendClient(_clients.size() - 1, MESSAGE_UPDATING, 0, 0);
  17.           }
  18.           break;
  19.         case MESSAGE_CLIENT_REMOVE:
  20.           Log.d(Tag, "removing client");
  21.           _clients.remove(msg.replyTo);
  22.           break;
  23.         case MESSAGE_UPDATE:
  24.           if (_updater != null)
  25.             return;
  26.           launchTask(true);
  27.         default:
  28.           super.handleMessage(msg);
  29.       }
  30.     }
  31.   };
  32.   /** Messager traitant les messages. */
  33.   private final Messenger _incoming = new Messenger(_handler);
  34.   /**
  35.    * {@inheritDoc}
  36.    * @param intent
  37.    * @return
  38.    */
  39.   @Override
  40.   public IBinder onBind(Intent intent) {
  41.     return _incoming.getBinder();
  42.   }

sendClients() est une simple fonction envoyant un Message à tous les clients, retirant ceux qui ont disparu. _informNewClients permet de ne pas informer les clients de l’état de mise à jour à leur connexion, si la tâche d’arrière-plan est encore en attente, ou ne fait pas (encore) la mise à jour effective.

Exécution effective de la mise à jour : partie la plus simple, quelque part.

  1.   protected void launchTask(boolean force) {
  2.     _informNewClients = false;
  3.     _updater = new AsyncTask<Boolean, Void, Void>() {
  4.     /**
  5.      * {@inheritDoc}
  6.      */
  7.     @Override
  8.     protected Void doInBackground(Boolean... force) {
  9.       if (!checkUpdate(force[0]))
  10.         purgeItems();
  11.       return null;
  12.     }
  13.     /**
  14.      * {@inheritDoc}
  15.      */
  16.     @Override
  17.     protected void onPostExecute(Void result) {
  18.       Log.i(Tag, "update finished");
  19.       sendClients(MESSAGE_UPDATED, 0, 0);
  20.       _updater = null;
  21.       super.onPostExecute(result);
  22.     }
  23.     }.execute(force);

À noter qu’ll faut créer un nouveau AsyncTask à chaque exécution, il est impossible de relancer un élément déjà terminé.

Côté activité cliente : le code se divise en différentes parties.

Tout d’abord le lancement du service :

  1.   protected void launchService() {
  2.     Intent service = new Intent(this, UpdateService.class);
  3.     startService(service);
  4.     bindService(service, _serviceConnection, BIND_AUTO_CREATE);
  5.   }

Rien de bien spécial, un startService() pour s’assurer qu’il démarre, et bindService() pour récupérer une façon de communiquer.

Tout comme le service, l’activité va utiliser un Messenger pour communiquer :

  1.   protected final Messenger _incoming = new Messenger(new Handler() {
  2.     @Override
  3.     public void handleMessage(Message msg) {
  4.       switch (msg.what) {
  5.         case UpdateService.MESSAGE_UPDATING:
  6.           _updateStatus.setVisibility(View.VISIBLE);
  7.           break;
  8.         case UpdateService.MESSAGE_UPDATED:
  9.           _updateStatus.setVisibility(View.GONE);
  10.           break;
  11.         case UpdateService.MESSAGE_UPDATE_ERROR:
  12.           Toast.makeText(MyActivity.this, msg.arg1, Toast.LENGTH_LONG).show();
  13.           break;
  14.         default:
  15.           super.handleMessage(msg);
  16.       }
  17.     }
  18.   });

Il faut ensuite un ServiceConnection pour suivre la vie du service (code tiré et adapté de l’exemple Google) :

  1.   protected Messenger _service;
  2.   private ServiceConnection _serviceConnection = new ServiceConnection() {
  3.     public void onServiceConnected(ComponentName className, IBinder service) {
  4.       synchronized(MonActivity.this) {
  5.         if (_cancelled)
  6.           return;
  7.         Log.d(Tag, "service connected");
  8.         // This is called when the connection with the service has been
  9.         // established, giving us the service object we can use to
  10.         // interact with the service.  We are communicating with our
  11.         // service through an IDL interface, so get a client-side
  12.         // representation of that from the raw service object.
  13.         _service = new Messenger(service);
  14.         // We want to monitor the service for as long as we are
  15.         // connected to it.
  16.         try {
  17.             Message msg = Message.obtain(null, UpdateService.MESSAGE_CLIENT_ADD);
  18.             msg.replyTo = _incoming;
  19.             _service.send(msg);
  20.         } catch (RemoteException e) {
  21.             // In this case the service has crashed before we could even
  22.             // do anything with it; we can count on soon being
  23.             // disconnected (and then reconnected if it can be restarted)
  24.             // so there is no need to do anything here.
  25.         }
  26.       }
  27.     }
  28.     public void onServiceDisconnected(ComponentName className) {
  29.       // This is called when the connection with the service has been
  30.       // unexpectedly disconnected -- that is, its process crashed.
  31.       _service = null;
  32.     }
  33.   };

Enfin, dernière brique, faire le ménage à la fermeture de l’activité

  1.   protected void onDestroy() {
  2.     Log.d(Tag, "onDestroy");
  3.     synchronized(this) {
  4.       _cancelled = true;
  5.       if (_service != null) {
  6.         Log.d(Tag, "disconnect from service");
  7.         try {
  8.             Message msg = Message.obtain(null, UpdateService.MESSAGE_CLIENT_REMOVE);
  9.             msg.replyTo = _incoming;
  10.             _service.send(msg);
  11.         } catch (RemoteException e) {
  12.           Log.d(Tag, e.toString());
  13.         }
  14.       }
  15.     }
  16.     unbindService(_serviceConnection);
  17.     super.onDestroy();
  18.   }

Remarques diverses :
- le _cancelled dans l’activité sert à éviter les problèmes d’exécution asynchrone où l’activité est détruite (onDestroy()) puis le service est connecté (appel de onServiceConnected()). Ceci entraînerait une référence depuis le service, empêchant sa fermeture
- le service a un timer régulier utilisé pour regarder s’il reste des clients connectés et si la mise à jour est terminée. Si aucun client et mise à jour terminée, le service appelle stopSelf() pour terminer. Lorsque l’activité cliente est détruite pour changement d’orientation, le onDestroy() est appelé, le lien est défait, puis la nouvelle activité se lie au service. Si celui-ci se termine immédiatement quand il n’a plus de client, il sera potentiellement relancé de suite, je préfère donc qu’il attende quelques secondes avant de se terminer
- le code fonctionne a priori car il se base sur des messages, traités sauf erreur de ma part séquentiellement sur le fil principal d’interface graphique. Il n’y a donc pas de problématique d’accès concurrent aux données, notamment sur les tableaux _clients ou sur _updater. En cas d’accès concurrents, il faudrait bien sûr utiliser synchronized pour éviter les conflits [6].

Conclusion

Je trouve surprenant que le SDK Android ne fournisse pas de façon plus simple de traiter de genre de cas [7].

Le framework version 2.3 introduit bien la classe DownloadManager, qui permettrait de résoudre en partie ce genre de problème en téléchargeant le fichier sur un emplacement temporaire puis en le traitant. Mais cela oblige à un téléchargement complet, ce qui peut utiliser de la place mémoire pour des grosses données.

Ceci dit, la solution du Service a le mérite d’être relativement propre, et de bien résoudre les problèmes liés au changement d’orientation.


[1Une des applications est un simple lecteur de flux RSS, et n’a pas de notion de « reprise après interruption », elle recommence la lecture à zéro.

[2Il est possible de traiter un changement d’orientation dans l’application sans utiliser le mécanisme standard de destruction et relancement, et de permettre au fil en arrière-plan de continuer. Cependant l’exécution peut être interrompue si l’utilisateur ferme la tâche, si le système la gèle car non visible à l’utilisateur, ou autres cas, ce qui dans le cas d’un téléchargement est gênant.

[3Il doit être possible de contourner en utilisant un static pour partager le AsyncTask entre les activités relancées, ou en stockant l’objet dans l’application ou le contexte, mais je ne suis pas sûr que le lien refonctionne quand même.

[4Il est bien sûr possible d’utiliser des notifications via NotificationManager, mais je n’ai pas très envie d’ennuyer l’utilisateur pour ce genre de choses.

[5Je ne prétends bien sûr pas être expert en Android, et suis prêt à écouter d’autres solutions :)

[6D’ailleurs j’en ai mis dans le onDestroy et le onServiceConnected(), je ne sais pas s’ils sont requis.

[7Bien sûr, je peux parfaitement être passé à côté d’une classe spécifiquement dédiée à cela...

Messages