Blog technique sur mes expériences de développeur.
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
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 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>
Je vous accorde le fait que ce layout aurait pu être 100 fois plus simple… Mais par fainéantise j’ai laissé un gros morceau du layout généré automatiquement par Android Studio.
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, à 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
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 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 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 ! ;)
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
;Mais qui va implémenter cette interface ?
J’y viens ;) !
Nous allons surcharger la classe Application
de base et c’est elle qui va implémenter notre interface ActivityLifecycleCallbacks
.
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>
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…
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.
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;
}
}
//...
}
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 !
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;
}
}
//...
}
Avec le code actuel, je ne serai pas notifié lors du tout premier lancement de l’application. Si vous souhaitez être notifié, il convient simplement d’initialiser la variable previousVisibility
avec la valeur 1, soit la valeur de la constante MyApplication.APP_HIDDEN
.
C’est tout ! Vous êtes maintenant correctement notifié comme en témoigne les captures d’écran suivantes :
Le projet Android créé pour la rédaction de cet article est disponible sur Github.