Skip to content

Instantly share code, notes, and snippets.

@pitt500
Last active March 5, 2024 14:55
Show Gist options
  • Save pitt500/f5e32fccb575ce112ffea2827c7bf942 to your computer and use it in GitHub Desktop.
Save pitt500/f5e32fccb575ce112ffea2827c7bf942 to your computer and use it in GitHub Desktop.
An Spanish translation of The Composable Architecture's README.md

The Composable Architecture

CI

The Composable Architecture (o simplemente TCA) es una librería para construir aplicaciones de una manera consistente y entendible, teniendo en mente composición, pruebas y ergonomía. Puede ser utilizada en SwiftUI, UIKit, y en cualquier plataforma de Apple (iOS, macOS, tvOS, y watchOS).

¿Que es The Composable Architecture?

Esta librería provee algunas herramientas básicas que pueden ser usadas para construir aplicaciones de diversa finalidad y complejidad. Provee casos de uso convincentes que se pueden seguir para resolver diferentes problemas que encuentras en tu día a día cuando construyes aplicaciones, tales como:

  • Manejo del estado
    Como gestionar el estado de tu aplicación usando simplemente tipos valor (structs y enums), y compartir el estado entre varias pantallas, de modo que la mutación del estado hecha en una pantalla puede ser observada inmediatamente en otra.

  • Composición
    Como desglosar features grandes en pequeños componentes que puedan ser extraídos a sus propios módulos asilados, y ser pegados de vuelta para formar la funcionalidad completa.

  • Efectos secundarios
    Como dejar que ciertas partes de la aplicación "hablen" con el mundo exterior de la manera más testeable y entendible posible.

  • Pruebas
    Como no solo probar un feature implementado en la arquitectura, sino también escribir pruebas de integración para los features que han sido compuestos por muchos elementos, y escribir pruebas end-to-end (de extremo a extremo) para entender como los efectos secundarios influyen en tu aplicación. Esto permite garantizar de manera sólida que tu lógica de negocio está funcionando de la manera que esperas.

  • Ergonomía
    Como lograr todo lo anterior en una API sencilla con la menor cantidad posible de conceptos y partes en movimiento.

Aprender más

The Composable Architecture fue diseñado a lo largo de muchos episodios en Point-Free, una serie de videos dedicados a programación funcional y el lenguaje Swift, presentado por Brandon Williams y Stephen Celis.

Puedes mirar todos los episodios aquí, así como un tour dedicado a explorar la arquitectura desde cero: parte 1, parte 2, parte 3 y parte 4.

imagen video poster

Ejemplos

Screenshots de aplicaciones de ejemplo

Este repo contiene muchos ejemplos para demostrar como resolver problemas comunes y complejos con TCA. Consulta este directorio para ver todos ellos, incluyendo:

¿Buscas algo más en serio? Consulta el código fuente de isowords, un juego de búsqueda de palabras para iOS implementado en SwiftUI y TCA.

Uso básico

Para implementar un feature utilizando TCA, debes definir tipos y valores que modelarán tu dominio:

  • Estado: Un tipo que describe la información que tu feature necesita para ejecutar su lógica y ser mostrado en la pantalla.
  • Acción: Un tipo que representa todas las acciones que pueden pasar en un feature, tal como una acción del usuario, notificaciones, fuentes de eventos y más.
  • Ambiente: Un tipo que contiene las dependencias que un feature necesita, tal como llamadas a APIs, analíticas, etc.
  • Reducer (o Reductor): Una función que describe como va a evolucionar el estado actual de tu app hacia el siguiente estado dada una acción. El reducer es tambien responsable de regresar efectos que deban ser ejecutados, tal como llamadas a APIs, las cuales pueden ser hechas regresando un valor Effect.
  • Store: El lugar donde se almacena la información de un feature. Se envían todas las acciones del usuario al Store, y el mismo se encarga de ejecutar el reducer y los efectos, así como observar cambios de estado que actualizarán la UI.

Los beneficios de esto es que se obtiene instantánteamente una gran capacidad de prueba para tu funcionalidad, y de dividir un feature grande y complejo en dominios mas pequeños que luego puedan ser unidos.

Como un ejemplo básico, considera una UI que muestre un número junto con botones de "+" y "-" que lo incrementen y decrementen. Para hacer las cosas interesantes, supón que hay además un botón que al ser presionado hace una llamada a una API para obtener una curiosidad del número y mostrarla en una alerta.

El estado de este feature consistiría en un entero para el contador actual, así como un String opcional que represente el dato curioso en un título para la alerta que queremos mostrar (es opcional dado que nil representa el no mostrar la alerta):

struct AppState: Equatable {
  var count = 0
  var numberFactAlert: String?
}

Después tenemos las acciones en el feature. Hay acciones obvias, tal como presionar el botón de incrementar, decrementar o el de la curiosidad, pero hay además otras no tan obvias, tal como la acción de el usuario cerrando la alerta, y la acción que ocurre cuando recibimos una respuesta de la API que devuelve la curiosidad del número:

enum AppAction: Equatable {
  case factAlertDismissed
  case decrementButtonTapped
  case incrementButtonTapped
  case numberFactButtonTapped
  case numberFactResponse(TaskResult<String>)
}

Lo siguiente es modelar el ambiente de dependencias que este feature necesita para llevar a cabo su trabajo. En particular, parar obtener la curiosidad del número necesitamos modelar una función asíncrona y lanzable con parametro Int y valor de regreso String.

struct AppEnvironment {
  var numberFact: (Int) async throws -> String
}

Después, debemos crear un reducer que implemente la lógica para este dominio. El reducer describe como cambiar el estado actual al siguiente estado, y describe que efectos necesitan ser ejecutados. Algunas acciones no necesitan ejecutar efectos, por lo que se puede regresar .none para representarlo:

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
  switch action {
  case .factAlertDismissed:
    state.numberFactAlert = nil
    return .none

  case .decrementButtonTapped:
    state.count -= 1
    return .none

  case .incrementButtonTapped:
    state.count += 1
    return .none

  case .numberFactButtonTapped:
    return .task {
       await .numberFactResponse(TaskResult { try await environment.numberFact(state.count) })
     }

  case let .numberFactResponse(.success(fact)):
    state.numberFactAlert = fact
    return .none

  case .numberFactResponse(.failure):
    state.numberFactAlert = "No pudimos cargar una curiosidad para este número :("
    return .none
  }
}

Y finalmente, definimos la vista que mostrará el feature. La vista tendrá una propiedad Store<AppState, AppAction> que va a observar todos los cambios del estado y volver a actualizar la UI. Podemos enviar todas las acciones del usuario al store para que el estado cambie. Debemos además crear un struct wrapper sobre la alerta de curiosidad para hacerla Identifiable, por lo cual, el modificador .alert requiere:

struct AppView: View {
  let store: Store<AppState, AppAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      VStack {
        HStack {
          Button("") { viewStore.send(.decrementButtonTapped) }
          Text("\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Curiosidad del número") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

Es importante destacar que fuimos capaces de implementar este feature por completo sin tener un efecto real directamente. Esto es muy importante ya que significa que los features pueden ser implementados aislados de sus dependencias, lo cual ayuda a mejorar el tiempo de compilación.

Es también muy fácil tener un controlador de UIKit fuera del store. Te subscribes al store en viewDidLoad para poder actualizar la UI y mostrar alertas. El código es un poco más largo que en la versión de SwiftUI, por lo que lo colapsamos aquí:

¡Click para expandir!
class AppViewController: UIViewController {
  let viewStore: ViewStore<AppState, AppAction>
  var cancellables: Set<AnyCancellable> = []

  init(store: Store<AppState, AppAction>) {
    self.viewStore = ViewStore(store)
    super.init(nibName: nil, bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) no ha sido implementado")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    let countLabel = UILabel()
    let incrementButton = UIButton()
    let decrementButton = UIButton()
    let factButton = UIButton()

    // Omitido: Agregar subviews y configurar constrains...

    self.viewStore.publisher
      .map { "\($0.count)" }
      .assign(to: \.text, on: countLabel)
      .store(in: &self.cancellables)

    self.viewStore.publisher.numberFactAlert
      .sink { [weak self] numberFactAlert in
        let alertController = UIAlertController(
          title: numberFactAlert, message: nil, preferredStyle: .alert
        )
        alertController.addAction(
          UIAlertAction(
            title: "Ok",
            style: .default,
            handler: { _ in self?.viewStore.send(.factAlertDismissed) }
          )
        )
        self?.present(alertController, animated: true, completion: nil)
      }
      .store(in: &self.cancellables)
  }

  @objc private func incrementButtonTapped() {
    self.viewStore.send(.incrementButtonTapped)
  }
  @objc private func decrementButtonTapped() {
    self.viewStore.send(.decrementButtonTapped)
  }
  @objc private func factButtonTapped() {
    self.viewStore.send(.numberFactButtonTapped)
  }
}

Una vez que estamos listos para mostrar la vista, podemos construir un store, por ejemplo, desde el punto de entrada de la app. Este es el momento donde necesitamos pasar las dependencias, incluyendo el endpoint de numberFact que está obteniendo la información desde el mundo real:

@main
 struct CaseStudiesApp: App {
 var body: some Scene {
   AppView(
     store: Store(
       initialState: AppState(),
       reducer: appReducer,
       environment: AppEnvironment(
         numberFact: { number in 
           let (data, _) = try await URLSession.shared
             .data(from: .init(string: "http://numbersapi.com/\(number)")!)
           return String(decoding: data, using: UTF8.self)
         }
       )
     )
   )
 }

Y esto es suficiente para ver algo en la pantalla con que jugar. Definitivamente serían solo unos cuantos pasos más si hicieras esto en SwiftUI vainilla, pero hay algunos beneficios. TCA nos brinda una manera consistente de aplicar mutaciones al estado, en lugar de tener lógica esparcida por varios observable objects y closures en los componentes de la UI. Además, también ganamos una forma consistente de expresar efectos secundarios. Y finalmente, podemos probar nuestra lógica fácilmente, incluyendo los efectos sin tener que hacer trabajo adicional.

Pruebas

Para probar, primero crea un TestStore con la misma información que un Store normal, excepto que esta vez podemos pasar dependencias adecuadas para probar. Particularmente, ahora podemos usar una implementación de numberFact que devuelve inmediatamente un valor que nosotros controlamos en lugar de tener que esperar uno del mundo real.

@MainActor
 func testFeature() async {
   let store = TestStore(
     initialState: AppState(),
     reducer: appReducer,
     environment: AppEnvironment(
       numberFact: { "\($0) es un gran número Brent" }
     )
   )
 }

Una vez que la prueba es creada, podemos usarla para comprobar un flujo de pasos hechos por el usuario. En cada paso, necesitamos comprobar que el estado ha cambiado tal cual esperamos. Además, si un paso hace que se ejecute un efecto que mande datos al store, debemos corroborar que esas acciones se recibieron correctamente.

La siguiente prueba hace que el usuario incremente y decremente el conteo, entonces se pregunta por la curiosidad de ese número, y la respuesta de ese efecto dispara una alerta para ser mostrada, y finalmente, al hacer dismiss la alerta desaparece.

// Prueba de como al presionar los botones de incrementar y decrementar el contador cambia
await store.send(.incrementButtonTapped) {
  $0.count = 1
}
await store.send(.decrementButtonTapped) {
  $0.count = 0
}

// Prueba de como al presionar el boton de la curiosidad recibimos una respuesta desde el efecto.
// Nota que tenemos que esperar la respuesta ya que el efecto es asincrono y toma una pequeña 
// cantidad de tiempo para ser emitido.
await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse(.success("0 es un gran número Brent"))) {
  $0.numberFactAlert = "0 es un gran número Brent"
}

// Y finalmente cerramos la alerta
await store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

Esto es lo básico para implementar y probar un feature en TCA. Hay muchas más cosas por explorar, tal como composición, modularidad, adaptabilidad y efectos complejos. El directorio de ejemplos tiene varios proyectos que puedes explorar para ver otros usos avanzados.

Debugging

TCA viene con un gran número de herramientas que nos ayudan en el debugging.

  • reducer.debug() imprime en la pantalla cada acción que el reducer recibe y cada mutación hecha en el estado.

    received action:
      AppAction.todoCheckboxTapped(id: UUID(5834811A-83B4-4E5E-BCD3-8A38F6BDCA90))
      AppState(
        todos: [
          Todo(
    -       isComplete: false,
    +       isComplete: true,
            description: "Milk",
            id: 5834811A-83B4-4E5E-BCD3-8A38F6BDCA90
          ),
          … (2 unchanged)
        ]
      )
  • reducer.signpost() instrumenta un reducer con señales para poder obtener información sobre cuanto tardan en ejecutarse las acciones y cuando se ejecutan los efectos.

Librerías complementarias

Uno de los principios más importantes de TCA es que los efectos secundarios nunca son ejecutados directamente, sino que se encapsulan en el tipo Effect, regresado por los reducers, y luego el Store ejecuta el efecto. Eso es crucial para simplificar la forma en que los datos fluyen a través de una aplicación y para poder probar el ciclo completo de acciones del usario end-to-end.

Sin embargo, esto también significa que muchas librerias y SDKs con las que interactuas diariamente necesitan actualizarse para ser un poco más amigables con el estilo TCA. Es por eso que nos gustaría aliviar el dolor de usar algunos de los frameworks mas populares de Apple al proporcionar librerías wrapper que expongan su funcionalidad de una manera que se adapte bien a nuestra librería. Hasta ahora tenemos:

  • ComposableCoreLocation: Un wrapper de CLLocationManager que facilita su uso en un reducer, y el escribir pruebas sobre cómo tu lógica interactúa con la funcionalidad de CLLocationManager.

  • ComposableCoreMotion: Un wrapper de CMMotionManager que facilita su uso en un reducer, y el escribir pruebas sobre cómo tu lógica interactúa con la funcionalidad de CMMotionManager.

  • Más librerias vendrán pronto. ¡Manténganse al tanto! 😉

Si estás interesado en contribuir para crear una librería wrapper de algún framework que no hayamos cubierto, siéntete libre de abrir un issue explicando tu interés para para que podamos discutirlo.

Preguntas frecuentes

  • ¿Cómo se compara TCA con Elm, Redux y otras?

    Expandir para ver la respuesta TCA se basa en ideas fundadas de la arquictectura Elm (TEA) y Redux, pero hechas para sentirse como en casa en el lenguaje Swift y en las plataformas de Apple.

    De alguna forma, TCA es un poco más estricto que otras librerías. Por ejemplo, Redux no explica como se deben ejecutar los efectos secundarios, pero TCA requiere que todos los efectos secundarios sean modealados en el tipo Effect and regresado desde el reducer.

    En otras, TCA es más relajado que otras librerías. Por ejemplo, Elm controla qué tipos de efectos se pueden crear a través del tipo Cmd, pero TCA permite regresar cualquier tipo de efecto, ya que Effect conforma el protocolo Publisher de Combine.

    Y además, hay ciertas cosas que TCA prioriza mucho y que no son puntos de enfoque para Redux, Elm o la mayoria de otras librerías. Por ejemplo, la composición es un aspecto muy importante de TCA, que es el proceso de dividir features grandes en unidades más pequeñas que se puedan unir. Esto se logra mediante los operadores pullback y combine en los reducers, y ayuda en el manejo de features complejos, así como en la modularización para una código mejor aislado y mejorar los tiempos de compilación.

Requisitos

TCA depende del framework de Combine, por lo que el deployment target mínimo requerido es iOS 13, macOS 10.15, Mac Catalyst 13, tvOS 13, y watchOS 6. Si tu aplicación tiene que dar soporte a versiones de sistemas operativos más antiguas, hay forks para ReactiveSwift y RxSwift que puedes utilizar.

Instalación

Puedes añadir ComposableArchitecture a un proyecto de Xcode agregándolo como un paquete de Swift:

  1. Desde el menú Archivo, selecciona Añadir paquetes...
  2. Introduce "https://github.com/pointfreeco/swift-composable-architecture" en el campo de texto de la url del repositorio.
  3. Dependiendo de como esté estructurado tu proyecto:
    • Si tienes un solo target que necesite acceso a la librería, solo agrega ComposableArchitecture directamente a tu aplicación.
    • Si quieres usar esta librería en múltiples targets de Xcode, o mezclar targets de Xcode con otros targets de SPM (Swift Package Manager), debes crear un framework compartido que dependa de ComposableArchitecture y luego hacer que tus targets dependan de él. Si quieres ver un ejemplo de esto, mira el demo de Tic-Tac-Toe, ya que en él se dividen muchos features en módulos y consumen la librería estática de esta manera en el paquete de Swift tic-tac-toe.

Documentación

La documentación de cada release y main está disponible aquí:

Otras versiones

Ayuda

Si quisieras discutir más sobre TCA o tienes alguna pregunta sobre como usarlo en un problema específico, puedes crear un tema en la pestaña de discusiones (o issues) de este repo, o preguntar en el foro de swift.org.

Traducciones

Las siguentes traducciones de este README han sido contribuidas por parte de miembros de la comunidad:

Si quisieras contribuir con las traducciones, por favor abre un PR con a link a un Gist.

Créditos y agradecimientos

Las siguientes personas dieron feedback a la librería en su etapa inicial y ayudaron a hacerla lo que es hoy en día:

Paul Colton, Kaan Dedeoglu, Matt Diephouse, Josef Doležal, Eimantas, Matthew Johnson, George Kaimakas, Nikita Leonov, Christopher Liscio, Jeffrey Macko, Alejandro Martinez, Shai Mishali, Willis Plummer, Simon-Pierre Roy, Justin Price, Sven A. Schmidt, Kyle Sherman, Petr Šíma, Jasdev Singh, Maxim Smirnov, Ryan Stone, Daniel Hollis Tavares, y a todos los subscriptores de Point-Free 😁.

Agradecimientos especiales para Chris Liscio quien nos ayudó resolviendo varias peculiaridades con SwiftUI y al refinamiento de la API final.

Y gracias a Shai Mishali y al proyecto de CombineCommunity, de donde tomamos su implementacion de Publishers.Create, la cual usamos en Effect para ayudar a unir APIs basadas en delegados y callbacks, lo que facilitó mucho la interfaz con frameworks de terceros.

Otras librerías

TCA fue implementado bajo la fundación de ideas iniciadas en otras librerías, particularmente Elm y Redux.

Hay muchas otras librerías en la comunidad de Swift y iOS. Cada una de ellas tiene su propio conjunto de ventajas y desventajas que difieren de TCA.

Licencia

Esta lbrería es publicada bajo la licencia del MIT. Ver LICENCIA para más detalles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment