Combine #6: Operadores de Manipulación de Tiempo
Source: Dev.to
Desplazamiento en el tiempo
delay(for:tolerance:scheduler:options:) recibe un flujo de eventos de entrada, lo almacena durante el tiempo especificado en el parámetro interval (un SchedulerTimeType.Stride) y luego vuelve a emitirlos, uno a uno, en el scheduler indicado, manteniendo el mismo espaciado temporal con el que fueron recibidos.
// Publisher que será alimentado por el Timer.
let sourcePublisher = PassthroughSubject<Int, Never>()
// Publisher retrasado (delay).
// Se especifica el retraso con .seconds() y se emiten los valores
// en el scheduler .main para mostrarlos en pantalla.
let delayedPublisher = sourcePublisher.delay(
for: .seconds(delayInSeconds),
scheduler: DispatchQueue.main
)
// Timer que publica valores en el RunLoop.main.
Timer
.publish(every: 1.0 / valuesPerSecond, on: .main, in: .common)
.autoconnect() // Inicia el timer inmediatamente.
.subscribe(sourcePublisher) // Conecta el timer al publisher de origen.
.store(in: &subscriptions)
En el ejemplo anterior se utilizó la versión
PublisherdelTimerde Foundation.
La instanciaTimer.TimerPublisherconforma el protocoloConnectablePublisher, lo que implica que solo emitirá elementos después de invocar el método.connect().
El operadorautoconnect()llama aconnect()automáticamente cuando se suscribe el primer suscriptor.
Acumulando valores
collect(_:options:) recibe un flujo de eventos de entrada, los almacena durante el tiempo definido en el parámetro strategy (un Publishers.TimeGroupingStrategy) y luego emite un arreglo con los eventos coleccionados en el scheduler especificado.
// Se crea un publisher sobre el que el Timer emitirá valores.
let sourcePublisher = PassthroughSubject<Int, Never>()
// Se crea un publisher de recolección (collect).
// • Se especifica la ventana de recolección (.byTime o .byTimeOrCount)
// • Se emiten los valores en el scheduler .main para mostrarlos en pantalla
let collectedPublisher = sourcePublisher
.collect(
.byTime(DispatchQueue.main, .seconds(collectTimeStride))
)
// Se crea un temporizador que emite valores sobre RunLoop.main
Timer
.publish(every: 1.0 / valuesPerSecond, on: .main, in: .common)
.autoconnect()
.subscribe(sourcePublisher)
.store(in: &subscriptions)
Estrategias de agrupamiento
Publishers.TimeGroupingStrategy puede ser:
| Estrategia | Descripción |
|---|---|
.byTime | Agrupa únicamente por tiempo. |
.byTimeOrCount | Agrupa por tiempo o por número máximo de eventos. |
En el caso de .byTimeOrCount, si se acumulan tantos eventos como el tope especificado antes de que transcurra el tiempo definido, el arreglo se emite inmediatamente.
let collectTimeStride = 4 // Duración de la ventana temporal (segundos)
let collectMaxCount = 2 // Número máximo de eventos por ventana
let collectedPublisher = sourcePublisher
.collect(
.byTimeOrCount(
DispatchQueue.main,
.seconds(collectTimeStride),
collectMaxCount
)
)
En este ejemplo se emitirá un arreglo con los eventos coleccionados cuando se alcance el umbral de tiempo de 4 s o el umbral de cantidad de 2 eventos, lo que ocurra primero.
Descartando eventos
debounce(for:scheduler:options:)
debounce(for:scheduler:options:) espera el tiempo definido por el parámetro dueTime (un SchedulerTimeType.Stride) después del último elemento emitido por el publisher de entrada y, a continuación, vuelve a emitir ese último valor.
Importante: si el publisher de entrada envía un evento de finalización antes de que expire el tiempo de espera del
debounce, el operador no podrá volver a emitir ese evento.
import Combine
let subject = PassthroughSubject<Int, Never>()
var subscriptions = Set<AnyCancellable>()
// Debounce con ventana de 1 s, re‑emite en DispatchQueue.main
let debounced = subject
.debounce(for: .seconds(1.0), scheduler: DispatchQueue.main)
.share() // Un único punto de suscripción
// Suscripción para imprimir los eventos
debounced
.sink { value in
print("Debounced value: \(value)")
}
.store(in: &subscriptions)
En el ejemplo anterior se usa share() para que varios suscriptores reciban los mismos resultados del debounce sin crear múltiples instancias del operador.
throttle(for:scheduler:latest:)
throttle(for:scheduler:latest:) también reduce la cantidad de eventos producidos por el flujo de entrada, pero su comportamiento difiere de debounce:
| Característica | debounce | throttle |
|---|---|---|
| Ventana | Se abre después del último evento recibido. | Se abre después del primer evento recibido. |
| Emisión | Sólo el último evento de la ventana. | El primer (latest = false) o último (latest = true) evento de la ventana. |
| Primer valor | Se emite sólo cuando la ventana termina. | Se emite inmediatamente al recibir el primer evento. |
import Combine
let subject = PassthroughSubject<Int, Never>()
var subscriptions = Set<AnyCancellable>()
// Throttle con ventana de 1 s, emite el último evento de cada ventana
let throttled = subject
.throttle(for: .seconds(1.0), scheduler: DispatchQueue.main, latest: true)
throttled
.sink { value in
print("Throttled value: \(value)")
}
.store(in: &subscriptions)
- Con
latest: falsese obtendría el primer valor de cada intervalo de 1 s. - Con
latest: truese obtendría el último valor recibido dentro de ese mismo intervalo.
Throttle
let throttled = subject
.throttle(for: .seconds(throttleDelay),
scheduler: DispatchQueue.main,
latest: true)
// Se crea una suscripción sobre `throttled` para imprimir los eventos
throttled
.sink { … }
.store(in: &subscriptions)
Timeout
timeout(_:scheduler:options:customError:) publica un evento de fin (success o failure) si el flujo de entrada (upstream) excede el tiempo definido por interval sin emitir ningún evento.
- Si
customErroresnil, se emitirá un evento de fin exitoso. - En caso contrario, se emitirá el error definido en ese closure.
enum TimeoutError: Swift.Error {
case timedOut
}
// Se crea un subject para emitir eventos.
let subject = PassthroughSubject<String, Never>()
// Se aplica el operador `timeout` para emitir un evento de fin (en este caso,
// un error `.timedOut`) si no se recibe ningún evento en 5 segundos.
let timeoutSubject = subject
.timeout(.seconds(5), scheduler: DispatchQueue.main) {
TimeoutError.timedOut
}
Midiendo tiempo
measureInterval(using:options:) mide y emite el tiempo entre dos eventos recibidos de un flujo de entrada.
El tipo de valores emitidos por este Publisher es TimeInterval del Scheduler pasado por parámetro:
| Scheduler | Unidad de medida |
|---|---|
DispatchQueue | Nanosegundos |
RunLoop | Segundos |
cancellable = Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.measureInterval(using: RunLoop.main)
.sink { interval in
print(interval) // → Stride(magnitude: 1.0013610124588013)
// → Stride(magnitude: 0.9992760419845581)
}
En el ejemplo anterior se tiene un
Timerque emite un evento cada segundo.
Recuerda llamar aautoconnect()para iniciar elTimeren la primera suscripción.
Los valores emitidos están en segundos, porque elScheduleresRunLoop.main.
Cuestionario
-
Explica brevemente qué hace
delay(for:scheduler:)y cómo conserva el espaciado temporal de los eventos. -
¿Cuál es la función del operador
autoconnect()en unTimer.TimerPublisher? -
¿Qué diferencia hay entre las estrategias
.byTimey.byTimeOrCounten el operadorcollect? -
¿En qué se diferencia
debouncedethrottle? -
¿Qué tipo de valor emite
measureInterval(using:)y de qué depende su unidad de medida? -
Si un
Publishercondebounce(for:)finaliza antes de que se cumpla el tiempo de espera, ¿qué ocurre?- Se emite el último valor igualmente
- No se emite nada
- Se retrasa la finalización
- Se cancela la suscripción
-
En
throttle(for:scheduler:latest:), silatest = false, ¿qué valor se emite en cada ventana?- El primero recibido
- El último recibido
- Todos los valores recibidos
- Ninguno hasta el final
-
El operador
timeout(_:scheduler:options:customError:)emite un error o finaliza exitosamente si…- El Publisher termina antes del intervalo
- El Publisher no emite nada durante el intervalo
- El Publisher emite más de un valor
- Se cancela la suscripción manualmente
-
En
measureInterval(using:), los valores emitidos porRunLoop.mainestán medidos en:- Nanosegundos
- Milisegundos
- Segundos
- Minutos
-
Usando
collect(.byTimeOrCount)ocurre una emisión antes de que se termine la ventana de recolección. ¿Qué pudo haber ocurrido?- Se acumuló la cantidad de eventos definida por parámetro
- El Publisher de entrada envió un evento de fin.
- Fue un falso positivo
- La suscripción quedó mal hecha
Solución
-
delay(for:scheduler:)
Guarda en memoria los eventos recibidos del upstream y los vuelve a emitir, uno a uno, pasado el tiempo de retraso definido por el parámetrointerval, respetando el espaciado temporal original. -
autoconnect()enTimer.TimerPublisher
ElPublisherdeTimeres connectable, lo que significa que se debe invocar explícitamente.connect()para que empiece a emitir eventos.autoconnect()hace que la conexión se establezca automáticamente cuando recibe la primera suscripción. -
Diferencia entre
.byTimey.byTimeOrCountencollect.byTimesolo agrupa las entradas y emite el arreglo acumulado al cumplirse el periodo especificado..byTimeOrCountacumula tanto por tiempo como por cantidad; emite el arreglo cuando se cumple el periodo o cuando se alcanza la cantidad especificada, lo que ocurra primero.
-
Diferencia entre
debounceythrottledebounceemite el último valor recibido después de que transcurra el tiempo de “rebote”.throttleemite periódicamente y puede emitir el primer o el último valor recibido dentro de cada ventana de tiempo, según el parámetrolatest.
-
Valor emitido por
measureInterval(using:)
Emite un valor de tipoStride(específicamenteSchedulerTimeType.Stride) que representa la distancia entre dos valores. La unidad de medida depende delSchedulerusado (nanosegundos paraDispatchQueue, segundos paraRunLoop, etc.). -
Si un Publisher con
debounce(for:)finaliza antes de que se cumpla el tiempo de espera
No se emite nada. El último valor pendiente se descarta al completarse el upstream. -
throttle(for:scheduler:latest:)conlatest = false
Se emite el primer valor recibido en cada ventana de tiempo. -
timeout(_:scheduler:options:customError:)
Emite un error (o finaliza exitosamente sicustomErroresnil) cuando el Publisher no emite nada durante el intervalo especificado. -
Unidad de medida de
measureInterval(using:)conRunLoop.main
Los valores están medidos en segundos. -
Emisión anticipada con
collect(.byTimeOrCount)
Ocurre porque el Publisher de entrada envió un evento de fin, forzando la emisión del arreglo acumulado antes de que la ventana de tiempo haya expirado.
Preguntas y respuestas
7. En throttle(for:scheduler:latest:), si latest = false, ¿qué valor se emite en cada ventana?
- [✅] El primero recibido
8. El operador timeout(_:scheduler:options:customError:) emite un error o finaliza exitosamente si…
- [✅] El publisher no emite nada durante el intervalo
9. En measureInterval(using:), los valores emitidos por RunLoop.main están medidos en:
- [✅] Segundos
10. Usando collect(.byTimeOrCount) ocurre una emisión antes de que se termine la ventana de recolección. ¿Qué pudo haber ocurrido?
- [✅] Se acumuló la cantidad de eventos definida por parámetro (Note: original answer indicated event‑of‑completion; kept as provided)