Combine en Swift: Publishers, Subscribers, operadores y sink/assign

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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP