Gestion des activités d’une application

Le projet d’exemple pour ce chapitre

Vous pouvez télécharger le projet servant d’exemple pour ce chapitre : android-demo-occupation.zip

Dans ce chapitre, nous allons voir un exemple d’application assez classique. Cette application sera l’occasion de présenter comment gérer plusieurs activités au sein d’une même application lorsque nous voulons présenter différents écrans à l’utilisateur. Nous aurons également l’occasion de découvrir comment gérer des listes d’items avec Android.

L’application de démo

À titre d’exemple, nous nous baserons sur une application de démo qui présente au démarrage une liste d’occupations (comme par exemple le bricolage, le cinéma…). Les occupations sont regroupées par catégories. Une catégorie est désignée par une icône dans l’application.

../_images/activity_liste_occupations.png

La liste des occupations

Lorsque l’utilisateur sélectionne une occupation, il peut consulter le détail ainsi que la liste des autres occupations appartenant à la même catégorie.

../_images/activity_jeuxvideo.png

Le détail d’une occupation

Pour simplifier notre exemple, la liste des occupations a été codée en dur dans l’application. Cependant, nous pourrions faire évoluer le code pour extraire cette information depuis une base de données stockée sur le mobile ou en effectuant une requête vers une API Web.

Structure de l’application

L’application fournit une classe OccupationsSingleton. Comme son nom l’indique, il s’agit d’un singleton qui permet d’obtenir la liste de toutes les occupations, la liste des occupations qui sont dans la même catégorie qu’une occupation donnée ou une occupation à partir de son identifiant.

La classe OccupationsSingleton
package dev.gayerie.occupation;

import java.util.ArrayList;
import java.util.List;

public class OccupationsSingleton {

    private static OccupationsSingleton instance = new OccupationsSingleton();

    public static OccupationsSingleton getInstance() {
        return instance;
    }

    private OccupationsSingleton() {
        // code omis
    }

    public List<Occupation> getOccupations() {
        // code omis
    }

    public Occupation getOccupation(int occupationId) {
        // code omis
    }

    public List<Occupation> getOccupationsMemeCategorie(Occupation occupation) {
        // code omis
    }
}

La classe Occupation représente bien sûr les informations sur une occupation.

La classe Occupation
package dev.gayerie.occupation;

public class Occupation {

    private int id;
    private String nom;
    private String categorie;
    private String description;

    // méthodes omises
}

L’activité principale et RecyclerView

L’activité principale est représentée par la classe MainActivity. Elle utilise un layout qui contient un objet de type RecyclerView. Un RecyclerView représente une liste d’items que l’utilisateur peut faire défilée. Le terme de recycler vient du fait que ce composant optimise l’utilisation des ressources en recyclant les composants graphiques utilisés pour afficher chaque élément de la liste. Il s’agit d’un composant évolué de l’API Android. Il repose sur le modèle MVVM (Model View ViewModel).

Le modèle MVVM (Model View ViewModel) est une variante du modèle MVC. Plutôt que de permettre au contrôleur et à la vue d’accéder au modèle, on remplace le contrôleur par le ViewModel qui fournit une interface d’accès au modèle. Il est notifié par la vue en cas de modification par l’utilisateur et il peut également notifier la vue si le modèle est modifié.

Ce modèle, bien que plus complexe dans sa conception, est plus facile à mettre en place car l’implémentation du RecyclerView prend en charge une bonne partie du code pour nous. Concrètement, nous devons fournir une implémentation du ViewModel qui se présente sous la forme d’une classe abstraite Java nommée Adapter.

Un héritage de la classe abstraite Adapter a pour principale responsabilité de fournir et de mettre à jour des objets de type ViewHolder. Ces objets permettent de gérer la représentation à l’écran de chaque élément de la liste du RecyclerView. Dans notre application, l’implémentation de cette classe abstraite est faite par la classe OccupationAdapter.

La classe OccupationAdapter
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package dev.gayerie.occupation;

import android.content.Intent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import java.util.List;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

public class OccupationAdapter extends RecyclerView.Adapter<OccupationAdapter.OccupationViewHolder> {

    public static class OccupationViewHolder extends RecyclerView.ViewHolder {
        private View content;
        private Occupation occupation;

        public OccupationViewHolder(View content) {
            super(content);
            this.content = content;
            this.content.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Intent intent = new Intent(v.getContext(), OccupationActivity.class);
                    intent.putExtra("occupationId", occupation.getId());
                    v.getContext().startActivity(intent);
                }
            });
        }

        public void setOccupation(Occupation occupation) {
            this.occupation = occupation;
            TextView textView = this.content.findViewById(R.id.nomOccupation);
            textView.setText(this.occupation.getNom());
            // On extrait l'identifiant de ressource correspondant à l'icône.
            int imageId = this.content.getResources().getIdentifier("ic_categorie_" + this.occupation.getCategorie(), "drawable", this.content.getContext().getPackageName());
            if (imageId == 0) {
                imageId = R.drawable.ic_categorie_defaut;
            }
            // On positionne l'image à gauche du libellé du texte.
            textView.setCompoundDrawablesWithIntrinsicBounds(imageId, 0, 0, 0);
        }
    }

    private List<Occupation> occupations;

    public OccupationAdapter(List<Occupation> occupations) {
        this.occupations = occupations;
    }

    @NonNull
    @Override
    public OccupationViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.occupation_view, parent, false);
        return new OccupationViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull OccupationViewHolder viewHolder, int position) {
        viewHolder.setOccupation(occupations.get(position));
    }

    @Override
    public int getItemCount() {
        return occupations.size();
    }
}

Cette classe connaît la liste des occupations qui lui est passée en paramètre de constructeur. Elle doit ensuite implémenter les méthode onCreateViewHolder, onBindViewHolder et getItemCount.

onCreateViewHolder

Cette méthode retourne une classe héritant de ViewHolder. Comme son nom l’indique, elle maintient une référence vers une View qui correspond à la représentation graphique d’un élément de la liste. Pour notre exemple, à la ligne 56, nous utilisons le LayoutInflater pour créer une View à partir d’un fichier XML de layout. Il est ainsi possible de créer n’importe quel type de layout pour afficher un élément de la liste.

onBindViewHolder

Cette méthode est appelée pour associer un ViewHolder précédemment créé à un élément de la liste. Un élément est représenté par sa position dans la liste. Ainsi le RecyclerView n’a pas besoin de connaître la type exact du modèle, il demande à l’adaptateur de s’assurer que la View référencée par le ViewHolder est bien associée à un élément particulier de la liste.

getItemCount

Retourne le nombre d’éléments dans la liste afin de permettre au RecyclerView de gérer l’affichage et le défilement des éléments à l’écran.

Pour notre implémentation, nous définissons une classe interne OccupationViewHolder des lignes 16 à 45. Cette classe maintient une référence vers le composant View d’affichage et un objet de type Occupation. La méthode setOccupation, déclarée des lignes 33 à 44, met à jour le composant View à partir des informations de l’occupation. Elle est appelée par l’adaptateur à la ligne 62 quand il lui est demandé d’assigner un élément de la liste à un ViewHolder.

Notre classe OccupationAdapter est utilisée lors de l’initialisation de l’activité principale de l’application dans la classe MainActivity.

La classe MainActivity
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package dev.gayerie.occupation;

import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        RecyclerView occupationsView = findViewById(R.id.occupationsView);
        occupationsView.setLayoutManager(new LinearLayoutManager(this));
        occupationsView.setAdapter(new OccupationAdapter(OccupationsSingleton.getInstance().getOccupations()));
    }

}

Cette classe est assez simple. Elle redéfinit la méthode onCreate afin de positionner un layout à partir d’un fichier XML de ressource. Ce layout contient une RecyclerView pour laquelle on spécifie son propre layout (pour afficher les éléments les uns sous les autres) et l’adaptateur à utiliser pour obtenir les objets de type ViewHolder (ligne 17).

Note

La RecyclerView est un composant fourni par Android JetPack, elle remplace le composant ListView introduit par les anciennes versions de l’API et qui est maintenant déprécié.

Pour être utilisée dans votre projet, vous devez déclarer une dépendance avec la RecyclerView dans le fichier build.gradle du module utilisant ce composant :

dependencies {
  // ...

  implementation 'androidx.recyclerview:recyclerview:1.1.0'

  // ...
}

Astuce

Le modèle MVVM permet des implémentations plus élaborées que notre exemple. Dans notre application de démo, la liste des occupations est figée mais nous pourrions imaginer que des éléments puissent être ajoutés, modifiés ou supprimés de la liste. Dans ce cas, la classe héritant de Adapter peut prévenir la vue de ces changements grâce à des méthodes de notification comme notifyItemInserted, notifyItemChanged ou notifyItemRemoved. Le RecyclerView est à l’écoute de ces notifications et se rafraîchit automatiquement.

Pour plus d’information, vous pouvez consulter la documentation officielle sur les layouts.

Détail d’une occupation et pile des activités

Pour afficher une activité présentant le détail d’une occupation, il faut capter l’événement de clic dans la liste mais il faut surtout ouvrir une seconde activité depuis l’activité principale. Le système Android gère les activités comme appartenant à une tâche (task). Une tâche peut être définie comme un ensemble cohérent d’activités qui permet de réaliser une macro fonctionnalité. Dans notre exemple, la tâche représente la consultation des occupations.

Note

Dans ce chapitre, nous ne détaillerons pas le mécanisme de gestion des tâches et nous nous limiterons à une application définissant une seule tâche.

Pour plus d’information, reportez-vous à la documentation officielle sur les tâches.

Au sein d’une tâche, le système gère les activités sous la forme d’une pile. Une nouvelle activité s’empile sur la précédente. Nous pouvons l’illustrer comme ci-dessous :

../_images/activity_stack_2.png

La gestion des activités sous la forme d’une pile

Dans l’illustration ci-dessus, à gauche, nous avons l’application au démarrage avec la pile des activités qui ne contient que l’activité principale. À droite, quand l’utilisateur choisit un élément dans la liste, une activité de détail est créée qui s’empile sur l’activité présentant la liste des occupations. Notez que l’activité présentant la liste est mise en pause puisqu’elle n’est plus visible de l’utilisateur.

Si l’utilisateur presse le bouton de retour en arrière, cela lui permet de revenir à la liste des occupations. L’activité de détail est dépilée et détruite par le système. Si l’utilisateur presse le bouton de retour en arrière alors qu’il ne reste qu’une seule activité dans la pile, cela amène simplement l’application en arrière plan.

Ouverture d’une activité (intent explicite)

Nous avons vu au chapitre d’introduction aux activités qu’il est possible d’émettre un intent pour déclencher l’ouverture d’une activité. Lorsque l’on veut réaliser l’affichage d’une activité interne à l’application, on utilise un intent explicite, c’est-à-dire que l’on précise le nom de la classe de l’activité. Il est également possible de passer des paramètres à l’intent (que l’on appelle des extras). Si l’activité qui affiche le détail d’une occupation s’appelle OccupationActivity, alors on peut ouvrir l’activité avec un code comme celui-ci :

Utilisation d’un intent explicite pour ouvrir une activité
Intent intent = new Intent(context, OccupationActivity.class);
intent.putExtra("occupationId", occupationId);
context.startActivity(intent);

Dans le code ci-dessus, la variable context correspond au contexte d’exécution de l’application disponible pour chaque composant graphique et la variable occupationId contient l’identifiant de l’occupation à afficher. À charge du code dans la classe OccupationActivity d’analyser l’intent pour extraire l’identifiant de l’occupation et de récupérer sa représentation objet pour afficher les informations.

Pour l’activité de présentation d’une occupation, le code est le suivant :

La classe OccupationActivity
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package dev.gayerie.occupation;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.content.Intent;
import android.os.Bundle;
import android.widget.TextView;

public class OccupationActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_occupation);

        Intent intent = getIntent();
        int occupationId = intent.getIntExtra("occupationId", 0);

        Occupation occupation = OccupationsSingleton.getInstance().getOccupation(occupationId);

        TextView nomView = findViewById(R.id.nomOccupation);
        nomView.setText(occupation.getNom());

        TextView descriptionView = findViewById(R.id.descriptionOccupation);
        descriptionView.setText(occupation.getDescription());

        RecyclerView occupationsView = findViewById(R.id.memeCategorieRecyclerView);
        occupationsView.setLayoutManager(new LinearLayoutManager(this));
        occupationsView.setAdapter(new OccupationAdapter(OccupationsSingleton.getInstance().getOccupationsMemeCategorie(occupation)));
    }
}

À la ligne 18, on récupère l’intent qui a déclenché l’ouverture de l’activité et on extrait l’id de l’occupation en tant qu’extra. Ensuite, ligne 21, on récupère l’objet Occupation grâce au singleton. Le reste du code consiste à remplir l’interface graphique avec les informations de l’occupation en utilisant les identifiants des composants définis dans le layout de l’activité.

Note

Pour notre application, la création et l’envoi de l’intent doit se faire lorsque l’utilisateur clique sur un élément de la liste. Ainsi dans la classe OccupationViewHolder présentée ci-dessus, nous enregistrons un listener pour réaliser ce traitement :

20
21
22
23
24
25
26
27
28
29
30
31
public OccupationViewHolder(View content) {
    super(content);
    this.content = content;
    this.content.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Intent intent = new Intent(v.getContext(), OccupationActivity.class);
            intent.putExtra("occupationId", occupation.getId());
            v.getContext().startActivity(intent);
        }
    });
}

Stratégie de gestion de la pile des activités

Dans notre application, il se trouve que l’activité présentant le détail d’une occupation affiche la liste des occupations de la même catégorie. Que se passe-t-il si l’utilisateur clique sur un élément de cette liste ? Dans notre code, la classe OccupationActivity utilise un RecyclerView auquel elle passe le même adaptateur que pour la liste principale. La seule différence est que l’adaptateur reçoit en paramètre de construction uniquement la liste des occupations de la même catégorie. Donc il est possible de cliquer sur un élément de cette liste pour ouvrir une activité de détail. Dans ce cas, le système continue d’empiler les activités les unes sur les autres comme illustré ci-dessous :

../_images/activity_stack_3.png

Empilement successif des activités de l’application

Cela signifie que l’utilisateur devra presser plusieurs fois le bouton de retour en arrière pour revenir à la liste principale. S’il s’agit du comportement attendu de l’application alors il n’est pas nécessaire de produire du code supplémentaire car il s’agit effectivement du comportement par défaut pour la gestion de la pile des activités.

Cependant, si nécessaire, il est possible de changer ce comportement. Imaginons que nous souhaitions ouvrir le détail d’une occupation que si l’utilisateur est en train de consulter la liste générale. S’il est déjà en train de consulter le détail d’une occupation, on veut simplement rafraîchir l’activité avec les nouvelles données. Dans ce cas, nous aurons une application qui fonctionnera plutôt comme l’illustration ci-dessous :

../_images/activity_stack_3_with_singletop.png

Empilement successif sans répétition de l’activité de détail

Dans cette situation, l’utilisateur clique sur une occupation dans la liste globale pour consulter le détail. Puis, quand il clique sur une occupation appartenant à la même catégorie depuis cet écran de détail, alors l’activité est simplement mise à jour avec les nouvelles données. Du point de vue de l’utilisateur, ce qui change vraiment, c’est le comportement de l’application lorsqu’il cliquera sur le bouton de retour en arrière. Peu importe le nombre d’occupations qu’il aura consulté, il sera directement ramené sur la liste générale.

Pour réaliser ce comportement, il faut considérer que l’activité de détail d’une occupation doit être démarrée en mode single top. Cela signifie que, si une telle activité n’est pas en haut de la pile, elle doit être créée, sinon l’activité présente doit être réutilisée.

Nous pouvons déclarer une activité single top de deux manières différentes. Soit nous pouvons préciser le mode grâce à l’attribut launchMode dans le manifeste de l’application :

Déclaration du mode singletop dans le manifeste de l’application
<activity android:name=".OccupationActivity" android:launchMode="singleTop">
</activity>

Ainsi nous sommes certain qu’une activité de ce type sera toujours single top. Soit, nous pouvons le faire de manière programmatique en ajoutant un flag à l’intent qui va servir à démarrer l’activité :

Ajout d’un flag à l’intent pour demander le mode single top
Intent intent = new Intent(context, OccupationActivity.class);
intent.putExtra("occupationId", occupationId);
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(intent);

Pour que notre application fonctionne correctement, nous somme obligés de revoir l’implémentation de la classe OccupationActivity. En effet le mode single top implique que, si l’activité est déjà au sommet de la pile, elle n’est pas recrée. Le système se contente alors d’appeler la méthode onNewIntent.

Évolution de la classe OccupationActivity pour le support du single top
package dev.gayerie.occupation;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.content.Intent;
import android.os.Bundle;
import android.widget.TextView;

public class OccupationActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_occupation);

        onNewIntent(getIntent());
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        int occupationId = intent.getIntExtra("occupationId", 0);

        Occupation occupation = OccupationsSingleton.getInstance().getOccupation(occupationId);

        TextView nomView = findViewById(R.id.nomOccupation);
        nomView.setText(occupation.getNom());

        TextView descriptionView = findViewById(R.id.descriptionOccupation);
        descriptionView.setText(occupation.getDescription());

        RecyclerView occupationsView = findViewById(R.id.memeCategorieRecyclerView);
        occupationsView.setLayoutManager(new LinearLayoutManager(this));
        occupationsView.setAdapter(new OccupationAdapter(OccupationsSingleton.getInstance().getOccupationsMemeCategorie(occupation)));
    }
}

Note

Il existe d’autres stratégies pour altérer le comportement par défaut de la pile des activités :

Single instance

Ce mode permet de s’assurer qu’il n’existe qu’une seule instance d’une activité dans la tâche courante de l’application. Si cette instance est déjà dans la pile, alors un nouveau lancement de cette activité a pour conséquence de dépiler et de détruire toutes les activités empilées au dessus d’elle. Ce mode agit comme un retour rapide en arrière.

Single task

Ce mode garantit qu’il n’existe qu’une seule activité de ce type dans tout le système. Il s’agit d’un usage particulier permettant de définir une activité qui se comporte comme un véritable singleton.

Pour plus d’information, reportez-vous à la documentation officielle.

Terminer une activité

Parfois une activité déclenche l’ouverture d’une autre activité et cette dernière doit se substituer à la précédente. Imaginons une activité représentant l’écran d’accueil de notre application. Cette activité, après la phase d’initialisation, va déclencher l’ouverture d’une nouvelle activité mais on ne souhaite pas empiler les activités l’une sur l’autre. En effet, on ne souhaite pas que l’utilisateur, en appuyant sur le bouton de retour en arrière, revienne sur l’écran d’accueil. Dans ce cas, il suffit d’appeler la méthode finish dans l’activité représentant l’écran d’accueil, juste après l’appel à startActivity.

Appel à la méthode finish depuis une activité
Intent intent = new Intent(this, MaNouvelleActivity.class);
this.startActivity(intent);
this.finish();

De manière générale, la méthode finish peut être appelée lorsqu’on veut fermer une activité par programmation.