Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
An Portuguese Translation

The Composable Architecture

CI

The Composable Architecture (ou simplesmente TCA) é uma biblioteca para construir aplicativos de forma consistente e compreensível, tendo em mente composição, teste e ergonomia. Pode ser usado no SwiftUI, UIKit e em qualquer plataforma da Apple (iOS, macOS, tvOS e watchOS).

O que é The Composable Architecture?

Essa biblioteca fornece algumas ferramentas básicas que podem ser usadas para criar aplicativos com várias finalidades e complexidades. Fornece casos de uso que você pode seguir para resolver muitos problemas que você encontra no dia-a-dia ao criar aplicativos, como:

  • Gerenciamento de estado
    Como gerenciar o estado do seu aplicativo usando tipos de valor simples, e compartilhar o estado em várias telas para que as mutações em uma tela possam ser observadas imediatamente em outra tela.

  • Composição
    Como dividir grandes features em componentes menores que podem ser extraídos para seus próprios módulos isolados e facilmente colados de volta para formar a feature.

  • Efeitos secundários
    Como permitir que certas partes do aplicativo "conversem" com o mundo exterior da maneira mais testável e compreensível possível.

  • Testes
    Como não apenas testar uma feature construída na arquitetura, mas também escrever testes de integração para features que foram compostas de muitas partes e escrever testes de ponta a ponta para entender como os efeitos colaterais influenciam seu aplicativo. Isso permite que você garanta de maneira sólida que sua lógica de negócios está sendo executada da maneira que você espera.

  • Ergonomia
    Como realizar todos os itens acima em uma API simples com o mínimo possível de conceitos e partes móveis.

Aprenda Mais

The Composable Architecture foi projetado ao longo de muitos episódios em Point-Free, uma série de vídeos explorando programação funcional e a linguagem Swift, hospedada por Brandon Williams e Stephen Celis.

Você pode assistir a todos os episódios aqui, bem como um tour dedicado em várias partes da arquitetura do zero: parte 1, parte 2, parte 3 e parte 4.

video poster image

Exemplos

Screen shots of example applications

Este repositório tem muitos exemplos para demonstrar como resolver problemas comuns e complexos com a Composable Architecture. Confira o diretório aqui para ver todos eles, incluindo:

Procurando algo mais sério? Confira o código-fonte de isowords, um jogo de busca de palavras para iOS construído no SwiftUI e na Composable Architecture.

Uso básico

Para construir uma feature usando a Composable Architecture você define alguns tipos e valores que modelam seu domínio:

  • Estado: Um tipo que descreve os dados que sua feature precisa para executar sua lógica e renderizar sua interface do usuário.
  • Ação: Um tipo que representa todas as ações que podem ocorrer em sua feature, como ações do usuário, notificações, fontes de eventos e muito mais.
  • Ambiente: Um tipo que contém todas as dependências que a feature precisa, como chamadas de API, análise, etc.
  • Reducer: Uma função que descreve como evoluir o estado atual do aplicativo para o próximo estado de acordo com uma ação. O Reducer também é responsável por retornar quaisquer efeitos que devem ser executados, como chamadas de API, o que pode ser feito retornando um valor Effect.
  • Store: O lugar que armazena sua feature. Você envia todas as ações do usuário para a Store para que possa executar o Reducer e os efeitos, e assim poder observar as alterações de estado e atualizar a UI.

Os benefícios de fazer isso é que você desbloqueará instantaneamente a testabilidade da sua feature e poderá dividir features grandes e complexas em domínios menores que podem ser unidos.

Como exemplo básico, considere uma UI que mostra um número junto com os botões "+" e "−" que aumentam e diminuem o número. Para tornar as coisas interessantes, suponha que também haja um botão que, quando tocado, faça uma chamada de API para buscar um fato aleatório sobre esse número e, em seguida, exiba o fato em um alerta.

O estado dessa feature consistiria em um inteiro para a contagem atual, bem como uma string opcional que representa o título do alerta que queremos mostrar (opcional porque nil representa não mostrar um alerta):

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

Em seguida, temos as ações na feature. Existem as ações óbvias, como tocar no botão de decremento, no botão de incremento ou no botão de fato. Mas também existem alguns pouco óbvios, como a ação do usuário dispensando o alerta e a ação que ocorre quando recebemos uma resposta da chamada da API de fato:

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

Next we model the environment of dependencies this feature needs to do its job. In particular, to fetch a number fact we can model an async throwing function from Int to String:

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

Em seguida, implementamos um Reducer que implementa a lógica para este domínio. Ele descreve como alterar o estado atual para o próximo estado e descreve quais efeitos precisam ser executados. Algumas ações não precisam executar efeitos e podem retornar .none para representar isso:

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 = "Could not load a number fact :("
    return .none
  }
}

E, finalmente, definimos a visualização que exibe a feature. Ele mantém um Store<AppState, AppAction> para que possa observar todas as alterações no estado e renderizar novamente, e podemos enviar todas as ações do usuário para a Store para que o estado mude. Também devemos introduzir um struct wrapper em torno do alerta de fato para torná-lo Identifiable, que o modificador de visão .alert requer:

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("Number fact") { 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 }
}

É importante observar que conseguimos implementar todo esse recurso sem ter um efeito real e ao vivo disponível. Isso é importante porque significa que os recursos podem ser construídos isoladamente sem construir suas dependências, o que pode ajudar nos tempos de compilação.

Também é simples ter um controlador UIKit acionado a partir desta Store. Você se inscreve na loja em viewDidLoad para atualizar a UI e mostrar alertas. O código é um pouco mais longo que a versão SwiftUI, então nós o recolhemos aqui:

Clique 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:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

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

    // Omitted: Add subviews and set up constraints...

    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)
  }
}

Quando estivermos prontos para exibir essa visualização, por exemplo, no ponto de entrada do aplicativo, podemos construir uma Store. Este é o momento em que precisamos fornecer as dependências, incluindo o endpoint numberFact que está obtendo as informações do 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)
        }
      )
    )
  )
}

E isso é suficiente para colocar algo na tela para brincar. Definitivamente, são mais algumas etapas do que se você fizesse isso no SwiftUI vanilla, mas há alguns benefícios. Ele nos dá uma maneira consistente de aplicar mutações de estado, em vez de espalhar a lógica em alguns observable objects e em vários closures de componentes de UI. Também nos dá uma maneira concisa de expressar os efeitos colaterais. E podemos testar imediatamente essa lógica, incluindo os efeitos, sem muito trabalho adicional.

Testando

Para testar, você primeiro cria um TestStore com as mesmas informações que você usaria para criar um Store normal, exceto que desta vez podemos fornecer dependências amigáveis para testes. Em particular, agora podemos usar uma implementação numberFact que retorna imediatamente um valor que controlamos em vez de esperar do mundo real:

@MainActor
func testFeature() async {
  let store = TestStore(
    initialState: AppState(),
    reducer: appReducer,
    environment: AppEnvironment(
      numberFact: { "\($0) is a good number Brent" }
    )
  )
}

Uma vez que o teste da Store é criado, podemos usá-lo para fazer uma comprovação de todo um fluxo de etapas do usuário. A cada passo do caminho precisamos provar que o estado mudou como esperamos. Além disso, se uma etapa fizer com que um efeito seja executado, o que alimenta os dados de volta a Store, devemos comprovar que essas ações foram recebidas corretamente.

O próximo teste faz com que o usuário aumente e diminua a contagem, depois pergunta sobre a curiosidade daquele número, e a resposta daquele efeito aciona um alerta a ser exibido, e por fim, dispensando o alerta desaparece.

// Teste se tocar nos botões de incremento/decremento altera a contagem
await store.send(.incrementButtonTapped) {
  $0.count = 1
}
await store.send(.decrementButtonTapped) {
  $0.count = 0
}

// Teste se tocar no botão de fato nos faz receber uma resposta do efeito. Observe
// que temos que aguardar o recebimento porque o efeito é assíncrono e, portanto, demora um pouco para ser emitido.
await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse(.success("0 is a good number Brent"))) {
  $0.numberFactAlert = "0 is a good number Brent"
}

// E finalmente descarte o alerta
await store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

Esse é o básico da construção e teste de uma feature na Composable Architecture. Há muito mais coisas a serem exploradas, como composição, modularidade, adaptabilidade e efeitos complexos. O diretório Exemplos tem vários projetos para explorar e ver usos mais avançados.

Debugging

A Composable Architecture vem com várias ferramentas para auxiliar na depuração.

  • reducer.debug() imprime na tela cada ação que o Reducer recebe e cada mutação feita no 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() implementa um Reducer com sinais para obter informações sobre quanto tempo as ações levam para serem executadas e quando os efeitos são executados.

Bibliotecas suplementares

Um dos princípios mais importantes da Composable Architecture é que os efeitos colaterais nunca são executados diretamente, mas sim agrupados no tipo Effect, retornados dos Reducers, e então o Store executa o efeito posteriormente. Isso é crucial para simplificar como os dados fluem por meio de um aplicativo e para obter testabilidade em todo o ciclo de ponta a ponta da ação do usuário para efetuar a execução.

No entanto, isso também significa que muitas bibliotecas e SDKs com os quais você interage diariamente precisam ser adaptados para serem um pouco mais amigáveis ao estilo Composable Architecture. É por isso que gostaríamos de aliviar a dor de usar alguns dos frameworks mais populares da Apple, fornecendo bibliotecas de wrapper que expõem sua funcionalidade de uma maneira que funcione bem com nossa biblioteca. Até agora apoiamos:

  • ComposableCoreLocation: Um wrapper em torno do CLLocationManager que facilita o uso a partir de um Reducer e fácil de escrever testes de como sua lógica interage com a funcionalidade do CLLocationManager.
  • ComposableCoreMotion: Um wrapper em torno do CMMotionManager que facilita o uso a partir de um Reducer e fácil de escrever testes de como sua lógica interage com a funcionalidade do CMMotionManager.
  • Mais para vir em breve. Fique de olho 😉

Se você estiver interessado em contribuir com uma biblioteca wrapper para um framework que ainda não abordamos, sinta-se à vontade para abrir um issue expressando seu interesse para que possamos discutir um caminho a seguir.

FAQ

  • Como a Composable Architecture se compara ao Elm, Redux e outros?

    Expanda para ver a resposta A Composable Architecture (TCA) é construída sobre uma base de ideias popularizadas pela Elm Architecture (TEA) e Redux, mas feita para se sentir em casa na linguagem Swift e nas plataformas da Apple.

    De certa forma, o TCA é um pouco mais opinativo do que as outras bibliotecas. Por exemplo, o Redux não é prescritivo com a forma como se executam os efeitos secundários, mas o TCA exige que todos os efeitos secundários sejam modelados no tipo Effect e retornados do reducer.

    De outras maneiras, o TCA é um pouco mais simples do que as outras bibliotecas. Por exemplo, Elm controla quais tipos de efeitos podem ser criados através do tipo Cmd, mas o TCA permite uma saída de escape para qualquer tipo de efeito desde que Effect esteja em conformidade com o protocolo Combine Publisher.

    E há certas coisas que o TCA prioriza altamente que não são pontos de foco para Redux, Elm ou a maioria das outras bibliotecas. Por exemplo, a composição é um aspecto muito importante do TCA, que é o processo de quebrar grandes features em unidades menores que podem ser coladas. Isso é feito com os operadores pullback e combine em reducers, e ajuda a lidar com recursos complexos, bem como a modularização para uma base de código melhor isolada e tempos de compilação aprimorados.

Requisitos

A Composable Architecture depende da estrutura Combine, portanto, o deployment targets mínimo necessário é o iOS 13, macOS 10.15, Mac Catalyst 13, tvOS 13 e watchOS 6. Se seu aplicativo deve suportar sistemas operacionais mais antigos, há forks para ReactiveSwift e RxSwift que você pode adotar!

Instalação

Você pode adicionar ComposableArchitecture a um projeto Xcode adicionando-o como uma dependência de pacote.

  1. No menu File, selecione Add Packages...
  2. Digite "https://github.com/pointfreeco/swift-composable-architecture" no campo de texto URL do repositório de pacotes
  3. Dependendo de como seu projeto está estruturado:
    • Se você tiver um único target que precisa de acesso à biblioteca, adicione ComposableArchitecture diretamente ao seu aplicativo.
    • Se você quiser usar essa biblioteca em vários Xcode targets ou misturar Xcode targets e SPM targets, deverá criar uma estrutura compartilhada que dependa de ComposableArchitecture e, em seguida, dependa dessa estrutura em todos os seus targets. Para um exemplo disso, confira o aplicativo de demonstração Tic-Tac-Toe, já que nele muitos recursos são divididos em módulos e consomem a biblioteca estática dessa forma no pacote Swift tic-tac-toe.

Documentação

A documentação para releases e main estão disponíveis aqui:

Outras versões

Ajuda

Se você quiser discutir a Composable Architecture ou tiver alguma dúvida sobre como usá-la para resolver um problema específico, você pode iniciar um tópico nas discussões deste repositório, ou pergunte no fórum Swift.

Traduções

As seguintes traduções deste README foram contribuídas por membros da comunidade:

Se você quiser contribuir com uma tradução, por favor abra um PR com um link para um Gist!

Créditos e agradecimentos

As seguintes pessoas deram feedback sobre a biblioteca em seus estágios iniciais e ajudaram a tornar a biblioteca o que ela é hoje:

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, and all of the Point-Free subscribers 😁.

Agradecimentos especiais a Chris Liscio que nos ajudou a trabalhar com muitas peculiaridades estranhas do SwiftUI e ajudou a refinar a API final.

E graças ao projeto Shai Mishali e CombineCommunity, do qual tiramos a implementação de Publishers.Create , que usamos em Effect para ajudar a conectar APIs baseadas em delegação e retorno de chamada, facilitando muito a interface com estruturas de terceiros.

Outras bibliotecas

A Composable Architecture foi construída com base em ideias iniciadas por outras bibliotecas, em particular Elm e Redux.

Há também muitas bibliotecas de arquitetura na comunidade Swift e iOS. Cada um deles tem seu próprio conjunto de prioridades e trade-offs que diferem da Composable Architecture.

Licença

Esta biblioteca é lançada sob a licença do MIT. Consulte LICENSE para obter detalhes.

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