Structurer l'application : écrire un contrôleur

Je ne vais pas vous réexpliquer ce qu'est le modèle MVC, qui vise à séparer la logique applicative de la présentation, et à orchestrer le fonctionnement de l'application à un seul endroit.

Pour résumer, le contrôleur a pour tâche d'appeler les modules nécessaires, puis à de déclencher l'affichage ou la génération de sorties (documents, fichiers, exports...). En gros, toute demande de tâche passe par le contrôleur (pas d'appel direct depuis une classe vers une autre classe), et c'est le contrôleur qui décide ou non, en fonction de contraintes qui lui sont propres, de répondre à cette demande.

En Java, pour respecter cette notion de "couplage faible", les classes n'ont pas à maîtriser la nature des classes appelantes. Elles vont simplement savoir à qui elles doivent annoncer qu'elles ont un "besoin", en lançant un appel. Cela se fait par le mécanisme "d'observateur".

Une classe qui doit être observée doit hériter de la classe Observable. Voici, par exemple, la classe gérant le menu d'une fenêtre :

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.util.Observable;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.KeyStroke;
/**
 * Menu général
 * @author quinton
 *
 */
public class MenuMain extends Observable{
    JMenuBar menuBar;
    Object obj;
    /**
     * Constructeur
     */
    public MenuMain() {
        obj = this;
        menuBar = new JMenuBar();
        JMenu mnFichier = new JMenu("Fichier");
        menuBar.add(mnFichier);
        mnFichier.setMnemonic('F');
        JMenuItem mntmQuitter = new JMenuItem("Quitter");
        mntmQuitter.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Q, InputEvent.CTRL_MASK));
        mntmQuitter.setMnemonic('Q');
        mnFichier.add(mntmQuitter);
        mntmQuitter.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent arg0) {
                ((MenuMain) obj).setAction("quitter");
            }
        });
    }
    /**
     * Déclenchement de l'action sur choix dans le menu
     * @param action
     */
    public void setAction(String action) {
        setChanged();
        notifyObservers(action);
    }
    /**
     * Retourne le menu
     * @return JMenuBar
     */
    public JMenuBar getMenu() {
        return menuBar;
    }
}

La partie la plus importante pour notre contrôleur est la fonction setAction. Elle contient deux commandes :

  • setChanged() : indique que le composant a changé
  • notifyObservers(action) : envoie à toutes les classes qui l'observent une information comme quoi une action a été déclenchée.

La fenêtre, qui implémente notre menu, va donc devoir écouter le menu pour savoir quand celui-ci sera sollicité par l'utilisateur. Dans l'exemple suivant, ce n'est pas la fenêtre qui écoute directement, mais une instance de classe qui contient la fenêtre (pour des questions d'héritage multiples non gérables en Java) :

public class Fenetre extends Observable implements Observer {
    JFrame fenetre;
    MenuMain menu;
    public Fenetre() {
        obj = this;
        fenetre = new JFrame();
        menu = new MenuMain();
        fenetre.setJMenuBar(menu.getMenu());
        menu.addObserver((Observer) obj);
        [...]
    }

Dans cette première partie du code, c'est implements Observer et menu.addObserver(...) qui permettent de gérer le fait que la fenêtre écoute les événements déclenchés par le menu. Tous les composants intégrés à notre fenêtre, qui doivent déclencher des actions qui ne les concernent pas directement, devraient être gérés de cette manière.

La classe Fenetre hérite de Observable pour pouvoir, elle-même, retransmettre les demandes à son objet parent :

    /**
     * Récupération des événements transmis
     */
    public void update(Observable arg0, Object pAction) {
        setAction(pAction);
    }
    /**
     * Notifie qu'une action est demandée (au contrôleur)
     *
     * @param pAction
     */
    public void setAction(Object pAction) {
        setChanged();
        notifyObservers(pAction);
    }

La fonction setAction est identique à celle utilisé pour la classe Menu.

Voyons maintenant comment se comporte le contrôleur :

public class Controleur implements Observer {
    Fenetre fenetre;
    public Controleur() {
        [...]
        fenetre.addObserver(this);
        defaultAction();
    }

Notre contrôleur est maintenant à l'écoute de la classe Fenetre. defaultAction() est la première opération réalisée au lancement de l'application. Maintenant, voyons comment notre contrôleur va gérer les actions demandées :

    /**
     * Récupération des événements transmis au controleur
     */
    public void update(Observable arg0, Object pCommande) {
String commande = (String) pCommande;
switch (commande) {
        case "quitter":
            exit();
            break;
        default:
            ecranVierge();
        }
    }

Pour le moment, notre contrôleur a compris ce qu'il devait faire. Mais cela peut être insuffisant : il peut avoir besoin de récupérer des paramètres. Nous rajoutons donc, dans la fonction update, un mécanisme de récupération d'un objet depuis l'objet observé :

try {
            Object data = ((DisplayObject) arg0).getValue(commande);
        } catch (Exception e) {
        Object data = null;
        }

Le même mécanisme peut être implémenté dans les objets fils intermédiaires, par exemple dans Fenetre :

public class Fenetre extends Observable implements Observer {
    Observable objetObserve;
    public Object getValue1(String commande) {
        try {
        return ((Fenetre) objetObserve).getValue(commande);
        } catch (Exception e) {
            return null;
        }
    }

Ainsi, il est possible de récupérer des informations depuis les objets fils et de les remonter jusqu'au contrôleur.

Il reste la dernière partie à traiter : comment redescendre des informations aux classes inférieures ? Deux mécanismes sont possibles. Soit l'information est destinée à un objet connu de la classe inférieure : il suffit d'appeler la fonction suivante (fonction déclarée dans la classe fille) :

    public void setValue(String pIdentifiant, Object pObjet) {
        if (pIdentifiant == "action") {
            [...]
        }
    }

Dans le second cas, l'appel à la fonction setValue est réalisé vers l'objet observé (ici, dans la classe Fenetre) :

public void setValue(Object pObjet) {
   try {
              ((Fenetre)objetObserve).setValue (pObjet);
   } catch (Exception e) { }
    }

Ainsi, nous disposons d'un endroit unique, le contrôleur, qui récupère les actions demandées, les objets à transférer, déclenche les opérations à réaliser, et renvoie les informations.

Je n'ai pas traité jusqu'ici l'affichage de différents composants dans la fenêtre. Je l'ai réalisé avec la fonction suivante dans Fenetre :

/**
     * Ajoute un JPanel en précisant la position, et s'il faut ou non
     * réinitialiser la fenêtre
     *
     * @param pPanel
     * @param pPosition
     * @param pReset
     */
    public void setPanel(JPanel pPanel, String pPosition, boolean pReset) {
        if (pReset)
            reset();
        fenetre.getContentPane().add(pPanel, pPosition);
        //fenetre.getContentPane().addComponentListener(this);
        fenetre.repaint();
        fenetre.validate();
    }

Un composant à afficher prend alors le code suivant (ici, quelque chose de très simple, l'affichage d'une image) :

public class EcranVierge extends DisplayObject {
    public EcranVierge(Fenetre pFenetre) {
        super.setFenetre(pFenetre);
        JPanel pane = new JPanel();
        pane.setLayout(new BorderLayout());
        ImageIcon image = new ImageIcon("images/monImage.jpg");
        JLabel label = new JLabel();
        label.setIcon(image);
        label.setHorizontalAlignment(JLabel.CENTER);
        pane.add(label, BorderLayout.CENTER);
        /*
         * Declenche l'affichage
         */
        fenetre.setPanel(pane, BorderLayout.CENTER, true);
    }
}

Et le code correspondant dans le contrôleur :

public void ecranVierge() {
        new EcranVierge(fenetre);
    }

Grâce à ce mécanisme, nous avons bien isolé le traitement des opérations de l'affichage, et tout est géré depuis un point unique, le contrôleur. Modifier l'ordre d'exécution des modules revient à modifier l'ordre d'appel des fonctions dans le contrôleur. Il est alors aisé de rajouter des conditions de déclenchement, voire une gestion des droits, en ne manipulant que le contrôleur.