Ludovic ROLAND

Blog technique sur mes expériences de développeur.

Android : Détecter si une application passe au premier plan ou en arrière plan

11 novembre 2015

Autant que je peux, je tente d’aider les développeurs en partageant mes connaissances et mes compétences. Sur les forums d’OpenClassrooms, une question revient souvent : “Comment détecter que son application passe en arrière plan ?” ou sa variante “Comment détecter que son application revient d’arière plan ?”.

Souvent, sans plus de détails, je renvoie les gens vers le code de la bibliothèque Cast Companion. Cette bibliothèque, développée par Google pour aider les développeurs à intégrer Chromecast dans leurs applications utilisent un système similaire pour savoir si la notification de contrôle Chromecast doit s’afficher où non (d’après les guidelines, cette notification ne doit s’afficher que quand l’application est en arrière plan.

Dans ce billet, je vous propose de voir pas à pas comment détecter que son application passe en arrière plan

Création de l’application Android

L’application sur laquelle nous allons nous appuyer dans le cadre de cet article sera extrêmement simple puisqu’elle ne sera composée que de deux écrans des plus basiques.

Le premier écran

Le premier écran est des plus simples puisqu’il s’agit tout simplement d’une Activity contenant un Fragment. Voici son code (sans les import pour gagner en visibilité) :

public final class MainActivity
  extends AppCompatActivity
{

  @Override
  protected void onCreate(Bundle savedInstanceState)
  {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    setSupportActionBar((Toolbar) findViewById(R.id.toolbar));
  }

}

et voici son layout :

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:fitsSystemWindows="true"
  tools:context=".MainActivity">

  <android.support.design.widget.AppBarLayout
    android:layout_height="wrap_content"
    android:layout_width="match_parent"
    android:theme="@style/AppTheme.AppBarOverlay">

    <android.support.v7.widget.Toolbar
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      android:background="?attr/colorPrimary"
      app:popupTheme="@style/AppTheme.PopupOverlay"/>

  </android.support.design.widget.AppBarLayout>

  <fragment
    android:id="@+id/fragment"
    android:name="fr.rolandl.blog.backgroundforeground.MainActivityFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:layout="@layout/fragment_main"
  />

</android.support.design.widget.CoordinatorLayout>

Voyons maintenant le Fragment contenu dans cette MainActivity (une fois de plus sans les import pour gagner en visibilité) :

public final class MainActivityFragment
    extends Fragment
    implements OnClickListener
{

  private Button button;

  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
  {
    final View rootView = inflater.inflate(R.layout.fragment_main, container, false);
    button = (Button) rootView.findViewById(R.id.button);
    button.setOnClickListener(this);
    return rootView;
  }

  @Override
  public void onClick(View view)
  {
    startActivity(new Intent(getActivity(), SecondActivity.class));
  }

}

et voici son layout :

<RelativeLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:paddingLeft="@dimen/activity_horizontal_margin"
  android:paddingRight="@dimen/activity_horizontal_margin"
  android:paddingTop="@dimen/activity_vertical_margin"
  android:paddingBottom="@dimen/activity_vertical_margin"
  tools:showIn="@layout/activity_main"
  tools:context=".MainActivityFragment"
>
  <Button
    android:id="@+id/button"
    android:text="Go to new Activity"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
  />
</RelativeLayout>

Comme vous pouvez le constater, cet écran ne contient qu’un bouton permettant de naviguer vers un second écran que nous allons détailler tout de suite.

Le second écran

Le second écran, à l’image du premier, est également une simple Activity contenant un Fragment :

public final class SecondActivity
    extends AppCompatActivity
{

  @Override
  protected void onCreate(Bundle savedInstanceState)
  {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_second);
    setSupportActionBar((Toolbar) findViewById(R.id.toolbar));
  }

}

Voici son layout :

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:fitsSystemWindows="true"
  tools:context=".SecondActivity">

  <android.support.design.widget.AppBarLayout
    android:layout_height="wrap_content"
    android:layout_width="match_parent"
    android:theme="@style/AppTheme.AppBarOverlay">

    <android.support.v7.widget.Toolbar
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      android:background="?attr/colorPrimary"
      app:popupTheme="@style/AppTheme.PopupOverlay"/>

  </android.support.design.widget.AppBarLayout>

  <fragment
    android:id="@+id/fragment"
    android:name="fr.rolandl.blog.backgroundforeground.SecondActivityFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:layout="@layout/fragment_second"
  />

</android.support.design.widget.CoordinatorLayout>

Voyons maintenant le Fragment contenu dans cette SecondActivity :

public final class SecondActivityFragment
    extends Fragment
{

  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
  {
    return inflater.inflate(R.layout.fragment_second, container, false);
  }

}

et voici son layout :

<RelativeLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:paddingLeft="@dimen/activity_horizontal_margin"
  android:paddingRight="@dimen/activity_horizontal_margin"
  android:paddingTop="@dimen/activity_vertical_margin"
  android:paddingBottom="@dimen/activity_vertical_margin"
  tools:showIn="@layout/activity_main"
  tools:context=".SecondActivityFragment"
>

  <TextView
    android:text="Hello !"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
  />
</RelativeLayout>

Ce second écran ne contient donc qu’un champ texte affichant le mot Hello !.

Le fichier build.gradle

Le fichier build.gradle de mon module est des plus classique. Voici son contenu

apply plugin: 'com.android.application'

android {
  compileSdkVersion 23
  buildToolsVersion "23.0.2"

  defaultConfig {
    applicationId "fr.rolandl.blog.backgroundforeground"
    minSdkVersion 14
    targetSdkVersion 23
    versionCode 1
    versionName "1.0"
  }
  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
  }
}

dependencies {
  compile fileTree(dir: 'libs', include: ['*.jar'])
  compile 'com.android.support:appcompat-v7:23.1.0'
  compile 'com.android.support:design:23.1.0'
}

Le Manifest

Le fichier AndroidManifest.xml est lui aussi des plus classique :

<?xml version="1.0" encoding="utf-8"?>
<manifest
  xmlns:android="http://schemas.android.com/apk/res/android"
  package="fr.rolandl.blog.backgroundforeground" >

  <application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/AppTheme"
  >
    <activity
      android:name=".MainActivity"
      android:label="MainActivity"
      android:theme="@style/AppTheme.NoActionBar"
    >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>

    <activity
      android:name=".SecondActivity"
      android:label="SecondActivity"
      android:theme="@style/AppTheme.NoActionBar"
    />
  </application>
</manifest>

Avant de coder… comment ça marche ?

Avant d’attaquer la mise en place de code spécifique à notre problème, je vais vous expliquer dans les grandes lignes comment nous allons procéder.

Nous allons en fait écouter le cycle de vie des écrans qui composent notre application. L’idée est de compter le nombre d’écrans qui s’ouvrent et le nombre d’écran qui se ferment. S’il y a au moins un écran ouvert, c’est que l’application est au premier plan, sinon c’est qu’elle est en arrière plan.

En réalité, ce n’est pas aussi simple que cela, mais nous allons trouver sans aucun doute des comportements de contournement et arriver à notre objectif. J’en suis certain ! ;)

Ecouter le cycle des écrans

L’interface ActivityLifecycleCallbacks

Pour écouter le cycle de vie des écrans qui composent l’application, nous allons utiliser l’interface ActivityLifecycleCallbacks disponible depuis l’API 14 d’Android. Cette interface nous permet d’avoir un point d’ancrage pour chacune des étapes qui composent le cycle de vie d’une activité, à savoir :

  • onCreate ;
  • onStart ;
  • onResume ;
  • onPause ;
  • onStop ;
  • onSaveInstanceState ;
  • onDestroy ;

J’y viens ;) !

Nous allons surcharger la classe Application de base et c’est elle qui va implémenter notre interface ActivityLifecycleCallbacks.

Surcharger l’application par défaut

Pour surcharger l’application par défaut, c’est assez simple, il convient de créer une nouvelle classe qui hérite de la classe Android Application puis d’indiquer dans le manifest que vous utilisez une application custom.

Débutons par créer le squelette de notre nouvelle classe. Dans le cadre de cet exemple, il s’agit d’une classe qui se nomme MyApplication (pas très original), qui hérite de la classe Android Application et qui implémente l’interface ActivityLifecycleCallbacks :

public final class MyApplication
  extends Application
  implements ActivityLifecycleCallbacks
{

  @Override
  public void onActivityCreated(Activity activity, Bundle savedInstanceState)
  {

  }

  @Override
  public void onActivityStarted(Activity activity)
  {

  }

  @Override
  public void onActivityResumed(Activity activity)
  {
    //Ici on compte les activités qui démarrent
  }

  @Override
  public void onActivityPaused(Activity activity)
  {
    //Ici on compte les activités qui s'arrêtent
  }

  @Override
  public void onActivityStopped(Activity activity)
  {

  }

  @Override
  public void onActivitySaveInstanceState(Activity activity, Bundle outState)
  {

  }

  @Override
  public void onActivityDestroyed(Activity activity)
  {

  }

}

Implémenter l’interface ne suffit pas, nous allons donc surcharger les méthodes onCreate et onTerminate de la classe Application pour s’abonner et se désabonner aux callbacks :

public final class MyApplication
  extends Application
  implements ActivityLifecycleCallbacks
{

  @Override
  public void onCreate()
  {
    super.onCreate();
    registerActivityLifecycleCallbacks(this);
  }

  @Override
  public void onTerminate()
  {
    super.onTerminate();
    unregisterActivityLifecycleCallbacks(this);
  }

  //...

}

Il convient maintenant de modifier notre manifest pour indiquer que l’on souhaite utiliser une application custom. C’est en réalité très simple puisqu’il suffit d’ajouter l’attribut android:name à la balise <application /> :

<?xml version="1.0" encoding="utf-8"?>
<manifest
  xmlns:android="http://schemas.android.com/apk/res/android"
  package="fr.rolandl.blog.backgroundforeground" >

  <application
    android:name=".MyApplication"
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/AppTheme"
  >
    <!-- les activités -->
  </application>
</manifest>

Compter les activités

Comme décrit plus haut dans cet article, “l’idée est de compter le nombre d’écrans qui s’ouvrent et le nombre d’écran qui se ferment. S’il y a au moins un écran ouvert, c’est que l’application est au premier plan, sinon c’est qu’elle est en arrière plan.”.

Je vous propose de mettre en place naïvement ce comportement. Nous allons donc créer un attribut qui va se contenter de compter les écrans. Pour simplifier un peu la gestion, je vais également utiliser un boolean me permettant de connaître l’état actuel de l’application à savoir foreground ou background (je coupe volontairement le code qui ne change pas pour plus de visibilité) :

public final class MyApplication
  extends Application
  implements ActivityLifecycleCallbacks
{

  private int visibilityCounter;

  private boolean isAppInForeground;

  @Override
  public void onCreate()
  {
    super.onCreate();
    registerActivityLifecycleCallbacks(this);
    visibilityCounter = 0; //pas écran de base
    isAppInForeground = false; //l'application est en background par défaut
  }

  @Override
  public void onActivityResumed(Activity activity)
  {
    visibilityCounter++;

    if (isAppInForeground == false)
    {
      isAppInForeground = true;

      //l'application vient de passer au premier plan
    }
  }

  @Override
  public void onActivityPaused(Activity activity)
  {
    visibilityCounter--;

    if (visibilityCounter <= 0)
    {
      visibilityCounter = 0;

      if (isAppInForeground == true)
      {
        isAppInForeground = false;

        //l'application vient de passer en arrière plan
      }
    }
  }

  //..

}

Malheureusement, ce code est un peu naif. En effet, lorsque je vais arriver sur mon premier écran, pas de soucis, tout va bien fonctionner. Mon application sera correctement détectée comme étant au premier plan. Cependant, les choses vont débuter à ne pas fonctionner correctement quand je vais cliquer sur le bouton pour accéder au second écran.

Nous pouvons imaginer que le premier écran va lancer le second avant de passer en pause. Aussi, notre compteur sera incrémenté avant d’être décrémenté. Malheureusement, si vous essayez le code ci-dessus, vous allez vous rendre compte que ce n’est pas le cas. Le premier écran se met en pause avant que nous passions dans la callback onActivityResumed. Le résultat est qu’à chaque changement d’écran, nous allons détecter une mise en arrière plan suivie d’une mise au premier plan ce qui n’est pas du tout ce que nous cherchons à faire…

Jouons avec les messages !

Pour corriger le problème, nous allons complètement changer de technique et regarder du côté de la classe Handler et l’envoi de messages. Je vous explique le fonctionnement tout de suite.

L’idée va être d’utiliser un Handler pour qu’à chaque fois que l’on rentre dans l’une des callbacks onActivityResumed ou onActivityPaused, un message soit envoyé avec un léger délais mais, dans chacune de ces callbacks, nous allons également supprimer l’envoi du message potentiellement envoyé dans la callback opposée.

Le but est allors de créer un centre de réception personnalisé des messages et de ne lui faire parvenir un message que lorsque l’application sera réellement dans un état background ou foreground. Le but de l’envoi d’un message différé dans une callback et d’avoir le temps d’annuler son envoi dans une autre callback si necessaire.

Je suis conscient que mes explications ne sont peut-être pas très claires. Aussi, je vous propose de les illustrer avec un peu de code.

Création d’un centre de réception personnalisé

Nous allons donc débuter par la création d’un centre de réception personnalisé pour les messages qui seront envoyés au sein de nos callbacks. Il s’agit en réalité d’une classe implémentant l’interface Handler.Callback. A noter que dans mon cas il s’agit d’une inner class à la classe MyApplication, mais ce n’est pas une obligation, c’est juste que j’aime les inner class :P !

public final class MyApplication
    extends Application
    implements ActivityLifecycleCallbacks
{

  private static final class VisibilityCallback
      implements Handler.Callback
  {

    private Context context;

    public VisibilityCallback(Context context)
    {
      this.context = context;
    }

    @Override
    public boolean handleMessage(Message msg)
    {

      return true;
    }

  }

  //...

}

L’envoi des messages

Pour envoyer les messages vers notre super centre de réception, nous allons utiliser un Handler. Pour cela, il convient de l’ajouter dans notre classe MyApplication :

public final class MyApplication
    extends Application
    implements ActivityLifecycleCallbacks
{

  private Handler visibilityHandler;

  @Override
  public void onCreate()
  {
    super.onCreate();
    registerActivityLifecycleCallbacks(this);
    visibilityHandler = new Handler(new VisibilityCallback(getApplicationContext()));
  }

  //...

}

Maintenant que nous avons notre Handler capable d’envoyer des messages à notre centre de réception, nous allons pouvoir envoyer réellement les messages !

Dans un premier temps, nous allons envoyer des messages vides, sans contenu particulier. Pour connaître la callback à l’origine du message, nous allons simplement utiliser un identifiant particulier.

Voici ce à quoi ressemble alors le code de l’envoi des messages :

public final class MyApplication
    extends Application
    implements ActivityLifecycleCallbacks
{

  //...

  private static final int APP_VISIBLE = 0;

  private static final int APP_HIDDEN = 1;

  private static final int VISIBILITY_DELAY_IN_MS = 300;

  //...


  @Override
  public void onActivityResumed(Activity activity)
  {
    //on annule l'envoi d'un précédent message
    visibilityHandler.removeMessages(MyApplication.APP_HIDDEN);

    //on envoie un nouveau message
    visibilityHandler.sendEmptyMessageDelayed(MyApplication.APP_VISIBLE, MyApplication.VISIBILITY_DELAY_IN_MS);
  }

  @Override
  public void onActivityPaused(Activity activity)
  {
    //on annule l'envoi d'un précédent message
    visibilityHandler.removeMessages(MyApplication.APP_VISIBLE);

    //on envoie un nouveau message
    visibilityHandler.sendEmptyMessageDelayed(MyApplication.APP_HIDDEN, MyApplication.VISIBILITY_DELAY_IN_MS);
  }

  //...

}

Et voyons leur réception :

public final class MyApplication
    extends Application
    implements ActivityLifecycleCallbacks
{

  private static final class VisibilityCallback
      implements Handler.Callback
  {

    private Context context;


    public VisibilityCallback(Context context)
    {
      this.context = context;
    }

    @Override
    public boolean handleMessage(Message msg)
    {
      if (msg.what == MyApplication.APP_VISIBLE)
      {
        Toast.makeText(context, "App is in foreground", Toast.LENGTH_SHORT).show();
      }
      else
      {
        Toast.makeText(context, "App is in background", Toast.LENGTH_SHORT).show();
      }

      return true;
    }

  }

  //...

}

On s’approche de notre cible, mais ce n’est pas encore ça !

Une gestion un peu plus fine

Comme je vous le disais, on s’approche de notre cible ! Mais nous allons devoir avoir une gestion un peu plus fine de ce changement de visibilité.

Pour cela, il convient que notre centre de réception connaisse, à la réception d’un message, le dernier état de l’application (background ou foreground). Si l’état du message n’est pas le même que le dernier état, alors il y a véritablement changement !

C’est parti pour l’implémentation technique ! Il convient simplement d’ajouter un attribut à notre centre de réception pour stocker le dernier état connu :

public final class MyApplication
    extends Application
    implements ActivityLifecycleCallbacks
{

  private static final class VisibilityCallback
      implements Handler.Callback
  {

    private Context context;

    private int previousVisibility;

    public VisibilityCallback(Context context)
    {
      this.context = context;
      previousVisibility = MyApplication.APP_VISIBLE;
    }

    @Override
    public boolean handleMessage(Message msg)
    {
      if (previousVisibility != msg.what)
      {
        previousVisibility = msg.what;
        if (msg.what == MyApplication.APP_VISIBLE)
        {
          Toast.makeText(context, "App is in foreground", Toast.LENGTH_SHORT).show();
        }
        else
        {
          Toast.makeText(context, "App is in background", Toast.LENGTH_SHORT).show();
        }
      }

      return true;
    }

  }


  //...

}

C’est tout ! Vous êtes maintenant correctement notifié comme en témoigne les captures d’écran suivantes :

Télécharger le projet

Le projet Android créé pour la rédaction de cet article est disponible sur Github.

Commentaires