Semana 16 — Programación orientada a eventos y cierre
Objetivos
- Explicar el modelo de programación orientada a eventos: un event loop que espera eventos y handlers que reaccionan a ellos.
- Relacionar los event handlers con Observer y con las lambdas.
- Modelar una interfaz de usuario como una máquina de estados y separar la interfaz del modelo.
- Repasar el diseño completo de una aplicación orientada a objetos y presentar el proyecto.
Apuntes de la semana: notes/week-16.md · Código: code/week-16
Sesión A — Programación orientada a eventos · ✂️ Interfaces gráficas
Lecturas:
- Eck, sec. 1.2 Asynchronous Events: Polling Loops and Interrupts.
- Eck, edición Swing (la versión 9 del libro tiene una edición con Swing, que viene con el JDK, y otra con JavaFX), subsecs. 6.1.3 Events and Listeners, 6.3.1 Event Handling y 6.4.4 State Machines.
Lecturas complementarias (✂️ interfaces gráficas):
- Eck, edición Swing, sec. 6.1 The Basic GUI Application, subsec. 6.5.1 JButton y subsec. 13.4.1 Model-View-Controller.
- Swing viene con el JDK, pero una ventana necesita una pantalla: el contenedor del curso no tiene, así que los ejemplos gráficos compilan ahí pero se corren fuera de él. Alternativa: dev.java, Developing Rich Client Applications with JavaFX (JavaFX se instala aparte).
- Martin Fowler, GUI Architectures, secciones sobre Model-View-Controller.
Temas:
- Programa secuencial frente a programa orientado a eventos: el programa no decide el orden; reacciona a lo que llega (una tecla, un clic, una línea de texto, un mensaje).
- El event loop y los handlers. Un menú de línea de comandos es un event loop sencillo: leer un comando, despacharlo, repetir.
- Handlers como objetos: una interfaz con un método (
CommandHandleren el ejemplo;ActionListeneren Swing), escrita como clase, clase anónima o lambda (semana 12). Registrar un handler es registrarse como observer (semana 14). - Máquinas de estados: el mismo evento hace cosas distintas según el estado. Ejemplo propio: un reproductor de música (detenido, sonando, en pausa) donde
playempieza, continúa o no hace nada; en el proyecto, “confirmar” en el menú de venta frente al menú principal. Estados con unenumy una expresiónswitch, que el compilador revisa (una sentenciaswitchsobre unenumno). - Separar el modelo de la interfaz (MVC): el modelo de la bodega no sabe si lo usa una consola o una ventana; cambiar de interfaz no toca el modelo ni sus pruebas.
- ✂️ Interfaces gráficas con Swing, como lectura:
JFrame,JPanel,JButton.addActionListener,SwingUtilities.invokeLater; el mismo modelo del reproductor con una ventana. Sin código que necesite pantalla encode/. Si se pierde la clase, este bloque pasa a lectura autónoma. - Error común: poner la lógica del negocio dentro del handler del botón o del
casedel menú; no se puede probar sin la interfaz.
Sesión B — Cierre: diseño de una aplicación completa · Repaso
Lecturas:
- Eck, edición Swing, subsec. 13.5.2 Design of the Program: cómo organiza Eck en paquetes y clases un programa completo (lectura de estructura; no hace falta correrlo).
- Eck, subsec. 5.3.4 Object-oriented Analysis and Design (relectura de la semana 4, ahora con todo el curso encima).
Temas:
- Recorrido por capas de una aplicación completa (la bodega como ejemplo): modelo con invariantes, reglas como strategies, repositorio con persistencia, interfaz de línea de comandos, pruebas en cada capa.
- Volver a las preguntas del principio: qué es un objeto, quién protege cada invariante, dónde se usa polimorfismo y dónde un tipo sealed, qué depende de qué.
- Lo que no vimos y viene después: concurrencia (Eck, cap. 12), interfaces gráficas a fondo (caps. 6 y 13), bases de datos, herramientas de build (Maven, Gradle).
- Repaso para el examen final: partes 3 y 4 (semanas 9 a 16), con preguntas de todo el curso.
Si el examen final se toma en esta sesión (ver la nota del calendario en el SYLLABUS), el cierre se da en versión corta durante el laboratorio y el bloque ✂️ de la sesión A pasa a lectura autónoma.
Laboratorio — Presentación de proyectos
- Cada equipo presenta P5 en 10 minutos: una demostración por línea de comandos, el diagrama de clases final y una decisión de diseño que cambiaría.
- Preguntas del docente y de otro equipo sobre el código: dónde está un invariante, qué prueba lo cubre, cómo se agregaría un tipo de producto nuevo.
- Revisión cruzada breve: cada equipo lee el
README.mdy las pruebas de otro y anota un code smell y una fortaleza.
Entregas
- P5 entrega.
Preguntas de repaso
- ¿En qué se parece registrar un event handler a registrar un observer? ¿Quién es el subject?
- Dibujen la máquina de estados del menú de su proyecto. ¿Qué pasa con un comando que no corresponde al estado actual?
- ¿Por qué la lógica del negocio no debe estar dentro de un event handler? ¿Qué se pierde en las pruebas?
- Si mañana la bodega necesitara una interfaz gráfica, ¿qué clases de su proyecto cambiarían y cuáles no?
- Elijan una decisión de diseño de su proyecto y expliquen qué principio o patrón la justifica y qué costo tiene.
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (10)Pruebas (3)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
import java.util.List;
/** Handler de un comando: recibe las palabras que siguen al nombre. */
@FunctionalInterface
public interface CommandHandler {
void handle(List<String> args);
}import java.io.PrintStream;
/** La vista de consola: observa al reproductor y muestra lo que pasa. */
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());
}
}import java.io.PrintStream;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
/**
* Un event loop de consola: espera una línea, busca el handler
* registrado para su primera palabra y lo ejecuta. No sabe qué hace cada
* comando.
*/
public class EventLoop {
private final Map<String, CommandHandler> handlers = new LinkedHashMap<>();
private final PrintStream out;
private boolean running;
public EventLoop(PrintStream out) {
this.out = out;
}
/** Registrar un handler es suscribirse a un tipo de evento. */
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()); // un handler que falla no detiene el ciclo
}
}
}
}pause
play
play
tick 200
pause
tick 100
play
tick 60
next
rewind
tick
next
salirimport java.util.ArrayList;
import java.util.List;
/**
* El modelo: un reproductor que es una máquina de estados. No sabe nada de
* consolas ni de ventanas; avisa a sus observers de lo que pasa.
*/
public class Player {
private final List<Song> queue;
private final List<PlayerListener> listeners = new ArrayList<>();
private State state = State.STOPPED;
private int index = 0;
private int elapsed = 0; // segundos de la canción actual
public Player(List<Song> queue) {
if (queue.isEmpty()) {
throw new IllegalArgumentException("lista vacía");
}
this.queue = List.copyOf(queue);
}
public void addListener(PlayerListener listener) {
listeners.add(listener);
}
public State state() {
return state;
}
public Song current() {
return queue.get(index);
}
/** El mismo evento hace cosas distintas según el estado. */
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");
}
}
public void stop() {
elapsed = 0;
change(State.STOPPED, "detenido");
}
public void next() {
switch (state) {
case STOPPED -> change(State.STOPPED, "detenido: use play");
case PLAYING, PAUSED -> advance();
}
}
/** Evento del reloj: pasaron unos segundos. Solo cuenta si está sonando. */
public void tick(int seconds) {
if (state != State.PLAYING) {
return;
}
elapsed += seconds;
if (elapsed >= current().seconds()) {
advance();
}
}
private void advance() {
elapsed = 0;
if (index + 1 < queue.size()) {
index++;
change(state, "siguiente");
} else {
change(State.STOPPED, "fin de la lista");
index = 0;
}
}
private void change(State newState, String message) {
state = newState;
PlayerEvent event = new PlayerEvent(state, current(), message);
for (PlayerListener l : List.copyOf(listeners)) {
l.onEvent(event);
}
}
}import java.io.PrintStream;
import java.util.List;
import java.util.Scanner;
/**
* Arma la aplicación: crea el modelo, conecta la vista y registra los
* handlers. Es el único lugar que conoce a todas las piezas.
*/
public class PlayerApp {
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;
}
public static void main(String[] args) {
Player player = new Player(List.of(
new Song("Contigo Perú", "Arturo Cavero", 254),
new Song("Cariñito", "Los Hijos del Sol", 187),
new Song("Valicha", "Miguel Ángel Hurtado", 192)));
build(player, System.out).run(new Scanner(System.in));
}
}/** Lo que el reproductor avisa a sus observers después de cada evento. */
public record PlayerEvent(State state, Song song, String message) {
}/** Observer del reproductor. */
public interface PlayerListener {
void onEvent(PlayerEvent event);
}/** Una canción (la misma de la semana 15, sin serialización). */
public record Song(String title, String artist, int seconds) {
public Song {
if (seconds <= 0) {
throw new IllegalArgumentException("duración inválida: " + seconds);
}
}
}/** Los estados del reproductor. */
public enum State {
STOPPED, PLAYING, PAUSED
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import org.junit.jupiter.api.Test;
class EventLoopTest {
private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
private final PrintStream out = new PrintStream(bytes, true, StandardCharsets.UTF_8);
private final EventLoop loop = new EventLoop(out);
private String output() {
return bytes.toString(StandardCharsets.UTF_8);
}
@Test
void dispatchesToTheRegisteredHandlerWithItsArguments() {
List<List<String>> received = new ArrayList<>();
loop.on("add", received::add);
loop.run(new Scanner("add 1 2\n\nadd\n"));
assertEquals(List.of(List.of("1", "2"), List.of()), received);
}
@Test
void unknownCommandIsReportedAndTheLoopGoesOn() {
List<String> seen = new ArrayList<>();
loop.on("ok", args -> seen.add("ok"));
loop.run(new Scanner("foo\nok\n"));
assertTrue(output().startsWith("comando desconocido: foo"));
assertEquals(List.of("ok"), seen);
}
@Test
void failingHandlerDoesNotStopTheLoop() {
List<String> seen = new ArrayList<>();
loop.on("boom", args -> {
throw new IllegalStateException("se rompió");
});
loop.on("ok", args -> seen.add("ok"));
loop.run(new Scanner("boom\nok\n"));
assertTrue(output().contains("error: se rompió"));
assertEquals(List.of("ok"), seen);
}
@Test
void stopEndsTheLoopBeforeTheInputEnds() {
List<String> seen = new ArrayList<>();
loop.on("salir", args -> loop.stop());
loop.on("ok", args -> seen.add("ok"));
loop.run(new Scanner("ok\nsalir\nok\n"));
assertEquals(List.of("ok"), seen);
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Scanner;
import org.junit.jupiter.api.Test;
/** Prueba de toda la aplicación: entrada de texto, salida de texto, sin teclado. */
class PlayerAppTest {
@Test
void wholeSessionFromText() {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
PrintStream out = new PrintStream(bytes, true, StandardCharsets.UTF_8);
Player player = new Player(List.of(new Song("A", "x", 10), new Song("B", "y", 10)));
PlayerApp.build(player, out).run(new Scanner("play\ntick 10\ntick\nstop\n"));
String nl = System.lineSeparator();
assertEquals("[PLAYING] suena: A" + nl
+ "[PLAYING] siguiente: B" + nl
+ "error: uso: tick <segundos>" + nl
+ "[STOPPED] detenido: B" + nl, bytes.toString(StandardCharsets.UTF_8));
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class PlayerTest {
private final Song a = new Song("A", "x", 100);
private final Song b = new Song("B", "y", 50);
private final List<PlayerEvent> events = new ArrayList<>();
private Player player;
@BeforeEach
void setUp() {
player = new Player(List.of(a, b));
player.addListener(events::add); // un spy hecho con una referencia a método
}
private String lastMessage() {
return events.get(events.size() - 1).message();
}
@Test
void startsStopped() {
assertEquals(State.STOPPED, player.state());
}
@Test
void playMeansDifferentThingsInEachState() {
player.play();
assertEquals("suena", lastMessage());
player.play();
assertEquals("ya está sonando", lastMessage());
player.pause();
player.play();
assertEquals("continúa", lastMessage());
assertEquals(State.PLAYING, player.state());
}
@Test
void pauseOnlyWorksWhilePlaying() {
player.pause();
assertEquals(State.STOPPED, player.state());
assertEquals("no hay nada que pausar", lastMessage());
player.play();
player.pause();
assertEquals(State.PAUSED, player.state());
}
@Test
void tickAdvancesWhenTheSongEnds() {
player.play();
player.tick(99);
assertEquals(a, player.current());
player.tick(1);
assertEquals(b, player.current());
assertEquals(State.PLAYING, player.state());
}
@Test
void tickIsIgnoredWhilePaused() {
player.play();
player.pause();
player.tick(500);
assertEquals(a, player.current());
assertEquals(State.PAUSED, player.state());
}
@Test
void nextAfterTheLastSongStops() {
player.play();
player.next();
player.next();
assertEquals(State.STOPPED, player.state());
assertEquals("fin de la lista", lastMessage());
assertEquals(b, events.get(events.size() - 1).song());
assertEquals(a, player.current());
}
@Test
void emptyQueueIsRejected() {
assertThrows(IllegalArgumentException.class, () -> new Player(List.of()));
}
}Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 16: programación orientada a eventos sin interfaz gráfica.
# make compila todo a build/
# make test corre el reproductor con player-input.txt y las pruebas JUnit
# make clean borra build/
JAVAC = javac
JFLAGS = -Xlint:all -Werror
SRC = $(wildcard *.java)
all: build/.stamp
build/.stamp: $(SRC)
mkdir -p build
$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
touch $@
test: all
@echo "== PlayerApp (entrada desde player-input.txt)"
java -cp build PlayerApp < player-input.txt
java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
--disable-banner --disable-ansi-colors --details=tree
clean:
rm -rf build
.PHONY: all test clean