- Introducción
- Entorno
- Publisher
- Subscriber
- Cómo combinar Publisher y Subscriber
- Scheduler
- Operadores
- Conclusiones
- Referencias
Introducción
Combine es el framework declarativo Swift para procesar valores a lo largo del tiempo. Impone el paradigma funcional reactivo de la programación, que es diferente del orientado a objetos que prevalece en la comunidad de desarrollo de iOS.
Reactivo significa programar con flujos asíncronos de valores. La programación funcional se trata de programar con funciones. En Swift las funciones pueden pasarse como argumentos a otras funciones, devolverse de funciones, almacenarse en variables y estructuras de datos y construirse en tiempo de ejecución como lambdas (closuras). El estilo de programación declarativa significa que se describe lo que hace el programa, sin describir el flujo de control. Y en un estilo imperativo, se describe cómo funciona el programa implementando y manejando una serie de tareas. Los programas imperativos dependen principalmente del estado, que generalmente se modifica con asignaciones.
La programación con el framework Swift Combine es declarativa, reactiva y funcional. Implica encadenar funciones y pasar valores de uno a otro. Esto crea los flujos de valores, que fluyen de la entrada a la salida.
Podemos imaginar que Combine funciona así:
Entorno
Este tutorial está escrito usando el siguiente entorno:
-
- Hardware: MacBook Pro 15’ (2,5 GHz Intel Core i7, 16GB DDR3, mediados de 2015)
- Sistema operativo: macOS Catalina 10.15
- Versiones del software:
- Xcode: 11
- iOS SDK: 13.4
Es importante saber que para probar SwiftUI se necesita instalar Mac OS Catalina 10.15 y Xcode 11.
Publisher
Publisher envía secuencias de valores a lo largo del tiempo a uno o más suscriptores.
Los Publishers tienen que cumplir con el siguiente protocolo:
protocol Publisher { associatedtype Output associatedtype Failure : Error func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input }
Publisher puede enviar valores o finalizar con éxito o error. Output define qué tipo de valores puede enviar un Publisher. Failure define el tipo de error con el que puede fallar.
El método de receive(suscriber: ) conecta un suscriptor con un Publisher. Define el contrato: la salida de Publisher debe coincidir con la entrada de Subscriber y también lo hacen los tipos de error.
Subscriber
Subscriber recibe valores de un Publisher.
Los suscriptores de Combine cumplen con el siguiente protocolo:
public protocol Subscriber : CustomCombineIdentifierConvertible { associatedtype Input associatedtype Failure : Error func receive(subscription: Subscription) func receive(_ input: Self.Input) -> Subscribers.Demand func receive(completion: Subscribers.Completion<Self.Failure>) }
Subscriber puede recibir un valor de tipo Input o un evento de finalización con éxito o fallo (Failure).
Los tres métodos de recepción describen diferentes pasos del ciclo de vida de Subscriber.
Cómo combinar Publisher y Subscriber
Combine tiene dos suscriptores integrados: Subscribers.Sink y Subscribers.Assign. Puedes conectarlos llamando a cualquiera de estos métodos en un Publisher:
- sink(receiveCompletion:receiveValue:) para manejar el nuevo elemento o evento de finalización en una clausura (la función lambda).
- assign(to:on:) para escribir un nuevo elemento en una propiedad.
// 1 let publisher = Just(1) // 2 publisher.sink(receiveCompletion: { _ in print("finished") }, receiveValue: { value in print(value) })
- Creamos un publisher Just que envía un valor único y luego se termina (envía el evento de finalización). Combine tiene varios publishers integrados, incluido Just.
- Conectamos a un Subscriber.
Imprimirá:
1 finished
Después de enviar 1, el publisher finaliza automáticamente. No tenemos que manejar ningún error, ya que Just no puede fallar.
Scheduler
Scheduler es el mecanismo de sincronización del framework Combine, que define el contexto de dónde y cuándo se realiza el trabajo. Combine no funciona directamente con hilos. En cambio, permite a los publishers operar en schedulers específicos. El dónde significa actual run loop, dispatch queue o operation queue. El cuándo significa tiempo virtual, de acuerdo con el reloj interno del scheduler. El trabajo realizado por un scheduler se adherirá únicamente al scheduler, que podría no corresponder con el tiempo real del sistema.
Tipos de scheduler
Combine proporciona diferentes tipos de schedulers y todos se ajustan al protocolo del Scheduler:
- DispatchQueue. Realiza el trabajo en una dispatch queue específica: serial, concurrente, principal y global. Normalmente se usa colas (queue) seriales y globales para el trabajo en segundo plano, y la cola principal para el trabajo relacionado con la interfaz de usuario. A partir de Xcode 11 GM Seed, no se recomienda usar colas concurrentes.
- OperationQueue. Realiza el trabajo en una cola de operación específica. De manera similar a las dispatch queue, usa OperationQueue.main para el trabajo de IU y otras colas para el trabajo en segundo plano. Según esta conversación en el foro Swift, no se recomienda utilizar colas de operación con maxConcurrentOperations mayor que 1.
- RunLoop. Realiza el trabajo en el runloop específico.
- ImmediateScheduler. Realiza acciones síncronas de inmediato. Terminará la aplicación con un error grave si intentas ejecutar un trabajo pospuesto con este programador.
Incluso si no especifica ningún scheduler, Combine le proporciona el scheduler predeterminado. El scheduler utiliza el mismo hilo, donde se generó el elemento. Digamos que si envías el elemento desde el hilo de fondo (background thread), lo recibe en el mismo hilo de fondo.
Cambiar entre los schedulers
Las operaciones que consumen recursos generalmente se procesan en segundo plano, de modo que la interfaz de usuario no se congela. Su resultado se maneja en el hilo principal. La forma en que Combine hace esto es cambiando los schedulers. Se logra con la ayuda de dos métodos: suscribe(on 🙂 y receive(on 🙂. El método receive cambia un scheduler para todos los publishers que vienen después. El método subscribe cambia el scheduler que se utiliza para realizar operaciones de suscripción, cancelación y solicitud. La cadena permanecerá en ese scheduler todo el tiempo, a menos que se especifique en alguna parte de receive. La posición de suscribe no importa, ya que afecta el momento de la suscripción.
Just(1) .subscribe(on: DispatchQueue.global()) .map { _ in print(Thread.isMainThread) } .sink { print(Thread.isMainThread) }
Se imprimirá:
false false
Todas las operaciones suceden en el scheduler DispatchQueue.global ().
Subject
El Subject es un tipo especial de publisher que puede insertar valores, pasados desde el exterior, en la secuencia. La interfaz del Subject proporciona tres formas diferentes de enviar elementos:
public protocol Subject : AnyObject, Publisher { func send(_ value: Self.Output) func send(completion: Subscribers.Completion<Self.Failure>) func send(subscription: Subscription) }
Combine tiene dos subjects integrados: PassthroughSubject y CurrentValueSubject.
// 1 let subject = PassthroughSubject<String, Never>() // 2 subject.sink(receiveCompletion: { _ in print("finished") }, receiveValue: { value in print(value) }) // 3 subject.send("Hello,") subject.send("World!") subject.send(completion: .finished) // 4
- Creamos un PassthoughSubject. Establecemos tipo de falla en Never (nunca) para indicar que siempre termina con éxito.
- Suscribimos al subject (recuerde, es un publisher).
- Enviamos dos valores a la secuencia y luego se termina.
El output será:
Hello, World! finished
CurrentValueSubject comienza con un valor inicial y publica todos sus cambios. Puede devolver su valor actual a través de la propiedad value.
// 1 let subject = CurrentValueSubject<Int, Never>(1) // 2 print(subject.value) // 3 subject.send(2) print(subject.value) // 4 subject.sink { value in print(value) }
- Creamos un subject con valor inicial 1.
- Accedemos al valor actual.
- Actualizamos el valor actual a 2.
- Suscribimos al subject.
Y tenemos el output:
1 2 2
Cómo combinar un Publisher y un Subscriber
Una conexión entre un publisher y un suscriptor se llama suscripción. Los pasos de dicha conexión definen un ciclo de vida del publisher-subscriber.
let subject = PassthroughSubject<Int, Never>() let token = subject .print() .sink(receiveValue: { print("received by subscriber: \($0)") }) subject.send(1)
Observa el operador print (_: to 🙂 en este código. Imprimimos todos los mensajes de registro para todos los eventos de publicación en la consola, que ya nos pueden decir mucho sobre el ciclo de vida.
Esto es lo que se imprime en la consola:
1. receive subscription: (PassthroughSubject) 2. request unlimited 3. receive value: (1) 4. received by subscriber: 1 5. receive cancel
Esto nos da una pista sobre el ciclo de vida del publisher-suscriptor. Examinemos el ciclo de vida de esta conexión:
- Un subscriber se conecta a un Publisher llamando a subscribe<S>(S).
- El publisher crea una suscripción llamando a receive<S>(subscriber: S) en sí mismo.
- El publisher reconoce la solicitud de suscripción. Llama a receive(subscription:) en el suscriptor.
- El subscriber solicita una cantidad de elementos que desea recibir. Llama a request(:) en la suscripción y pasa Demand como parámetro. Demand define cuántos objetos puede enviar un publisher a un subscriber a través de la suscripción. En nuestro caso, la demanda es ilimitada.
- El publisher envía valores llamando a receive(_:) de subscriber. Este método devuelve una instancia de Demand, que muestra cuántos elementos más espera recibir el subscriber. El subscriber solo puede aumentar la demanda o dejarla igual, pero no puede reducirla.
- La suscripción finaliza con uno de estos resultados:
-
- Cancelled: esto puede suceder automáticamente cuando se libera el subscriber, que se muestra en el ejemplo anterior. Otra forma es cancelar manualmente con token.cancel() (token también es un subscriber que cumple el protocolo AnyCancellable).
- Finish: terminar con el éxito
- Fail: fallar con el error.
Operadores de Combine
Los operadores son métodos especiales que están dentro de los publishers y devuelven otro publisher. Esto permite aplicarlos uno tras otro, creando una cadena. Cada operador transforma al publisher, devuelto por el operador anterior. Al principio de cada cadena tiene que estar un publisher. Entonces los operadores se pueden aplicar a su vez. Cada operador recibe el publisher creado por el operador anterior en la cadena. Nos referimos a su orden relativo como upstream y downstream, es decir, el operador inmediatamente anterior y siguiente.
Veamos cómo podemos encadenar operadores al manejar una solicitud de URL HTTP con el framework Combine de SwiftUI.
func send<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, APIError> { return URLSession.shared //1 .dataTaskPublisher(for: request) //2 .mapError{ APIError.serverError(code: $0.errorCode, message: $0.localizedDescription) } //3 .map { $0.data } //4 .decode(type: T.self, decoder: decoder) //5 .print() //6 .mapError { _ in APIError.decodingError } //7 .receive(on: DispatchQueue.main) //8 .eraseToAnyPublisher() }
- combine está bien integrado con los frameworks y el SDK de iOS. Esto nos permite utilizar un publisher incorporado para manejar las tareas de datos de URLSession.
- mapError nos permite transformar un error interno a un error nuestro mas descriptivo
- pasa los datos de respuesta. Usamos el operador map (_ :), que transforma el valor ascendente de (data: Data, response: URLResponse) a Data.
- decodifica el contenido de la respuesta con JSONDecoder.
- imprimimos el contenido a la consola
- transformamos el error de descodificación si lo tenemos
- especifica un scheduler que va a recibir los elementos de publisher
- quitamos el tipo de publisher y lo transformamos a AnyPublisher. Esta forma de borrar el tipo conserva la abstracción de la API, y de este Mode podemos usarla en diferentes módulos.
Conclusiones
Combine se ajusta mejor cuando tú deseas configurar algo que reacciona a un flujo de valores. Las interfaces de usuario encajan muy bien en este patrón. Pero Combine no se limita a las interfaces de usuario. Cualquier secuencia de operaciones asincrónicas puede ser efectiva, especialmente cuando los resultados de cada paso fluyen al siguiente paso. Un ejemplo de esto podría ser una serie de peticiones de servicio de red, seguido de la decodificación de los resultados.
La aplicación de estos conceptos a un lenguaje fuertemente tipado como Swift es parte de lo que Apple ha creado en Combine. Combine extiende la programación funcional reactiva al incorporar el concepto de contrapresión. La contrapresión es la idea de que el suscriptor debe controlar cuánta información obtiene de una vez y necesita procesar. Esto conduce a una operación eficiente con la noción adicional de que el volumen de datos procesados a través de una secuencia es controlable y cancelable.
Combine se usa por muchos frameworks de Apple. SwiftUI es el ejemplo obvio que tiene más atención, tanto con elementos de subscriber como de publisher. RealityKit también tiene publishers que se puede usar para reaccionar a los eventos. Y Foundation tiene una serie de adiciones específicas de Combine, que incluyen NotificationCenter, URLSession y Timer como publishers. De hecho, cualquier API asincrónica puede aprovechar el framework Combine. Es inevitable que con el paso del tiempo y con el progreso de SwiftUI, Combine sea una parte fundamental de toda plataforma Apple.
Referencias