Tema 2 · Callbacks, listeners, handlers y despacho
Programación Dirigida por Eventos · Grado en Ingeniería Informática · 2026/27
Entregar código para que otro lo invoque: callbacks síncronos y diferidos, listeners, ciclo de vida, tablas de despacho, Observer, pub/sub, MVVM y todo lo que puede salir mal al notificar.
Callbacks y listeners
Qué es entregar una función para que otro la llame, las cuatro formas de escribirla, clausuras, callbacks síncronos frente a diferidos y cómo una fuente gestiona su lista de listeners.
Teoría 1. Entregar, no ejecutar
En la Unidad 1 decíamos alegremente «el framework me llama». Hoy toca saber exactamente qué le entregas y cuándo lo ejecuta.
Un callback es una función que entregas a otra pieza (una fuente, una librería, un framework) para que ella la invoque más tarde.
Entre el paso 1 y el 3 tu código no espera: sigue con otra cosa o incluso termina.
Tres ideas que lo definen
1 · Se entrega, no se ejecuta
Pasas la función como un valor más. Los paréntesis de la llamada los pondrá otro.
2 · Inversión de control
Tú decides QUÉ ocurre; el que guarda el callback decide CUÁNDO (y cuántas veces, quizá ninguna).
3 · Un contrato de por medio
Ambos lados acuerdan la firma: qué parámetros recibe el callback y qué devuelve.
- El código que define la reacción, al pasarla como argumento
- El compilador, que reordena las llamadas
- Quien guarda el callback (fuente, librería o framework)
- El sistema operativo, siempre en otro hilo
Tú decides qué ocurre; quien guarda el callback decide cuándo y cuántas veces lo invoca. Eso es la inversión de control.
inversion de controlInversión de control: el flujo ya no lo dirige tu código, sino quien guarda tus callbacks.
Ejercicio 2. Tu primer callback: descargar()
Un ejemplo de consola, sin Android ni GUI, para ver las piezas desnudas:
// Quien OFRECE el servicio declara qué función espera
fun descargar(url: String, alTerminar: (String) -> Unit) {
val contenido = "…datos de $url…" // trabajo (simulado)
alTerminar(contenido) // «ya te llamo yo»
}
// Quien USA el servicio entrega la función; no la ejecuta
fun main() {
descargar("https://uax.com") { texto ->
println("Recibido: $texto")
}
}Fíjate en tres cosas:
- El contrato es un tipo función.
(String) -> Unitsignifica «recibe unStringy no devuelve nada». Cualquier función con esa forma sirve. - La invocación la hace el otro.
descargar()decide cuándo llamar aalTerminar, con qué dato… y si llega a llamarlo. - Entregamos código, no un resultado. La lambda
{ texto -> … }viaja como argumento.main()no sabe en qué momento se ejecutará.
Responde antes de seguir: lee el código como si fueras descargar() y no main().
descargar("https://uax.com") { texto -> println("Recibido: $texto") }, la lambda…- Viaja como argumento y la ejecuta
descargar()cuando decide - Se ejecuta antes de llamar a
descargar()y se le pasa su resultado - Se lanza automáticamente en otro hilo
- No se ejecuta nunca porque no lleva paréntesis
La lambda es un valor. Quien pone los paréntesis de la llamada es descargar(), en la línea alTerminar(contenido).
descargar() y no main()? Marca todas.- Cuándo llamar a
alTerminar - Qué hace la reacción con el texto recibido
- Con qué dato lo llama
- Si llega a llamarlo o no
Qué hacer con el texto lo decide quien escribe la lambda (main). Cuándo, con qué dato y si se llama, lo decide descargar().
Int y no devuelve nada».(Int) -> Unit(Int) -> Unit. Unit es el «no devuelve nada» de Kotlin (el void de Java o C).
Teoría 3. Cuatro formas, un mismo contrato
El contrato clásico es una interfaz de un solo método. Es lo que hay debajo de View.OnClickListener en Android:
// El contrato
fun interface OnPulsado {
fun pulsado(id: Int)
}
// La fuente guarda el callback
class Boton(val id: Int) {
var listener: OnPulsado? = null
fun pulsar() {
listener?.pulsado(id)
}
}El compilador acepta cualquier cosa que cumpla el contrato. Hay cuatro formas de fabricarla:
// A · Clase con nombre
class Registrador : OnPulsado {
override fun pulsado(id: Int) = println("Botón $id")
}
boton.listener = Registrador()
// B · Clase anónima
boton.listener = object : OnPulsado {
override fun pulsado(id: Int) { println("Botón $id") }
}
// C · Lambda (conversión SAM)
boton.listener = OnPulsado { id -> println("Botón $id") }
// D · Referencia a función
fun registrar(id: Int) = println("Botón $id")
boton.listener = OnPulsado(::registrar)| Forma | Úsala cuando… | Cuidado con… |
|---|---|---|
| A · Clase con nombre | La reacción tiene estado propio, se reutiliza o se quiere probar aparte. | Mucho código para reacciones de una línea. |
| B · Clase anónima | La interfaz tiene varios métodos o necesitas this del propio listener. |
Verbosa; captura la clase que la rodea. |
| C · Lambda | Reacción corta y local. La opción por defecto en Kotlin. | Cada lambda es un objeto distinto (importa al desregistrar). |
D · Referencia ::f |
La función ya existe y su firma encaja con el contrato. | Una referencia ligada (obj::f) retiene a obj. |
OnPulsado { id -> … } con una lambda?SAMConversión SAM: una interfaz con un único método abstracto (fun interface) se puede implementar con una lambda.
- A · Clase con nombre
- B · Clase anónima
- D · Referencia
::f - C · Lambda
La lambda es la forma corta y local. Ojo solo a una cosa: cada lambda es un objeto distinto, lo que importará al darse de baja.
Ejercicio 4. Elige la forma adecuada
Las cuatro formas compilan y hacen lo mismo. Lo que cambia es cuánto cuesta escribirlas, leerlas y mantenerlas. Decide para cada situación.
Situación 1. Tienes un AnimatorListener con cuatro métodos (onAnimationStart, onAnimationEnd, onAnimationCancel, onAnimationRepeat) y en uno de ellos necesitas referirte al propio listener con this para quitarlo.
Situación 2. Ya existe en tu clase fun guardarEnLog(id: Int), que hace justo lo que quieres al pulsar.
Situación 3. La reacción lleva un contador interno de pulsaciones y un umbral, la usan tres pantallas y quieres probarla con un test unitario sin botones.
Situación 4. Te piden revisar este código:
class Pantalla {
val datos = cargarImagenesEnormes()
fun alPulsar(id: Int) { /* … */ }
}
val p = Pantalla()
botonGlobal.listener = OnPulsado(p::alPulsar)botonGlobal vive toda la aplicación. ¿Qué consecuencia tiene esa referencia ligada?
this del listener):- C · Lambda
- B · Clase anónima
- D · Referencia
::f - Ninguna sirve
Con varios métodos no hay conversión SAM, y dentro de un object : … el this es el propio listener. Clase anónima.
- D · Referencia
::guardarEnLog - A · Clase con nombre
- B · Clase anónima
- C · Lambda que la llama
Si la función ya existe con la firma del contrato, la referencia ::f evita escribir una lambda envoltorio.
p y sus imágenes?- Nada: las referencias a función no retienen objetos
- Se copian las imágenes dentro del botón
- Siguen en memoria mientras
botonGlobalguarde el listener, porquep::alPulsarretiene ap - Se liberan en cuanto termina la función que creó
p
Una referencia ligada obj::f guarda dentro a obj. Mientras la fuente la guarde, p (y sus imágenes) no se puede liberar. Es el germen de las fugas de la Clase 2.
Teoría 5. Clausuras: la lambda recuerda dónde nació
Una lambda puede usar variables del sitio donde se escribió, aunque se ejecute mucho después y desde otro sitio:
fun main() {
var pulsaciones = 0 // local de main()
val boton = Boton(1)
boton.listener = OnPulsado { // captura pulsaciones
pulsaciones++
println("Van $pulsaciones")
}
boton.pulsar(); boton.pulsar()
}Boton.pulsar() no tiene ni idea de que existe pulsaciones… pero la lambda sí. Eso es una clausura (closure): función + entorno.
1 · Función + entorno
La lambda recuerda las variables del lugar donde nació, no del lugar donde la llaman.
2 · Captura la variable, no una copia
Si la lambda incrementa pulsaciones, el cambio se ve fuera. En Java solo se pueden capturar variables efectivamente finales.
3 · Vive lo que viva el callback
Mientras el botón guarde la lambda, todo lo que esta referencia sigue en memoria.
Van 1, Van 2Van 1 y Van 2: la lambda incrementa la misma variable pulsaciones en cada llamada.
- Una copia del valor 0 en el momento de crearse
- Nada:
pulsar()le pasa el valor como parámetro - La propia variable
pulsaciones, así que sus cambios se ven fuera - Solo el objeto
boton
Kotlin captura la variable. Por eso el contador avanza. Java, en cambio, solo te deja capturar variables efectivamente finales (que no cambian).
Teoría 6. Delegación: la pantalla firma el contrato
Otra forma muy común en Android (y en Java en general): la propia clase implementa la interfaz del listener y se entrega a sí misma con this.
class PantallaLogin : AppCompatActivity(), View.OnClickListener {
override fun onCreate(b: Bundle?) {
super.onCreate(b)
setContentView(R.layout.login)
findViewById<Button>(R.id.btnEntrar).setOnClickListener(this)
findViewById<Button>(R.id.btnSalir).setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.btnEntrar -> entrar()
R.id.btnSalir -> finish()
}
}
}- La pantalla firma el contrato. Al implementar
OnClickListener, la Activity se convierte en un callback válido. - Dos fuentes, un mismo delegado. Cada botón solo sabe a quién avisar (
this). El botón sigue siendo reutilizable: no sabe nada del login. - El delegado reparte.
when (v.id)asocia fuente → reacción. Es, literalmente, una tabla de despacho escrita a mano.
- El botón llama a
finish()directamente - El botón llama a
onClick(v)de la pantalla conv.id == R.id.btnSalir, y elwheneligefinish() - Android llama a
onCreatede nuevo y registra otra vez los botones - El botón llama a
entrar()y luego afinish()
El botón invoca el delegado (onClick) y es el delegado quien despacha según v.id.
when (v.id) dentro de onClick, en el vocabulario de la unidad?- Una tabla de despacho escrita a mano (fuente → reacción)
- Un callback diferido
- Una clausura
- Un listener por botón
Asocia cada fuente con su reacción: una tabla de despacho hecha con código. En la Clase 2 la convertiremos en datos.
Teoría 7. ¿Ahora o después? Síncrono frente a diferido
La sintaxis de entregar un callback es idéntica en lista.forEach { … } y en boton.setOnClickListener { … }. Lo que cambia es qué hace con él quien lo recibe.
Síncrono · forEach { … }
Se invoca antes de que retorne la función que lo recibe, en la misma pila y el mismo hilo. Ej.: forEach, map, sortedBy, el descargar() de antes.
Diferido / asíncrono · setOnClickListener { … }
La función solo lo guarda y retorna. Lo invoca más tarde el bucle de eventos u otro hilo: 0, 1 o N veces. Ej.: listeners de UI, temporizadores, red, sensores.
| ¿Cuándo corre? | Callback síncrono | Callback diferido / asíncrono |
|---|---|---|
| Momento | Antes de que retorne la función que lo recibe. | Después: cuando ocurra el suceso (o nunca). |
| Pila de llamadas | La del llamante: en el depurador ves quién te llamó. | La del bucle de eventos o la de otro hilo. |
| Excepciones | Un try/catch alrededor de la llamada las captura. |
El try/catch del registro no las ve: hay que tratarlas dentro. |
| Hilo | El mismo que hizo la llamada. | Puede ser otro (hilo de UI, del timer, de red). |
| Estado | No cambia entre el registro y la ejecución. | Puede haber cambiado: compruébalo de nuevo al entrar. |
| Vida de lo capturado | Dura lo que la llamada. | Dura mientras la fuente guarde el callback. |
function aplicar(x, cb) { cb(x); }
aplicar(5, console.log); // síncrono: imprime ya
setTimeout(() => console.log(5), 0); // diferido: queda programadoLa primera reacción se ejecuta antes de que aplicar retorne; la segunda queda en la cola y la ejecutará el mismo hilo cuando termine el código actual, aunque el retardo sea 0.
lista.forEach { … }boton.setOnClickListener { … }lista.sortedBy { … }setTimeout(() => …, 0)- El
descargar(url) { … }de antes lista.map { … }
forEach, sortedBy, map y nuestro descargar() llaman al callback antes de volver. Los listeners de UI y los temporizadores solo lo guardan.
boton.setOnClickListener { error("fallo") } en un try/catch. Al pulsar el botón, ¿quién captura la excepción?- Tu
try/catch, porque rodea el código de la lambda - Nadie de tu
try/catch: cuando se lanza, el registro ya terminó hace rato - El compilador la convierte en un aviso
- La propia lambda, automáticamente
El callback es diferido: se ejecuta en otra pila (la del bucle de eventos). El try/catch del registro ya no existe. Hay que tratar las excepciones dentro del callback.
aplicar(5, console.log); setTimeout(() => console.log("T"), 0); console.log("fin");aplicar llama al callback antes de retornar (5). setTimeout solo programa; se imprime fin y, cuando el código actual termina, el bucle ejecuta el callback (T).
Ejercicio 8. Predice: A, B, C, D con un Timer
Dos callbacks con la misma pinta. Predice la salida antes de ejecutar (en clase: 1 minuto en pareja).
import java.util.Timer
import kotlin.concurrent.schedule
fun main() {
println("A")
listOf(1, 2).forEach { println("B$it") }
Timer().schedule(1000) { println("C") }
println("D")
}Piénsalo así:
- ¿Qué llamadas ejecutan el callback antes de retornar?
- ¿Cuáles solo lo guardan para más tarde?
- Cuando por fin se ejecute «C», ¿seguirá
main()en marcha? ¿En qué hilo correrá?
Cuando tengas tu predicción, compruébala en la línea temporal:
forEach ejecuta su lambda antes de retornar (B1, B2). schedule la guarda y retorna enseguida, así que se imprime D. Un segundo después, el hilo del Timer imprime C.
println("C")?- En
main, cuando termina - En el hilo de UI
- En el hilo del Timer (
Timer-0) - En ninguno: no llega a ejecutarse
El Timer tiene su propio hilo. Diferido aquí sí implica otro hilo (aunque no siempre es así).
main() en marcha?- Sí,
main()espera bloqueada enschedulehasta que pase el segundo - No:
main()ya retornó; la lambda sigue viva porque elTimerla guarda - Sí, porque
println("D")tarda un segundo - No, y por eso C no se imprime nunca
main() terminó tras imprimir D. La lambda sobrevive porque la guarda el Timer: lo capturado vive lo que viva el callback.
Teoría 9. Listener frente a handler
Dos palabras que se usan casi como sinónimos, pero que responden a preguntas de diseño distintas.
LISTENER · «el que escucha»
Se suscribe a una fuente concreta y es avisado cuando algo ocurre. De 0 a N por fuente; la fuente no sabe quiénes son ni qué hacen. Se registra y se da de baja en ejecución: addXxxListener / removeXxxListener.
Ejemplos: OnClickListener, SensorEventListener, el consumidor de una cola.
HANDLER · «el que se hace cargo»
Es el responsable de un tipo de evento: el código al que el despacho entrega ese caso. Normalmente uno por tipo; lo elige el despachador según el evento. Puede consumir el evento e impedir que siga propagándose (p. ej. devolver true en onTouch).
Ejemplos: manejar() de agendaeventos.kt, cada rama de un when, una rutina de interrupción.
La pregunta que te tienes que hacer al diseñar:
¿Se suscribe a una fuente (listener) o atiende un tipo de evento (handler)?
Dicho de otra forma, como lo resumen los obligatorios de la unidad: suscripción = quién recibe; handler = qué hace al recibir. El listener expresa un interés continuado (puede recibir muchas notificaciones); el handler contiene la reacción que procesa un evento concreto.
- Un listener
- Un handler
- Una fuente
- Una clausura
Se hace cargo de un tipo de evento concreto: es un handler.
android.os.Handler?- El nombre que da Android a cualquier listener
- La interfaz que implementa
onClick - Una clase ligada a la cola de mensajes de un hilo
- El despachador de eventos táctiles
Comparte nombre, pero es otra cosa: la vía para encolar mensajes o tareas en la cola de un hilo.
Teoría 10. Una fuente por dentro: lista, alta y baja
¿Cómo está hecha por dentro una fuente que admite varios listeners? Una lista y tres operaciones:
typealias Listener = (String) -> Unit
class Sensor {
private val listeners = mutableListOf<Listener>()
fun addListener(l: Listener) { listeners += l }
fun removeListener(l: Listener) { listeners -= l }
fun publicar(dato: String) {
for (l in listeners.toList()) l(dato)
}
}1 · La lista es de la fuente
Es privada: desde fuera solo se puede pedir alta o baja. Cuándo se recorre lo decide la fuente.
2 · Se recorre una copia
toList() hace una copia. Así un listener puede darse de baja (o añadir otro) dentro de su propio callback sin romper la iteración.
3 · La baja exige la misma referencia
Dos lambdas con el mismo texto son dos objetos. Guarda el listener en una val si piensas quitarlo.
val enPantalla: Listener = { println("UI: $it") }
sensor.addListener(enPantalla)
sensor.removeListener(enPantalla) // OK
sensor.removeListener { println("UI: $it") } // no quita nadatoList() en publicar()?- Para que los listeners se ejecuten en paralelo
- Para ordenar los listeners por prioridad
- Para que un listener pueda darse de alta o de baja durante la difusión sin romper la iteración
- Para convertir el
Stringen una lista de caracteres
Se recorre una copia estable: las altas y bajas que ocurran durante la ronda modifican la lista real, no la que se está recorriendo.
listeners es private?- Para que solo la fuente decida cuándo se recorre y desde fuera solo se pueda pedir alta o baja
- Porque Kotlin no permite listas públicas de funciones
- Para que la recolección de basura la libere antes
- Para que los listeners no se ejecuten dos veces
La lista es de la fuente. Si fuese pública, cualquiera podría vaciarla, reordenarla o invocar a los listeners cuando quisiera.
Ejercicio 11. Encuentra el fallo: la baja que no da de baja
Un compañero escribe esto y jura que ha quitado el listener:
val sensor = Sensor()
sensor.addListener { println("UI: $it") }
sensor.addListener { println("LOG: $it") }
// … más tarde, al cerrar la pantalla
sensor.removeListener { println("UI: $it") }
sensor.publicar("21 ºC")Antes de mirar las preguntas, razona:
- ¿Cuántos objetos función hay en la lista antes de
removeListener? - ¿Qué objeto le estás pidiendo a la lista que quite?
- ¿Está ese objeto en la lista?
Ver solución comentada
La lista tiene dos lambdas. removeListener { … } crea una tercera lambda, que no está en la lista, así que -= no encuentra nada que quitar. Al publicar se imprimen UI: 21 ºC y LOG: 21 ºC. Arreglo: guardar el listener en una val y usar esa misma referencia en el alta y en la baja (o, como veremos en la Clase 2, que el alta devuelva una suscripción cancelable).
sensor.publicar("21 ºC")?- Solo
LOG: 21 ºC UI: 21 ºCyLOG: 21 ºC- Nada:
removeListenervació la lista - Lanza una excepción porque el listener no existe
La baja no quitó nada: se le pasó un objeto nuevo. Los dos listeners siguen ahí. Y -= sobre una lista no falla si el elemento no está.
- Guardar la lambda en
val enPantallay usar esa mismavalenaddListeneryremoveListener - Llamar a
removeListener { … }dos veces para asegurarse - Hacer que
addListenerdevuelva un objeto/función que, al invocarlo, quite exactamente ese listener - Escribir la lambda de la baja exactamente igual, con los mismos espacios
Solo funciona usar la misma referencia, o que la fuente te devuelva una suscripción cancelable que ya la recuerda por ti.
Teoría 12. Varios suscriptores en una misma fuente
Una misma fuente, un mismo evento, N reacciones independientes:
Es una difusión (broadcast): cada listener recibe su propia llamada con el mismo evento, una detrás de otra. Cuatro reglas:
- El orden habitual es el de alta… pero solo es un contrato si la documentación lo dice. El DOM lo garantiza; Swing, no.
- No dependas del orden. Si B necesita que A haya corrido antes, no son dos listeners: es uno solo, o A debe emitir su propio evento.
- Una excepción corta la cadena. Si el 2 falla, el 3 no se entera. Una fuente robusta envuelve cada llamada en
try/catch. - Consumir el evento. Algunos despachos permiten parar la propagación: devolver
true,stopPropagation()…
fun publicar(dato: String) {
for (l in listeners.toList()) {
try { l(dato) }
catch (e: Exception) { println("listener falló: ${e.message}") }
}
}publicar() original (sin try/catch), enFichero lanza una excepción. ¿Qué le pasa a alarma?- Se ejecuta igualmente, después del error
- Se ejecuta antes que
enFicheropara evitar el fallo - No se entera: la excepción corta el bucle de difusión
- Se ejecuta dos veces para compensar
La excepción sale del bucle for y los listeners posteriores no se llaman. Por eso una fuente robusta envuelve cada llamada.
- Swing
- El DOM del navegador
- En todas: es una ley general
- En ninguna
El DOM lo garantiza; Swing no. Por eso la regla es: no presupongas el orden salvo que la documentación lo prometa.
- Registrar A antes que B y confiar en el orden
- Fusionarlos en uno solo, o que A emita su propio evento al que se suscriba B
- Registrar B dos veces
- Añadir un
Thread.sleepen B
Si hay dependencia, no son dos listeners independientes: o son uno, o A publica un evento propio («ya he terminado») al que escucha B.
Repaso 13. Repaso de la Clase 1
| Concepto | Pregunta que responde |
|---|---|
| Listener | ¿Quién se suscribe a esta fuente? |
| Handler | ¿Quién se hace cargo de este tipo de evento? |
| Delegación | ¿A qué objeto avisa la fuente para que decida? |
Para pensar antes de la Clase 2
- Una pantalla se registra como listener del GPS y el usuario la cierra. ¿Quién la da de baja?
- Nuestro
when (v.id)crece con cada botón nuevo. ¿Cómo asociar evento → reacción con datos en vez de con código? - En C no hay lambdas ni clausuras. ¿Cómo le llega a un callback «su» contexto?
- El síncrono se escribe con lambda y el diferido con clase anónima
- El síncrono se invoca antes de que retorne la función que lo recibe; el diferido se guarda y se invoca después (o nunca)
- El diferido siempre se ejecuta en otro hilo
- El síncrono solo existe en JavaScript
La diferencia está en quién recibe el callback y qué hace con él: ejecutarlo ya o guardarlo. La sintaxis es idéntica, y diferido no implica otro hilo.
removeListener para quitar de verdad un listener? (dos o tres palabras)la misma referenciaLa misma referencia que se usó en el alta. Dos lambdas iguales en texto son objetos distintos.
- Una referencia ligada
obj::fretiene aobjmientras la fuente la guarde - Un
try/catchalrededor desetOnClickListenercaptura las excepciones del clic - En Java, una lambda solo puede capturar variables efectivamente finales
- Si un listener depende de que otro se ejecute antes, basta con registrarlo después
when (v.id)en unonClickcompartido es una tabla de despacho escrita a mano
El try/catch del registro no ve excepciones diferidas, y depender del orden de alta es frágil salvo que la plataforma lo garantice.
Ciclo de vida y despacho
Quién da de baja a un listener y por qué olvidarlo fuga memoria; el mismo concepto de referencia a función en seis lenguajes; tablas de despacho en C, Kotlin y JS, y el contexto (void *ctx) que acompaña a cada callback.
Teoría 1. El listener olvidado
Repaso exprés de la Clase 1:
Callback
Función que entregas para que otro la invoque. Tú decides QUÉ; quien la guarda decide CUÁNDO. Si es diferido: otra pila, quizá otro hilo.
Listener
Callback suscrito a una FUENTE. De 0 a N por fuente, con alta y baja en ejecución. La baja exige la misma referencia.
Handler
Callback responsable de un TIPO de evento. Lo elige el despacho. Hasta ahora, escrito a mano: when (evento) { … }.
Hoy: quién es el dueño de cada callback y cuánto debe vivir, y cómo montar el despacho para que sirva igual en Android, en un servidor o en un ESP32.
Quien guarda un callback mantiene vivo todo lo que ese callback referencia
Cada flecha es una referencia fuerte: mientras exista la primera caja, existe la última. El recolector de basura no puede liberar nada de lo que todavía es alcanzable desde el GPS.
Síntoma 1 · Fuga de memoria
El usuario cierra la pantalla, pero el GPS sigue apuntando a ella: ni la pantalla ni sus vistas pueden liberarse. Diez aperturas = diez pantallas en memoria.
Síntoma 2 · Callbacks fantasma
La fuente sigue invocando al listener de una pantalla que ya no existe: trabajo inútil, estado incoherente y, a menudo, una excepción.
- Porque Android nunca libera Activities
- Porque el GPS guarda la lambda y la lambda captura
this(la pantalla): hay una cadena de referencias fuertes desde algo que vive toda la app - Porque la lambda se ejecuta en otro hilo
- Porque
pintar()es una función privada
Gps → listeners → lambda → PantallaMapa. Mientras el GPS viva, toda la cadena es alcanzable y no se puede liberar.
- Fuga de memoria: pantallas cerradas que siguen ocupando memoria
- Error de compilación en el
addListener - Callbacks fantasma: se invoca código de una pantalla que ya no existe
- El GPS deja de emitir posiciones
El compilador no avisa de nada: el fallo aparece en ejecución, como fuga y como callbacks fantasma.
Teoría 2. Regla nº 1: toda alta tiene su baja
Se registra y se desregistra en métodos simétricos del ciclo de vida, con la misma referencia.
class PantallaMapa : AppCompatActivity() {
private val alMoverse: Listener = { pos ->
pintar(pos) // captura this
}
override fun onStart() { // alta
super.onStart()
Gps.addListener(alMoverse)
}
override fun onStop() { // baja
super.onStop()
Gps.removeListener(alMoverse)
}
}- Una sola referencia. El listener vive en una propiedad: es el mismo objeto en el alta y en la baja.
- Alta cuando empiezo a necesitarlo.
onStart: la pantalla se hace visible. Antes no tiene sentido recibir posiciones. - Baja en el método simétrico.
onStart ↔ onStop,onCreate ↔ onDestroy,onResume ↔ onPause. Nunca cruzados.
El mismo patrón en la web
class PantallaMapa {
alMoverse = (pos) => { // captura this
this.pintar(pos);
};
montar() { gps.addListener(this.alMoverse); } // alta
desmontar() { gps.removeListener(this.alMoverse); } // baja
}La función flecha en un campo fija this y es el mismo objeto en el alta y en la baja. montar equivale a onStart: connectedCallback en un Web Component o el efecto en React. desmontar es disconnectedCallback o la función de limpieza del efecto. Si se olvida, la misma fuga que en Android.
onResume, ¿en qué método lo das de baja?onPauseonResume ↔ onPause. Cada alta en su pareja simétrica.
alMoverse es un campo con función flecha y no se escribe gps.addListener((p) => this.pintar(p))?- Porque las funciones flecha son más rápidas
- Porque así fija
thisy es la misma referencia en el alta y en la baja - Porque JavaScript no admite lambdas como argumento
- Porque así el listener se ejecuta de forma síncrona
Una flecha escrita dentro de addListener sería un objeto nuevo imposible de pasar luego a removeListener. El campo da una sola referencia.
- En la función de limpieza que devuelve el efecto
- En el constructor
- En ningún sitio: React da de baja solo
- En el siguiente renderizado
La función de limpieza del efecto es el «desmontar» simétrico del alta.
Ejercicio 3. Revisión de código: tres pantallas
Haces de revisor. Tres compañeros han escrito su pantalla de mapa. Gps es un object que vive toda la app.
// Pantalla 1
class P1 : AppCompatActivity() {
private val alMoverse: Listener = { pos -> pintar(pos) }
override fun onCreate(b: Bundle?) { super.onCreate(b); Gps.addListener(alMoverse) }
override fun onDestroy() { super.onDestroy(); Gps.removeListener(alMoverse) }
}
// Pantalla 2
class P2 : AppCompatActivity() {
private val alMoverse: Listener = { pos -> pintar(pos) }
override fun onStart() { super.onStart(); Gps.addListener(alMoverse) }
override fun onPause() { super.onPause(); Gps.removeListener(alMoverse) }
}
// Pantalla 3
class P3 : AppCompatActivity() {
override fun onStart() { super.onStart(); Gps.addListener { pos -> pintar(pos) } }
override fun onStop() { super.onStop(); Gps.removeListener { pos -> pintar(pos) } }
}Para cada una pregúntate: ¿alta y baja son simétricas? ¿Usan la misma referencia?
Ver solución comentada
P1 es correcta: onCreate ↔ onDestroy y la misma propiedad. (Recibe posiciones también cuando no es visible, pero no fuga.)
P2 cruza pares: alta en onStart, baja en onPause. Tras un diálogo (onPause → onResume) se queda dada de baja aunque sigue visible, porque onStart no se vuelve a llamar.
P3 crea una lambda nueva en la baja: no quita nada. Cada apertura deja una lambda (y su pantalla) colgada del GPS.
- P1
- P2
- P3
P2 cruza onStart/onPause; P3 da de baja una lambda distinta de la registrada.
- Nada: cada
removeListenerquita la suya - Una sola lambda, la última
- Diez lambdas, cada una reteniendo su pantalla: fuga y callbacks fantasma
- Veinte lambdas, porque cada baja añade una
Las bajas nunca encuentran su lambda. Quedan diez lambdas y, con ellas, diez pantallas en memoria recibiendo posiciones.
Teoría 4. ¿Quién es el dueño? Cuatro estrategias
Ownership: quién responde de que el callback deje de existir a tiempo.
1 · Baja explícita
El suscriptor se da de baja en el método simétrico. Simple, pero depende de la disciplina de quien programa.
2 · Suscripción cancelable
El alta devuelve un objeto «suscripción». Quien lo guarda es el dueño; cancelarlo es la baja.
3 · Referencia débil
La fuente guarda WeakReference: no impide liberar al listener. La limpieza llega tarde y sin avisar.
4 · Ligado al ciclo de vida
El alta recibe el ciclo de vida del suscriptor y la fuente da de baja sola (LiveData, DisposableEffect).
Estrategia 2 en Kotlin
fun interface Suscripcion { fun cancelar() }
class Sensor {
private val listeners = mutableListOf<Listener>()
fun addListener(l: Listener): Suscripcion {
listeners += l
return Suscripcion { listeners -= l }
}
}
val s = sensor.addListener { println(it) }
// …
s.cancelar() // baja sin guardar la lambdaLa suscripción recuerda por ti la referencia exacta. Adiós al problema de la lambda distinta.
Estrategia 2 en JavaScript (la habitual)
class Sensor {
#listeners = [];
addListener(l) {
this.#listeners.push(l);
return () => this.removeListener(l); // el alta devuelve la baja
}
}
const cancelar = sensor.addListener(console.log);
cancelar();En el DOM existe AbortController:
const control = new AbortController();
boton.addEventListener("click", alPulsar, { signal: control.signal });
// …
control.abort(); // da de baja todo lo ligado a esa señal- Impide liberar al listener
- La limpieza llega tarde y sin avisar: el listener puede seguir recibiendo eventos hasta que actúe el recolector
- Solo funciona en C
- Obliga a guardar la lambda en una
val
La referencia débil no retiene, pero no sabes cuándo desaparecerá: mientras tanto puede haber callbacks fantasma.
- La fuente
- El recolector de basura
- Quien guarda el objeto suscripción
- El hilo principal
Quien guarda la suscripción (o la función cancelar, o el AbortController) es el dueño; cancelarla es la baja.
signal?AbortControllerAbortController: al llamar a abort(), todos los listeners registrados con su signal se dan de baja.
Teoría 5. Una idea, seis sintaxis (y el caso base en C)
Para despachar eventos solo hace falta poder guardar «qué función llamar» en una variable. Todos los lenguajes lo permiten:
| Lenguaje | Mecanismo | Guardar una referencia a la función | ¿Con contexto? |
|---|---|---|---|
| C | Puntero a función | void (*cb)(int) = al_pulsar; |
No |
| C++ | std::function, lambdas |
std::function<void(int)> cb = [&](int id) { … }; |
Sí (captura) |
| C# | Delegados y eventos | boton.Click += AlPulsar; |
Sí (objeto) |
| Java | Interfaz funcional, this::m |
boton.addActionListener(this::alPulsar); |
Sí (objeto) |
| Kotlin | Tipos función, lambdas, ::f |
val cb: (Int) -> Unit = ::alPulsar |
Sí (captura) |
| Python / JS | Funciones de primera clase | btn.addEventListener("click", alPulsar) |
Sí (clausura) |
Fíjate en la última columna: C es el único que entrega solo la dirección del código. Lo resolveremos con void *ctx.
Punteros a función en C
Si se entiende aquí, se entiende en cualquier lenguaje: es lo que hay debajo de todos los demás.
typedef enum { EV_BOTON, EV_TIMER, EV_UART, EV_COUNT } ev_tipo_t;
typedef struct {
ev_tipo_t tipo;
int dato;
} evento_t;
/* El «tipo función» de C */
typedef void (*handler_t)(const evento_t *ev, void *ctx);
static void al_pulsar(const evento_t *ev, void *ctx) {
printf("Botón %d\n", ev->dato);
}
handler_t h = al_pulsar; /* guardar */
h(&ev, NULL); /* invocar */- El evento es un dato: un tipo (
enum) más su carga útil. Igual que lasealed class EventoAgendade la U1. - El contrato es un
typedef: equivale a(Evento, Ctx) -> Uniten Kotlin; fija parámetros y retorno de todo handler. - El nombre es la dirección: sin paréntesis,
al_pulsares un valor que se guarda, se pasa… y se mete en una tabla.
- Kotlin
- C#
- JavaScript
- C
Un puntero a función de C es solo una dirección. El contexto hay que pasarlo aparte (void *ctx).
handler_t?typedeftypedef void (*handler_t)(const evento_t *ev, void *ctx); define el contrato de todos los handlers.
handler_t h = al_pulsar;?- Llama a
al_pulsary guarda lo que devuelve - Guarda la dirección de
al_pulsarenh, sin llamarla - Copia el código de la función en
h - No compila: falta
&
Sin paréntesis, el nombre de la función es su dirección. La llamada llega después con h(&ev, NULL).
Teoría 6. Del switch a la dispatch table
La asociación tipo de evento → handler deja de ser código y pasa a ser un dato.
Antes · el código decide
void despachar(const evento_t *ev) {
switch (ev->tipo) {
case EV_BOTON: al_pulsar(ev, NULL); break;
case EV_TIMER: al_vencer(ev, NULL); break;
case EV_UART: al_recibir(ev, NULL); break;
default: break;
}
}Después · los datos deciden
static const handler_t tabla[EV_COUNT] = {
[EV_BOTON] = al_pulsar,
[EV_TIMER] = al_vencer,
[EV_UART] = al_recibir,
};
void despachar(const evento_t *ev) {
if (ev->tipo < EV_COUNT && tabla[ev->tipo])
tabla[ev->tipo](ev, NULL);
}Coste constante
Un acceso por índice, haya 3 eventos o 300.
Crecer = añadir una fila
despachar() no se vuelve a tocar.
Modificable en ejecución
Sin const, la tabla admite registrar() y dar de baja.
Siempre validar
Índice en rango y entrada no nula antes de llamar.
switch?- Coste constante: un acceso por índice
- Elimina la necesidad de validar el índice
- Añadir un evento es añadir una fila;
despachar()no se toca - El compilador comprueba que todos los tipos tienen handler
- Sin
const, se puede registrar y dar de baja en ejecución
Validar sigue siendo obligatorio (índice en rango y entrada no nula), y el compilador no garantiza que todas las filas estén rellenas.
ev->tipo < EV_COUNT && tabla[ev->tipo]?- Para ordenar los eventos por prioridad
- Para no salirse de la tabla y no llamar a un puntero nulo
- Para que el despacho sea síncrono
- Para liberar el evento después de procesarlo
Un índice fuera de rango leería memoria ajena; una entrada NULL haría saltar a la dirección 0. Ambas cosas cuelgan el programa (o algo peor).
Ejercicio 7. Traza la tabla en C
Con esta tabla (fíjate en qué filas están rellenas):
static void al_pulsar(const evento_t *ev, void *ctx) { printf("Botón %d\n", ev->dato); }
static void al_vencer(const evento_t *ev, void *ctx) { printf("Timer\n"); }
static const handler_t tabla[EV_COUNT] = {
[EV_BOTON] = al_pulsar,
[EV_TIMER] = al_vencer,
};
void despachar(const evento_t *ev) {
if (ev->tipo < EV_COUNT && tabla[ev->tipo])
tabla[ev->tipo](ev, NULL);
}…se despachan, en este orden, estos eventos:
| # | tipo | dato |
|---|---|---|
| 1 | EV_BOTON |
1 |
| 2 | EV_UART |
65 |
| 3 | EV_TIMER |
0 |
| 4 | 7 (valor corrupto) |
9 |
| 5 | EV_BOTON |
2 |
Recorre la tabla a mano: para cada evento, ¿pasa la validación? ¿Qué handler se llama?
Ver traza
1 → al_pulsar: «Botón 1». 2 → índice válido pero tabla[EV_UART] es NULL: no hace nada. 3 → «Timer». 4 → 7 no es menor que 3: se descarta. 5 → «Botón 2».
3Tres: «Botón 1», «Timer» y «Botón 2».
ev->tipo < EV_COUNT, ¿qué pasa con el evento 4?- Nada: C ignora los índices fuera de rango
- Se llama a
al_pulsarpor defecto - Se lee fuera de la tabla y se salta a una dirección cualquiera: comportamiento indefinido
- El compilador lo detecta y no compila
C no comprueba los límites de un array. tabla[7] lee memoria ajena y se intenta llamar a lo que haya allí.
EV_ADC. Con la tabla, ¿qué tocas?- Añades el valor al
enum(antes deEV_COUNT) y una fila[EV_ADC] = al_medir;despachar()no cambia - Añades un
case EV_ADCdentro dedespachar() - Reescribes
despachar()con un bucle - Nada: la tabla detecta el evento sola
Crecer = añadir una fila. Esa es la gracia de pasar del código a los datos.
Teoría 8. La misma tabla en Kotlin y JS, con varios suscriptores
Un mapa tipo de evento → lista de handlers: aquí se encuentran listener y handler.
enum class Tipo { ANADIR, LISTAR, ELIMINAR, SALIR }
data class Evento(val tipo: Tipo, val dato: String = "")
typealias Handler = (Evento) -> Unit
class Despachador {
private val tabla = mutableMapOf<Tipo, MutableList<Handler>>()
fun on(tipo: Tipo, h: Handler) {
tabla.getOrPut(tipo) { mutableListOf() } += h
}
fun despachar(ev: Evento) {
tabla[ev.tipo]?.toList()?.forEach { it(ev) }
}
}
d.on(Tipo.ANADIR) { tareas += it.dato }
d.on(Tipo.ANADIR, ::registrarEnLog) // 2º suscriptor
d.despachar(Evento(Tipo.ANADIR, "Estudiar U2"))- La tabla: clave = tipo de evento; valor = lista de handlers. Es la lista de callbacks de la Clase 1, una por tipo.
- Registrar = escribir en la tabla:
on()crea la fila si no existe. Lambdas y referencias::fconviven. - Despachar = buscar y difundir: sin
when. El bucle de eventos no sabe qué significa ningún evento: solo entrega.
class Despachador {
#tabla = new Map(); // tipo -> [handlers]
on(tipo, h) {
if (!this.#tabla.has(tipo)) this.#tabla.set(tipo, []);
this.#tabla.get(tipo).push(h);
}
despachar(ev) {
const lista = this.#tabla.get(ev.tipo) ?? [];
[...lista].forEach((h) => h(ev));
}
}
d.on("ANADIR", (ev) => tareas.push(ev.dato));
d.on("ANADIR", registrarEnLog);
d.despachar({ tipo: "ANADIR", dato: "Estudiar U2" });Es, en pequeño, lo que hacen por dentro EventEmitter en Node y EventTarget en el navegador.
despachar(), ¿para qué sirven toList() (Kotlin) y [...lista] (JS)?- Para recorrer una copia: un handler puede dar de alta o de baja sin romper la ronda
- Para ordenar los handlers
- Para convertir el evento en texto
- Para ejecutar los handlers en paralelo
La misma idea que en Sensor.publicar: difundir sobre una foto estable de la lista.
d.on("ANADRI", h) por error. ¿Qué ocurre al despachar { tipo: "ANADIR" }?- Error de sintaxis al cargar el script
- Excepción en
despachar() - Se llama a
higualmente hno se llama nunca y nadie avisa
Son dos filas distintas de la tabla. El fallo es silencioso: por eso conviene usar constantes o tipos.
Ejercicio 9. Predice la salida del despachador
Usa el Despachador de Kotlin del paso anterior:
val tareas = mutableListOf<String>()
fun registrarEnLog(ev: Evento) = println("log: ${ev.tipo} ${ev.dato}")
fun main() {
val d = Despachador()
d.on(Tipo.ANADIR) { tareas += it.dato }
d.on(Tipo.ANADIR, ::registrarEnLog)
d.on(Tipo.LISTAR) { println("tareas: $tareas") }
d.despachar(Evento(Tipo.ANADIR, "Estudiar U2"))
d.despachar(Evento(Tipo.ELIMINAR, "x"))
d.despachar(Evento(Tipo.ANADIR, "Repasar C"))
d.despachar(Evento(Tipo.LISTAR))
}Pistas para la traza:
- Para
ANADIRhay dos handlers en la fila: el primero no imprime nada, el segundo sí. - ¿Hay fila para
ELIMINAR? Mira qué hacetabla[ev.tipo]?cuando la clave no existe. printlnde unaMutableListimprime sus elementos entre corchetes, separados por coma y espacio.
Cada ANADIR añade la tarea y la registra en el log; ELIMINAR no tiene fila; LISTAR imprime la lista acumulada.
Evento(Tipo.ELIMINAR, "x")?- Lanza
NullPointerException - Nada: no hay fila,
tabla[ev.tipo]esnully el?.corta la cadena - Se ejecutan los handlers de
ANADIR - Elimina la última tarea
Sin fila no hay nada que difundir. Igual que en el bus de la Clase 3: si nadie escucha, el evento se pierde en silencio.
Teoría 10. Callback table frente a dispatch table
Las dos son tablas de referencias a función. Cambia quién elige la entrada y cuándo.
Callback table
typedef struct {
void (*al_conectar)(void *ctx);
void (*al_recibir)(const uint8_t *buf, size_t n, void *ctx);
void (*al_cerrar)(void *ctx);
} red_callbacks_t;
red_iniciar(&mis_callbacks, &mi_ctx);Dispatch table
static const handler_t tabla[EV_COUNT] = {
[EV_BOTON] = al_pulsar,
[EV_TIMER] = al_vencer,
[EV_UART] = al_recibir,
};
tabla[ev->tipo](ev, ctx);| Callback table | Dispatch table | |
|---|---|---|
| Entradas | Con nombre, fijadas al compilar. | Elegidas por un dato en ejecución: tipo de evento, código de operación, comando recibido. |
| Uso | El cliente rellena los huecos que le interesan y entrega el struct entero. | El despachador indexa la tabla con el dato que llega. |
| Es… | Una interfaz: vtables de C++, drivers, pilas de red o Bluetooth. | Un selector: bucles de eventos, intérpretes, tablas de interrupciones, protocolos. |
Los obligatorios de la unidad lo resumen así: la callback table guarda funciones o delegados junto a su contexto; la dispatch table asocia cada tipo de evento con uno o varios handlers. La sintaxis cambia entre lenguajes; la asociación evento → reacción permanece.
struct con al_conectar, al_recibir y al_desconectar. Es una…- Dispatch table
- Callback table
- Cola de eventos
- Suscripción cancelable
Entradas con nombre, fijadas al compilar, que tú rellenas: una callback table (una interfaz).
- Callback table
- Clausura
- Dispatch table
- Referencia débil
La entrada la elige un dato en ejecución (el código de operación): dispatch table.
Teoría 11. void *ctx: el contexto que acompaña al callback
En C no hay clausuras. ¿Cómo sabe un handler sobre qué LED actuar? Quien registra entrega función + puntero opaco; quien despacha devuelve ese puntero en cada llamada.
typedef struct { handler_t fn; void *ctx; } entrada_t;
static entrada_t tabla[EV_COUNT];
void registrar(ev_tipo_t t, handler_t fn, void *ctx) {
tabla[t] = (entrada_t){ fn, ctx };
}
typedef struct { int pin; bool encendido; } led_t;
static void conmutar(const evento_t *ev, void *ctx) {
led_t *led = ctx; /* recuperar contexto */
led->encendido = !led->encendido;
gpio_set_level(led->pin, led->encendido);
}
static led_t rojo = { .pin = 2 }, verde = { .pin = 3 };
registrar(EV_BOTON, conmutar, &rojo);
registrar(EV_TIMER, conmutar, &verde);- La entrada guarda la pareja función + contexto. El despachador no sabe qué hay tras el
void*: solo lo devuelve. - El handler lo recupera convirtiendo el
void*a su tipo real. Ni variables globales ni dependencia de una GUI. - Un handler, N contextos: el mismo código sirve al LED rojo y al verde.
Una clausura es función + contexto
| Kotlin · lo hace el compilador | C · lo haces tú |
|---|---|
val led = Led(pin = 2)boton.onClick { led.conmutar() } |
static led_t led = { .pin = 2 };registrar(EV_BOTON, conmutar, &led); |
APIs reales donde viaja el contexto: ESP-IDF esp_event_handler_register(…, fn, arg), FreeRTOS xTaskCreate(fn, …, pvParameters, …), POSIX pthread_create(&hilo, attr, fn, arg).
void *ctx?- Que siempre es un
led_t - Su tamaño, para copiarlo
- Nada: solo lo guarda y lo devuelve en cada llamada
- Que es una variable global
Es un puntero opaco: el despachador no lo interpreta. Solo el handler sabe convertirlo a su tipo real.
void configurar(void) { led_t azul = { .pin = 4 }; registrar(EV_UART, conmutar, &azul); }- Nada, es correcto
azules local: deja de existir al retornarconfigurar()y la tabla se queda con un puntero colgante- Falta un
free(azul) conmutarno acepta LEDs azules
El contexto debe vivir más que el registro. Usa static, una global o memoria dinámica que liberes después de la baja.
malloc, ordena el final de su vida.Si liberas primero, entre medias un despacho podría usar memoria ya liberada. Primero baja, después free().
Teoría 12. El despacho completo, sin ninguna GUI
Las mismas cuatro piezas en una app Android, en un servidor o en un microcontrolador:
Ejemplo de tabla en ejecución:
| Tipo | Handler | Contexto |
|---|---|---|
EV_BOTON |
conmutar |
&rojo |
EV_TIMER |
conmutar |
&verde |
EV_UART |
al_recibir |
&buf |
Es portable porque ninguna pieza menciona botones de pantalla ni vistas: solo tipos de evento, referencias a función y un puntero de contexto.
Diseño de un dispatcher
registrar(tipo, callback, contexto)devuelve una suscripción identificable.despachar(evento)localiza los handlers por tipo y aplica la política de orden.desregistrar(suscripción)retira el interés y define qué pasa con las entregas pendientes.
La tabla de despacho separa la fuente del código de cada reacción. En C++ moderno, la misma idea cabe en tres líneas:
using Handler = std::function<void(int)>;
std::unordered_map<int, Handler> t;
t[1] = [](int x) { log(x); };
t.at(tipo)(dato);Aquí el contexto puede viajar en el evento o en la clausura (la lambda captura lo que necesite).
Crear → buscar → seleccionar → invocar → continuar. Ninguno de los pasos necesita una pantalla.
- Porque todos usan Kotlin
- Porque ninguna pieza depende de la GUI: solo tipos de evento, referencias a función y contexto
- Porque todos tienen recolector de basura
- Porque el bucle de despacho conoce el significado de cada evento
El bucle no sabe qué significa cada evento; la tabla asocia tipo → reacción. Eso lo hace independiente de la plataforma.
Repaso 13. Repaso: lista de comprobación de la Unidad 2
Seis preguntas que hacerse antes de dar por bueno un diseño dirigido por eventos:
| # | Pregunta | Qué mirar |
|---|---|---|
| 1 | ¿Cuál es el contrato? | Firma del callback: parámetros, retorno y quién lo invoca. |
| 2 | ¿Síncrono o diferido? | En qué hilo y con qué pila corre; dónde se tratan sus excepciones. |
| 3 | ¿Cuántos suscriptores? | Uno o N por fuente; ¿algo depende del orden de invocación? |
| 4 | ¿Quién da de baja? | Dueño del callback, alta y baja simétricas, misma referencia o suscripción. |
| 5 | ¿Cómo se asocia evento → handler? | when/switch, o una tabla que crece añadiendo filas. |
| 6 | ¿De dónde saca el handler sus datos? | Clausura, objeto ligado o void *ctx; y cuánto vive ese contexto. |
- Baja explícita en el método simétrico
- Registrar el listener dos veces por seguridad
- Suscripción cancelable devuelta por el alta
- Referencia débil en la fuente
- Alta ligada al ciclo de vida del suscriptor
Las cuatro estrategias: baja explícita, suscripción cancelable, referencia débil y ligado al ciclo de vida.
contextoFunción + contexto: lo que en C pasas a mano con void *ctx, Kotlin lo hace solo al capturar variables.
- onCreate ↔ onDestroy
- onStart ↔ onStop
- onResume ↔ onPause
- onStart ↔ onDestroy
onStart se empareja con onStop. Cruzar pares deja la suscripción viva (o muerta) cuando no toca.
Observer, pub/sub y MVVM
Ponemos nombre a lo que ya habíamos construido: el patrón Observer del GoF con sus invariantes, el bus de publicación-suscripción con topics y la pantalla como un observador más en MVC y MVVM.
Teoría 1. Ya lo habéis escrito: el Observer del GoF
Las dos clases anteriores construyeron las piezas; hoy les ponemos nombre y contrato.
| Lo que escribiste | Cómo se llama hoy |
|---|---|
Clase 1 · Sensor: fuente con lista privada de listeners, alta, baja y difusión sobre una copia. |
Sujeto (Subject) y observadores. Le faltaba estado propio y contrato. |
Clase 2 · Despachador: Map tipo → lista de handlers; on() registra; despachar() difunde. |
Bus de eventos; el tipo era el topic. |
| Lo que falta | La pantalla como un suscriptor cualquiera: MVC / MVVM. |
Dos roles, cuatro operaciones
Sujeto · Subject / Observable
Tiene estado (p. ej. temperatura) y una lista de observadores. Operaciones: attach (suscribir), detach (desuscribir) y notify (notificar), que recorre la lista y llama a update() en cada uno.
Observador · Observer
Solo promete un método, update() (aquí actualizar(t)), que él nunca llama: se lo llama el sujeto. Pantalla pinta, Alarma avisa si se supera un umbral, Registro guarda.
Tres invariantes
- Solo el sujeto cambia su estado.
private set: nadie de fuera escribetemperatura. - Sin cambio, no hay aviso.
if (nueva == temperatura) return. - El observador no pregunta. Le llaman. No hace polling ni conoce a los demás.
Desacoplamiento: el termómetro solo sabe que le escuchan «observadores». Añadir un cuarto mañana no toca ni una línea de Termometro.
- Generic Observer Framework
- Gang of Four: los cuatro autores del libro Design Patterns (1994)
- Graph of Functions
- Google Open Framework
Gang of Four: Gamma, Helm, Johnson y Vlissides.
- Solo el sujeto cambia su estado
- Cada observador consulta periódicamente al sujeto
- Sin cambio, no hay aviso
- El observador no pregunta: le llaman
- Los observadores se conocen entre sí para coordinarse
Las otras dos son justo lo contrario: nada de polling y los observadores no se conocen.
Teoría 2. El sujeto en Kotlin: Termometro
En clase se escribe en vivo en pde-consola (Observer.kt, paso 1), nombrando cada pieza al teclearla:
interface Observador { // update(): lo llama el sujeto
fun actualizar(temperatura: Double)
}
class Termometro { // Subject / Observable
private val observadores = mutableListOf<Observador>()
var temperatura = 20.0
private set // solo el sujeto lo cambia
fun suscribir(o: Observador) { observadores += o } // attach
fun desuscribir(o: Observador) { observadores -= o } // detach
fun medir(nueva: Double) {
if (nueva == temperatura) return // sin cambio -> sin aviso
temperatura = nueva
notificar()
}
private fun notificar() {
for (o in observadores.toList()) o.actualizar(temperatura)
}
}1 · El contrato en una interfaz
Observador solo promete actualizar(). El sujeto no sabe nada más de quien le escucha.
2 · Estado con private set
Se lee desde fuera, se escribe solo desde medir(). Es lo que lo distingue del Sensor de la Clase 1, que no tenía estado.
3 · Notificar solo si cambia
Y sobre una copia de la lista, como en Sensor.publicar. notificar() es privado: nadie fuerza avisos desde fuera.
notificar() es private?- Porque Kotlin no permite funciones públicas que recorran listas
- Para que nadie de fuera fuerce avisos: solo el sujeto decide notificar cuando su estado cambia
- Para que los observadores no puedan darse de baja
- Para que sea más rápido
Si fuese pública, cualquiera podría provocar avisos sin cambio real y se rompería «sin cambio, no hay aviso».
temperatura se pueda leer desde fuera pero solo escribir desde dentro de la clase?private setprivate set en la propiedad: el getter es público, el setter privado. Invariante 1.
Ejercicio 3. Observadores que no se conocen
Tres observadores que no saben nada unos de otros; el termómetro no conoce el tipo de ninguno:
class Pantalla : Observador {
override fun actualizar(temperatura: Double) =
println("Pantalla: %.1f ºC".format(temperatura))
}
class Alarma(private val umbral: Double) : Observador {
override fun actualizar(temperatura: Double) {
if (temperatura > umbral) println("ALARMA: $temperatura > $umbral")
}
}
class Registro : Observador {
val historial = mutableListOf<Double>()
override fun actualizar(temperatura: Double) { historial += temperatura }
}
fun main() {
val termometro = Termometro()
termometro.suscribir(Pantalla())
termometro.suscribir(Alarma(umbral = 30.0))
termometro.suscribir(Registro())
termometro.medir(22.5)
termometro.medir(22.5) // igual: no notifica
termometro.medir(31.0) // salta la alarma
}(Detalle: String.format usa la configuración regional; en un equipo en español %.1f puede escribir 22,5. La salida de clase usa punto.)
Predice la salida y después juega con el laboratorio: suscribe y da de baja observadores, mide temperaturas repetidas y por encima del umbral.
main().22.5 notifica (Pantalla imprime; Alarma no salta; Registro guarda en silencio). El segundo 22.5 no notifica. 31.0 notifica: Pantalla y luego Alarma.
- Porque
Pantallaignora valores repetidos - Porque
medir()hacereturnsi el valor no cambia: sin cambio no hay aviso - Porque la lista de observadores se vacía tras la primera notificación
- Porque
Registroconsume el evento
La comprobación vive en el sujeto, no en cada observador: if (nueva == temperatura) return.
main(), ¿qué contiene el historial del Registro?- [22.5, 22.5, 31.0]
- [31.0]
- [22.5, 31.0]
- [] (vacío)
Solo recibe las dos notificaciones reales: 22.5 y 31.0.
Teoría 4. El mismo Observer en JavaScript
Sin interfaces: el contrato es «tener un método actualizar()».
class Termometro { // sujeto
#observadores = [];
#temperatura = 20.0; // privado: solo lo cambia el sujeto
suscribir(o) { this.#observadores.push(o); }
desuscribir(o) { this.#observadores = this.#observadores.filter(x => x !== o); }
medir(nueva) {
if (nueva === this.#temperatura) return; // sin cambio -> sin aviso
this.#temperatura = nueva;
this.#notificar();
}
#notificar() {
for (const o of [...this.#observadores]) o.actualizar(this.#temperatura);
}
}
class Pantalla { actualizar(t) { console.log(`Pantalla: ${t.toFixed(1)} ºC`); } }
class Alarma {
constructor(umbral) { this.umbral = umbral; }
actualizar(t) { if (t > this.umbral) console.log(`ALARMA: ${t} > ${this.umbral}`); }
}1 · Contrato implícito
No hay interface: cualquier objeto con actualizar() vale. El error, si lo hay, aparece en ejecución, no al compilar.
2 · La baja por identidad
filter(x => x !== o): otra instancia «igual» no quita nada. Igual que removeEventListener.
3 · Observadores sin herencia
Pantalla y Alarma no comparten superclase. Duck typing puro: «si camina como un pato y grazna como un pato…».
{ actualisar(t) { … } } (con «s»). ¿Cuándo te enteras?- Al compilar, porque no implementa la interfaz
- Al suscribirlo,
suscribir()lanza un error - Al notificar:
o.actualizarno es una función y salta unTypeErroren ejecución - Nunca, JavaScript lo corrige solo
Sin interfaz, nadie comprueba el contrato hasta que se intenta llamar a actualizar().
t.suscribir(new Pantalla()); t.desuscribir(new Pantalla()); ¿Cuántos observadores quedan?- 0
- 1
- 2
- Depende del navegador
La baja compara por identidad (!==). Una segunda new Pantalla() es otro objeto: no quita nada.
Ejercicio 5. Traduce Sensor.kt y elige push o pull
Dos minutos en voz alta: el Sensor de la Clase 1, con vocabulario GoF.
En Sensor.kt (Clase 1) |
En el GoF |
|---|---|
addListener(l) |
attach / suscribir |
removeListener(l) |
detach / desuscribir |
publicar(dato) |
notify (pero sin estado propio) |
val enPantalla: Listener |
un observador cuyo update es la lambda |
| ¿Qué le falta? | estado propio y «sin cambio, no hay aviso» |
Un matiz del GoF: push frente a pull
PUSH
El sujeto empuja el dato: actualizar(temperatura). Simple. Es lo que hemos escrito y lo que hace un listener de Android.
PULL
El sujeto solo avisa: actualizar(sujeto). El observador pregunta sujeto.temperatura. Cada uno coge lo que necesita cuando el estado es grande.
Decide para cada caso:
- Caso 1. Un sensor de temperatura que avisa a una pantalla, un log y una alarma. Todos quieren el mismo
Double. - Caso 2. Un documento de un editor con texto, estilos, cursor, selección e historial. La barra de estado quiere el número de palabras; la miniatura, el texto; el botón «deshacer», el historial.
- Pull
- Push
- Ninguno: hay que usar polling
- Da igual, pull siempre es mejor
Un Double que todos necesitan: se empuja directamente en actualizar(t).
- Push: se envía el documento entero a todos en cada cambio
- Pull: se avisa con
actualizar(documento)y cada observador lee la parte que necesita - Ninguno: cada observador debe guardar su propia copia del documento
- Pub/sub con un topic por carácter
Con estado grande y necesidades distintas, cada observador tira (pull) de lo suyo.
Sensor para ser un sujeto Observer completo?- Estado propio
- Una lista de listeners
- La regla «sin cambio, no hay aviso»
- Operaciones de alta y baja
Lista, alta y baja ya las tenía. Le faltaban estado y notificar solo si cambia.
Teoría 6. Aparece un intermediario: el bus
Quien publica no tiene lista; quien se suscribe no le conoce. Los une un topic.
typealias Suscriptor = (Any) -> Unit
object Bus { // uno para toda la aplicación
private val topics = mutableMapOf<String, MutableList<Suscriptor>>()
fun suscribir(topic: String, s: Suscriptor): () -> Unit {
topics.getOrPut(topic) { mutableListOf() } += s
return { topics[topic]?.remove(s) } // función de baja (Clase 2)
}
fun publicar(topic: String, dato: Any) {
val lista = topics[topic] ?: return // nadie escucha: se pierde sin error
for (s in lista.toList()) s(dato)
}
}1 · object: uno por aplicación
Es el Despachador de la Clase 2 con el tipo sustituido por un String. En Node: EventEmitter; entre máquinas: Kafka, RabbitMQ, MQTT.
2 · suscribir devuelve la baja
El patrón de la suscripción cancelable. Quien guarda la función es el dueño.
3 · ?: return
Si nadie escucha, el mensaje se pierde en silencio. Decisión de diseño, no bug… y tiene consecuencias.
Bus.publicar("sensor/humedad", 55) y nadie se ha suscrito a ese topic. ¿Qué pasa?- Lanza una excepción
- Se guarda hasta que llegue un suscriptor
- El mensaje se pierde en silencio
- Se reenvía a todos los topics
topics[topic] ?: return: sin lista no hay nada que hacer, y nadie se entera. Es una decisión de diseño.
Bus.suscribir(...)?- El número de suscriptores del topic
- Una función que, al llamarla, da de baja a ese suscriptor
- El último dato publicado en el topic
- Nada (
Unit)
Suscripción cancelable (estrategia 2 de la Clase 2): quien guarda esa función es el dueño de la suscripción.
Ejercicio 7. Predice la salida del bus
El mismo bus en JavaScript (un EventEmitter escrito a mano):
const Bus = {
topics: new Map(),
suscribir(topic, s) { // on()
if (!this.topics.has(topic)) this.topics.set(topic, []);
this.topics.get(topic).push(s);
return () => this.topics.set(topic, // off()
this.topics.get(topic).filter(x => x !== s));
},
publicar(topic, dato) { // emit()
const lista = this.topics.get(topic);
if (!lista) return; // nadie escucha: silencio
for (const s of [...lista]) s(dato);
},
};
const bajaLog = Bus.suscribir("sensor/temperatura", d => console.log(`log: ${d}`));
Bus.suscribir("sensor/temperatura", d => { if (d > 30) console.log(`alarma: ${d}`); });
Bus.suscribir("usuario/login", u => console.log(`bienvenida a ${u}`));
Bus.publicar("sensor/temperatura", 28.0);
Bus.publicar("sensor/humedad", 55); // nadie suscrito
Bus.publicar("usuario/login", "ana");
bajaLog();
Bus.publicar("sensor/temperatura", 35.0);Traza cada publicar: ¿qué topic?, ¿quién está suscrito en ese momento?, ¿qué condición se cumple?
Equivalencias: Node EventEmitter (on / emit / off), CustomEvent en el DOM y, entre procesos, MQTT o Kafka: los mismos tres verbos.
28 → solo log (la alarma no salta). Humedad → silencio. Login → bienvenida. Tras la baja del log, 35 → solo alarma.
log: 35?- Porque 35 supera el umbral y la alarma consume el evento
- Porque
bajaLog()quitó ese suscriptor antes de publicar 35 - Porque el bus solo entrega el primer mensaje de cada topic
- Porque
sensor/temperaturase borró al publicar humedad
bajaLog es la función de baja que devolvió suscribir. Al invocarla, el log deja de escuchar.
Teoría 8. Observer frente a pub/sub
Se gana desacoplamiento total; se pierde la ayuda del compilador.
| Observer (GoF) | Publicación-suscripción | |
|---|---|---|
| Quién conoce a quién | El observador tiene la referencia del sujeto (se suscribe a él). | Nadie conoce a nadie; comparten un topic. |
| Quién guarda la lista | El sujeto. | El intermediario (bus, broker). |
| Quién puede notificar | Solo el sujeto, al cambiar su estado. | Cualquiera que publique en el topic. |
| Tipo del dato | Fuerte: la interfaz lo fija (Double). |
Débil: Any y un String como clave → errores en ejecución. |
| Si nadie escucha | El sujeto lo sabe (lista vacía). | El mensaje se pierde y nadie se entera. |
| Cuándo usarlo | Pocos sujetos, relación clara, misma unidad de código. | Muchos productores/consumidores; módulos, procesos o máquinas distintos. |
Riesgos heredados de la Clase 2, agravados
- Reentrancia: un suscriptor que publica en su propio topic dispara una difusión dentro de otra.
- Suscriptores lentos: la difusión es síncrona; uno lento retrasa a todos los que van detrás.
- Trazabilidad: «quién escucha qué» ya no se ve siguiendo una referencia; hay que buscar el string por todo el proyecto.
String donde se esperaba un Double?- En el bus pub/sub, gracias al topic
- En el Observer, porque la interfaz fija el tipo de
actualizar() - En ninguno de los dos
- En los dos por igual
El bus usa Any y un String como clave: los errores aparecen en ejecución. El Observer tiene tipado fuerte.
- Módulos que no deben conocerse entre sí
- Un único sujeto y dos observadores en la misma clase
- Productores y consumidores en procesos o máquinas distintas
- Muchos productores y muchos consumidores
Pocos sujetos y una relación clara en el mismo código: Observer. El bus brilla cuando hace falta desacoplar mucho.
- Definir los nombres de topic como constantes
- Usar un
enumo unasealed classcomo clave del bus - Publicar dos veces cada mensaje
- Recorrer una copia de la lista de suscriptores
Con constantes o tipos, un error de escritura se convierte en error de compilación.
Teoría 9. La GUI como observador: Observable<T> y el ViewModel
En pde-plantilla (vistas clásicas, TRAB U1) el onClick hace dos trabajos:
botonPulsa.setOnClickListener {
pulsaciones++ // 1. cambia el estado (regla de negocio)
actualizarInterfaz() // 2. repinta a mano
}
private fun actualizarInterfaz() {
textPulsaciones.text = "Pulsaciones: $pulsaciones"
}pulsaciones++ es lógica que debería poder probarse sin pantalla; actualizarInterfaz() conoce el TextView y crece una línea con cada vista nueva. Objetivo: que el estado sea un sujeto y cada trozo de pantalla un observador. Así el listener del botón solo hace un trabajo: avisar al modelo.
class Observable<T>(valorInicial: T) { // LiveData en miniatura
private val observadores = mutableListOf<(T) -> Unit>()
var valor: T = valorInicial
set(nuevo) {
field = nuevo
for (o in observadores.toList()) o(nuevo)
}
fun observar(o: (T) -> Unit) {
observadores += o
o(valor) // valor actual al suscribirse
}
}
class ContadorViewModel { // estado y reglas; sin pantallas
val pulsaciones = Observable(0)
val activo = Observable(true)
fun onPulsar() { if (activo.valor) pulsaciones.valor++ }
fun onCambiarActivo(v: Boolean) { activo.valor = v }
}1 · El setter notifica
Cambiar valor es el medir() del termómetro: guarda y avisa. El sujeto ahora es genérico.
2 · Valor actual al suscribirse
o(valor) en observar(): una vista que llega tarde se pinta bien. LiveData hace exactamente esto.
3 · Reglas sin pantalla
onPulsar() decide si cuenta. Se prueba creando el ViewModel y leyendo pulsaciones.valor.
onClick de pde-plantilla?- Registrar el listener y darlo de baja
- Cambiar el estado y repintar la interfaz a mano
- Leer el sensor y escribir en el log
- Crear la Activity y destruirla
Estado y pantalla pegados: pulsaciones++ y actualizarInterfaz() en el mismo sitio.
o(valor) dentro de observar()?- Para que la vista que se suscribe se pinte enseguida con el valor actual
- Para dar de baja al observador
- Para evitar notificar si no hay cambio
- Para copiar la lista de observadores
Una vista que llega tarde no se queda en blanco esperando el próximo cambio. LiveData se comporta igual.
Ejercicio 10. Las vistas se suscriben
Tres «vistas» sobre el mismo estado, añadidas sin abrir el ViewModel. Hoy son println; mañana, un TextView o un Composable.
fun main() {
val vm = ContadorViewModel()
vm.pulsaciones.observar { println("[TextView] Pulsaciones: $it") }
vm.pulsaciones.observar { if (it >= 3) println("[Toast] ¡tres ya!") }
vm.activo.observar { println("[Button] enabled = $it") }
vm.onPulsar(); vm.onPulsar(); vm.onPulsar()
vm.onCambiarActivo(false)
vm.onPulsar() // no cuenta: la regla vive en el ViewModel
}Traza con cuidado:
- Cada
observarllama inmediatamente al observador con el valor actual. - Cada
pulsaciones.valor++pasa por el setter y avisa a los dos observadores depulsaciones, en orden de alta. - ¿Qué hace
onPulsar()cuandoactivo.valoresfalse?
Suscribirse pinta el valor actual (0 y true; el Toast calla con 0). Tres pulsaciones: 1, 2, 3 y, tras el 3, el Toast. Desactivar avisa al botón. La última pulsación no cuenta.
vm.onPulsar()?[TextView] Pulsaciones: 4[Toast] ¡tres ya!otra vez- Nada:
activoesfalsey el ViewModel no cuenta la pulsación [Button] enabled = falseotra vez
La regla («si no está activo, no cuenta») vive en el ViewModel y se puede probar sin pantalla.
Teoría 11. MVVM en JavaScript y los nombres: MVC y MVVM
El mismo mini-MVVM en el navegador. Las vistas son span.textContent y button.disabled; el ViewModel no cambia:
class Observable { // LiveData en miniatura
#observadores = []; #valor;
constructor(inicial) { this.#valor = inicial; }
get valor() { return this.#valor; }
set valor(nuevo) { this.#valor = nuevo; for (const o of [...this.#observadores]) o(nuevo); }
observar(o) { this.#observadores.push(o); o(this.#valor); }
}
class ContadorViewModel { // nada de DOM aquí
pulsaciones = new Observable(0);
activo = new Observable(true);
onPulsar() { if (this.activo.valor) this.pulsaciones.valor++; }
onCambiarActivo(v) { this.activo.valor = v; }
}
const vm = new ContadorViewModel();
vm.pulsaciones.observar(n => span.textContent = `Pulsaciones: ${n}`); // vista 1
vm.pulsaciones.observar(n => { if (n >= 3) mostrarToast("¡tres ya!"); }); // vista 2
vm.activo.observar(a => boton.disabled = !a); // vista 3
boton.addEventListener("click", () => vm.onPulsar()); // el listener solo avisaLa vista es una línea de binding, sin lógica; el listener del clic solo avisa al ViewModel.
| Pieza de hoy | MVC (Smalltalk, 1979) | MVVM (Android, .NET, Vue) | En Android real |
|---|---|---|---|
ContadorViewModel + Observable |
Modelo (sujeto) | ViewModel expone estado observable | ViewModel + LiveData / StateFlow |
println / TextView |
Vista (observador) | La vista se suscribe: binding | observe(owner) { } · collectAsState() |
onPulsar(), onCambiarActivo() |
Controlador: recibe la entrada, modifica el modelo | La vista llama métodos del ViewModel | setOnClickListener { vm.onPulsar() } |
- Vista
- Modelo
- Controlador
- Observable
El controlador. En MVVM ese papel lo cubren los métodos del ViewModel (onPulsar()), a los que llama la vista.
observar()?setContentView()collectAsState()findViewById()onCreate()
collectAsState() suscribe el Composable al estado; cada cambio provoca una recomposición, que es el repintado automático.
Ejercicio 12. La escalera de la Unidad 2
Cinco nombres, una sola idea: cada peldaño añade una pieza.
Idea de la clase: Sensor era un Observer, Despachador era un bus. Una vista es un observador más.
Criterio de elección
| Usa… | Cuando… |
|---|---|
| Callback | Una operación puntual. |
| Observer | Estado claro, pocos interesados, mismo código. |
| Bus | Módulos que no deben conocerse o viven en sitios distintos. |
| MVVM | Siempre que haya pantalla. |
Cuanto más subes, más desacoplo y menos compilador (menos comprobaciones en tiempo de compilación).
Aplica el criterio a los casos de las preguntas.
Callback → listener → Observer → pub/sub → MVC/MVVM. Cada peldaño añade una pieza a la anterior.
- MVVM
- Pub/sub con un broker
- Callback
- Observer con tres observadores
Operación puntual: un callback como el descargar(url, alTerminar) de la Clase 1.
- Un bus pub/sub con topics
- Un callback
- Un
whenen el módulo de pagos - Un
Observabledentro de una Activity
Módulos que no deben conocerse: el intermediario (bus) desacopla al publicador de sus consumidores.
Teoría 13. Ampliación: invariantes y riesgos durante el despacho
Invariantes de una notificación
Baja efectiva
Una suscripción retirada no recibe entregas futuras, según el contrato.
Registro íntegro
Un handler no corrompe la lista al registrar o retirar otro (de ahí la ronda sobre una copia).
Datos suficientes
El evento transporta lo necesario para procesarlo sin consultar al emisor.
Riesgos durante el despacho
- Reentrancia: un handler provoca otro despacho antes de que acabe el primero.
- Cambios en notify: altas y bajas durante la iteración alteran quién recibe el evento.
- Ciclos: A avisa a B y B vuelve a avisar a A sin condición de parada.
Costes de un callback mal gestionado
- Lentitud: el despacho síncrono retrasa a todos los receptores posteriores.
- Referencia obsoleta: el callback sobrevive a la pantalla, tarea o dispositivo que lo creó.
- Anidación: varias dependencias anidadas dispersan errores y cancelación.
Traza: un observador que toca el sujeto desde dentro
val temp = Observable(20)
temp.observar { if (it > 40) temp.valor = 40 } // A: «corrige» desde dentro
temp.observar { println("Pantalla: $it") } // B
temp.valor = 45
println("valor final: ${temp.valor}")Al poner 45, el setter recorre [A, B] con nuevo = 45. A, antes de que B se entere, pone 40: se abre una segunda ronda completa (A con 40, B con 40). Al volver, la primera ronda continúa y entrega 45 a B. Resultado: la pantalla muestra 45 pero el valor real es 40.
La ronda anidada (40) se completa antes de que la exterior entregue 45 a B. B se queda con un valor obsoleto: eso es la reentrancia.
- No, nunca más recibe nada, ni este evento
- Sí recibe este (está en la copia de la ronda), pero no los siguientes
- Recibe este evento dos veces
- Se lanza
ConcurrentModificationException
La copia es una foto de la ronda actual. La baja es efectiva para entregas futuras, como dice la invariante. Si quieres otra política, el contrato debe decirlo.
Repaso 14. Repaso: tres preguntas de parcial y puente a la U3
Ideas para responderlas
1. Rompe «solo el sujeto cambia su estado» y provoca reentrancia: se abre una notificación dentro de otra, los demás reciben avisos anidados y desordenados y pueden quedarse con un valor obsoleto (la traza del paso anterior).
2. Constantes para los nombres de topic; o tipos (enum / sealed class) como clave, como en el Despachador.
3. Desaparece la llamada a actualizarInterfaz() (y la función): el TextView observa vm.pulsaciones y se repinta solo. El listener queda en setOnClickListener { vm.onPulsar() }.
Puente a la Unidad 3
Hoy todo ha sido síncrono: publicar() recorre la lista y vuelve. En la U3 metemos una cola entre el que publica y el que escucha: bucle de eventos, orden, prioridades… y el handler lento que congela la pantalla.
MainActivity con ContadorViewModel, ¿qué desaparece del listener del botón?setOnClickListener- La llamada a
actualizarInterfaz(): la vista observa el estado y se repinta sola vm.onPulsar()- Nada, queda igual
El listener pasa a hacer un solo trabajo: vm.onPulsar(). El repintado lo hace el observador del TextView.
- En pub/sub, si nadie está suscrito a un topic, el publicador recibe un error
- En Observer, el sujeto es quien guarda la lista de observadores
Observable<T>llama al observador con el valor actual en cuanto se suscribe- En MVVM la vista es la dueña del estado
- Un observador que modifica el sujeto dentro de
actualizar()provoca reentrancia
En pub/sub el mensaje se pierde en silencio, y en MVVM el estado es del ViewModel, nunca de la vista.
colaUna cola de eventos: el despacho deja de ser síncrono y aparecen el bucle de eventos, el orden y las prioridades.
Cuando notificar sale mal
La letra pequeña de publicar(): orden, altas y bajas en plena ronda, reentrancia y ciclos, antipatrones de suscripción (duplicados, bajas fallidas, callbacks tardíos, excepciones, punteros colgantes) y los errores típicos de Observer, MVP y MVVM.
Teoría 1. Un publicar() tiene letra pequeña
En las clases anteriores de esta unidad construimos callbacks, listeners, un sujeto observable y un bus de mensajes. Todo funcionaba por una razón que no dijimos en voz alta: los suscriptores se portaban bien. Recibían el aviso, hacían su trabajo y devolvían el control.
Hoy toca lo contrario. Mientras el sujeto recorre su lista, cada suscriptor puede cambiar el sistema que le está llamando.
Qué es una ronda
Una ronda empieza al entrar en publicar() y termina cuando publicar() retorna. Si un suscriptor publica durante su turno, empieza una ronda anidada antes de que acabe la primera.
Ahora imagina que la Pantalla, durante su turno…
- …da de baja a la Sirena → orden y cambios en la lista (bloque 1).
- …vuelve a llamar a
publicar()→ reentrancia y ciclos (bloque 2). - …lanza una excepción o tarda 3 segundos → antipatrones (bloque 3).
Las cuatro preguntas de la letra pequeña
1 · Orden
¿Se garantiza que se avisa en el orden de alta?
2 · Altas y bajas en la ronda
¿Afectan a la ronda en curso o a la siguiente?
3 · Reentrancia
¿Se puede publicar desde un suscriptor? ¿Se entrega ya o se encola?
4 · Fallos
Si un suscriptor lanza, ¿se sigue avisando a los demás?
- Cuando el primer suscriptor devuelve el control
- Cuando el sujeto cambia de valor
- Cuando
publicar()retorna - Cuando se vacía la lista de suscriptores
La ronda empieza al entrar en publicar() y termina cuando publicar() retorna. Si un suscriptor publica durante su turno, se abre una ronda anidada dentro de la primera.
publicar().- ¿Se garantiza el orden de alta?
- ¿Qué tipo devuelve
publicar()? - ¿Las altas y bajas afectan a la ronda en curso o a la siguiente?
- ¿Se puede publicar desde un suscriptor?
- ¿Cómo se llama el topic?
- Si un suscriptor lanza, ¿se sigue avisando a los demás?
Orden, altas/bajas durante la ronda, reentrancia y fallos. Ninguna aparece en la firma, pero todas forman parte del contrato de la API.
Ejercicio 2. Caso: el envío gratis que no aparece
Tienda web. Dos módulos se suscriben al mismo evento del bus. banner.js se carga antes que resumen.js, así que su suscriptor se registra primero. La tienda regala el envío a partir de 50 €.
// banner.js (se carga primero)
bus.suscribir("carrito/anadido", (item) => {
if (resumen.total >= 50) banner.mostrar("¡Envío gratis!");
});
// resumen.js (se carga después)
bus.suscribir("carrito/anadido", (item) => {
resumen.total += item.precio;
});
// El carrito lleva 45 €; se añaden unas zapatillas de 10 €
bus.publicar("carrito/anadido", { nombre: "Zapatillas", precio: 10 });Antes de seguir, predice: recorre la ronda suscriptor por suscriptor y apunta cuánto vale resumen.total en cada momento.
- El banner «¡Envío gratis!»
- Nada: el total ya es 55 € y no hay banner
- El banner aparece dos veces
- Un error porque
resumentodavía no existe
El banner va primero y lee resumen.total cuando aún vale 45 €. Después el resumen lo sube a 55 €, pero el banner ya ha decidido. No hay ningún error: simplemente no sale nada.
<script> y resumen.js pasa a cargarse primero, ¿qué ocurre?- Sigue sin aparecer el banner
- El bus lanza una excepción por suscriptores duplicados
- Aparece el banner: el fallo desaparece… hasta que otro cambio de orden lo traiga de vuelta
- El banner aparece antes de añadir las zapatillas
El resultado depende del orden de carga de los módulos. Reordenar «arregla» este caso, pero el fallo puede reaparecer en otro sitio: la dependencia sigue escondida.
Teoría 3. Estado derivado en el sujeto
El banner no falla por un error de sintaxis, sino por una dependencia oculta: necesita un resultado que calcula otro suscriptor del mismo evento.
Lee un estado que aún no existe
El banner consulta resumen.total antes de que se actualice.
El orden lo decide el <script>
Si se reordenan los imports, el fallo desaparece… o aparece en otro sitio.
Nadie lo ha prometido
Si la API no garantiza el orden (pregunta 1), depender de él es apostar.
Solución: el sujeto calcula y luego avisa
// carrito.js
class Carrito {
#total = 0;
anadir(item) {
this.#total += item.precio; // primero el estado
bus.publicar("carrito/cambiado",
{ item, total: this.#total }); // después el aviso
}
}
bus.suscribir("carrito/cambiado", ({ total }) => {
if (total >= 50) banner.mostrar("¡Envío gratis!");
});El total pasa a ser estado derivado del sujeto: el carrito lo calcula antes de avisar y el evento ya lleva el valor correcto. Nadie depende de nadie, y el orden de carga deja de importar.
- Garantizar con un comentario que
banner.jsse cargue siempre después deresumen.js - Que el banner espere 100 ms con
setTimeoutantes de leer el total - Que el banner se suscriba dos veces al evento
- Que el carrito actualice su total y publique
carrito/cambiadocon el total ya calculado
El estado derivado (el total) vive en el sujeto. El evento ya lleva el dato correcto, así que los suscriptores no dependen unos de otros ni del orden de carga. Las otras opciones solo esconden la dependencia.
carrito/cambiado lee el banner para decidir? (una palabra)totalEl banner desestructura { total } del propio evento: no consulta a resumen ni a nadie más.
Ejercicio 4. Caso: la valoración que nunca se pide
App de reparto. Un Pedido avisa de sus cambios de estado a tres suscriptores: el mapa (solo le interesa el pedido hasta que se entrega, así que se da de baja él solo), la petición de valoración y el historial.
class Pedido {
#oyentes = [];
suscribir(f) {
this.#oyentes.push(f);
return () => this.#oyentes.splice(this.#oyentes.indexOf(f), 1);
}
cambiarEstado(estado) {
this.#oyentes.forEach((f) => f(estado)); // lista VIVA
}
}
const pedido = new Pedido();
const bajaMapa = pedido.suscribir((e) => {
console.log(`Mapa: ${e}`);
if (e === "entregado") bajaMapa(); // un solo uso
});
pedido.suscribir((e) => {
if (e === "entregado") console.log("¿Qué tal tu pedido?");
});
pedido.suscribir((e) => console.log(`Historial: ${e}`));
pedido.cambiarEstado("entregado");Dos detalles: suscribir devuelve la función de baja (como vimos en la clase 2), y cambiarEstado recorre la propia lista #oyentes, no una copia. forEach avanza por índices: 0, 1, 2…
Dibuja el array antes y después del splice y predice la salida.
Mapa: entregado¿Qué tal tu pedido?Historial: entregado- Un
TypeErrorpor modificar el array duranteforEach
Salen Mapa: entregado e Historial: entregado. Nadie pide la valoración y no hay ningún error: JavaScript no protesta si modificas el array mientras lo recorres.
- Valoración en la posición 1; el bucle va a la 1
- Valoración en la posición 2; el bucle vuelve a la 0
- Valoración en la posición 0; el bucle pasa a la 1
- Valoración desaparece del array junto con el Mapa
Al quitar el elemento 0, Valoración baja a la posición 0 e Historial a la 1. El bucle ya había procesado el índice 0, así que pasa al 1 y llama a Historial: Valoración se queda sin turno.
Teoría 5. Recorrer una foto de la lista
Lo que ha pasado, índice a índice:
| Momento | Posición 0 | Posición 1 | Posición 2 | Bucle |
|---|---|---|---|---|
| Antes de la baja | Mapa | Valoración | Historial | i = 0 → Mapa |
| Tras la baja | Valoración | Historial | — | i = 1 → Historial |
Valoración pasa a la posición 0 cuando el bucle ya va por la 1. Se la salta sin avisar: no hay excepción, no hay mensaje, simplemente un suscriptor que no se entera. Es el peor tipo de fallo, porque solo lo detecta quien echa de menos la línea.
Solución: recorrer una foto
cambiarEstado(estado) {
[...this.#oyentes].forEach((f) => f(estado)); // copia
} // Mapa · ¿Qué tal tu pedido? · HistorialLa ronda recorre una copia de la lista tal y como estaba al empezar. Las altas y bajas modifican la lista real, no la foto, así que el bucle no se descoloca.
| Lenguaje | Cómo se hace la foto |
|---|---|
| JavaScript | [...listeners] |
| Kotlin | oyentes.toList() |
| C++ | auto foto = handlers_; |
for (f in oyentes)?ConcurrentModificationExceptionConcurrentModificationException: el iterador detecta que la lista ha cambiado mientras se recorría. No es concurrencia de hilos: basta con un solo hilo que modifica la lista dentro del bucle.
- Porque un fallo que se ve siempre es mejor que un suscriptor que desaparece en silencio
- Porque Kotlin es más rápido recorriendo listas
- Porque en Kotlin la baja no se ejecuta nunca
- Porque Kotlin hace la foto automáticamente
La excepción te obliga a arreglarlo el primer día. En JavaScript el programa sigue funcionando y la valoración simplemente no se pide: puede pasar meses sin que nadie lo note.
Ejercicio 6. Caso: «Ha llegado a su destino. Gire a la derecha»
App de navegación en Kotlin. Aprendida la lección anterior, el Gps ya recorre una foto de su lista (subs.toList()), así que nadie rompe el bucle. Hay dos suscriptores, dados de alta en este orden: controlRuta y voz.
// suscritos en este orden: controlRuta, voz
val controlRuta = gps.suscribir { pos ->
if (pos == destino) {
println("Ha llegado a su destino")
gps.cancelar(voz) // se apaga la voz…
}
}
val voz = gps.suscribir { pos ->
println(ruta.instruccionPara(pos)) // …pero estaba en la foto
}(Fragmento tal y como se presenta en clase: lo que importa es el orden de alta y que controlRuta cancela a voz durante su turno.)
El coche llega al destino y el Gps publica esa posición. controlRuta tiene el primer turno y cancela la voz. Para esa posición, la instrucción de ruta sería «En 200 m, gire a la derecha».
Piensa en qué contiene la foto en el momento en que empezó la ronda.
- Solo «Ha llegado a su destino»
- «En 200 m, gire a la derecha» y después «Ha llegado a su destino»
- Una
ConcurrentModificationException - «Ha llegado a su destino» y después «En 200 m, gire a la derecha»
La foto se hizo al empezar la ronda y voz estaba en ella. Aunque se cancele durante el turno de controlRuta, el bucle llega a su posición en la foto y la llama: el conductor oye una instrucción después de haber llegado.
ConcurrentModificationException aunque se modifique la lista durante la ronda?- Porque el bucle recorre una copia (
toList()), no la lista que se modifica - Porque
cancelarno modifica la lista - Porque esa excepción solo existe en Java, no en Kotlin
- Porque
voztodavía no estaba suscrita
La foto protege al iterador: la lista real cambia, pero el bucle recorre otra. El precio es que la foto puede contener a alguien que ya se ha dado de baja.
Teoría 7. Foto pura o foto + comprobar si sigue activa
La foto arregla el bucle, pero un suscriptor dado de baja durante la ronda aún recibe el evento en curso. Si eso no es lo que queremos, se añade una comprobación: cada suscripción lleva una bandera activa.
class Suscripcion(val f: (Posicion) -> Unit) { var activa = true }
class Gps {
private val subs = mutableListOf<Suscripcion>()
fun suscribir(f: (Posicion) -> Unit) =
Suscripcion(f).also { subs += it }
fun cancelar(s: Suscripcion) { s.activa = false; subs -= s }
fun publicar(p: Posicion) {
for (s in subs.toList()) // foto: nadie rompe el bucle
if (s.activa) s.f(p) // una baja cuenta desde ya
}
}La foto sigue conteniendo a voz, pero su suscripción ya está marcada como inactiva y el bucle la salta. Salida: solo «Ha llegado a su destino».
Dos políticas válidas
| Foto pura | Foto + comprobar activa | |
|---|---|---|
| Baja durante la ronda | Recibe el evento en curso | No lo recibe |
| Alta durante la ronda | Espera a la siguiente | Espera a la siguiente |
| Quién lo hace así | Node EventEmitter, nuestro Bus |
DOM addEventListener / removeEventListener |
removeEventListener durante la ronda de un evento…- …recibe igualmente el evento en curso
- …no recibe el evento en curso
- …provoca que se cancele la ronda entera
- …lanza una excepción en el siguiente listener
El DOM se comporta como «foto + comprobar activa»: la baja cuenta desde ya. Node EventEmitter es foto pura: el retirado recibe el evento en curso.
- …recibe el evento en curso al final de la ronda
- …recibe el evento en curso inmediatamente
- …espera a la siguiente ronda
- …no recibe nunca nada hasta que se reinicie el sujeto
La foto se hizo antes del alta, así que el nuevo suscriptor no está en ella: su primer aviso será el de la siguiente ronda.
Suscripcion que comprueba el bucle antes de llamar?activaactiva: cancelar la pone a false y el bucle salta las suscripciones inactivas aunque sigan en la foto.
Ejercicio 8. Caso: el carrito que muestra 12 cuando hay 10
Bloque 2: reentrancia. Usamos el Observable de la clase 3, sin aviso inicial al suscribirse. Ya recorre una foto. Hay 10 unidades en stock; un observador limita la cantidad al stock y otro la pinta. El usuario escribe 12.
class Observable<T>(inicial: T) {
private val observadores = mutableListOf<(T) -> Unit>()
var valor: T = inicial
set(nuevo) {
if (nuevo == field) return
field = nuevo
for (o in observadores.toList()) o(nuevo)
}
fun observar(o: (T) -> Unit) { observadores += o }
}
fun main() {
val stock = 10
val cantidad = Observable(1)
cantidad.observar { if (it > stock) cantidad.valor = stock } // limitar
cantidad.observar { println(" [Carrito] cantidad: $it") } // pintar
cantidad.valor = 12 // el usuario escribe 12
println(" valor real: ${cantidad.valor}")
}Pista de lectura: cantidad.valor = stock no es una simple asignación. Es una llamada al set, que tiene su propio for. ¿Termina ese for antes o después de que el primero llegue a pintar? ¿Y qué valor lleva cada for en su variable nuevo?
Ronda 1 (valor 12): limitar(12) pone el valor a 10 y abre la ronda 2, que pinta «10». Al volver, la ronda 1 sigue con su nuevo = 12 y pinta «12». El valor real es 10, pero lo último que se ha pintado es 12.
- Que lo último que pinta la vista coincida con el valor real del modelo
- Que el stock nunca baje de cero
- Que cada observador se suscriba una sola vez
- Que el valor inicial sea 1
La vista se queda con «12» cuando el modelo vale 10. El síntoma típico de la reentrancia: la vista muestra un valor distinto del modelo.
Teoría 9. Rondas anidadas y dos arreglos
Un observador que cambia el sujeto en su turno abre una ronda anidada. La traza:
ronda 1 · valor = 12
① limitar(12) → valor = 10
ronda 2 · valor = 10
① limitar(10) · nada
② pintar(10) → «cantidad: 10»
② pintar(12) → «cantidad: 12»
↑ lo último que se pinta es el valor viejoLa ronda 1 no se entera de que el valor ha cambiado: sigue repartiendo el 12 que tenía en nuevo. Todo ocurre en un solo hilo: la reentrancia es una llamada dentro de otra.
A · La regla vive en el sujeto (preferida)
class CarritoViewModel(private val stock: Int) {
val cantidad = Observable(1)
fun onCambiarCantidad(n: Int) {
cantidad.valor = n.coerceIn(1, stock)
}
}
// Los observadores solo pintan
// SALIDA A: [Carrito] cantidad: 10Que el observador no tenga que corregir al sujeto: la validación forma parte del sujeto (coerceIn recorta al intervalo 1..stock) y el valor inválido nunca llega a publicarse.
B · Entrega diferida: las rondas no se anidan
class Observable<T>(inicial: T) {
private val observadores = mutableListOf<(T) -> Unit>()
private val pendientes = ArrayDeque<T>() // cola de avisos
private var notificando = false
var valor: T = inicial
set(nuevo) {
if (nuevo == field) return
field = nuevo
pendientes.addLast(nuevo)
if (notificando) return // reentrada: encola y vuelve
notificando = true
try {
while (pendientes.isNotEmpty()) {
val v = pendientes.removeFirst()
for (o in observadores.toList()) o(v)
}
} finally { notificando = false }
}
fun observar(o: (T) -> Unit) { observadores += o }
}
// SALIDA B: [Carrito] cantidad: 12
// [Carrito] cantidad: 10 ← valor real
// valor real: 10Si no se puede evitar que un observador publique, que la ronda anidada espere: cada ronda termina antes de que empiece la siguiente. En la Unidad 3 esto tiene nombre: run-to-completion. Con B se ve un instante el 12; por eso A va primero.
- Porque B no compila en Kotlin
- Porque A usa menos memoria al no tener observadores
- Porque B anida todavía más rondas
- Porque con A el valor inválido nunca se publica; con B la vista llega a mostrar el 12 un instante
B evita que las rondas se aniden y deja la vista con el valor correcto al final, pero el 12 llega a pintarse. A elimina el problema de raíz: el sujeto valida y los observadores solo pintan.
run-to-completionRun-to-completion: cada evento se procesa entero antes del siguiente. Es la base del bucle de eventos de la Unidad 3.
limitar asigna 10 durante la ronda, ¿qué hace el set?- Abre una ronda anidada como antes
- Descarta el 10 porque ya se está notificando
- Guarda el 10 en
pendientesy retorna, porquenotificandoestrue - Lanza una excepción por reentrada
La reentrada solo encola. El while de la ronda que ya está en marcha recogerá el 10 cuando termine de repartir el 12.
Ejercicio 10. Caso: el tema oscuro que rebota
App de móvil con smartwatch. Cuando el usuario cambia el tema, cada módulo lo aplica y, para que «el otro se entere», avisa de que ya lo tiene por el mismo topic.
// Módulo Reloj
bus.suscribir("ajustes/tema", (tema) => {
reloj.aplicarTema(tema);
bus.publicar("ajustes/tema", tema); // reenvía por el mismo topic
});
// Módulo Móvil
bus.suscribir("ajustes/tema", (tema) => {
movil.aplicarTema(tema);
bus.publicar("ajustes/tema", tema);
});
bus.publicar("ajustes/tema", "oscuro");El bus entrega de forma síncrona: publicar llama a los suscriptores dentro de la propia llamada, como todos los que hemos construido en la unidad.
Antes de seguir, sigue la pila de llamadas a mano durante tres o cuatro niveles: publicar → suscriptor del reloj → publicar → … ¿Hay algún punto en el que alguien decida no volver a publicar? ¿Comprueba alguien si el tema ya era «oscuro»?
- Ambos aplican el tema oscuro una vez y termina
- Cada uno aplica el tema dos veces y termina
RangeError: Maximum call stack size exceeded- El bus ignora la segunda publicación porque el tema no ha cambiado
Es un ciclo de notificación: cada publicación abre una ronda anidada que vuelve a publicar. Nadie corta la cadena y la pila de llamadas se desborda.
- Sí: la cola evita las rondas anidadas y con ello el problema
- Sí, pero solo si hay un único suscriptor
- No, porque la cola descarta los mensajes repetidos
- No: cambia el desbordamiento de pila por un bucle infinito de mensajes
La cola impide que la pila crezca, pero cada entrega vuelve a encolar otro aviso: la cola nunca se vacía. Si los módulos hablaran por MQTT entre dos dispositivos, sería tráfico sin fin y batería agotada.
Teoría 11. Separar órdenes y hechos
Dos módulos que reenvían lo que reciben forman un ciclo de notificación. El problema no es la entrega síncrona (por eso la cola no lo arregla), sino que el mismo topic se usa para dos cosas distintas: pedir un cambio y anunciar que ya ha ocurrido.
Orden
ajustes/cambiar-tema: «quiero que el tema sea oscuro». La escucha solo el dueño del dato.
Hecho
ajustes/tema-cambiado: «el tema ya es oscuro». Lo publica solo el dueño; los demás reaccionan y nunca lo reenvían.
// Solo el dueño escucha ÓRDENES y publica HECHOS
bus.suscribir("ajustes/cambiar-tema", (tema) => {
if (tema === ajustes.tema) return; // sin cambio, sin aviso
ajustes.tema = tema;
bus.publicar("ajustes/tema-cambiado", tema);
});
// Los demás reaccionan a hechos y nunca los reenvían
bus.suscribir("ajustes/tema-cambiado", (t) => reloj.aplicarTema(t));
bus.suscribir("ajustes/tema-cambiado", (t) => movil.aplicarTema(t));
bus.publicar("ajustes/cambiar-tema", "oscuro");Tres medidas, cada una suficiente para cortar el ciclo y juntas mucho más robustas:
- Un único dueño del dato
tema: es el único que lo modifica. - Órdenes y hechos en topics distintos: quien reacciona a un hecho no publica otro hecho igual.
- Sin cambio, sin aviso: si el valor no cambia, no se publica nada. (El
Observablede la clase 3 ya lo hacía conif (nuevo == field) return.)
- Usar un topic para órdenes y otro para hechos
- Pasar el bus a entrega diferida con una cola
- No publicar si el valor no ha cambiado
- Que solo el dueño del dato publique el hecho
- Que cada módulo reenvíe el hecho a los demás para asegurarse
Separar órdenes y hechos, un único dueño que publica el hecho y «sin cambio, sin aviso». La cola solo convierte el desbordamiento en un bucle infinito de mensajes.
ajustes/tema-cambiadoajustes/tema-cambiado: es un hecho. Reaccionan aplicando el tema y no publican nada.
Ejercicio 12. Caso: pedido cobrado tres veces
Bloque 3: antipatrones de suscripción y despacho. Empezamos por un checkout web con un modal de pago.
function abrirModalPago() {
modal.hidden = false;
// alta en CADA apertura
botonConfirmar.addEventListener("click", () => {
enviarPedido(carrito);
modal.hidden = true;
});
}
// cerrar no da de baja
function cancelarModal() { modal.hidden = true; }El usuario duda: abre el modal, cancela, lo vuelve a abrir, cancela otra vez, lo abre por tercera vez y por fin pulsa Confirmar. Un solo clic. Nada en la interfaz delata el problema: el modal se abre y se cierra con normalidad cada vez.
enviarPedido() con ese único clic en Confirmar? (un número)3Tres. Cada apertura añade un listener nuevo y cancelar no quita ninguno. Al pulsar Confirmar, los tres se ejecutan: tres pedidos, tres cobros.
addEventListener no descarta los listeners repetidos?- Porque solo ignora duplicados de la misma función, y cada apertura crea una lambda nueva
- Porque el modal está oculto cuando se registran
- Porque
addEventListenernunca ignora duplicados - Porque
modal.hidden = truereinicia los listeners
Cada vez que se ejecuta abrirModalPago() nace una función flecha nueva. Para el navegador son funciones distintas aunque tengan el mismo texto.
Teoría 13. Alta única y misma referencia
En el pedido cobrado tres veces hay dos causas: una lambda nueva cada vez y sin baja simétrica (abrir y cancelar no son un par alta/baja). Remedio: dar de alta donde se nace, no donde se repite.
// Se registra UNA vez, al cargar la página, con una función con nombre
botonConfirmar.addEventListener("click", confirmar);
function confirmar() {
enviarPedido(carrito);
modal.hidden = true;
}
function abrirModalPago() { modal.hidden = false; }La otra opción es mantener el alta en la apertura pero hacer la baja en cancelarModal(), por ejemplo registrando el listener con { signal } de un AbortController y llamando a abort() al cerrar.
El feed que sigue cargando tras salir
Ahora el error inverso: hay baja, pero no da de baja nada.
class FeedInfinito {
montar() {
window.addEventListener("scroll", this.cargarMas.bind(this));
}
desmontar() {
window.removeEventListener("scroll", this.cargarMas.bind(this));
}
cargarMas() { if (cercaDelFinal()) this.pedirPagina(); }
}bind crea una función nueva en cada llamada. La baja busca otra función, no la encuentra y calla. El usuario sale del feed a su perfil y cada scroll sigue pidiendo páginas; el FeedInfinito no se libera nunca.
class FeedInfinito {
cargarMas = () => { // campo: una sola función, fija this
if (cercaDelFinal()) this.pedirPagina();
};
montar() { window.addEventListener("scroll", this.cargarMas); }
desmontar() { window.removeEventListener("scroll", this.cargarMas); }
}removeEventListener("scroll", this.cargarMas.bind(this)) no quita el listener?- Porque
removeEventListenersolo funciona conwindow.onscroll - Porque
thisvale otra cosa dentro dedesmontar() - Porque el scroll se sigue produciendo mientras se desmonta
- Porque
binddevuelve una función nueva, distinta de la que se registró
Cada bind crea una función distinta. La baja busca por referencia, no encuentra la suya y no da ningún error. Por eso la solución guarda una sola función en un campo.
- Registrar el listener una sola vez al cargar la página
- Mantener el alta en la apertura y darlo de baja al cancelar con un
AbortController - Desactivar el botón durante 300 ms tras cada clic
- Registrar la misma lambda flecha escrita dentro de
abrirModalPago()
Alta única, o alta y baja simétricas. Desactivar el botón no quita los listeners acumulados, y una flecha escrita dentro de la función sigue naciendo nueva en cada apertura.
- Un
flow.collect { }dentro deonResume()sin cancelarlo - Llamar a
setContentViewdos veces - Usar
ViewModelen lugar dePresenter - Registrar el
OnClickListenerenonCreate
onResume() se ejecuta cada vez que la pantalla vuelve a primer plano: cada vez se añade un colector más si nadie cancela el anterior.
Ejercicio 14. Caso: el buscador con resultados de otra búsqueda
Autocompletado de una tienda. Cada vez que cambia el texto, se espera 300 ms y se piden sugerencias al servidor.
input.addEventListener("input", () => {
setTimeout(async () => { // espera 300 ms, nadie la cancela
const q = encodeURIComponent(input.value);
const res = await fetch(`/buscar?q=${q}`);
pintarSugerencias(await res.json());
}, 300);
});El usuario escribe «mango» letra a letra: m, ma, man, mang, mango. Cada tecla acaba lanzando su propia petición. La de «man» resulta ser lenta: su respuesta llega después que la de «mango».
teclas m ma man mang mango
peticiones fetch fetch fetch fetch fetch
llegadas … «mango» ✓ … «man» (lenta, la última en llegar)Piensa en dos cosas: cuántos temporizadores se programan, y qué respuesta es la última en llamar a pintarSugerencias.
- Las de «mango», porque es la última búsqueda que se pidió
- Ninguna: las dos respuestas se anulan
- Las de «man» («mandarina, manzana…»), porque es la última respuesta en llegar
- Las de «m», porque fue la primera
Las dos peticiones siguen vivas. La de «mango» pinta primero y la lenta de «man» pinta encima: carrera de respuestas.
5Cinco. Cada tecla programa su propio temporizador y nadie cancela los anteriores: retrasar no es agrupar.
- El temporizador de la tecla anterior, si aún no ha saltado
- La petición
fetchanterior que sigue en vuelo - El propio listener de
input - El callback de la tecla actual
Hay que anular el temporizador pendiente (para no lanzar búsquedas inútiles) y la petición anterior en vuelo (para que su respuesta no llegue a pintar). El listener de input sí debe seguir vivo.
Teoría 15. Cancelar lo pendiente: debounce y abort
Un callback diferido que nadie cancela llega cuando ya no tiene sentido. Cada callback diferido tiene un dueño que debe poder anularlo antes de que se ejecute.
let temporizador;
let control;
input.addEventListener("input", () => {
clearTimeout(temporizador); // 1 anula el callback pendiente
temporizador = setTimeout(async () => {
control?.abort(); // 2 anula la petición anterior
control = new AbortController();
try {
const q = encodeURIComponent(input.value);
const res = await fetch(`/buscar?q=${q}`, { signal: control.signal });
pintarSugerencias(await res.json());
} catch (e) {
if (e.name !== "AbortError") throw e; // 3 cancelar no es un error
}
}, 300);
});1 · Debounce
Solo sobrevive el temporizador de la última tecla.
2 · Una petición en vuelo
La anterior se aborta y su callback no llega a ejecutarse.
3 · AbortError se filtra
Cancelar no es un fallo; el resto de errores se propaga.
Con el arreglo, si el usuario escribe «mango» sin pausas largas, los temporizadores de m, ma, man y mang se anulan: 300 ms después de la última tecla sale una petición y se pintan los resultados de «mango».
clearTimeout anula el temporizador pendiente → Se programa un nuevo setTimeout de 300 ms → Al saltar, se aborta la petición anterior con control?.abort() → Se crea un AbortController nuevo → Se lanza fetch con { signal: control.signal } → Se llama a pintarSugerencias con la respuestaEl debounce actúa en el listener (paso 1-2) y el abort dentro del temporizador (pasos 3-5). Si la petición se aborta, su await lanza AbortError y nunca llega a pintar.
catch filtra AbortError y relanza el resto?- Porque
AbortErrorindica que el servidor ha caído - Porque
fetchnunca lanza otros errores - Para que el temporizador se reprograme solo
- Porque cancelar una petición a propósito no es un fallo, pero un error de red real sí debe propagarse
Abortar es la baja de la petición: es el comportamiento esperado. Tragarse todos los errores escondería fallos reales.
Ejercicio 16. Caso: la sirena que no suena
Centralita domótica en C++17 sobre una Raspberry Pi. Ya recorre una foto. Tres suscriptores ante un evento de humo: una notificación push, la sirena y la apertura de puertas.
enum class Tipo { Humo, Puerta };
struct Evento { Tipo tipo; std::string zona; };
using Handler = std::function<void(const Evento&)>;
class Centralita {
std::vector<Handler> handlers_;
public:
void suscribir(Handler h) { handlers_.push_back(std::move(h)); }
void publicar(const Evento& ev) {
auto foto = handlers_;
for (auto& h : foto) h(ev);
}
};
centralita.suscribir([](const Evento& ev) { enviarPush(ev); }); // sin red: lanza
centralita.suscribir([](const Evento&) { activarSirena(); });
centralita.suscribir([](const Evento&) { abrirPuertas(); });
centralita.publicar({Tipo::Humo, "cocina"});Se ha ido el wifi. Hay humo en la cocina y el detector llama a publicar. enviarPush no puede conectar y lanza una excepción.
Sigue la excepción: ¿quién la captura?
- No llega el push, pero suenan la sirena y se abren las puertas
- Ni sirena ni puertas: la excepción corta la ronda y sube hasta el código del detector
- Suena la sirena, pero las puertas no se abren
- La centralita reintenta el push hasta que vuelve la red y luego sigue
La excepción sale de h(ev), interrumpe el for y sale por publicar. Los suscriptores que iban detrás nunca se enteran.
addEventListener y el primero lanzara, ¿qué haría el navegador?- Informa de la excepción y sigue con el siguiente listener
- Detiene la ronda igual que el bucle de C++
- Elimina automáticamente el listener que ha fallado
- Repite el evento desde el principio
El DOM informa de la excepción (en la consola) y continúa con el siguiente listener. Node EventEmitter y un bucle escrito a mano en Kotlin o C++ se detienen.
Teoría 17. Aislar a cada suscriptor
Una excepción en un suscriptor interrumpe la ronda y los que van detrás nunca se enteran. Con humo en la cocina, que falle el push no debería impedir que suene la sirena.
void publicar(const Evento& ev) {
auto foto = handlers_;
for (auto& h : foto) {
try {
h(ev);
} catch (const std::exception& e) {
registrarError(ev, e.what()); // se informa
} catch (...) {
registrarError(ev, "desconocida");
}
}
}Cada suscriptor queda aislado: si lanza, se registra el error y el bucle sigue con el siguiente. Fíjate en que no se traga en silencio: registrarError deja constancia. Aislar también es decidir quién se entera del fallo.
Cada plataforma decide
| Plataforma | Si un listener lanza… |
|---|---|
DOM (addEventListener) |
Informa de la excepción y sigue con el siguiente |
Node EventEmitter |
Se detiene |
| Bucle propio en Kotlin o C++ | Se detiene (salvo que lo aisléis como arriba) |
El primo de la excepción: el handler lento
El despacho es síncrono: si un suscriptor tarda 3 segundos (por ejemplo, hace una petición de red en su turno), retrasa a todos los que van detrás y, si está en el hilo de la interfaz, la congela. El remedio es delegar y retornar: el handler encarga el trabajo largo y devuelve el control enseguida. Lo estudiaremos a fondo en la Unidad 3.
- Listeners del DOM con
addEventListener - Node
EventEmitter - Un
forescrito a mano en Kotlin - Un
forescrito a mano en C++
Solo el DOM sigue con el siguiente listener. Node, Kotlin y C++ propagan la excepción y cortan la ronda.
try/catch para aislar a cada suscriptor?- Alrededor de toda la llamada a
publicaren el detector - Dentro de
suscribir - En el constructor de
Centralita - Dentro del
for, alrededor de cadah(ev)
Un try alrededor de toda la ronda capturaría la excepción, pero el bucle ya se habría cortado. Tiene que envolver cada llamada para que el for continúe.
Ejercicio 18. Caso: el termostato que se reinicia sin motivo
Termostato con pantalla (ESP32, Arduino, C++). Al entrar en el menú de ajustes se crea una pantalla que se suscribe al sensor; al pulsar «Atrás» se destruye.
class SensorTemp {
std::vector<std::function<void(float)>> oyentes_;
public:
void suscribir(std::function<void(float)> f) { oyentes_.push_back(std::move(f)); }
void publicar(float t) { for (auto& f : oyentes_) f(t); }
};
class PantallaAjustes {
float ultima_ = 0;
public:
explicit PantallaAjustes(SensorTemp& s) {
s.suscribir([this](float t) { ultima_ = t; pintar(); }); // captura this
}
void pintar();
};
void menuAjustes(SensorTemp& sensor) {
auto* p = new PantallaAjustes(sensor);
esperarBotonAtras();
delete p; // la lambda sigue en oyentes_
}
// 5 s después: sensor.publicar(22.5) → ¿?En Kotlin o JavaScript ya conocemos este olvido (clase 2): el recolector de basura mantiene viva la pantalla, con su fuga de memoria y sus callbacks fantasma. Pero en C++ no hay recolector: delete p libera la memoria aunque alguien siga apuntando a ella.
- Nada: la lambda detecta que la pantalla ya no existe
- El compilador lo impide: no deja capturar
thisen una lambda - La pantalla se vuelve a crear automáticamente
- La lambda escribe en memoria liberada a través de un puntero colgante: basura en el display, un reinicio… o nada durante semanas
[this] no tiene dueño: la lambda guarda un puntero a un objeto que ya no existe. Es comportamiento indefinido: un fallo intermitente que depende de quién ocupe esa memoria (en un ESP32 puede acabar en un reinicio tipo Guru Meditation).
destructorEl destructor. Si la baja vive en un destructor, nadie tiene que acordarse de hacerla: la llama el compilador.
Teoría 19. RAII: la suscripción se da de baja sola
Sin recolector de basura, el listener olvidado no provoca una fuga: provoca un puntero colgante. La solución en C++ es RAII: si la suscripción es un miembro del suscriptor, muere con él, y su destructor hace la baja.
class SensorTemp {
std::map<int, std::function<void(float)>> oyentes_;
int siguiente_ = 0;
public:
class Suscripcion { // el objeto «baja»
SensorTemp* sensor_; int id_;
public:
Suscripcion(SensorTemp* s, int id) : sensor_(s), id_(id) {}
~Suscripcion() { sensor_->oyentes_.erase(id_); } // baja
Suscripcion(const Suscripcion&) = delete;
Suscripcion& operator=(const Suscripcion&) = delete;
};
std::unique_ptr<Suscripcion> suscribir(std::function<void(float)> f) {
int id = siguiente_++;
oyentes_[id] = std::move(f);
return std::make_unique<Suscripcion>(this, id);
}
void publicar(float t) {
auto foto = oyentes_;
for (auto& [id, f] : foto) f(t);
}
};
class PantallaAjustes {
std::unique_ptr<SensorTemp::Suscripcion> sub_; // miembro
public:
explicit PantallaAjustes(SensorTemp& s)
: sub_(s.suscribir([this](float t) { pintar(t); })) {}
void pintar(float t);
}; // delete pantalla → ~sub_ → baja automática1 · La suscripción es un objeto
Es la suscripción cancelable de la clase 2, ahora con forma de clase.
2 · Destructor = baja
Nadie tiene que acordarse: la llama el compilador.
3 · Mismo tiempo de vida
Miembro de la pantalla y no copiable: muere con ella.
| Sin baja… | Consecuencia |
|---|---|
| Kotlin / JavaScript | El recolector mantiene viva la pantalla: fuga de memoria y callbacks fantasma |
| C++ | La pantalla ya no existe: basura en el display, reinicio… o nada durante semanas |
Suscripcion borra su constructor de copia y su asignación por copia?- Para que no haya dos objetos cuyo destructor intente dar de baja la misma suscripción
- Para que ocupe menos memoria en el ESP32
- Porque
std::mapno admite objetos copiables - Para que la lambda pueda capturar
this
Una suscripción tiene un único dueño. Si se copiara, la primera copia en morir daría de baja al oyente mientras la otra cree que sigue suscrito. Con unique_ptr y sin copia, la vida de la suscripción es exactamente la de la pantalla.
DisposableEffecten ComposeAbortControlleren el DOM- Qt al destruir un
QObject setTimeouten JavaScriptprintlnen Kotlin
Los tres atan la baja al fin de vida de algo (un composable, una señal de aborto, un objeto Qt). setTimeout es justo lo contrario: un callback diferido que hay que cancelar a mano.
Ejercicio 20. Ficha de diagnóstico: del síntoma al antipatrón
En producción nunca te dicen «hay una reentrancia en la línea 12». Te dicen «la pantalla pone 12 y hay 10». La ficha de diagnóstico es un mapa que va del síntoma que se observa al antipatrón que lo causa y a su remedio. La usaremos tal cual en la clase 5.
Intenta responder las preguntas sin abrir la ficha; después compruébala completa.
Ver la ficha completa (11 filas)
| # | Síntoma que se observa | Antipatrón | Remedio |
|---|---|---|---|
| 1 | El resultado depende del orden de carga de los módulos | Suscriptores que dependen unos de otros | Estado derivado en el sujeto |
| 2 | Un suscriptor nunca se entera, sin ningún error | Baja durante el recorrido de la lista viva | Foto de la lista |
| 3 | Un suscriptor dado de baja reacciona una última vez | Foto sin comprobar si sigue activo | Bandera activa |
| 4 | La vista muestra un valor distinto del modelo | Reentrancia: un observador corrige al sujeto | Regla en el sujeto / cola |
| 5 | Desbordamiento de pila o tráfico infinito | Ciclo de notificación | Órdenes vs hechos; sin cambio, sin aviso |
| 6 | La acción se ejecuta 2, 3… N veces | Alta en un método que se repite | Alta única o baja simétrica |
| 7 | Pantalla cerrada que reacciona; memoria que crece | Baja con otra referencia | Misma referencia / suscripción |
| 8 | Aparecen resultados de una búsqueda anterior | Callback diferido sin cancelar | clearTimeout, abort, cancel |
| 9 | Tras un fallo, el resto no reacciona | Excepción sin aislar | try/catch por suscriptor |
| 10 | Reinicios aleatorios en C/C++ | Puntero colgante en el callback | RAII: suscripción como miembro |
| 11 | La interfaz se congela mientras un handler trabaja | Handler lento en la ronda | Delegar y retornar (U3 / U6) |
- Ciclo de notificación
- Callback diferido sin cancelar
- Alta en un método que se repite (sin baja simétrica)
- Puntero colgante en el callback
Fila 6: alguien se suscribe en un método que se ejecuta muchas veces (como onResume o abrir un modal) y nunca da de baja. Remedio: alta única o baja simétrica.
try/catchpor suscriptor- Recorrer una foto de la lista
- Separar órdenes y hechos
- Debounce con
clearTimeout
Fila 2: baja durante el recorrido de la lista viva. Los índices se desplazan y alguien se queda sin turno. Remedio: recorrer una foto.
- Delegar el trabajo lento y retornar enseguida
- Recorrer una foto de la lista
- Usar RAII para la suscripción
- Publicar el mensaje dos veces
Fila 11: handler lento en la ronda. El despacho es síncrono y bloquea a todos. El remedio, delegar y retornar, se desarrolla en la Unidad 3 (y la Unidad 6).
Teoría 21. GoF Observer, MVP y MVVM: quién conoce a quién
Bloque 4. Los tres modelos son la misma idea colocada en sitios distintos. Para compararlos, sigue la flecha que llega a la vista: indica quién conoce a quién.
GoF Observer
El sujeto conoce una lista de «observadores», no sus tipos. Les llama con update(t).
MVP
El Presenter conoce la vista (a través de una interfaz) y le ordena qué pintar: mostrar(n).
MVVM
El ViewModel no conoce la vista: la vista se suscribe a su estado observable.
El mismo contador en los dos
// MVP · el Presenter ordena pintar
interface ContadorView { // contrato de la vista
fun mostrarPulsaciones(n: Int)
fun habilitarBoton(activo: Boolean)
}
class ContadorPresenter(private var vista: ContadorView?) {
private var pulsaciones = 0
private var activo = true
fun onPulsar() {
if (!activo) return
pulsaciones++
vista?.mostrarPulsaciones(pulsaciones) // ordena
}
fun onCambiarActivo(v: Boolean) {
activo = v; vista?.habilitarBoton(v)
}
}
// Activity : ContadorView
// boton.setOnClickListener { presenter.onPulsar() }
// override fun mostrarPulsaciones(n: Int) { texto.text = "…$n" }// MVVM · la vista observa el estado (clase 3)
class ContadorViewModel { // no conoce ninguna vista
val pulsaciones = Observable(0)
val activo = Observable(true)
fun onPulsar() {
if (activo.valor) pulsaciones.valor++ // solo estado
}
fun onCambiarActivo(v: Boolean) { activo.valor = v }
}
// Activity
// boton.setOnClickListener { vm.onPulsar() }
// vm.pulsaciones.observar { texto.text = "Pulsaciones: $it" }
// vm.activo.observar { boton.isEnabled = it }- MVP
- MVVM
- En los dos
- En ninguno
El Presenter guarda vista: ContadorView? y le llama. El ViewModel solo expone Observables; es la vista la que se suscribe.
- El listener del botón: solo avisa a la lógica
- Cómo llega el nuevo valor a la pantalla
- La interfaz
ContadorView - El uso de
Observable
setOnClickListener { …onPulsar() } es igual en los dos: la vista avisa. La diferencia está en el camino de vuelta.
Teoría 22. Comparativa y regla de MVVM
Qué aporta cada modelo y dónde suele fallar:
| GoF Observer | MVP | MVVM | |
|---|---|---|---|
| Quién conoce a quién | El sujeto, una lista de observadores | Presenter ↔ Vista (por interfaz) | La vista conoce el ViewModel; el ViewModel no conoce la vista |
| Cómo llega el cambio | notify() → update() en cada uno |
Llamada directa vista.mostrar…() |
La vista observa el estado |
| Número de vistas | N | Normalmente 1 | N |
| Cómo se prueba | Sujeto + observador falso | Presenter + vista falsa | ViewModel solo; se lee el estado |
| Rotación / ciclo de vida | No lo contempla | A mano: adjuntar / soltar | Lo resuelve el framework (ViewModel, LiveData, repeatOnLifecycle) |
| Error típico | Observador que modifica al sujeto (reentrancia) | Presenter que llama a una vista muerta | Evento puntual tratado como estado, o vista guardada en el ViewModel |
| Dónde lo veis | addListener, EventTarget, Sensor |
Android clásico, WinForms | Android Jetpack, WPF, Vue, SwiftUI |
- Con una vista falsa que registra las llamadas
- Arrancando la Activity en un emulador
- Con un observador falso y un sujeto real
- Con el ViewModel solo, leyendo su estado
Como el ViewModel no conoce la vista, basta con llamar a sus métodos y comprobar el estado. En MVP hace falta una vista falsa porque el Presenter le llama.
- Una referencia a la
Activity - Un
StateFlowcon el estado de la pantalla - Una
Viewconcreta, como unTextView - Un
Contextde pantalla - Un repositorio de datos
Activity, View y Context de pantalla son la vista: guardarlos convierte el ViewModel en un MVP mal hecho y además provoca fugas, porque el ViewModel sobrevive a la rotación. El estado y el repositorio sí le pertenecen.
Ejercicio 23. Caso MVP: el perfil que llega a una pantalla muerta
Android con MVP. El Presenter recibe la vista en el constructor y la guarda.
class PerfilPresenter(
private val vista: PerfilView, // referencia fija
private val repo: Repositorio
) {
fun cargar() {
repo.cargarPerfil { perfil -> // 3 s de red
vista.mostrarPerfil(perfil) // ¿sigue viva?
}
}
}Escena: el usuario abre su perfil, se llama a cargar() y la petición tarda 3 segundos. A mitad de la espera gira el móvil. En Android, girar destruye la Activity y crea una nueva (lo vimos al hablar del ciclo de vida).
Piensa en dos cosas:
- Guardar la vista en el Presenter, ¿a qué equivale en el vocabulario de la unidad? ¿Hay su pareja?
- Cuando llega la respuesta, ¿a qué
Activityapuntavista?
- El perfil en la nueva Activity, sin problemas
- Un error de compilación por usar
vistaen una lambda - La nueva Activity vacía: el perfil se pinta en la Activity muerta, que además no se puede liberar (fuga)
- El perfil aparece dos veces, una en cada Activity
mostrarPerfil se llama sobre la Activity destruida: pinta en una pantalla que nadie ve, la mantiene viva en memoria y la nueva se queda vacía.
- …un alta implícita sin su baja
- …una baja anticipada
- …un callback síncrono
- …una foto de la lista
El Presenter «se suscribe» a la vista al guardarla: es un alta implícita. Sin una baja (soltar la vista), el callback de red la encuentra muerta.
Teoría 24. Adjuntar y soltar la vista
El Presenter guarda la vista, que es un alta implícita. Sin baja, el callback de red la encuentra muerta. La solución es dar al Presenter un alta y una baja explícitas, ligadas al ciclo de vida de la Activity:
class PerfilPresenter(private val repo: Repositorio) {
private var vista: PerfilView? = null
private var ultimo: Perfil? = null
fun adjuntar(v: PerfilView) {
vista = v
ultimo?.let { v.mostrarPerfil(it) } // repinta al volver
}
fun soltar() { vista = null }
fun cargar() = repo.cargarPerfil { p ->
ultimo = p
vista?.mostrarPerfil(p)
}
}
// Activity: onStart { adjuntar(this) } onStop { soltar() }Tres cambios:
vistaes anulable: puede no haber nadie escuchando, yvista?.no llama si esnull.- Adjuntar y soltar son el alta y la baja, colocadas en
onStartyonStop. - Estado guardado (
ultimo): si la respuesta llega cuando no hay vista, se guarda; al adjuntar la nueva, se repinta con el último valor.
Para que el arreglo funcione tras un giro, el Presenter tiene que sobrevivir a la recreación de la Activity (si se crea uno nuevo con cada Activity, se pierde ultimo).
adjuntar y soltar?onCreateyonDestroyonResumeyonCreate- En el constructor del Presenter
onStartyonStop
onStart { adjuntar(this) } y onStop { soltar() }: un par simétrico. Mientras la pantalla no es visible, el Presenter no le habla.
ultimo en el Presenter corregido?- Para no volver a llamar a la red si el perfil ya se cargó una vez
- Para saber cuál fue la última vista adjuntada
- Para cancelar la petición en curso
- Para repintar la nueva vista con el último perfil cuando se adjunta
Si el perfil llega con la vista soltada, se guarda en ultimo; adjuntar lo repinta. Es exactamente el «valor actual al suscribirse» de un observable.
Ejercicio 25. Caso MVVM: el Toast que vuelve al girar
Ahora MVVM. Al confirmar un pedido, el ViewModel quiere que la pantalla muestre «Pedido enviado ✔».
class PedidoViewModel : ViewModel() {
val mensaje = MutableLiveData<String>() // un aviso como estado
fun onConfirmar() {
// … enviar el pedido
mensaje.value = "Pedido enviado ✔"
}
}
// PedidoActivity.onCreate
vm.mensaje.observe(this) { texto ->
Toast.makeText(this, texto, Toast.LENGTH_SHORT).show()
}Recuerda dos cosas de la clase 3:
LiveDataguarda el último valor. Justo lo que queríamos parapulsaciones: al suscribirse, la vista recibe el valor actual, comoo(valor)enobservar().- El
ViewModelsobrevive al giro; la Activity no. La nueva Activity hace unobserve()nuevo, es decir, una suscripción nueva.
Escena: el usuario confirma, ve el Toast y después gira el móvil. Y lo vuelve a girar.
- Nada: el Toast solo se muestra al confirmar
- El Toast «Pedido enviado ✔» vuelve a aparecer
- La app falla porque el ViewModel apunta a la Activity vieja
- El pedido se envía otra vez
Cada Activity nueva hace observe() y LiveData le entrega el último valor guardado: el Toast se repite en cada giro. El pedido no se reenvía (eso lo hace onConfirmar), pero el aviso sí.
navegar(PantallaConfirmacion), ¿qué notaría el usuario al girar o al volver con «Atrás»?- Que la app lo vuelve a llevar a la confirmación cada vez que la pantalla del pedido se suscribe de nuevo
- Nada, la navegación solo ocurre una vez
- Que el botón «Atrás» deja de funcionar por completo
- Que la confirmación aparece vacía
Al volver a la pantalla del pedido (girar, o regresar con «Atrás»), su observe() recibe otra vez el valor guardado y vuelve a navegar: el usuario queda atrapado. El mensaje no es algo que es, sino algo que ha pasado.
Teoría 26. Estado frente a evento, con Flow
Repintar con el valor actual al suscribirse es correcto para un estado e incorrecto para un aviso. Lo que es un estado se repinta siempre; lo que es un aviso se consume una sola vez.
data class PedidoUi(val enviando: Boolean = false, val enviado: Boolean = false)
class PedidoViewModel(private val repo: Repositorio) : ViewModel() {
private val _estado = MutableStateFlow(PedidoUi()) // ESTADO
val estado: StateFlow<PedidoUi> = _estado
private val _avisos = Channel<String>(Channel.BUFFERED) // EVENTO
val avisos = _avisos.receiveAsFlow()
fun onConfirmar() = viewModelScope.launch {
_estado.update { it.copy(enviando = true) }
repo.enviar()
_estado.update { it.copy(enviando = false, enviado = true) }
_avisos.send("Pedido enviado ✔")
}
}
// PedidoActivity.onCreate
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { // alta/baja solas
launch { vm.estado.collect { pintar(it) } } // se repinta al girar
launch { vm.avisos.collect { toast(it) } } // se muestra una vez
}
}StateFlowguarda el valor actual y lo entrega a cada suscriptor nuevo: estado.Channel+receiveAsFlow(): cada aviso lo consume un único colector una sola vez; al girar no se repite.repeatOnLifecycle(STARTED)hace el alta al llegar a STARTED y la baja al salir: el adjuntar/soltar del MVP, gratis.
El modelo de la Unidad 2 en Android
| Unidad 2 | Android |
|---|---|
observar() / suscribir() |
collect { } / observe() |
| Valor actual al suscribirse | StateFlow, LiveData |
| Aviso que no se repite | Channel + receiveAsFlow() |
| Toda alta tiene su baja | repeatOnLifecycle(STARTED) |
OnClickListener que solo avisa |
vm.onConfirmar() |
- El número de pulsaciones del contador
- «Mostrar el Toast: pedido enviado»
- Que el pedido está enviado (
enviado = true) - «Navegar a la pantalla de confirmación»
- Que el botón está deshabilitado mientras se envía
Pulsaciones, enviado y botón deshabilitado describen cómo está la pantalla: estado. El Toast y la navegación son cosas que han pasado: eventos que se consumen una vez.
receiveAsFlow)?ChannelChannel<String>(Channel.BUFFERED), expuesto como flujo con receiveAsFlow(). Para el estado se usa MutableStateFlow.
Ejercicio 27. Caso integrador: encuentra los cuatro fallos
Pantalla de seguimiento de una app de reparto. Compila, funciona en la demo… y falla en producción.
class SeguimientoActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
Pedidos.suscribir { estado ->
textoEstado.text = estado
if (estado == "entregado") {
Pedidos.publicar("pendiente-valoracion")
Pedidos.desuscribir { }
}
Analitica.enviar(estado) // petición HTTP; lanza si no hay red
}
}
}Pistas: hay cuatro fallos, uno de cada familia:
- uno de ciclo de vida,
- uno de referencia,
- uno de reentrancia,
- uno de aislamiento o lentitud.
Para cada fallo: línea, síntoma que vería el usuario y remedio. Contesta la pregunta y después abre la solución.
Ver la solución
| # | Fallo | Síntoma | Regla |
|---|---|---|---|
| 1 | Alta en onResume() sin baja | Cada vuelta a primer plano añade un suscriptor: N repintados, fuga | Alta y baja simétricas |
| 2 | Publicar en el mismo sujeto en su ronda | Los demás reciben pendiente-valoracion antes que entregado | Reentrancia |
| 3 | desuscribir { } con lambda nueva | No da de baja nada | Misma referencia |
| 4 | Red en el handler, hilo principal, sin aislar | UI congelada; sin red, la excepción corta la ronda | Aislar y delegar |
class SeguimientoActivity : AppCompatActivity() {
private var baja: (() -> Unit)? = null
override fun onStart() {
super.onStart()
baja = Pedidos.suscribir { estado -> // 1 alta con baja
textoEstado.text = estado
if (estado == "entregado")
mostrarValoracion() // 2 sin republicar
}
}
override fun onStop() {
baja?.invoke(); baja = null // 3 baja simétrica
super.onStop()
}
}
// 4 Analítica: fuera de la pantalla y en segundo plano
Pedidos.suscribir { estado ->
appScope.launch(Dispatchers.IO) {
runCatching { Analitica.enviar(estado) }
}
}
SeguimientoActivity.- Suscribirse en
onResume()sin dar de baja en ningún sitio - Asignar
textoEstado.textdentro del suscriptor - Publicar
pendiente-valoraciondesde dentro de la ronda dePedidos desuscribir { }con una lambda recién creada- Comparar
estadocon==en lugar deequals - Hacer una petición HTTP que puede lanzar dentro del handler, en el hilo principal
- alta sin baja en un método que se repite; 2) reentrancia al publicar en el mismo sujeto; 3) baja con otra referencia; 4) red en el handler: congela la UI y, sin red, la excepción corta la ronda. Pintar es correcto y en Kotlin
==compara contenido.
appScope.launch(Dispatchers.IO) y runCatching alrededor de Analitica.enviar?- Que la analítica se envíe antes que el repintado
- Que la analítica solo se envíe cuando la pantalla está visible
- Que la petición se repita hasta tener red
- Delegar el trabajo lento fuera del hilo principal y aislar su excepción para que no corte la ronda
Delegar y retornar (el handler no bloquea) y aislar (un fallo de red no afecta a nadie más). Además se saca de la Activity: la analítica no depende de que la pantalla exista.
Repaso 28. Repaso: lista de comprobación ampliada y puente a la U3
Antes de dar por buena cualquier suscripción, pasa esta lista. Las seis primeras preguntas vienen de la clase 2; las cuatro nuevas son el contrato de entrega que hemos visto hoy.
| # | Pregunta | Qué buscas |
|---|---|---|
| 1 | ¿Cuál es el contrato del callback? | Qué recibe, qué devuelve, cuándo se llama |
| 2 | ¿Síncrono o diferido? | ¿Corre dentro de la llamada o más tarde (y en qué hilo)? |
| 3 | ¿Cuántos suscriptores? | Uno o N |
| 4 | ¿Quién da de baja? | Con la misma referencia que el alta |
| 5 | ¿Cómo se asocia evento → handler? | Lista, mapa, topic… |
| 6 | ¿De dónde saca el handler sus datos? | Del evento, no del emisor |
| 7 | ¿Qué pasa con las altas y bajas durante una ronda? | Foto pura o foto + activa |
| 8 | ¿Puede un suscriptor publicar? | ¿Se entrega ya o se encola? ¿Puede formar un ciclo? |
| 9 | ¿Qué pasa si un suscriptor falla o tarda? | ¿Se aísla? ¿Quién se entera? |
| 10 | ¿Es un estado o un evento? | ¿Debe recibirlo quien llega tarde? |
Puente a la Unidad 3
La cola pendientes + while del arreglo B es un bucle de eventos en miniatura: productor → cola → dispatcher → handler, y cada ronda termina antes de la siguiente (run-to-completion). En la Unidad 3 se construye completo, con prioridades, y se estudia su peor enemigo: el handler lento que congela la pantalla.
Observable corrige el valor durante su turno y la vista acaba mostrando un valor viejo. ¿Qué pregunta de la lista nadie se hizo?- 3 · ¿Cuántos suscriptores?
- 10 · ¿Es un estado o un evento?
- 8 · ¿Puede un suscriptor publicar? ¿Se entrega ya o se encola?
- 5 · ¿Cómo se asocia evento → handler?
Es reentrancia: un suscriptor que publica (asigna un valor nuevo) en su turno abre una ronda anidada. La pregunta 8 obliga a decidir si eso se permite y si se entrega ya o se encola.
bajaTambién tiene baja: clearTimeout, abort(), cancel(). Quien da de alta guarda lo que necesita para dar de baja.
- La foto
toList() - El
try/catchpor suscriptor - La interfaz
ContadorViewdel MVP - La cola
pendientescon suwhile(arreglo B)
Productor → cola → dispatcher → handler, con run-to-completion. La Unidad 3 lo completa con prioridades y estudia el handler lento.
Predice, ejecuta, diagnostica
Cinco escenas con la central de pedidos: predice la salida línea a línea, compárala con la real y diagnostica el fallo con la ficha y la lista de diez preguntas.
Teoría 1. La central de pedidos y el método
Hoy no hay ningún concepto nuevo: los fallos los encontráis vosotros. Todas las escenas usan la misma central de pedidos, que es el Sensor de la clase 1 con otro nombre.
// CentralPedidos.kt
class CentralPedidos {
private val oyentes = mutableListOf<OyentePedido>()
fun suscribir(o: OyentePedido) { oyentes += o } // alta
fun desuscribir(o: OyentePedido) { oyentes -= o } // baja
fun cuantos(): Int = oyentes.size
// Una ronda: avisa a todos los suscriptores, uno detrás de otro.
fun publicar(estado: String) {
for (o in oyentes.toList()) {
o(estado)
}
}
}OyentePedido es un tipo función: recibe el estado del pedido (un String) y no devuelve nada. Mira bien la línea del for: hoy vuelve a salir.
El método
En esta sala cada escena tiene dos pasos: primero predices (con el código o el escenario delante) y después, en el paso siguiente, ves la salida real y haces el diagnóstico.
publicar recorre la lista viva o una foto?- La lista viva:
oyentesse recorre directamente - Una foto:
toList()crea una copia antes de recorrerla - Depende de si hay altas durante la ronda
- Ninguna: Kotlin recorre las listas en paralelo
oyentes.toList() devuelve una lista nueva con los mismos elementos. Las altas y bajas durante la ronda no rompen el bucle… pero tampoco afectan a quién recibe el aviso en curso. Tenlo en mente para la escena 2.
Predecir → ejecutar → diagnosticar. La línea que no coincide con la predicción es el síntoma.
Teoría 2. Las dos herramientas: ficha y lista
Para diagnosticar usaremos las dos herramientas de la clase anterior. Te las dejamos numeradas, porque cada escena se resolverá citando una fila y una pregunta.
Ficha de diagnóstico
| Fila | Síntoma que se observa | Antipatrón |
|---|---|---|
| 1 | El resultado depende del orden de carga | Suscriptores que dependen unos de otros |
| 2 | Un suscriptor nunca se entera, sin error | Baja durante el recorrido de la lista viva |
| 3 | Un suscriptor dado de baja reacciona una última vez | Foto sin comprobar si sigue activo |
| 4 | La vista muestra un valor distinto del modelo | Reentrancia: un suscriptor publica en su turno |
| 5 | Desbordamiento de pila o tráfico infinito | Ciclo de notificación |
| 6 | La acción se ejecuta 2, 3… N veces | Alta en un método que se repite |
| 7 | Pantalla cerrada que reacciona; memoria que crece | Baja con otra referencia |
| 8 | Aparecen resultados de una búsqueda anterior | Callback diferido sin cancelar |
| 9 | Tras un fallo, el resto no reacciona | Excepción sin aislar |
| 10 | Reinicios aleatorios en C/C++ | Puntero colgante en el callback |
| 11 | La interfaz se congela mientras un handler trabaja | Handler lento en la ronda |
Lista de diez preguntas
| # | Pregunta |
|---|---|
| 1 | ¿Cuál es el contrato del callback? |
| 2 | ¿Síncrono o diferido? |
| 3 | ¿Cuántos suscriptores? |
| 4 | ¿Quién da de baja? ¿Con la misma referencia que el alta? |
| 5 | ¿Cómo se asocia evento → handler? |
| 6 | ¿De dónde saca el handler sus datos? |
| 7 | ¿Qué pasa con las altas y bajas durante una ronda? |
| 8 | ¿Puede un suscriptor publicar? ¿Se entrega ya o se encola? |
| 9 | ¿Qué pasa si un suscriptor falla o tarda? ¿Quién se entera? |
| 10 | ¿Es un estado o un evento? |
4Fila 4: la vista (o el historial) muestra algo distinto de lo que pasó porque un suscriptor publica durante su turno.
- 9 · ¿Qué pasa si un suscriptor falla o tarda?
- 2 · ¿Síncrono o diferido?
- 6 · ¿De dónde saca el handler sus datos?
- 10 · ¿Es un estado o un evento?
La pregunta 9, que se corresponde con la fila 9 de la ficha: excepción sin aislar.
Ejercicio 3. Escena 1 · ¿Qué imprime?
La escena 1 se hace entre todos y sirve de modelo. Tres minutos, sin ejecutar.
// Escena1Valoracion.kt
fun main() {
val central = CentralPedidos()
val historial = mutableListOf<String>()
// Suscriptor 1: la app del cliente
central.suscribir { estado ->
println(" App: $estado")
if (estado == "entregado") central.publicar("pendiente de valoracion")
}
// Suscriptor 2: el historial del pedido
central.suscribir { estado ->
println(" Historial: $estado")
historial += estado
}
println("Escena 1")
central.publicar("en camino")
central.publicar("entregado")
println("Historial guardado: $historial")
}Tres cosas en las que fijarse:
- Suscriptor 1, la app: pinta el estado. Mira lo que hace cuando recibe
entregado. - Suscriptor 2, el historial: pinta y guarda cada estado, en el orden en que le llega.
- Dos avisos seguidos:
en caminoyentregado. ¿Qué queda guardado?
Escena1Valoracion.kt.La ronda de entregado llama primero a la app, que abre una ronda anidada con pendiente de valoracion: la app y el historial la reciben completa. Solo después la ronda de fuera continúa y el historial recibe entregado, ya viejo.
- 2 rondas, ninguna anidada
- 3 rondas, 1 anidada
- 3 rondas, ninguna anidada
- 4 rondas, 2 anidadas
en camino, entregado y pendiente de valoracion; esta última empieza dentro de la ronda de entregado, antes de que termine.
Ejercicio 4. Escena 1 · Diagnóstico y dos arreglos
Lo que sale: una ronda dentro de otra, y el historial recibe los avisos al revés.
RONDA 1 · publicar("entregado")
1 La app recibe entregado
RONDA 2 · publicar("pendiente de valoracion")
2 La app recibe pendiente de valoracion
3 El historial recibe pendiente de valoracion
4 El historial recibe entregado ← estado viejoLa ronda 1 sigue donde estaba: su último aviso es un estado viejo. Aquí hay un solo hilo: la reentrancia es una llamada dentro de otra, no un problema de concurrencia.
Diagnóstico
| Síntoma | Fila de la ficha | Pregunta de la lista |
|---|---|---|
| El historial guarda los estados en un orden que no ocurrió | 4 · Reentrancia: un suscriptor publica en el mismo sujeto durante su turno | 8 · ¿Puede un suscriptor publicar? ¿Se entrega ya o se encola? |
Arreglo A · la app no republica
if (estado == "entregado")
println(" App: que tal tu pedido?")
// Historial guardado: [en camino, entregado]Arreglo B · una palabra: la central entrega con cola
val central = CentralConCola()CentralConCola tiene una cola pendientes y una bandera notificando, como el Observable con entrega diferida de la clase 4: si alguien publica durante una ronda, el aviso espera en la cola a que termine. Con B no se toca a ningún suscriptor. Guarda esa imagen para el final.
- La app, porque imprime los estados mal
- El historial, que guarda la lista en orden inverso
- Nadie a propósito: la central entrega la ronda 2 dentro de la ronda 1 y el historial guarda en el orden en que le llega
- Kotlin, porque ejecuta los suscriptores en varios hilos
El historial es fiel a lo que recibe; el problema es el orden de entrega cuando un suscriptor publica en su turno (reentrancia). Es una pregunta del contrato de la central, no de un suscriptor.
CentralConCola), ordena lo que acaba guardado en el historial.Historial guardado: [en camino, entregado, pendiente de valoracion]. La app sigue publicando, pero el aviso se entrega cuando acaba la ronda en curso: el orden vuelve a ser el real.
Ejercicio 5. Escena 2 · El timbre
Desde aquí, en parejas, unos cinco minutos por escena. La central es la del principio, sin los arreglos de la escena 1.
Escenario. El cliente tiene activado el modo «no molestar». Cuando el repartidor llega a la puerta, el suscriptor de «no molestar» da de baja al timbre. Hay tres suscriptores, en este orden: no molestar, timbre e historial. ¿Suena el timbre?
En clase solo se proyectan la salida y el arreglo. Este es un código en versión simplificada, coherente con esa salida:
// Escena2Timbre.kt (versión simplificada)
fun main() {
val central = CentralPedidos()
val timbre: OyentePedido = { estado ->
if (estado == "en la puerta") println(" Timbre: DING DONG")
}
val noMolestar: OyentePedido = { estado ->
if (estado == "en la puerta") {
println(" No molestar: se da de baja el timbre")
central.desuscribir(timbre)
}
}
central.suscribir(noMolestar)
central.suscribir(timbre)
central.suscribir { estado -> println(" Historial: $estado") }
println("Escena 2")
central.publicar("en la puerta")
println("Suscriptores: ${central.cuantos()}")
central.publicar("entregado")
}Recuerda lo que respondiste en el primer paso: publicar recorre oyentes.toList().
Timbre: DING DONG?- No: la baja se hace antes de que le llegue el turno al timbre
- Sí: el timbre estaba en la foto de la ronda y la central no comprueba si sigue suscrito
- No: salta
ConcurrentModificationException - Sí, y además suena otra vez con
entregado
El timbre está en la foto que se hizo al empezar la ronda. La baja lo quita de oyentes, pero la foto no cambia: el timbre suena después de haberlo dado de baja.
Suscriptores: …?22: no molestar e historial. La baja del timbre sí se ha hecho en la lista real; lo que no ha hecho es impedir el aviso en curso.
Ejercicio 6. Escena 2 · El timbre suena después de la baja
Salida real:
Escena 2
No molestar: se da de baja el timbre
Timbre: DING DONG
Historial: en la puerta
Suscriptores: 2
Historial: entregadoEl cliente pidió no ser molestado y el timbre suena igualmente. En la segunda ronda (entregado) el timbre ya no está, porque la foto nueva se hace sin él.
| Síntoma | Fila | Pregunta |
|---|---|---|
| Un suscriptor dado de baja reacciona una última vez | 3 · Foto sin comprobar si el suscriptor sigue activo | 7 · ¿Qué pasa con las altas y bajas durante una ronda? |
Arreglo en CentralPedidos.kt: foto, y comprobar que sigue suscrito
for (o in oyentes.toList()) {
if (o in oyentes) o(estado)
}
// con el arreglo, la línea «Timbre: DING DONG» desapareceLa foto protege el bucle; la comprobación o in oyentes hace que una baja cuente desde ya. Es la misma idea que la bandera activa del GPS de la clase 4, sin necesidad de una clase Suscripcion.
if (o in oyentes) dentro del bucle?- Que el suscriptor de la foto siga en la lista real en el momento de su turno
- Que
ono seanull - Que la lista no se haya vaciado
- Que
ono se haya suscrito dos veces
La foto decide quién podría recibir; la lista real, quién sigue suscrito. Si alguien se dio de baja antes de su turno, no se le llama.
CentralPedidos original en esta pregunta del contrato?- Al DOM
- A Node
EventEmitter - A ninguna: es un fallo de Kotlin
- A los dos por igual
Foto pura: el suscriptor dado de baja durante la ronda recibe el aviso en curso, como en Node. Con el arreglo se comporta como el DOM.
Ejercicio 7. Escena 3 · La pantalla
Escenario. El usuario entra en la pantalla de seguimiento, sale, vuelve a entrar, vuelve a salir y entra por tercera vez. Estando dentro, el pedido pasa a «en camino». ¿Cuántas veces se pinta el aviso?
Versión simplificada, coherente con la salida que se vio en clase:
// Escena3Pantalla.kt (versión simplificada)
class PantallaSeguimiento(private val central: CentralPedidos) {
private fun pintar(estado: String) = println(" Pantalla: $estado")
fun alVolver() { central.suscribir { estado -> pintar(estado) } }
fun alSalir() { central.desuscribir { estado -> pintar(estado) } }
}
fun main() {
val central = CentralPedidos()
val pantalla = PantallaSeguimiento(central)
println("Escena 3")
pantalla.alVolver(); pantalla.alSalir() // 1.ª visita
pantalla.alVolver(); pantalla.alSalir() // 2.ª visita
pantalla.alVolver() // 3.ª visita: sigue dentro
central.publicar("en camino")
println("Suscriptores: ${central.cuantos()}")
}Hay alta y hay baja, en métodos simétricos. A primera vista está bien. Fíjate en qué objeto se pasa a suscribir y qué objeto se pasa a desuscribir.
Pantalla: en camino se imprimen?3Tres. Cada alVolver añade una lambda nueva; cada alSalir intenta quitar otra lambda, también nueva, que nunca estuvo en la lista.
desuscribir cuando la lambda no está en la lista?- Sí,
NoSuchElementException - Sí,
ConcurrentModificationException - No:
oyentes -= osimplemente no quita nada y sigue - No compila: no se puede pasar una lambda a
desuscribir
Quitar de una lista un elemento que no está no es un error en Kotlin: no hace nada. Por eso este fallo es silencioso.
Ejercicio 8. Escena 3 · Un aviso, tres repintados
Salida real:
Escena 3
Pantalla: en camino
Pantalla: en camino
Pantalla: en camino
Suscriptores: 3| Síntoma (fila 6) | Causa (fila 7) | Pregunta |
|---|---|---|
| La acción se ejecuta N veces | Baja con otra referencia | 4 · ¿Quién da de baja? ¿Con la misma referencia que el alta? |
Aquí el síntoma está en una fila y la causa en otra. Dos lambdas con el mismo texto son dos objetos. La baja buscó un objeto que nunca estuvo en la lista, y no dijo nada. En una app real, además del triple repintado, cada pantalla «cerrada» sigue viva en memoria a través de su lambda.
Arreglo: una sola referencia, guardada
private val oyente: OyentePedido = { estado -> pintar(estado) } // propiedad nueva
central.suscribir(oyente) // en alVolver
central.desuscribir(oyente) // en alSalir
// con el arreglo: una línea «Pantalla: en camino» y «Suscriptores: 1»La lambda se crea una vez, al construir la pantalla, y se guarda en una propiedad. El alta y la baja usan la misma referencia.
- Ninguna línea de pantalla y
Suscriptores: 0 - Tres líneas y
Suscriptores: 1 - Una línea y
Suscriptores: 3 - Una línea
Pantalla: en caminoySuscriptores: 1
Las dos primeras visitas dan de alta y de baja la misma referencia; en la tercera el usuario sigue dentro, así que queda exactamente un suscriptor.
referenciaLa misma referencia: el objeto que se dio de alta es el que hay que dar de baja.
Ejercicio 9. Escena 4 · La cocina
Escenario. Restaurante con reparto. Cuando un pedido pasa a pagado, tres suscriptores reaccionan en este orden: la app del cliente, la analítica y la cocina. Hoy la analítica no tiene red y lanza una excepción. ¿Se entera la cocina?
Versión simplificada, coherente con la salida que se vio en clase:
// Escena4Cocina.kt (versión simplificada)
fun main() {
val central = CentralPedidos()
central.suscribir { estado -> println(" App: $estado") }
central.suscribir { estado -> // analítica: hoy sin red
throw IllegalStateException("analitica sin red ($estado)")
}
central.suscribir { estado ->
if (estado == "pagado") println(" Cocina: a preparar el pedido")
}
println("Escena 4")
central.publicar("pagado")
println("Fin de la escena 4")
}Recuerda cómo es el bucle de publicar: un for sobre la foto que llama a o(estado). ¿Hay algún try en algún sitio?
App: pagado?Cocina: a preparar el pedidoyFin de la escena 4- Solo
Fin de la escena 4 - La traza de la excepción: ni la cocina ni el «Fin» llegan a imprimirse
Cocina: a preparar el pedidoy después la traza de la excepción
La excepción de la analítica corta la ronda: la cocina va detrás y no recibe el aviso. Como nadie la captura, también corta main y el programa termina con la traza.
- De la cocina, que debería suscribirse la primera
- De
main, que debería envolverpublicaren untry - De la central, que decide qué pasa cuando un suscriptor falla
- De nadie: es inevitable
Es la pregunta 4 de la letra pequeña: «si un suscriptor lanza, ¿se sigue avisando a los demás?». La respuesta la da el bucle de la central. Reordenar suscriptores o capturar en main no hace que la cocina se entere.
Ejercicio 10. Escena 4 · Falla la analítica y lo paga la cocina
Salida real (el texto rojo):
Escena 4
App: pagado
Exception in thread "main" java.lang.IllegalStateException: analitica sin red (pagado)
at Escena4CocinaKt.main$lambda$1(Escena4Cocina.kt:11)
at CentralPedidos.publicar(CentralPedidos.kt:18)
at Escena4CocinaKt.main(Escena4Cocina.kt:19)
at Escena4CocinaKt.main(Escena4Cocina.kt)Lee la traza de abajo arriba: main llama a publicar (línea 19), publicar llama a la lambda de la analítica (línea 18 de la central) y la lambda lanza. La línea de CentralPedidos.publicar es la que delata dónde debería haberse detenido la excepción.
| Síntoma | Fila | Pregunta |
|---|---|---|
| Falla la analítica y la cocina no se entera | 9 · Excepción sin aislar | 9 · ¿Qué pasa si un suscriptor falla o tarda? |
Arreglo en CentralPedidos.kt: aislar a cada suscriptor
try {
if (o in oyentes) o(estado)
} catch (e: Exception) {
println(" [Central] un suscriptor ha fallado: ${e.message}")
}(El if (o in oyentes) es el arreglo de la escena 2, que se conserva.) Salida con el arreglo:
Escena 4
App: pagado
[Central] un suscriptor ha fallado: analitica sin red (pagado)
Cocina: a preparar el pedido
Fin de la escena 4at CentralPedidos.publicar(CentralPedidos.kt:18)at Escena4CocinaKt.main(Escena4Cocina.kt)Exception in thread "main"at Escena4CocinaKt.main$lambda$1(Escena4Cocina.kt:11)
La línea 18 de la central es la llamada o(estado) dentro del for: ahí es donde debe ir el try/catch por suscriptor.
- Quien lea la salida de la consola (el mensaje de la central)
- El código que llamó a
publicar - La propia analítica, que reintentará
- La cocina
Solo quien lea el mensaje. publicar retorna con normalidad, la analítica no recibe ninguna señal y la cocina ni lo sabe ni le importa. Aislar sin informar bien es esconder el fallo.
Ejercicio 11. Escena 5 · ¿En qué orden salen las líneas?
Esta escena la ejecuta el profesor; portátiles cerrados. Cuatro minutos.
// Escena5Buscador.kt
fun main() {
val reloj = Timer()
var enPantalla = "(nada)"
// Cada vez que el usuario cambia el texto se lanza una búsqueda
fun alEscribir(texto: String) {
println("Buscador: el usuario ha escrito '$texto'")
buscarEnTienda(reloj, texto) { resultado ->
enPantalla = resultado
println(" Pantalla (hilo ${Thread.currentThread().name}): $resultado")
}
}
println("Escena 5")
alEscribir("man")
alEscribir("mango")
println("main (hilo ${Thread.currentThread().name}): sigue sin esperar")
Thread.sleep(1000) // deja tiempo a que lleguen las respuestas
reloj.cancel()
println("El usuario ha escrito 'mango' y ve: $enPantalla")
}buscarEnTienda no devuelve el resultado: devuelve la tarea programada (TimerTask). El resultado llega más tarde, por el callback. «man» tarda 600 ms en contestar; «mango», 200 ms. Una versión simplificada de esa función, coherente con la escena:
fun buscarEnTienda(reloj: Timer, texto: String, alLlegar: (String) -> Unit): TimerTask {
val tarea = object : TimerTask() {
override fun run() = alLlegar("resultados de '$texto'")
}
reloj.schedule(tarea, if (texto == "man") 600L else 200L)
return tarea
}Escena5Buscador.kt.Las tres primeras líneas de main salen de inmediato (callback diferido). A los 200 ms llega «mango» y a los 600 ms «man», que sobrescribe enPantalla. Al final de main el usuario ve los resultados de «man».
- En
main, cuando terminaThread.sleep - En un hilo nuevo por cada búsqueda
- En el hilo del
Timer(Timer-0) - En el mismo hilo que llamó a
alEscribir
Un java.util.Timer ejecuta sus tareas en su propio hilo de fondo, que aquí se llama Timer-0. Es un callback diferido, como en la clase 1: corre después y en otro hilo.
Ejercicio 12. Escena 5 · Llega tarde la búsqueda vieja
Lo que ve el usuario: ha escrito «mango» y en la pantalla están los resultados de «man».
Diferido (clase 1)
main sigue sin esperar. El callback corre después, y en otro hilo: Timer-0.
Gana la última en llegar
No la última que se pidió. Las dos búsquedas siguen vivas.
Fila 8 · preguntas 2 y 4
Callback diferido sin cancelar. ¿Síncrono o diferido? ¿Quién da de baja?
¿Qué hace alEscribir con lo que devuelve buscarEnTienda? Lo tira. Sin la tarea en la mano no hay nada que cancelar.
Arreglo: guardar la tarea y cancelar la anterior
var pendiente: TimerTask? = null
fun alEscribir(texto: String) {
println("Buscador: el usuario ha escrito '$texto'")
pendiente?.cancel()
pendiente = buscarEnTienda(reloj, texto) { resultado ->
// … igual que antes
}
}Salida con el arreglo:
Escena 5
Buscador: el usuario ha escrito 'man'
Buscador: el usuario ha escrito 'mango'
main (hilo main): sigue sin esperar
Pantalla (hilo Timer-0): resultados de 'mango'
El usuario ha escrito 'mango' y ve: resultados de 'mango'TimerTask se llama para anular la búsqueda anterior?cancelcancel(): si la tarea aún no se ha ejecutado, ya no se ejecutará. Es la baja del callback diferido.
- Los dos son reentrancia
- Los dos lanzan una excepción silenciosa
- Los dos se arreglan con una foto de la lista
- En los dos, quien da de alta no guarda lo que necesita para dar de baja (la lambda / la tarea)
Sin la referencia (lambda o TimerTask) no hay forma de dar de baja. La regla común: quien da de alta guarda lo que necesita para dar de baja.
Repaso 13. Repaso: cinco fallos, cinco preguntas
| Escena | Lo que se ve | Fila | Remedio | Pregunta |
|---|---|---|---|---|
| 1 · La valoración | El historial guarda un orden que no ocurrió | 4 | No republicar, o entregar con cola | 8 |
| 2 · El timbre | Suena después de darlo de baja | 3 | Foto, y comprobar que sigue suscrito | 7 |
| 3 · La pantalla | Un aviso, tres repintados | 6 y 7 | La misma referencia en alta y baja | 4 |
| 4 · La cocina | Falla la analítica y la cocina no se entera | 9 | try/catch por suscriptor |
9 |
| 5 · El buscador | Llegan resultados de una búsqueda anterior | 8 | Guardar la tarea y cancelarla | 2 y 4 |
Cinco preguntas para el parcial
Intenta contestarlas en voz alta (treinta segundos cada una) antes de abrir la respuesta.
1 · Un suscriptor dado de baja durante una ronda recibe el aviso en curso. ¿Es un fallo?
No necesariamente: es la política de foto pura (la de Node EventEmitter). Es un fallo si el contrato promete lo contrario (como el DOM) o si nadie sabe qué política ofrece la central, como el «no molestar» de la escena 2.
2 · ¿Por qué desuscribir { estado -> pintar(estado) } no da de baja nada y tampoco da error?
Porque esa lambda acaba de nacer: es un objeto distinto del que se dio de alta, aunque tenga el mismo texto. Quitar de la lista algo que no está no es un error; simplemente no quita nada.
3 · En la escena 1, ¿qué recibe el historial en último lugar y por qué es un problema?
Recibe entregado, un estado ya viejo: la ronda anidada de pendiente de valoracion se entregó antes. Quien lea el último estado del historial se equivoca.
4 · Con el try/catch por suscriptor, ¿quién se entera de que la analítica ha fallado?
Solo quien lea el mensaje que imprime la central. Ni el publicador ni la analítica lo saben: aislar también es decidir quién se entera.
5 · ¿En qué se parecen cancelar un TimerTask y dar de baja un listener?
Son lo mismo: un callback diferido es una suscripción de un solo uso. En ambos casos hay que guardar la referencia (tarea o lambda) en el alta para poder dar de baja.
- Escena 1 · La valoración
- Escena 3 · La pantalla
- Escena 4 · La cocina
- Escena 5 · El buscador
Solo la escena 4 termina con una traza. Las otras cuatro acaban «bien»: sus fallos solo los detecta quien predijo la salida antes de ejecutar.
- Fila 4 · pregunta 8
- Fila 9 · pregunta 9
- Fila 3 · pregunta 7
- Fila 8 · preguntas 2 y 4
Fila 8 (callback diferido sin cancelar) y preguntas 2 (¿síncrono o diferido?) y 4 (¿quién da de baja?).
bajaPara dar de baja: la lambda en la escena 3, la TimerTask en la escena 5.
Repaso 14. Repaso final: lo que queda para la Unidad 3
Lo que queda para la Unidad 3
Recuerda el arreglo B de la escena 1: cambiar una palabra para usar CentralConCola. Este es su publicar:
// CentralPedidos.kt · class CentralConCola
fun publicar(estado: String) {
pendientes.addLast(estado)
if (notificando) return
notificando = true
try {
while (pendientes.isNotEmpty()) {
val siguiente = pendientes.removeFirst()
for (o in oyentes.toList()) {
o(siguiente)
}
}
} finally {
notificando = false
}
}1 · La cola
pendientes guarda los avisos que llegan durante una ronda.
2 · Quién la vacía
Quien publicó primero: se queda hasta entregarlo todo.
3 · El bucle
El while entrega los avisos uno a uno. Cada ronda termina antes de la siguiente.
CentralConCola, ¿quién vacía la cola pendientes?- Un hilo aparte que la revisa cada cierto tiempo
- El último suscriptor de cada ronda
- Quien publicó primero: se queda en el
whilehasta entregarlo todo - Cada llamada a
publicar, incluidas las reentrantes
Las llamadas reentrantes solo encolan y retornan. La primera es la que recorre el while. La pregunta para la U3 es qué pasa si ni siquiera ella se queda: dejar el aviso e irse lleva al bucle de eventos.
CentralConCola.publicar en la primera llamada (la que no es reentrante).pendientes → Comprueba notificando y, como es false, sigue → Pone notificando = true → Saca el primer aviso de la cola → Lo entrega a la foto de suscriptores → Con la cola vacía, pone notificando = false en el finallyEncolar → comprobar → marcar → sacar y entregar (en bucle mientras queden avisos) → desmarcar en el finally, que se ejecuta aunque un suscriptor lance.