DEV Community

David Goyes
David Goyes

Posted on

Combine #8: Conexión Combine/SwiftUI

Presentando vistas

Cuando se quiere conectar algunos datos con controles de UI, es mejor usar CurrentValueSubject en lugar de PassthroughSubject. El primero garantiza que haya al menos un valor cuando ocurre una suscripción, lo que significa que la UI no tendrá un estado indefinido. En general, CurrentValueSubject se usa para representar estado de una vista, mientras que PassthroughSubject se usa para representar eventos, como los toques de un botón.

private let images = CurrentValueSubject<[UIImage], Never>([])
Enter fullscreen mode Exit fullscreen mode

@Published es un property wrapper que envuelve una propiedad simple con un Publisher, lo cual permite conectar fácilmente la vista porque el modelo conforma el protocolo ObservableObject.

@Published var imagePreview: UIImage?
Enter fullscreen mode Exit fullscreen mode

Al usar assign(to: &$publishedVariable) pasándole un @Published (i.e. publishedVariable), no es necesario almacenar la suscripción manualmente, sino que internamente Combine guarda el AnyCancellable dentro del propio wrapper. Si, en cambio, se usara .assign(to: \.publishedVariable, on: self), se devolvería un AnyCancellable que se tendría que guardar manualmente en subscriptions.

En cuanto a memoria, tener en cuenta que en Combine, los Publishers retienen a los suscriptores (i.e. Subscriber) mientras la suscripción viva (i.e. Subscription), razón por la cual es necesario almacenar la suscripción.

.assign(to: \.imagePreview, on: self)
// .assign(to:on:) 
// Devuelve la suscripción
.assign(to: &$imagePreview)
// .assign(to:)
// El Publisher almacena internamente la suscripción dentro del wrapper
Enter fullscreen mode Exit fullscreen mode

Cuando se envía un evento de fin por un Subject, (e.g. .finished), la suscripción termina manualmente. Esto hace que cualquier Subscriber del Subject se complete y cancele automáticamente. Por esta razón, es necesario volver a crear el Subject cada vez que se abre la segunda pantalla.

// PhotosView
.onDisappear {
  model.selectedPhotosSubject.send(completion: .finished)
}
// CollageNeueModel
func add() {
  selectedPhotosSubject = PassthroughSubject<UIImage, Never>()
  selectedPhotosSubject
    .map { ... }
    .assign(to: \.value, on: images)
    .store(in: &subscriptions) // Esta suscripción se cancela cuando PhotosView envía el .finished
}
Enter fullscreen mode Exit fullscreen mode

Envolviendo el llamado a una función con un Future

Cuando se tenga una funcionalidad que reciba un closure de callback para llevar a cabo una tarea asíncrona, se lo puede envolver con un Future. Si dentro del callback original de dicha funcionalidad hay algún error, se puede llamar resolve(.failure(…)). En caso de éxito se puede llamar resolve(.success(…)). Después de crear el Future, recordar hacer la suscripción con un sink o un assign(to:).

Es posible que el Future del modelo modifique una propiedad cruda que no tiene Publisher. Sin embargo, en la vista se puede crear un observador manualmente con el método onChange(of:perform:).

// MainView
.onChange(of: model.lastSavedPhotoID, perform: { lastSavedPhotoID in
  isDisplayingSavedMessage = true
})
Enter fullscreen mode Exit fullscreen mode
// CollageNeueModel
func save() {
  guard let image = imagePreview else { return }
  PhotoWriter.save(image)
    .sink { 
      // ...
      lastSavedPhotoID = id 
    }
    .store(in: &subscriptions)
}
Enter fullscreen mode Exit fullscreen mode
// PhotoWriter
class PhotoWriter {
  static func save(_ image: UIImage) -> Future<String, PhotoWriter.Error> {
    Future { resolve in
      do {
        try PHPhotoLibrary.shared().performChangesAndWait {
          // ...
          resolve(.success(savedAssetID))
        }
      } catch {
        resolve(.failure(.generic(error)))
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

Atención: Al trabajar con estructuras no es necesario prestar atención a los ciclos de retención al usar los closures de map, flatMap, etc. Sin embargo, al usar clases (especialmente en el caso de ObservableObject), se debe tener cuidado con los ciclos de retención.

Compartir suscripciones

Por defecto, cada suscripción a un Publisher ejecuta toda la cadena aguas arriba desde cero. Por ejemplo, si tengo:

let publisher = URLSession.shared.dataTaskPublisher(for: url)
Enter fullscreen mode Exit fullscreen mode

y dos vistas o suscriptores se conectan a él, entonces se harán dos peticiones separadas. En otras palabras, se podría decir que sin share(), cada suscripción repite todo el trabajo desde el inicio.

Por otro lado, share() hace que todas las suscripciones compartan el mismo flujo de datos aguas a bajo.

Se dice que .share() convierte un Publisher frío en uno caliente (en términos de reactividad), al implementar un multicast con un PassthroughSubject interno, seguido de .autoconnect().

Cuando queramos tener varias suscripciones al mismo Publisher, lo ideal es usar share() porque permite emitir el mismo evento a todos los suscriptores sin repetir el trabajo.

Operadores en la práctica

Se pidió cancelar la suscripción de emisión de fotos seleccionadas después de acumular seis. Para ello se usó el operador prefix(while:):

selectedPhotosSubject = PassthroughSubject<UIImage, Never>()

let newPhotos = selectedPhotosSubject
  .prefix(while: { [unowned self] _ in
    self.images.value.count < 6
  })
  // Añadir prefix(while:) antes de share() permite filtrar los valores entrantes para todas las suscripciones que reciben newPhotos
  .share()
Enter fullscreen mode Exit fullscreen mode

Reto

Se pidió convertir en Future una funcionalidad de PHPhotoLibrary que recibía un closure:

extension PHPhotoLibrary {
  static func fetchAuthorizationStatus(callback: @escaping (Bool) -> Void) {
//    ...
  }
  static var isAuthorized: Future<Bool, Never> {
    Future { resolve in
      fetchAuthorizationStatus { status in
        resolve(.success(status))
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Luego, se pidió modificar PhotosView para suscribirse a isAuthorized. Cabe resaltar el uso de .receive(on: DispatchQueue.main) para ejecutar el código en el hilo principal y .store(in: &subscriptions) para guardar la suscripción:

.onAppear {
  PHPhotoLibrary
    .isAuthorized
    .receive(on: DispatchQueue.main)
    .sink { status in
      if status {
          self.photos = model.loadPhotos()
      } else {
        isDisplayingError = true
      }
    }
    .store(in: &subscriptions)

  // ...
}
Enter fullscreen mode Exit fullscreen mode

También se pidió cerrar la vista cuando se presione el botón de cerrar de la alerta, para lo cual se hizo uso de presentationMode.wrappedValue.dismiss():

.alert("No access to Camera Roll", isPresented: $isDisplayingError) {
  Button("OK") {
    self.presentationMode.wrappedValue.dismiss()
  }
} message: {
  Text("You can grant access to Collage Neue from the Settings app")
}
Enter fullscreen mode Exit fullscreen mode

Cuestionario

1. ¿Por qué es preferible usar CurrentValueSubject en lugar de PassthroughSubject para manejar el estado de una vista en SwiftUI?

2. Explica la diferencia práctica entre usar .assign(to: \.variable, on: self).assign(to: &$variable) en Combine.

3. ¿Qué ocurre con las suscripciones cuando un Subject recibe un evento .finished?

4. ¿Qué ventaja ofrece envolver una función basada en callbacks dentro de un Future?

5. ¿Por qué share() convierte un publisher "frío" en "caliente"?

6. ¿Qué tipo de datos se usa comúnmente para representar eventos como toques de botones? ✅

  • [ ] CurrentValueSubject
  • [ ] PassthroughSubject
  • [ ] @Published

7. ¿Qué hace Combine cuando usas .assign(to: &$variable)? ✅

  • [ ] Retiene la suscripción automáticamente dentro del wrapper
  • [ ] Requiere guardarla manualmente
  • [ ] Cancela la suscripción al completarse el flujo

8. ¿Qué operador se utilizó para detener la emisión de imágenes después de acumular seis? ✅

  • [ ] filter
  • [ ] prefix(while:)
  • [ ] drop(while:)

9. ¿Qué ocurre si no se usa share() y se tienen dos suscriptores a un mismo dataTaskPublisher? ✅

  • [ ] Ambos comparten la misma respuesta
  • [ ] Solo el primero recibe los datos
  • [ ] Se ejecutan dos peticiones independientes

10. ¿Qué operador se usó para asegurarse de que el código en la suscripción de isAuthorized se ejecute en el hilo principal? ✅

  • [ ] receive(on:)
  • [ ] subscribe(on:)
  • [ ] dispatch(to:)

Solución

1. ¿Por qué es preferible usar CurrentValueSubject en lugar de PassthroughSubject para manejar el estado de una vista en SwiftUI?

Con CurrentValueSubject la vista siempre va a tener un valor inicial disponible, evitando estados indefinidos.

2. Explica la diferencia práctica entre usar .assign(to: \.variable, on: self).assign(to: &$variable) en Combine.

.assign(to: \.variable, on: self) retorna una referencia a la suscripción AnyCancellable, mientras que .assign(to: &$variable) guarda directamente esa suscripción en el wrapper @Published.

3. ¿Qué ocurre con las suscripciones cuando un Subject recibe un evento .finished?

Se termina la suscripción y Combine la cancela, liberando al suscriptor y al publisher.

4. ¿Qué ventaja ofrece envolver una función basada en callbacks dentro de un Future?

Se puede transformar callbacks en Publishers, permitiendo usar dicha funcionalidad dentro del flujo declarativo de Combine.

5. ¿Por qué share() convierte un publisher "frío" en "caliente"?

share() envuelve el Publisher en un multicast y hace autoconnect(), esto significa que tan pronto ocurre la primera suscripción, empieza a emitir. Luego, cualquier que se suscriba recibirá los eventos pero sin volver a ejecutar todo el pipeline. Sin share(), en cada suscripción se vuelve a ejecutar el pipeline.

6. ¿Qué tipo de datos se usa comúnmente para representar eventos como toques de botones?

  • [✅] PassthroughSubject

7. ¿Qué hace Combine cuando usas .assign(to: &$variable)?

  • [✅] Retiene la suscripción automáticamente dentro del wrapper

8. ¿Qué operador se utilizó para detener la emisión de imágenes después de acumular seis?

  • [✅] prefix(while:)

9. ¿Qué ocurre si no se usa share() y se tienen dos suscriptores a un mismo dataTaskPublisher?

  • [✅] Se ejecutan dos peticiones independientes

10. ¿Qué operador se usó para asegurarse de que el código en la suscripción de isAuthorized se ejecute en el hilo principal?

  • [✅] receive(on:)

Top comments (0)