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).
- ¿Qué es The Composable Architecture?
- Aprender más
- Ejemplos
- Uso básico
- Librerías complementarias
- Preguntas frecuentes
- Requisitos
- Instalación
- Documentación
- Ayuda
- Traducciones
- Créditos y agradecimientos
- Otras librerías
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.
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.
Este repo contiene muchos ejemplos para demostrar como resolver problemas comunes y complejos con TCA. Consulta este directorio para ver todos ellos, incluyendo:
- Casos de estudio
- Primeros pasos
- Efectos
- Navegación
- Reducers de orden superior
- Componentes reutilizables
- Location manager
- Motion manager
- Búsqueda
- Reconocimiento del habla
- Tic-Tac-Toe
- Lista de cosas por hacer
- Notas de voz
¿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.
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.
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.
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.
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 deCLLocationManager
que facilita su uso en un reducer, y el escribir pruebas sobre cómo tu lógica interactúa con la funcionalidad deCLLocationManager
. -
ComposableCoreMotion
: Un wrapper deCMMotionManager
que facilita su uso en un reducer, y el escribir pruebas sobre cómo tu lógica interactúa con la funcionalidad deCMMotionManager
. -
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.
-
¿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 queEffect
conforma el protocoloPublisher
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
ycombine
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.
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.
Puedes añadir ComposableArchitecture a un proyecto de Xcode agregándolo como un paquete de Swift:
- Desde el menú Archivo, selecciona Añadir paquetes...
- Introduce "https://github.com/pointfreeco/swift-composable-architecture" en el campo de texto de la url del repositorio.
- 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.
La documentación de cada release y main
está disponible aquí:
Otras versiones
- 0.38.0
- 0.37.0
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.1
- 0.33.0
- 0.32.0
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.1
- 0.28.0
- 0.27.1
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.0
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.0
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.0
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.0
- 0.8.0
- 0.7.0
- 0.6.0
- 0.5.0
- 0.4.0
- 0.3.0
- 0.2.0
- 0.1.5
- 0.1.4
- 0.1.3
- 0.1.2
- 0.1.1
- 0.1.0
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.
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.
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.
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.
Esta lbrería es publicada bajo la licencia del MIT. Ver LICENCIA para más detalles.