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 connait 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.
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 température 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 :
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 :
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 :
public
interface
TemperatureListener {
void
temperatureChangee
(
double
ancienneTemperature, double
nouvelleTemperature);
void
temperatureAugmentee
(
double
ancienneTemperature, double
nouvelleTemperature);
void
temperatureDiminuee
(
double
ancienneTemperature, double
nouvelleTemperature);
}
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 :
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.
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 loin 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.
public
interface
TemperatureListener extends
EventListener {
void
temperatureChangee
(
double
ancienneTemperature, double
nouvelleTemperature);
void
temperatureAugmentee
(
double
ancienneTemperature, double
nouvelleTemperature);
void
temperatureDiminuee
(
double
ancienneTemperature, double
nouvelleTemperature);
}
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 :
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ésout 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 multithreads.
En réalité, pour le moment (même dans Java SE 6 actuel), EventListenerList est buguée, et dans des cas extrêmement rares, la synchronisation n'est pas fiable (14 commentaires ). 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 :
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;
}
}
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 :
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 :
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 :
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 :
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 :
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 texte d'un TextField :
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 :
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 soient 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 :
public
class
Meteo implements
Serializable {
/** Pour la sérialisation. */
private
static
final
long
serialVersionUID =
1
L;
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é
}