Combine vs async/await en Swift: Patrones de Migración Progresiva

Guía completa para migrar de Combine a async/await en Swift: estrategias progresivas, patrones de puente y coexistencia de paradigmas en bases de código iOS.

Migración de Combine a async/await en Swift con patrones de coexistencia

La llegada de Swift Concurrency con async/await ha transformado las prácticas de programación asíncrona en iOS. Para los proyectos que utilizan Combine, surge naturalmente la pregunta de la migración. ¿Hay que reescribirlo todo? ¿Pueden coexistir ambos enfoques? ¿Qué patrones permiten una transición fluida? Esta guía explora las estrategias de migración progresiva, permitiendo adoptar async/await sin abandonar Combine de manera abrupta.

Lo que cubre esta guía

Esta guía presenta patrones concretos para migrar progresivamente de Combine a async/await, con ejemplos de puentes bidireccionales y estrategias de coexistencia adaptadas a las bases de código existentes.

Comprender las Diferencias Fundamentales

Antes de iniciar una migración, es esencial comprender qué distingue a Combine de async/await. Estos dos enfoques responden a necesidades diferentes, y ciertos casos de uso siguen estando mejor servidos por Combine.

El Modelo Mental de Combine

Combine se basa en un modelo de flujos de datos. Un Publisher emite valores a lo largo del tiempo, los operadores transforman esos valores y un Subscriber recibe el resultado final. Este modelo destaca para los flujos continuos como eventos de UI, notificaciones o WebSockets.

CombineExample.swiftswift
// Event stream with Combine - stream-based model
import Combine

class SearchViewModel {
    @Published var searchText = ""
    private var cancellables = Set<AnyCancellable>()

    // Combine excels for continuous streams with transformations
    func setupSearch() {
        $searchText
            // Wait 300ms pause in typing
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            // Ignore consecutive duplicates
            .removeDuplicates()
            // Filter searches that are too short
            .filter { $0.count >= 3 }
            // Transform text into network request
            .flatMap { query in
                self.searchAPI(query: query)
                    // Local error handling
                    .catch { _ in Just([]) }
            }
            // Final subscription
            .sink { results in
                self.updateUI(with: results)
            }
            .store(in: &cancellables)
    }

    private func searchAPI(query: String) -> AnyPublisher<[SearchResult], Error> {
        // Network implementation
    }
}

Este código ilustra la fortaleza de Combine: encadenar operadores declarativos para procesar un flujo continuo de eventos.

El Modelo Mental de async/await

Async/await adopta un modelo secuencial: una operación comienza, el código espera su resultado y luego continúa. Este modelo resulta más intuitivo para operaciones puntuales como solicitudes de red aisladas o lecturas de archivos.

AsyncAwaitExample.swiftswift
// One-off operations with async/await - sequential model
import Foundation

actor SearchService {
    // async/await excels for sequential operations
    func performSearch(query: String) async throws -> [SearchResult] {
        // Pre-validation - clear sequential reading
        guard query.count >= 3 else {
            return []
        }

        // Network request with await
        let url = URL(string: "https://api.example.com/search?q=\(query)")!
        let (data, response) = try await URLSession.shared.data(from: url)

        // Response verification
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw SearchError.invalidResponse
        }

        // Result decoding
        let results = try JSONDecoder().decode([SearchResult].self, from: data)
        return results
    }
}

La lectura es lineal, los errores se propagan naturalmente con try y el flujo de ejecución resulta inmediatamente comprensible.

Cuándo elegir cada enfoque

Combine sigue siendo relevante para los flujos continuos (eventos de UI, temporizadores, WebSockets). Async/await está mejor adaptado para las operaciones puntuales (solicitudes API, lectura de archivos, cálculos aislados).

Hacer Puente de Combine hacia async/await

El primer paso de una migración suele consistir en consumir Publishers existentes en código async/await. Swift proporciona herramientas nativas para este puente.

Usar AsyncSequence con Publisher.values

Desde Swift 5.5, todo Publisher expone una propiedad .values que devuelve un AsyncPublisher. Esta secuencia asíncrona permite iterar sobre los valores emitidos con un bucle for await.

BridgingCombineToAsync.swiftswift
// Publisher → AsyncSequence conversion via .values
import Combine

class NotificationObserver {
    private let notificationPublisher: AnyPublisher<Notification, Never>

    init() {
        // Existing Combine Publisher
        notificationPublisher = NotificationCenter.default
            .publisher(for: UIApplication.didBecomeActiveNotification)
            .eraseToAnyPublisher()
    }

    // Consuming the Publisher with async/await
    func observeNotifications() async {
        // .values converts the Publisher to AsyncSequence
        for await notification in notificationPublisher.values {
            // Process each notification
            await handleAppBecameActive(notification)
        }
        // This line is never reached for an infinite Publisher
    }

    private func handleAppBecameActive(_ notification: Notification) async {
        // Async processing logic
    }
}

Este enfoque preserva el Publisher original permitiendo al mismo tiempo su consumo en un contexto asíncrono.

Obtener un Valor Único con firstValue

Para los Publishers que emiten un único valor (como una solicitud de red), la propiedad .values.first(where:) o una extensión personalizada simplifican el puente.

SingleValueBridging.swiftswift
// Extension to extract a single value from a Publisher
import Combine

extension Publisher where Failure == Never {
    // Awaits and returns the first emitted value
    var firstValue: Output {
        get async {
            await withCheckedContinuation { continuation in
                var cancellable: AnyCancellable?
                cancellable = self.first()
                    .sink { value in
                        continuation.resume(returning: value)
                        cancellable?.cancel()
                    }
            }
        }
    }
}

extension Publisher {
    // Throwing version for Publishers with errors
    var firstValueThrowing: Output {
        get async throws {
            try await withCheckedThrowingContinuation { continuation in
                var cancellable: AnyCancellable?
                cancellable = self.first()
                    .sink(
                        receiveCompletion: { completion in
                            if case .failure(let error) = completion {
                                continuation.resume(throwing: error)
                            }
                            cancellable?.cancel()
                        },
                        receiveValue: { value in
                            continuation.resume(returning: value)
                        }
                    )
            }
        }
    }
}

// Usage in async code
class UserRepository {
    private let apiClient: APIClient

    func fetchCurrentUser() async throws -> User {
        // Consume an existing Publisher asynchronously
        try await apiClient.userPublisher().firstValueThrowing
    }
}

Esta extensión encapsula la complejidad del puente y ofrece una API limpia.

¿Listo para aprobar tus entrevistas de iOS?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Hacer Puente de async/await hacia Combine

La migración inversa también resulta necesaria: consumir código async en pipelines Combine existentes.

Crear un Publisher a partir de una Función async

El enfoque más directo utiliza Future combinado con un Task para encapsular la llamada async.

BridgingAsyncToCombine.swiftswift
// async → Publisher conversion via Future
import Combine

extension Publisher {
    // async flatMap operator for Combine pipelines
    func asyncMap<T>(
        _ transform: @escaping (Output) async throws -> T
    ) -> AnyPublisher<T, Error> {
        flatMap { value in
            Future { promise in
                Task {
                    do {
                        // Execute the async transformation
                        let result = try await transform(value)
                        promise(.success(result))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// Usage in a Combine pipeline
class ImageProcessor {
    @Published var selectedImageURL: URL?
    private var cancellables = Set<AnyCancellable>()

    func setupProcessingPipeline() {
        $selectedImageURL
            .compactMap { $0 }
            // Use an async function in the Combine pipeline
            .asyncMap { url in
                // downloadImage is an async function
                try await self.downloadImage(from: url)
            }
            .asyncMap { imageData in
                // processImage is also async
                try await self.processImage(imageData)
            }
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { completion in
                    if case .failure(let error) = completion {
                        print("Error: \(error)")
                    }
                },
                receiveValue: { processedImage in
                    self.displayImage(processedImage)
                }
            )
            .store(in: &cancellables)
    }

    private func downloadImage(from url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }

    private func processImage(_ data: Data) async throws -> UIImage {
        // Async image processing
    }
}

Publisher Personalizado para Streams Async

Para necesidades más avanzadas, un Publisher personalizado puede encapsular un flujo AsyncSequence completo.

AsyncSequencePublisher.swiftswift
// Publisher wrapper for AsyncSequence
import Combine

struct AsyncSequencePublisher<S: AsyncSequence>: Publisher {
    typealias Output = S.Element
    typealias Failure = Error

    private let sequence: S

    init(_ sequence: S) {
        self.sequence = sequence
    }

    func receive<Sub>(subscriber: Sub) where Sub: Subscriber,
                                              Failure == Sub.Failure,
                                              Output == Sub.Input {
        let subscription = AsyncSubscription(
            sequence: sequence,
            subscriber: subscriber
        )
        subscriber.receive(subscription: subscription)
    }
}

private final class AsyncSubscription<S: AsyncSequence, Sub: Subscriber>: Subscription
where Sub.Input == S.Element, Sub.Failure == Error {

    private var task: Task<Void, Never>?
    private var subscriber: Sub?
    private let sequence: S

    init(sequence: S, subscriber: Sub) {
        self.sequence = sequence
        self.subscriber = subscriber
    }

    func request(_ demand: Subscribers.Demand) {
        // Start asynchronous iteration
        task = Task {
            do {
                for try await element in sequence {
                    // Check subscription is still active
                    guard subscriber != nil else { break }
                    _ = subscriber?.receive(element)
                }
                subscriber?.receive(completion: .finished)
            } catch {
                subscriber?.receive(completion: .failure(error))
            }
        }
    }

    func cancel() {
        task?.cancel()
        subscriber = nil
    }
}

// Convenience extension for any AsyncSequence
extension AsyncSequence {
    var publisher: AsyncSequencePublisher<Self> {
        AsyncSequencePublisher(self)
    }
}

Estrategias de Coexistencia en una Base de Código

La migración completa de una base de código importante toma tiempo. He aquí los patrones para hacer coexistir armoniosamente Combine y async/await.

Arquitectura por Capas con Abstracción

Definir protocolos que abstraen la implementación permite migrar progresivamente sin modificar el código que llama.

RepositoryAbstraction.swiftswift
// Abstraction enabling two implementations
import Combine

// Protocol defining the contract
protocol UserRepositoryProtocol {
    // Modern async interface
    func fetchUser(id: String) async throws -> User

    // Legacy Combine interface (optional with default implementation)
    func fetchUserPublisher(id: String) -> AnyPublisher<User, Error>
}

// Default Publisher implementation based on async
extension UserRepositoryProtocol {
    func fetchUserPublisher(id: String) -> AnyPublisher<User, Error> {
        Future { promise in
            Task {
                do {
                    let user = try await self.fetchUser(id: id)
                    promise(.success(user))
                } catch {
                    promise(.failure(error))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// Modern implementation - async first
class UserRepository: UserRepositoryProtocol {
    private let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }

    func fetchUser(id: String) async throws -> User {
        // Native async implementation
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }

    // fetchUserPublisher is provided by the default extension
}

Este enfoque permite a los nuevos llamadores utilizar async/await mientras el código legacy continúa empleando Publishers.

Atención a la gestión de memoria

Al realizar puentes, las Task creadas pueden sobrevivir a los objetos que las crearon. Conviene utilizar siempre [weak self] o cancelar las tareas explícitamente para evitar fugas de memoria.

ViewModel Híbrido

Un ViewModel puede exponer ambas interfaces durante el período de transición.

HybridViewModel.swiftswift
// ViewModel supporting both Combine and async/await
import Combine
import SwiftUI

@MainActor
class ProfileViewModel: ObservableObject {
    // Published state for SwiftUI (Combine)
    @Published private(set) var user: User?
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    private let repository: UserRepositoryProtocol
    private var cancellables = Set<AnyCancellable>()
    private var loadTask: Task<Void, Never>?

    init(repository: UserRepositoryProtocol) {
        self.repository = repository
    }

    // Async interface for modern UIKit or SwiftUI with .task
    func loadUser(id: String) async {
        isLoading = true
        errorMessage = nil

        do {
            user = try await repository.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    // Combine interface for legacy code
    func loadUserPublisher(id: String) {
        isLoading = true
        errorMessage = nil

        repository.fetchUserPublisher(id: id)
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] user in
                    self?.user = user
                }
            )
            .store(in: &cancellables)
    }

    // Clean cancellation
    func cancelLoading() {
        loadTask?.cancel()
        cancellables.removeAll()
        isLoading = false
    }
}

Migrar los Operadores Combine Comunes

Algunos operadores Combine no tienen un equivalente directo en async/await. He aquí cómo reproducirlos.

Equivalente de Debounce con async

DebounceAsync.swiftswift
// Debounce implementation with async/await
import Foundation

actor Debouncer {
    private var task: Task<Void, Never>?
    private let duration: Duration

    init(duration: Duration) {
        self.duration = duration
    }

    // Cancels previous execution and schedules a new one
    func debounce(_ operation: @escaping @Sendable () async -> Void) {
        task?.cancel()

        task = Task {
            do {
                // Wait for the specified duration
                try await Task.sleep(for: duration)
                // Execute operation if not cancelled
                await operation()
            } catch {
                // Task cancelled - expected behavior
            }
        }
    }
}

// Usage in a ViewModel
@MainActor
class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published private(set) var results: [SearchResult] = []

    private let debouncer = Debouncer(duration: .milliseconds(300))
    private let searchService: SearchService

    init(searchService: SearchService) {
        self.searchService = searchService
    }

    func onSearchTextChanged(_ text: String) {
        Task {
            await debouncer.debounce { [weak self] in
                guard let self else { return }
                await self.performSearch(text)
            }
        }
    }

    private func performSearch(_ query: String) async {
        guard query.count >= 3 else {
            results = []
            return
        }

        do {
            results = try await searchService.search(query: query)
        } catch {
            // Error handling
        }
    }
}

Equivalente de Merge con TaskGroup

MergeAsync.swiftswift
// Combining multiple async streams with TaskGroup
import Foundation

struct AsyncMerge {
    // Executes multiple async operations in parallel and returns all results
    static func merge<T>(
        _ operations: [@Sendable () async throws -> T]
    ) async throws -> [T] {
        try await withThrowingTaskGroup(of: T.self) { group in
            // Launch all operations in parallel
            for operation in operations {
                group.addTask {
                    try await operation()
                }
            }

            // Collect results
            var results: [T] = []
            for try await result in group {
                results.append(result)
            }
            return results
        }
    }

    // Streaming version that emits results as they arrive
    static func mergeStream<T: Sendable>(
        _ operations: [@Sendable () async throws -> T]
    ) -> AsyncThrowingStream<T, Error> {
        AsyncThrowingStream { continuation in
            Task {
                await withThrowingTaskGroup(of: T.self) { group in
                    for operation in operations {
                        group.addTask {
                            try await operation()
                        }
                    }

                    do {
                        for try await result in group {
                            continuation.yield(result)
                        }
                        continuation.finish()
                    } catch {
                        continuation.finish(throwing: error)
                    }
                }
            }
        }
    }
}

// Usage
class DataAggregator {
    func fetchAllData() async throws -> AggregatedData {
        // Execute three requests in parallel
        let results = try await AsyncMerge.merge([
            { try await self.fetchUsers() },
            { try await self.fetchPosts() },
            { try await self.fetchComments() }
        ])

        return AggregatedData(
            users: results[0] as! [User],
            posts: results[1] as! [Post],
            comments: results[2] as! [Comment]
        )
    }
}

¿Listo para aprobar tus entrevistas de iOS?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Casos de Uso Donde Combine Sigue Siendo Preferible

A pesar de las ventajas de async/await, ciertos escenarios siguen estando mejor servidos por Combine.

Streams de Eventos UI Reactivos

SwiftUI y UIKit generan flujos continuos de eventos donde los operadores Combine (debounce, throttle, combineLatest) brillan.

UIEventsCombine.swiftswift
// Combine remains optimal for reactive UI events
import Combine
import SwiftUI

class FormViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var confirmPassword = ""

    // Derived states computed via Combine
    @Published private(set) var isEmailValid = false
    @Published private(set) var isPasswordStrong = false
    @Published private(set) var passwordsMatch = false
    @Published private(set) var canSubmit = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        setupValidation()
    }

    private func setupValidation() {
        // Email validation with debounce
        $email
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .map { email in
                let regex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/
                return email.wholeMatch(of: regex) != nil
            }
            .assign(to: &$isEmailValid)

        // Password strength validation
        $password
            .map { password in
                password.count >= 8 &&
                password.rangeOfCharacter(from: .uppercaseLetters) != nil &&
                password.rangeOfCharacter(from: .decimalDigits) != nil
            }
            .assign(to: &$isPasswordStrong)

        // Password matching
        Publishers.CombineLatest($password, $confirmPassword)
            .map { password, confirm in
                !password.isEmpty && password == confirm
            }
            .assign(to: &$passwordsMatch)

        // Final combination to enable submit button
        Publishers.CombineLatest3($isEmailValid, $isPasswordStrong, $passwordsMatch)
            .map { $0 && $1 && $2 }
            .assign(to: &$canSubmit)
    }
}

Este patrón declarativo resultaría mucho más verboso con async/await.

Gestión de Conexiones WebSocket

Los WebSockets emiten mensajes de forma continua, un caso de uso natural para Combine.

WebSocketCombine.swiftswift
// WebSocket with Combine for continuous stream
import Combine
import Foundation

class WebSocketManager: ObservableObject {
    @Published private(set) var messages: [ChatMessage] = []
    @Published private(set) var connectionState: ConnectionState = .disconnected

    private var webSocketTask: URLSessionWebSocketTask?
    private let messageSubject = PassthroughSubject<ChatMessage, Never>()
    private var cancellables = Set<AnyCancellable>()

    // Exposed Publisher for consumers
    var messagePublisher: AnyPublisher<ChatMessage, Never> {
        messageSubject.eraseToAnyPublisher()
    }

    func connect(to url: URL) {
        webSocketTask = URLSession.shared.webSocketTask(with: url)
        webSocketTask?.resume()
        connectionState = .connected

        // Start reception loop
        receiveMessages()

        // Message processing pipeline
        messageSubject
            // Buffer messages to avoid too frequent UI updates
            .collect(.byTime(RunLoop.main, .milliseconds(100)))
            // Accumulate in history
            .scan([ChatMessage]()) { accumulated, new in
                accumulated + new
            }
            .assign(to: &$messages)
    }

    private func receiveMessages() {
        webSocketTask?.receive { [weak self] result in
            switch result {
            case .success(let message):
                if case .string(let text) = message,
                   let data = text.data(using: .utf8),
                   let chatMessage = try? JSONDecoder().decode(ChatMessage.self, from: data) {
                    self?.messageSubject.send(chatMessage)
                }
                // Continue reception
                self?.receiveMessages()

            case .failure(let error):
                self?.connectionState = .error(error.localizedDescription)
            }
        }
    }
}

Lista de Verificación de Migración Progresiva

Una migración exitosa sigue un enfoque metódico. Estas son las etapas recomendadas.

Fase 1: Preparación

  • ✅ Identificar los Publishers utilizados en la base de código
  • ✅ Categorizar: streams continuos vs operaciones puntuales
  • ✅ Crear extensiones de puente (firstValue, asyncMap)
  • ✅ Definir protocolos abstractos para los repositorios

Fase 2: Migración de Operaciones Puntuales

  • ✅ Convertir las solicitudes de red simples a async/await
  • ✅ Migrar las lecturas de archivos
  • ✅ Transformar las operaciones de base de datos
  • ✅ Preservar los Publishers mediante implementaciones por defecto

Fase 3: Adaptación de los ViewModels

  • ✅ Añadir métodos async a los ViewModels existentes
  • ✅ Utilizar .task en SwiftUI para las nuevas pantallas
  • ✅ Mantener los bindings @Published para la compatibilidad

Fase 4: Limpieza

  • ✅ Eliminar los métodos Combine que se han vuelto inútiles
  • ✅ Suprimir las extensiones de puente no utilizadas
  • ✅ Documentar los patrones Combine conservados intencionalmente

Conclusión

La migración de Combine a async/await representa una evolución natural para los proyectos Swift modernos. El enfoque progresivo, utilizando patrones de puente bidireccionales, permite adoptar las ventajas de async/await sin disrupción brutal.

Puntos clave a recordar:

  • ✅ Combine y async/await responden a necesidades diferentes
  • .values convierte un Publisher en AsyncSequence
  • Future + Task encapsula código async en un Publisher
  • ✅ Los protocolos abstractos facilitan la coexistencia
  • ✅ Combine sigue siendo relevante para los streams UI reactivos
  • ✅ Operadores como debounce pueden recrearse en async
  • ✅ La migración progresiva reduce los riesgos de regresión

El objetivo no es eliminar Combine, sino elegir la herramienta adecuada para cada contexto: async/await para las operaciones puntuales, Combine para los streams continuos de eventos.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

#swift
#ios
#combine
#async-await
#migration

Compartir

Artículos relacionados