I. Introduction

Pour programmer une application correctement, il est indispensable de structurer son application en suivant le design pattern Model-View-Controller. Le modèle doit être indépendant du reste de l'application. Dans ce cas, comment la vue peut-elle prendre en compte les modifications effectuées sur le modèle, puisque le modèle ne connaît pas l'interface graphique, et ne peut donc pas lui envoyer de messages (effectuer des appels de méthodes) ? Le problème est d'ailleurs plus général : comment un objet peut-il écouter un autre objet ? Ce problème est résolu par le design pattern Observer.

Si vous avez déjà programmé en Swing par exemple, vous avez déjà utilisé ActionListener : cela permet d'être averti qu'un bouton a été cliqué. Dans ce cas, le modèle est "votre clavier et votre souris" et la vue est votre interface graphique.

II. Exemple

Supposons une classe faisant partie du modèle représentant la température et la pression atmosphérique.

 
Sélectionnez

public class Meteo {
 
    private double temperature;
    private double pression;
 
    public Meteo(double temperature, double pression) {
    	this.temperature = temperature;
    	this.pression = pression;
    }
 
    public double getTemperature() {
        return temperature;
    }
 
    public double getPression() {
    	return pression;
    }
 
    public void setTemperature(double temperature) {
    	this.temperature = temperature;
    }
 
    public void setPression(double pression) {
    	this.pression = pression;
    }
 
}

Les valeurs de temperature et de pression doivent pouvoir être affichées dans une ou plusieurs interfaces graphiques. Le problème, c'est que dès qu'une valeur change, les interfaces graphiques doivent mettre à jour leur affichage.

III. Mauvaise approche

Lorsque l'on ne connait pas du tout le design pattern Observer, une idée qui peut venir à l'esprit est de lancer un thread qui vérifie toutes les x millisecondes que la variable écoutée n'a pas été modifiée. Cette méthode est vraiment mauvaise, car d'une part elle consomme en ressources (attente active), et d'autre part il faut ajouter de la synchronisation pour accéder à la variable écoutée. De plus, le thread écouteur ne sera pas au courant aussitôt, mais éventuellement x millisecondes plus tard, durée pendant laquelle un autre changement de valeur a eu lieu, ce qui peut, dans certains cas, poser problème.

IV. Bonne approche

IV-A. Principe

L'idée est de donner la possibilité au modèle d'annoncer à l'interface graphique que quelque chose d'écouté a changé. Dans l'exemple, comme le modèle ne connait pas la vue, il n'est pas question dans la méthode setTemperature de faire quelque chose comme :

 
Sélectionnez

monInterfaceGraphique.temperatureChangee(ancienneTemperature, nouvelleTemperature);

L'idée est là, mais l'implantation doit être différente pour respecter le Model-View-Controller.

Il faut en fait donner la possibilité à la vue de s'ajouter en tant qu'écouteur auprès du modèle. C'est ce que vous faites en Swing quand vous écrivez :

 
Sélectionnez

ActionListener monActionListener = new ActionListener() {
    public void actionPerformed(ActionEvent e) {
        System.out.println("bouton cliqué");
    }
};
monButton.addActionListener(monActionListener);

Pour cela, vous devez donc définir une interface pour chaque écouté, qui décrit les actions qui peuvent être signalées. Dans notre exemple :

 
Sélectionnez

public interface TemperatureListener {
    void temperatureChangee(double ancienneTemperature, double nouvelleTemperature);
    void temperatureAugmentee(double ancienneTemperature, double nouvelleTemperature);
    void temperatureDiminuee(double ancienneTemperature, double nouvelleTemperature);
}
 
Sélectionnez

public interface PressionListener {
    void pressionChangee(double anciennePression, double nouvellePression);
    void pressionAugmentee(double anciennePression, double nouvellePression);
    void pressionDiminuee(double anciennePression, double nouvellePression);
}

Avec ces interfaces, nous remarquons que des actions différentes (pas forcément indépendantes) peuvent être définies. Ainsi, lorsque la température augmentera, les méthodes temperatureChange et temperatureAugmentee seront appelées. Dans le cas de cet exemple simpliste, je vous accorde que cela ne présente que peu d'intérêt.

Toute classe qui veut écouter un changement de température doit tout d'abord implémenter TemperatureListener, et toute classe qui veut écouter un changement de pression doit implémenter PressionListener.

Une classe pouvant implémenter plusieurs interfaces, il est possible de définir une classe qui écoute à la fois les changements de température et les changements de pression.

Une classe qui veut écouter les changements de température va pouvoir s'enregistrer auprès du modèle (en tant que TemperatureListener). Le modèle doit donc avoir la possibilité de garder en mémoire une liste des écouteurs enregistrés pour chaque écouté, ainsi que des méthodes public pour en ajouter et pour en retirer (et éventuellement une pour les lister), et des méthodes protected (commençant généralement par fire) pour signaler aux écouteurs un changement.

Ainsi, pour écouter les changements de pression et de température, il suffit de faire ceci :

 
Sélectionnez

maMeteo.addTemperatureListener(maClasseImplementantTemperatureListener);
maMeteo.addPressionListener(maClasseImplementantPressionListener);

IV-B. Stockage

IV-B-1. Collections

La première façon de faire, c'est de simplement stocker ces écouteurs dans une Collection.

 
Sélectionnez

public class Meteo {
 
    private double temperature;
    private double pression;
 
    private final Collection<TemperatureListener> temperatureListeners = new ArrayList<TemperatureListener>();
    private final Collection<PressionListener> pressionListeners = new ArrayList<PressionListener>();
 
    public Meteo(double temperature, double pression) {
        this.temperature = temperature;
        this.pression = pression;
    }
 
    public double getTemperature() {
        return temperature;
    }
 
    public double getPression() {
        return pression;
    }
 
    public void setTemperature(double temperature) {
        double oldTemperature = this.temperature;
        this.temperature = temperature;
        fireTemperatureChanged(oldTemperature, temperature);
    }
 
    public void setPression(double pression) {
        double oldPression = this.pression;
        this.pression = pression;
        firePressionChanged(oldPression, pression);
    }
 
    public void addTemperatureListener(TemperatureListener listener) {
        temperatureListeners.add(listener);
    }
 
    public void removeTemperatureListener(TemperatureListener listener) {
        temperatureListeners.remove(listener);
    }
 
    public TemperatureListener[] getTemperatureListeners() {
        return temperatureListeners.toArray(new TemperatureListener[0]);
    }
 
    public void addPressionListener(PressionListener listener) {
        pressionListeners.add(listener);
    }
 
    public void removePressionListener(PressionListener listener) {
        pressionListeners.remove(listener);
    }
 
    public PressionListener[] getPressionListeners() {
        return pressionListeners.toArray(new PressionListener[0]);
    }
 
    protected void fireTemperatureChanged(double oldTemperature, double newTemperature) {
        if(newTemperature > oldTemperature) {
            for(TemperatureListener listener : temperatureListeners) {
                listener.temperatureChangee(oldTemperature, newTemperature);
                listener.temperatureAugmentee(oldTemperature, newTemperature);
            }
        } else if(newTemperature < oldTemperature) {
            for(TemperatureListener listener : temperatureListeners) {
                listener.temperatureChangee(oldTemperature, newTemperature);
                listener.temperatureDiminuee(oldTemperature, newTemperature);
            }
        }
    }
 
    protected void firePressionChanged(double oldPression, double newPression) {
        if(newPression > oldPression) {
            for(PressionListener listener : pressionListeners) {
                listener.pressionChangee(oldPression, newPression);
                listener.pressionAugmentee(oldPression, newPression);
            }
        } else if(newPression < oldPression) {
            for(PressionListener listener : pressionListeners) {
                listener.pressionChangee(oldPression, newPression);
                listener.pressionDiminuee(oldPression, newPression);
            }
        }
    }
 
}

Cette manière de faire a déjà un inconvénient visible au premier coup d'œil : il faut autant de Collections de listeners que de types d'écouteurs.

Mais des inconvénients plus génants apparaissent dès que l'on utilise plusieurs threads s'enregistrant et écoutant des évènements sur le modèle. Et là c'est nettement plus compliqué. Avoir une collection synchronisée par Collections.synchronizedCollection(temperatureListeners); ne suffit pas à résoudre le problème, car le parcours par un Iterator lors du fireXXX(...) n'est pas en exclusion mutuelle avec d'éventuels ajouts ou suppressions. Les problèmes de synchronisation étant loins d'être triviaux, je vous conseille de ne pas utiliser les Collections (du moins de cette manière) pour gérer vos écouteurs.

IV-B-2. EventListenerList

Pour éviter les problèmes posés par l'utilisation des Collections que l'on vient de voir, nous pouvons utiliser la classe javax.swing.event.EventListenerlist.

Avant tout, il faut que les interfaces indiquant les actions qui peuvent être signalées étendent java.util.EventListener.

 
Sélectionnez

public interface TemperatureListener extends EventListener {
    void temperatureChangee(double ancienneTemperature, double nouvelleTemperature);
    void temperatureAugmentee(double ancienneTemperature, double nouvelleTemperature);
    void temperatureDiminuee(double ancienneTemperature, double nouvelleTemperature);
}
 
Sélectionnez

public interface PressionListener extends EventListener {
    void pressionChangee(double anciennePression, double nouvellePression);
    void pressionAugmentee(double anciennePression, double nouvellePression);
    void pressionDiminuee(double anciennePression, double nouvellePression);
}

Voici comment utiliser EventListenerList pour la classe Meteo :

 
Sélectionnez

public class Meteo {
 
    private double temperature;
    private double pression;
 
    // un seul objet pour tous les types d'écouteurs
    private final EventListenerList listeners = new EventListenerList();
 
    public Meteo(double temperature, double pression) {
        this.temperature = temperature;
        this.pression = pression;
    }
 
    public double getTemperature() {
        return temperature;
    }
 
    public double getPression() {
        return pression;
    }
 
    public void setTemperature(double temperature) {
        double oldTemperature = this.temperature;
        this.temperature = temperature;
        fireTemperatureChanged(oldTemperature, temperature);
    }
 
    public void setPression(double pression) {
        double oldPression = this.pression;
        this.pression = pression;
        firePressionChanged(oldPression, pression);
    }
 
    public void addTemperatureListener(TemperatureListener listener) {
        listeners.add(TemperatureListener.class, listener);
    }
 
    public void removeTemperatureListener(TemperatureListener listener) {
        listeners.remove(TemperatureListener.class, listener);
    }
 
    public TemperatureListener[] getTemperatureListeners() {
        return listeners.getListeners(TemperatureListener.class);
    }
 
    public void addPressionListener(PressionListener listener) {
        listeners.add(PressionListener.class, listener);
    }
 
    public void removePressionListener(PressionListener listener) {
        listeners.remove(PressionListener.class, listener);
    }
 
    public PressionListener[] getPressionListeners() {
        return listeners.getListeners(PressionListener.class);
    }
 
    protected void fireTemperatureChanged(double oldTemperature, double newTemperature) {
        if(newTemperature > oldTemperature) {
            for(TemperatureListener listener : getTemperatureListeners()) {
                listener.temperatureChangee(oldTemperature, newTemperature);
                listener.temperatureAugmentee(oldTemperature, newTemperature);
            }
        } else if(newTemperature < oldTemperature) {
            for(TemperatureListener listener : getTemperatureListeners()) {
                listener.temperatureChangee(oldTemperature, newTemperature);
                listener.temperatureDiminuee(oldTemperature, newTemperature);
            }
        }
    }
 
    protected void firePressionChanged(double oldPression, double newPression) {
        if(newPression > oldPression) {
            for(PressionListener listener : getPressionListeners()) {
                listener.pressionChangee(oldPression, newPression);
                listener.pressionAugmentee(oldPression, newPression);
            }
        } else if(newPression < oldPression) {
            for(PressionListener listener : getPressionListeners()) {
                listener.pressionChangee(oldPression, newPression);
                listener.pressionDiminuee(oldPression, newPression);
            }
        }
    }
 
}

A priori, en voyant le code, ça ne change pas grand chose. Et pourtant...

EventListenerList résoud le problème de la synchronisation. Les méthodes d'ajout et de suppression d'écouteurs sont synchronisées, et ne modifient pas la liste (tableau) des écouteurs, mais en créent un nouveau. Ainsi, plus besoin de synchroniser le parcours, car il s'effectuera sur un tableau qui ne sera jamais modifié (facilité de programmation, gain de temps à l'exécution). EventListenerList est donc sûr du point de vue de la synchronisation multi-threads.

En réalité, pour le moment (même dans Java SE 6 actuel), EventListenerList est buguée, et dans des cas extrêmement rare, la synchronisation n'est pas fiable (plus d'informations). J'espère qu'elle sera corrigée rapidement.

Un petit inconvénient théorique de cette classe, c'est qu'elle se trouve dans le package javax.swing.event, alors qu'elle n'est pas forcément liée à Swing on peut très bien faire un objet qui en écoute un autre, alors que l'on n'a même pas d'interface graphique. Cependant, cela n'est pas génant en pratique, Swing étant présente dans tous les JDK.

IV-C. Paramètres des méthodes d'évènements

Une petite remarque concernant le code des méthodes fireXXX() . Dans notre exemple, nous avons passé des paramètres de type double aux méthodes déclarées dans les interfaces. Il arrive cependant souvent de vouloir passer un objet spécifique, dont le nom se termine généralement par Event. Dans notre exemple, cela donnerait :

 
Sélectionnez

public class TemperatureEvent {
 
    private double oldTemperature;
    private double newTemperature;
 
    TemperatureEvent(double oldTemperature, double newTemperature) {
        this.oldTemperature = oldTemperature;
        this.newTemperature = newTemperature;
    }
 
    public double getOldTemperature() {
        return oldTemperature;
    }
 
    public double getNewTemperature() {
        return newTemperature;
    }
 
}
 
Sélectionnez

public interface TemperatureListener {
    void temperatureChangee(TemperatureEvent e);
    void temperatureAugmentee(TemperatureEvent e);
    void temperatureDiminuee(TemperatureEvent e);
}

Pour la classe Meteo, seule la méthode fireTemperatureChanged est modifiée :

 
Sélectionnez

protected void fireTemperatureChanged(double oldTemperature, double newTemperature) {
    TemperatureEvent event = null;
    if(newTemperature > oldTemperature) {
        for(TemperatureListener listener : getTemperatureListeners()) {
            if(event == null)
                event = new TemperatureEvent(oldTemperature, newTemperature);
            listener.temperatureChangee(event);
            listener.temperatureAugmentee(event);
        }
    } else if(newTemperature < oldTemperature) {
        for(TemperatureListener listener : getTemperatureListeners()) {
            if(event == null)
                event = new TemperatureEvent(oldTemperature, newTemperature);
            listener.temperatureChangee(event);
            listener.temperatureDiminuee(event);
        }
    }
}

Ce qui peut surprendre au premier abord, c'est de tester si event est null à chaque tour de boucle pour le créer lors de la première itération. Faire cela permet de ne pas créer un objet TemperatureEvent qui ne servirait à rien si aucun écouteur n'est enregistré, qui est plus coûteux que quelques tests d'égalité à null.

IV-D. Adapters

Pour chaque interface représentant un listener qui possède plusieurs méthodes, il est conseillé de créer une classe abstraite appelée adapter, qui implémente l'interface et qui définit toutes ses méthodes avec un corps vide. Par exemple, créons un adapter pour notre interface TemperatureListener :

 
Sélectionnez

public abstract class TemperatureAdapter implements TemperatureListener {
    public void temperatureChangee(double ancienneTemperature, double nouvelleTemperature) {}
    public void temperatureAugmentee(double ancienneTemperature, double nouvelleTemperature) {}
    public void temperatureDiminuee(double ancienneTemperature, double nouvelleTemperature) {}
}

Ainsi, lorsqu'un écouteur voudra simplement écouter l'action temperatureChangee, il n'aura qu'à redéfinir cette méthode, sans donner un corps vide pour toutes les autres de l'interface.

Attention, lorsque vous utilisez un adapter, si vous ne faites pas attention et que vous ne redéfinissez pas une méthode de l'interface, vous n'aurez aucun problème de compilation. Par exemple, vous écrivez ceci :

 
Sélectionnez

TemperatureListener listener = new TemperatureAdapter() {
    public void temperatureChange(double ancienneTemperature, double nouvelleTemperature) {
        System.out.println("changée");
    }
};

Ensuite, vous utilisez cet écouteur, et vous ne comprenez pas pourquoi, mais l'affichage "changée" ne se produit jamais. C'est tout simplement parce qu'il manque un e au nom de la méthode temperatureChangee. Si vous aviez fait cette erreur en utilisant l'interface directement, vous auriez eu un message d'erreur à la compilation, mais pas avec une classe abstraite dont la méthode utilisée est concrète. Pour éviter ce genre d'ennuis, utilisez l'annotation @Override : le compilateur indiquera une erreur si cette annotation est définie pour une méthode alors qu'elle n'écrase aucune méthode. Ensuite, quand vous utilisez un adapter, faites simplement :

 
Sélectionnez

TemperatureListener listener = new TemperatureAdapter() {
    @Override public void temperatureChange(double ancienneTemperature, double nouvelleTemperature) {
        System.out.println("changée");
    }
};

Et là, le code ne compile plus, car il manque un e, là au moins il ne vous échappe plus.

IV-E. Évènements et threads

Comme vous avez pu le remarquer, le signal d'un évènement se traduit par l'exécution d'une méthode par le signaleur. Dans notre exemple, c'est le thread exécutant setTemperature de la classe Meteo qui exécute le code associé à l'évènement temperatureChangee de chaque écouteur.

Imaginons maintenant le cas de figure suivant :

 
Sélectionnez

public class TemperatureModifier extends Thread {
 
    private Random random = new Random();
    private Meteo meteo;
 
    public TemperatureModifier(Meteo meteo) {
        this.meteo = meteo;
        start();
    }
 
    @Override public void run() {
        // en pratique on utiliserait un Timer, mais c'est pour illustrer le problème
        while(true) {
            float temperatureAleatoire = random.nextFloat() * 25;
            meteo.setTemperature(temperatureAleatoire);
            try {
                Thread.sleep(1000); //attend 1 seconde
            } catch(InterruptedException e) {}
        }
    }
 
}

Cette classe permet, simplement en faisant new TemperatureModifier(meteo);, de changer la température aléatoirement toutes les secondes.

Imaginons donc qu'un écouteur soit enregistré, et qu'il mette à jour le text d'un TextField :

 
Sélectionnez

public class TestFaux {
 
    public static void main(String... args) {
        final Meteo meteo = new Meteo(15, 1013);
 
        new TemperatureModifier(meteo); //change la température toutes les secondes
 
        // à exécuter dans l'EDT
        SwingUtilities.invokeLater(new Runnable() {
 
            public void run() {
                JFrame frame = new JFrame();
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                final JTextField textField = new JTextField("15");
                frame.getContentPane().add(textField);
                frame.pack();
                meteo.addTemperatureListener(new TemperatureAdapter() {
                    @Override public void temperatureChangee(double oldTemperature, double newTemperature) {
                        // à chaque fois que la température change, on met à jour le textfield
                        textField.setText("" + newTemperature);
                    }
                });
                frame.setVisible(true);
            }
        });
 
    }
 
}

Cet exemple est incorrect ! (si vous l'exécutez, certes, il a de grandes chances de fonctionner correctement, mais il reste incorrect).

En effet, la ligne textField.setText("" + newTemperature) est exécutée dans le thread qui change la température toutes les secondes. Or, un élément graphique Swing doit toujours être exécuté dans l'EventDispatchThread (EDT), ce qui n'est pas le cas ici. Il faut donc changer le listener comme ceci :

 
Sélectionnez

                meteo.addTemperatureListener(new TemperatureAdapter() {
                    @Override public void temperatureChangee(double oldTemperature, double newTemperature) {
                        // exécution dans l'EDT
                        SwingUtilities.invokeLater(new Runnable() {
                            public void run() {
                                // à chaque fois que la température change, on met à jour le textfield
                                textField.setText(""+newTemperature);
                            }
                        });
                    }
                });

Notez que c'est loin d'être un problème trivial, car il se peut que certaines actions soit exécutées dans l'EDT et d'autres non. La méthode statique SwingUtilities.isEventDispatchThread() se révèle alors bien pratique.

IV-F. Sérialisation

Ceci ne concerne pas spécialement les écouteurs, mais il faut le garder à l'esprit lorsque l'on crée ses propres écouteurs.

Deux cas se présentent
  • Les écouteurs doivent être sérialisés (par exemple vous stockez l'état de votre application à un instant donné) ;
  • les écouteurs ne doivent pas être sérialisés (par exemple vous ne voulez stocker que le modèle, et donc la vue n'a rien à faire dans la sérialisation).

Pour le premier cas, c'est parfait, il n'y a rien à changer. Notez bien que seuls les écouteurs Serializable seront sérialisés, les autres seront ignorés sans erreur.

Pour le second cas, il faut changer la classe Meteo comme ceci, pour éviter de sérialiser les écouteurs :

 
Sélectionnez

public class Meteo implements Serializable {
 
    /** Pour la sérialisation. */
    private static final long serialVersionUID = 1L;
 
    private double temperature;
    private double pression;
 
    private transient EventListenerList listeners;
 
    public Meteo(double temperature, double pression) {
    	this.temperature = temperature;
    	this.pression = pression;
    	// Permet ici d'initialiser les collections.
    	readResolve();
    }
 
    /** Méthode appelée lors de la désérialisation. */
    private Meteo readResolve() {
    	listeners = new EventListenerList();
    	return this;
    }
 
    // reste inchangé
 
}