Apuntes · Capítulo 16Diseño
Programación orientada a eventos y cierre
Programación orientada a eventos y cierre
La semana pasada escribimos un menú de línea de comandos: un bucle que espera una línea, decide qué comando es y lo ejecuta. Parece un detalle de implementación, pero es la forma de casi todos los programas que usamos todos los días. Un editor de texto, un navegador o la aplicación del celular no siguen un orden fijo de instrucciones: esperan a que pase algo (una tecla, un toque, un mensaje que llega por la red) y reaccionan. Eso es la programación orientada a eventos (event-driven programming).
Esta semana tiene dos partes. En la sesión A estudiamos ese modelo sin ventanas, en la consola: el event loop, los handlers como observers, las máquinas de estados y la separación entre el modelo y la interfaz. Las interfaces gráficas quedan como lectura (✂️). En la sesión B cerramos el curso: recorremos por capas una aplicación completa, volvemos a las preguntas del principio y repasamos para el examen final.
Lectura. Eck, sección 1.2 Asynchronous Events: Polling Loops and Interrupts. Para los eventos en interfaces gráficas usamos la edición Swing del libro (la versión 9 trae una edición con JavaFX y otra con Swing, que viene con el JDK): subsecciones 6.1.3 Events and Listeners, 6.3.1 Event Handling y 6.4.4 State Machines. Para el cierre, 13.5.2 Design of the Program y la relectura de 5.3.4 Object-oriented Analysis and Design. El código del capítulo está en
code/week-16.
Ruta de lectura.
- Antes de clase (obligatorio, 15–20 min): Programas secuenciales y programas que esperan y El event loop.
- En clase: en la sesión A, Handlers como objetos, El modelo como máquina de estados y Separar el modelo de la interfaz; en la sesión B, Cierre: el diseño de una aplicación completa, con Repaso para el examen final.
- Después de clase: Ejemplo resuelto: una sesión con el reproductor, Probar sin interfaz, Lo que no vimos, el Resumen y los Ejercicios. Para ampliar, ✂️ Interfaces gráficas (lectura). Para el final, el final de práctica de
exams/, con su lista de verificación.
Programas secuenciales y programas que esperan
Los programas de las primeras semanas eran secuenciales: el programa decide el orden. Primero pide un número, después otro, después imprime la suma. Si el usuario quisiera hacer otra cosa en el medio, no puede: el programa está esperando exactamente el segundo número.
Un programa orientado a eventos invierte el control. El programa no decide qué pasa después; espera, y lo que llega decide. Un evento es algo que pasa y que el programa tiene que atender: una línea escrita en la consola, un clic, una tecla, un paquete de red, un temporizador que vence. Para cada tipo de evento hay un handler: el código que reacciona a ese evento. Y en el centro hay un event loop, un ciclo que repite siempre lo mismo: esperar el siguiente evento, buscar su handler, ejecutarlo.
Eck, en la sección 1.2, cuenta que la computadora misma funciona así: la CPU no pregunta a cada rato si se presionó una tecla (eso sería polling), sino que el hardware la interrumpe cuando pasa algo. Los programas con ventanas repiten la idea un nivel más arriba.
El event loop
Nuestro menú de la semana 15 ya era un event loop sencillo, pero con
todo mezclado: el switch sabía qué comandos había y qué hacía cada uno. Lo
separamos en dos piezas. El ciclo
(EventLoop.java) no sabe qué hace ningún
comando; solo guarda, para cada nombre, un handler:
public class EventLoop {
private final Map<String, CommandHandler> handlers = new LinkedHashMap<>();
private final PrintStream out;
private boolean running;
public void on(String command, CommandHandler handler) {
handlers.put(command, handler);
}
public void stop() {
running = false;
}
public void run(Scanner in) {
running = true;
while (running && in.hasNextLine()) {
String line = in.nextLine().strip();
if (line.isEmpty()) {
continue;
}
List<String> words = Arrays.asList(line.split("\\s+"));
CommandHandler handler = handlers.get(words.get(0));
if (handler == null) {
out.println("comando desconocido: " + words.get(0) + "; comandos: " + handlers.keySet());
continue;
}
try {
handler.handle(words.subList(1, words.size()));
} catch (RuntimeException e) {
out.println("error: " + e.getMessage());
}
}
}
}
Tres cosas para notar:
- El despacho es una búsqueda en un mapa, no un
switch. Un comando nuevo se agrega conon(...), sin tocarEventLoop(abierto/cerrado, semana 13). - Un handler que falla no detiene el ciclo. El error se informa y el ciclo espera el siguiente evento. Es la misma decisión que tomó el termómetro de la semana 14 con un observer que falla: una pieza rota no apaga todo.
- El ciclo termina cuando se acaba la entrada o cuando un handler llama a
stop(). En una interfaz gráfica, el ciclo lo maneja la biblioteca y termina al cerrar la ventana.
![Animación del event loop. A la izquierda, una cola de eventos que llegan: play, tick 60, rewind. En el centro, el ciclo EventLoop con su mapa de handlers: play, pause, next, stop, tick y salir, cada uno con una flecha a una lambda. Paso 1: el ciclo toma play, lo busca en el mapa y ejecuta su handler, que llama a player.play(); la vista imprime [PLAYING] suena. Paso 2: toma tick 60 y llama al handler de tick con el argumento 60. Paso 3: toma rewind, no lo encuentra en el mapa e imprime comando desconocido. Al final vuelve a esperar](/figures/week-16/ciclo-eventos.gif)
![Animación del event loop. A la izquierda, una cola de eventos que llegan: play, tick 60, rewind. En el centro, el ciclo EventLoop con su mapa de handlers: play, pause, next, stop, tick y salir, cada uno con una flecha a una lambda. Paso 1: el ciclo toma play, lo busca en el mapa y ejecuta su handler, que llama a player.play(); la vista imprime [PLAYING] suena. Paso 2: toma tick 60 y llama al handler de tick con el argumento 60. Paso 3: toma rewind, no lo encuentra en el mapa e imprime comando desconocido. Al final vuelve a esperar](/figures/week-16/ciclo-eventos.dark.gif)
Handlers como objetos
Un handler es un objeto con un método. La interfaz
(CommandHandler.java) tiene uno solo,
así que es una interfaz funcional:
@FunctionalInterface
public interface CommandHandler {
void handle(List<String> args);
}
Se puede escribir de tres maneras, que ya conocen. Las tres registran lo mismo:
// 1. Una clase con nombre (semana 7).
class PlayHandler implements CommandHandler {
private final Player player;
PlayHandler(Player player) { this.player = player; }
public void handle(List<String> args) { player.play(); }
}
loop.on("play", new PlayHandler(player));
// 2. Una clase anónima: la clase se declara y se instancia en el mismo lugar.
loop.on("play", new CommandHandler() {
public void handle(List<String> args) { player.play(); }
});
// 3. Una lambda (semana 12).
loop.on("play", args -> player.play());
Con la lambda, el handler es una línea. Es la forma habitual hoy; las clases anónimas aparecen en código anterior a Java 8 y en el libro de Eck.
Idea clave. Registrar un handler es registrarse como observer (semana 14). El subject es quien produce los eventos (el ciclo, un botón, un temporizador), y el handler es un observer con una interfaz de un método.
loop.on("play", handler)cumple el papel dethermometer.addListener(listener): el que produce los eventos no conoce a quien los atiende.
El modelo como máquina de estados
Los handlers de la aplicación de esta semana controlan un reproductor de
música (Player.java). El reproductor está
siempre en uno de tres estados: detenido, sonando o en pausa
(State.java):
public enum State {
STOPPED, PLAYING, PAUSED
}
Lo interesante es que el mismo evento hace cosas distintas según el
estado. play en un reproductor detenido empieza la lista desde el
principio; en uno pausado, continúa donde se quedó; en uno que ya está
sonando, no hace nada. Un programa así es una máquina de estados (state
machine): un conjunto de estados y, para cada par (estado, evento), a qué
estado se pasa y qué se hace.
| Estado \ Evento | play |
pause |
next |
stop |
tick |
|---|---|---|---|---|---|
STOPPED |
→ PLAYING, desde la primera |
nada | nada (“use play”) | sigue STOPPED |
se ignora |
PLAYING |
nada (“ya está sonando”) | → PAUSED |
siguiente canción | → STOPPED |
cuenta segundos |
PAUSED |
→ PLAYING, continúa |
nada | siguiente canción | → STOPPED |
se ignora |


En Java, el estado es un enum y cada evento es un método que pregunta en
qué estado está con un switch:
public void play() {
String message = switch (state) {
case STOPPED -> {
index = 0;
elapsed = 0;
yield "suena";
}
case PAUSED -> "continúa";
case PLAYING -> "ya está sonando";
};
change(State.PLAYING, message);
}
public void pause() {
switch (state) {
case PLAYING -> change(State.PAUSED, "en pausa");
case PAUSED, STOPPED -> change(state, "no hay nada que pausar");
}
}
change actualiza el estado y avisa a los observers con un
PlayerEvent (estado, canción y un
mensaje): el reproductor es un subject, como el termómetro de la semana 14.
El reloj también es una fuente de eventos. tick(segundos) dice que pasó el
tiempo; si el reproductor está sonando y la canción terminó, pasa a la
siguiente. Así, el cambio de canción puede venir del usuario (next) o del
tiempo (tick), y el modelo no distingue quién lo pidió.
Error común. Confiar en que el compilador avisa cuando se agrega un estado nuevo. Lo hace con una expresión
switch(la deplay, que devuelve un valor), pero no con una sentenciaswitchsobre unenum(la depause): por compatibilidad con el código anterior a Java 21, una sentencia sobre unenumsin patrones puede dejar casos sin cubrir. Si se agregaBUFFERINGaState:$ javac -Xlint:all Player.java ... Player.java:36: error: the switch expression does not cover all possible input values String message = switch (state) { ^ 1 errorSolo
playfalla.pausecompila y, con el reproductor enBUFFERING, no hace nada en silencio. Cuando elswitchdecide el comportamiento de una máquina de estados, conviene escribirlo como expresión (o agregar undefaultque lance una excepción) para que el compilador lo revise.
Separar el modelo de la interfaz
El reproductor no imprime nada. Lo que se ve en la consola lo escribe una
vista (ConsoleView.java), que
observa al reproductor:
public class ConsoleView implements PlayerListener {
private final PrintStream out;
public ConsoleView(PrintStream out) {
this.out = out;
}
@Override
public void onEvent(PlayerEvent e) {
out.printf("[%s] %s: %s%n", e.state(), e.message(), e.song().title());
}
}
Y el único lugar que conoce todas las piezas es
PlayerApp.java, que las crea y las
conecta:
public static EventLoop build(Player player, PrintStream out) {
player.addListener(new ConsoleView(out));
EventLoop loop = new EventLoop(out);
loop.on("play", args -> player.play());
loop.on("pause", args -> player.pause());
loop.on("next", args -> player.next());
loop.on("stop", args -> player.stop());
loop.on("tick", args -> {
if (args.size() != 1) {
throw new IllegalArgumentException("uso: tick <segundos>");
}
player.tick(Integer.parseInt(args.get(0)));
});
loop.on("salir", args -> loop.stop());
return loop;
}
Es la división clásica modelo-vista-controlador (MVC, Model-View-Controller):
- El modelo (
Player) tiene el estado y las reglas. No sabe que existe una consola. - La vista (
ConsoleView) muestra el modelo. Lo observa: el modelo no la llama por su nombre. - El controlador (el event loop y sus handlers) convierte lo que hace el usuario en llamadas al modelo.
Las flechas de dependencia van todas hacia el modelo. Por eso se puede
cambiar la interfaz sin tocarlo: una vista que escriba en un archivo, o una
ventana, se agregan con otro addListener y otros handlers.
Ejemplo resuelto: una sesión con el reproductor
make test ejecuta la aplicación con la entrada de
player-input.txt:
pause
play
play
tick 200
pause
tick 100
play
tick 60
next
rewind
tick
next
salir
$ java -cp build PlayerApp < player-input.txt
[STOPPED] no hay nada que pausar: Contigo Perú
[PLAYING] suena: Contigo Perú
[PLAYING] ya está sonando: Contigo Perú
[PAUSED] en pausa: Contigo Perú
[PLAYING] continúa: Contigo Perú
[PLAYING] siguiente: Cariñito
[PLAYING] siguiente: Valicha
comando desconocido: rewind; comandos: [play, pause, next, stop, tick, salir]
error: uso: tick <segundos>
[STOPPED] fin de la lista: Valicha
Sigan la entrada línea por línea con la tabla de estados. tick 200 no
imprime nada: “Contigo Perú” dura 254 segundos y todavía no terminó. tick 100
llega con el reproductor en pausa y se ignora. Después de play, tick 60
suma 200 + 60 = 260 segundos y la canción termina: el reproductor pasa solo a
“Cariñito”. next salta a “Valicha”. rewind no tiene handler, y tick sin
número hace fallar a su handler; en los dos casos el ciclo informa y sigue.
El último next llega al final de la lista y detiene el reproductor.
Error común. Poner la lógica del negocio dentro del handler: que la lambda de
playdecida si hay que empezar desde el principio o continuar, o que elcase "vender"de un menú calcule el total y descuente el stock. Eso no se puede probar sin simular la interfaz, y hay que reescribirlo cuando cambia la interfaz. El handler traduce un evento en una llamada al modelo; las decisiones están en el modelo.
Probar sin interfaz
Como el modelo no depende de la consola, sus pruebas no la necesitan
(PlayerTest.java). El spy esta vez es una
lista y una referencia a método:
private final List<PlayerEvent> events = new ArrayList<>();
@BeforeEach
void setUp() {
player = new Player(List.of(a, b));
player.addListener(events::add); // un spy hecho con una referencia a método
}
@Test
void playMeansDifferentThingsInEachState() {
player.play();
assertEquals("suena", lastMessage());
player.play();
assertEquals("ya está sonando", lastMessage());
player.pause();
player.play();
assertEquals("continúa", lastMessage());
}
EventLoopTest prueba el event loop con handlers que solo anotan lo que
reciben, sin reproductor, y
PlayerAppTest prueba la aplicación entera con un Scanner sobre un texto y
la salida capturada en un arreglo de bytes. Cada capa se prueba por separado,
y una prueba más las prueba juntas.
✂️ Interfaces gráficas (lectura)
Esta sección es de lectura. Si se pierde la clase, pasa a lectura autónoma.
En una interfaz gráfica, los eventos son clics, teclas y movimientos del
mouse, y el event loop lo maneja la biblioteca. Java trae Swing en
el JDK (módulo java.desktop); JavaFX se instala aparte. En Swing, un botón
(JButton) es un subject, y se le registra un observer con
addActionListener. La interfaz ActionListener tiene un solo método,
actionPerformed(ActionEvent e), así que el handler puede ser una lambda.
El mismo reproductor, con una ventana:
Player player = new Player(List.of(new Song("Valicha", "Miguel Ángel Hurtado", 192)));
SwingUtilities.invokeLater(() -> {
JLabel status = new JLabel("detenido");
JButton play = new JButton("Play");
JButton pause = new JButton("Pausa");
play.addActionListener(e -> player.play()); // handler = observer del botón
pause.addActionListener(e -> player.pause());
player.addListener(ev -> status.setText(ev.message() + ": " + ev.song().title()));
JPanel panel = new JPanel();
panel.add(play);
panel.add(pause);
panel.add(status);
JFrame frame = new JFrame("Reproductor");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setContentPane(panel);
frame.pack();
frame.setVisible(true);
});
Fíjense en lo que no cambió: Player es exactamente la misma clase, con
las mismas pruebas. Cambió la vista (una etiqueta en lugar de la consola) y
el controlador (botones en lugar de comandos escritos). Eso es lo que gana la
separación entre modelo e interfaz.
Dos detalles de Swing. SwingUtilities.invokeLater pide que el código corra
en el hilo del event loop de Swing (el event dispatch thread), que es
el único que debe tocar los componentes. Y una ventana necesita una pantalla:
el contenedor del curso no tiene, así que este código compila ahí pero no
corre:
Exception in thread "AWT-EventQueue-0" java.awt.HeadlessException:
No X11 DISPLAY variable was set,
or no headful library support was found,
...
Para probarlo, cópienlo junto con las clases del reproductor a una computadora con JDK y pantalla. El capítulo 6 de la edición Swing de Eck (secciones 6.1 a 6.5) explica componentes, eventos del mouse y del teclado, temporizadores y diseño de ventanas; la subsección 13.4.1 vuelve sobre MVC en Swing.
Cierre: el diseño de una aplicación completa
Una aplicación por capas
Con todo el curso encima, una aplicación orientada a objetos de tamaño mediano, como el proyecto Bodega al final de P5, se organiza en capas. Cada capa tiene una responsabilidad, y las dependencias apuntan hacia el centro, hacia el modelo:
| Capa | Qué hay ahí | Temas del curso |
|---|---|---|
| Modelo | Entidades con invariantes; valores inmutables; jerarquías o tipos sealed | Semanas 2 a 8: encapsulamiento, record, herencia, polimorfismo, sealed |
| Reglas | Precios, descuentos, órdenes de reportes: strategies detrás de interfaces | Semanas 7, 12 y 14: interfaces, lambdas, Strategy |
| Operaciones | Ventas, reportes, consultas sobre colecciones | Semanas 9 a 12: excepciones, genéricos, colecciones, streams |
| Persistencia | Un repositorio detrás de una interfaz; CSV; decorators | Semana 15 |
| Interfaz | Event loop, handlers, vista que observa | Semanas 15 y 16 |
| Armado | Una clase que crea las piezas y las conecta por sus constructores | Semana 13: inversión de dependencias |
| Pruebas | Unit tests por capa, con test doubles; de ida y vuelta; de la aplicación entera | Desde la semana 3 |
Un buen ejercicio para la presentación de P5 es recorrer el propio proyecto con esta tabla: ¿en qué clase está cada fila? ¿Hay alguna clase que esté en dos filas a la vez (una vista que calcula, un modelo que imprime, un repositorio que valida reglas del negocio)? Esas son las que más cuesta cambiar.
Las preguntas del principio
La semana 1 empezamos con una tarjeta de transporte cuyo saldo cualquiera podía poner en −500. Las preguntas de entonces tienen ahora respuestas más completas:
- ¿Qué es un objeto? Estado más comportamiento, con una identidad. Los demás le envían mensajes y no tocan su estado.
- ¿Quién protege cada invariante? La clase que tiene el dato, con campos privados y validación en el constructor. Un record lo hace en su constructor compacto, también al deserializarse (semana 15).
- ¿Dónde se usa polimorfismo y dónde un tipo sealed? Polimorfismo cuando
se agregan tipos nuevos con frecuencia; un tipo sealed con
switchcuando los tipos son fijos y se agregan operaciones (semanas 6, 8 y 14). - ¿Qué depende de qué? Lo que cambia seguido (interfaces, formatos de archivo) depende de lo estable (el modelo), y no al revés. Las dependencias entran por el constructor y apuntan a interfaces (semana 13).
- ¿Cómo sabemos que funciona? Porque hay pruebas que lo dicen, y porque esas pruebas permiten cambiar el diseño sin miedo (semana 13).
Lo que no vimos
- Concurrencia: programas con varios hilos que trabajan a la vez (Eck, capítulo 12). Aparece en cuanto una interfaz gráfica hace algo lento, o un servidor atiende a muchos usuarios.
- Interfaces gráficas a fondo (Eck, capítulos 6 y 13).
- Bases de datos: guardar el estado en un sistema que maneja consultas, concurrencia y transacciones, en lugar de archivos.
- Herramientas de build como Maven o Gradle, que reemplazan
nuestro
Makefiley descargan las bibliotecas que el proyecto necesita. - Más patrones: el libro de 1994 tiene 23, y vimos seis (Strategy,
Observer, Factory Method, Composite, Decorator y, sin nombrarlo así,
Template Method en
LineImporter.importAll).
Repaso para el examen final
El examen final cubre las partes 3 y 4 (semanas 9 a 16), con preguntas que usan todo el curso. Una guía para repasar:
| Semana | Preguntas que deberían poder responder |
|---|---|
| 9 | ¿Checked o unchecked? ¿Dónde se captura una excepción? ¿Qué garantiza try-with-resources? |
| 10 | ¿Qué es el type erasure? ¿Cuándo ? extends T y cuándo ? super T? |
| 11 | ¿Qué colección para cada problema? ¿Qué contrato une equals, hashCode y compareTo? |
| 12 | ¿Qué interfaz funcional corresponde a una lambda? ¿Qué hace cada paso de un stream? |
| 13 | ¿Qué principio viola este código? ¿Cuál es el siguiente paso pequeño de refactoring? |
| 14 | ¿Qué problema resuelve cada patrón? ¿Cuándo sería un exceso? |
| 15 | ¿Dónde va el código que lee archivos? ¿Por qué no deserializar datos de fuera? |
| 16 | ¿Qué es un event loop? ¿Qué hace este evento en este estado? ¿Qué cambia si cambia la interfaz? |
El código de la semana y make test
$ cd code/week-16
$ make test
...
== PlayerApp (entrada desde player-input.txt)
...
├─ JUnit Jupiter ✔
│ ├─ EventLoopTest ✔
│ ├─ PlayerTest ✔
│ └─ PlayerAppTest ✔
...
[ 12 tests successful ]
[ 0 tests failed ]
El ejemplo de Swing de este capítulo no está en code/week-16, porque
necesita una pantalla.
Resumen
- En un programa orientado a eventos, el orden lo deciden los eventos que llegan; el programa decide cómo reacciona a cada uno.
- Un event loop espera un evento, busca su handler y lo ejecuta. Un handler que falla no debería detener el ciclo.
- Un handler es un objeto con un método: una clase, una clase anónima o una lambda. Registrar un handler es registrarse como observer.
- Un modelo cuyo comportamiento depende de en qué estado está es una
máquina de estados: un
enumpara los estados y unswitchpor evento. Una expresiónswitchsobre unenumobliga a cubrir todos los estados; una sentencia no. - MVC: el modelo no sabe de la interfaz; la vista lo observa; el controlador traduce eventos en llamadas al modelo. Las dependencias apuntan al modelo, y por eso la interfaz se cambia sin tocarlo.
- La lógica del negocio no va en los handlers: va en el modelo, donde se prueba sin interfaz.
- ✂️ En Swing, un
JButtones un subject yaddActionListenerregistra un observer; el modelo es el mismo de la consola. - Una aplicación completa se organiza en capas (modelo, reglas, persistencia, interfaz, armado), cada una con sus pruebas, y con las dependencias hacia el centro.
Ejercicios
- Expliquen con sus palabras la diferencia entre un programa secuencial y uno orientado a eventos. Den un ejemplo de cada uno entre los programas que usan a diario.
- En el ciclo de
EventLoop, ¿quién es el subject y quiénes los observers? Compárenlo con el termómetro de la semana 14: ¿qué cumple el papel deaddListenery qué el deonReading? - Escriban el handler de
nextde las tres formas (clase con nombre, clase anónima y lambda). ¿Qué ventaja tiene la clase con nombre cuando el handler necesita estado propio (por ejemplo, contar cuántas veces se usó)? - Agreguen un comando
ayudaque imprima los comandos registrados. ¿Dónde lo registran? ¿Necesitan cambiarEventLoop? - Sigan a mano la máquina de estados con esta entrada y escriban la salida
esperada antes de ejecutarla:
play,next,pause,next,tick 500,play,stop,next. Después compruébenlo cambiandoplayer-input.txt. - Agreguen el estado
BUFFERINGaState. ¿Qué métodos dePlayerdejan de compilar y cuáles no? Cambien los que no fallaron para que el compilador también los revise. - Agreguen al reproductor un evento
shuffleque reordene la lista solo si está detenido, y que en los otros estados responda “detenga primero”. Escriban primero las pruebas enPlayerTest. - Escriban una segunda vista,
HistoryView, que guarde en una lista las canciones que empezaron a sonar (sin repetir la misma dos veces seguidas). Regístrenla enPlayerAppjunto conConsoleView. ¿Tuvieron que tocarPlayer? - Dibujen la máquina de estados del menú de su proyecto. ¿Hay algún comando que deba hacer cosas distintas según el estado (por ejemplo, “confirmar” durante una venta y fuera de ella)? ¿Qué pasa con un comando que no corresponde al estado actual?
- Busquen en su proyecto un handler o un
casedel menú que tenga lógica del negocio. Muévanla al modelo, escriban una prueba que no use la interfaz y verifiquen que el handler quedó en una línea. - Si mañana la bodega necesitara una interfaz gráfica, ¿qué clases de su proyecto cambiarían y cuáles no? Hagan la lista y comparen con la tabla de capas de este capítulo.
- ✂️ En una computadora con pantalla, ejecuten el ejemplo de Swing de este
capítulo. Agreguen un botón “Siguiente” y un temporizador
(
javax.swing.Timer, Eck 6.4.1) que llame aplayer.tick(1)cada segundo. ¿Qué observers hay en el programa y de qué subjects?