Combine es el framework reactivo de Apple, integrado en el SDK desde iOS 13. En lugar de callbacks anidados o delegados, Combine modela las fuentes de datos asíncronas como publishers y los consumidores como subscribers. Los operadores conectan ambos extremos transformando, filtrando y combinando valores a lo largo de la cadena.
Publishers básicos: Just, PassthroughSubject y CurrentValueSubject
Just emite un único valor y completa inmediatamente. Es útil para pruebas y para envolver valores síncronos en una cadena Combine:
import Combine
let publisher = Just(42)
let cancellable = publisher.sink(
receiveCompletion: { print("Completado: ($0)") },
receiveValue: { print("Valor: ($0)") }
)
// Valor: 42
// Completado: finished
PassthroughSubject actúa como un emisor manual: no tiene valor inicial y envía valores cuando se llama a send(_:). Es el equivalente a un EventEmitter:
let subject = PassthroughSubject()
let cancellable = subject.sink { print("Recibido: ($0)") }
subject.send("hola")
subject.send("mundo")
subject.send(completion: .finished)
// Recibido: hola
// Recibido: mundo
CurrentValueSubject almacena el último valor emitido y lo entrega inmediatamente a cualquier nuevo suscriptor. Es equivalente a una variable observable:
let temperaturaActual = CurrentValueSubject(20.0)
// Nuevo suscriptor recibe 20.0 de inmediato
let cancellable = temperaturaActual.sink { print("Temperatura: ($0)") }
temperaturaActual.send(22.5)
temperaturaActual.send(23.1)
// Temperatura: 20.0
// Temperatura: 22.5
// Temperatura: 23.1
Suscripciones: sink y assign
sink es el subscriber más flexible: acepta un closure para valores y otro para el evento de completado o error:
var cancellables = Set()
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://api.ejemplo.com/datos")!)
.map(.data)
.decode(type: [Producto].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
print("Error: (error)")
}
},
receiveValue: { productos in
print("Recibidos (productos.count) productos")
}
)
.store(in: &cancellables)
assign(to:on:) conecta el output de un publisher directamente a una propiedad de un objeto, sin closure:
class TemperaturaVM: ObservableObject {
@Published var temperatura: Double = 0
private var cancellables = Set()
init(sensor: CurrentValueSubject) {
sensor
.receive(on: DispatchQueue.main)
.assign(to: .temperatura, on: self)
.store(in: &cancellables)
}
}
Operadores clave: map, filter y debounce
Los operadores transforman la cadena de valores sin crear suscripciones independientes:
let numeros = [1, 2, 3, 4, 5, 6].publisher
numeros
.filter { $0.isMultiple(of: 2) } // 2, 4, 6
.map { $0 * $0 } // 4, 16, 36
.sink { print($0) }
debounce retrasa la entrega de valores hasta que pasa un intervalo sin nuevas emisiones. Es imprescindible para campos de búsqueda:
let busquedaSubject = PassthroughSubject()
busquedaSubject
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.sink { texto in
print("Buscar: (texto)")
}
.store(in: &cancellables)
// El usuario escribe rápido: solo la última palabra activa la búsqueda
busquedaSubject.send("s")
busquedaSubject.send("sw")
busquedaSubject.send("swi")
busquedaSubject.send("swift")
// ? Buscar: swift (solo una vez, tras 300 ms de silencio)
Ejemplo real: buscador con URLSession.DataTaskPublisher
Un buscador que combina @Published, debounce y URLSession.DataTaskPublisher en un ViewModel limpio:
struct Repositorio: Decodable {
let name: String
let stargazers_count: Int
}
struct BusquedaResponse: Decodable {
let items: [Repositorio]
}
class BuscadorVM: ObservableObject {
@Published var query = ""
@Published var resultados: [Repositorio] = []
@Published var cargando = false
private var cancellables = Set()
init() {
$query
.debounce(for: .milliseconds(400), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.handleEvents(receiveOutput: { [weak self] _ in
self?.cargando = true
})
.compactMap { query -> URL? in
var components = URLComponents(string: "https://api.github.com/search/repositories")
components?.queryItems = [URLQueryItem(name: "q", value: query)]
return components?.url
}
.flatMap { url in
URLSession.shared
.dataTaskPublisher(for: url)
.map(.data)
.decode(type: BusquedaResponse.self, decoder: JSONDecoder())
.replaceError(with: BusquedaResponse(items: []))
}
.receive(on: DispatchQueue.main)
.sink { [weak self] response in
self?.cargando = false
self?.resultados = response.items
}
.store(in: &cancellables)
}
}
La cadena completa convierte pulsaciones de teclado en resultados de red sin un solo callback anidado: el flujo se lee de arriba a abajo, cada operador tiene una responsabilidad y la gestión de memoria se reduce a un único Set<AnyCancellable>.
Resumen
Combine transforma el código asíncrono de iOS en pipelines declarativos y componibles. Just y los subjects son las fuentes básicas; sink y assign son los puntos de consumo; y operadores como map, filter, debounce y flatMap conectan ambos extremos. El resultado es código más legible, testeable y libre de retención de memoria involuntaria cuando se usa correctamente con AnyCancellable.
