Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Composable Architecture ITA

The Composable Architecture

Composable Architecture (TCA, abbr) è una libreria per creare applicazioni in modo lineare e comprensibile, tenendo conto della composizione, dei test e dell'ergonomia. Può essere utilizzato in SwiftUI, UIKit e su qualsiasi piattaforma Apple (iOS, macOS, tvOS e watchOS).

Che cosa è la Composable Architecture?

Questa libreria fornisce alcuni strumenti di base che possono essere utilizzati per creare applicazioni con finalità e complessità diverse. Fornisce use case che è possibile seguire per risolvere problemi di natura odierna che si incontrano durante la fase di sviluppo, come ad esempio:

  • Gestione dello stato
    Come gestire lo stato della tua applicazione utilizzando semplici tipi di valore e condividere lo stato su più schermate in modo che le mutazioni in una schermata possano essere immediatamente osservate in un'altra schermata.

  • Composizione
    Come scomporre proprietà di grandi dimensioni in componenti più piccoli che possono essere estratti nei propri moduli ed essere facilmente ricomposti per formare la proprietà.

  • Effetto collaterale
    Come permettere che determinate parti dell'applicazione parlino con il mondo esterno nel modo più verificabile e comprensibile possibile.

  • Fase Test
    Non solo testare una caratteristica dell'architettura, ma anche scrivere test di integrazione per caratteristiche composte di più parti, comporre test end-to-end per comprendere come la tua applicazione possa essere influenzata da effetti collaterali.

  • Ergonomia
    Come realizzare il tutto con una semplice API con il minor numero possibile di concetti.

Scopri di più

La Composable Architecture è stata concepita nel corso di molti episodi su Point-Free, una serie di video che esplora la programmazione funzionale e il linguaggio Swift, condotto da Brandon Williams e Stephen Celis.

È possibile visionare gli episodi qui, così come un tour dedicato, in più parti dell'architettura partendo da zero: parte 1, parte 2, parte 3 e parte 4.

video poster image

Esempi

Screen shots of example applications

Questa repository include molti esempi per dimostrare come risolvere problemi comuni e complessi con la Composable Architecture. È possibile dare uno sguardo alla directory per visualizzarli tutti, inclusi:

Cerchi qualcosa di più sostanzioso? Dai un'occhiata al codice sorgente di isowords, un rompicapo di ricerca di parole iOS costruito in SwiftUI e ls Composable Architecture.

Usi Base

Per costruire una funzionalità utilizzando la Composable architecture, è possibile definire alcuni tipi e valori che modellano il dominio:

  • State: un tipo che descrive i dati necessari alla funzionalità per eseguire la sua logica e renderizzare la sua interfaccia utente.
  • Action: un tipo che rappresenta tutte le azioni che possono verificarsi, come azioni dell'utente, notifiche, origini di eventi e altro.
  • Environment: un tipo che contiene tutte le dipendenze necessarie alla funzione, come client API, client di analisi, ecc.
  • Reducer: una funzione che descrive come evolvere lo stato corrente dell'app allo stato successivo data un'azione. Il reducer è anche responsabile della restituzione di qualsiasi effetto che dovrebbe essere eseguito, come le richieste API, che possono essere eseguite restituendo un valore Effect.
  • Store: il runtime che guida effettivamente la tua funzionalità. Invii tutte le azioni dell'utente all'archivio in modo che l'archivio possa eseguire il reducers e gli effetti e puoi osservare i cambiamenti di stato nell'archivio in modo da poter aggiornare l'interfaccia utente.

I vantaggi di questa operazione sono che sbloccherai immediatamente la testabilità della tua funzionalità e sarai in grado di suddividere funzionalità grandi e complesse in domini più piccoli che possono essere incollati insieme.

Come esempio di base, considera un'interfaccia utente che mostra un numero insieme ai pulsanti "+" e "-" che aumentano e diminuiscono il numero. Per rendere le cose interessanti, supponiamo che ci sia anche un pulsante che, quando viene tappato, fa una richiesta API per recuperare info su quel numero e quindi visualizza il fatto in un avviso.

Lo stato di questa funzione consisterebbe in un numero intero per il conteggio corrente, nonché in una stringa facoltativa che rappresenta il titolo dell'avviso che vogliamo mostrare (opzionale perché "nil" rappresenta la mancata visualizzazione di un avviso):

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

Successivamente abbiamo le azioni nella funzione. Ci sono le azioni ovvie, come toccare il pulsante di riduzione, il pulsante di incremento o il pulsante delle info. Ma ce ne sono anche alcune leggermente non ovvie, come l'azione dell'utente che ignora l'avviso e l'azione che si verifica quando riceviamo una risposta dalla richiesta API:

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

struct ApiError: Error, Equatable {}

Ora modelliamo l'ambiente delle dipendenze di cui questa funzionalità ha bisogno per svolgere il suo lavoro. In particolare, per recuperare un fatto numerico dobbiamo costruire un valore Effect che incapsula la richiesta di rete. Quindi quella dipendenza è una funzione da Int a Effect<String, ApiError>, dove String rappresenta la risposta dalla richiesta. Inoltre, l'effetto in genere fa il suo lavoro su un thread in background (come nel caso di URLSession), quindi abbiamo bisogno di un modo per ricevere i valori dell'effetto sulla coda principale. Lo facciamo tramite uno scheduler, che è una dipendenza importante da controllare in modo da poter scrivere i test. Dobbiamo usare un AnyScheduler in modo da poter utilizzare un DispatchQueue live in produzione e uno scheduler di test nei test.

struct AppEnvironment {
  var mainQueue: AnySchedulerOf<DispatchQueue>
  var numberFact: (Int) -> Effect<String, ApiError>
}

Successivamente, implementiamo un reducer che implementa la logica per questo dominio. Descrive come cambiare lo stato corrente allo stato successivo e descrive quali effetti devono essere eseguiti. Alcune azioni non hanno bisogno di eseguire effetti e possono restituire .none per rappresentare ciò:

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 environment.numberFact(state.count)
      .receive(on: environment.mainQueue)
      .catchToEffect()
      .map(AppAction.numberFactResponse)

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

  case .numberFactResponse(.failure):
    state.numberFactAlert = "Could not load a number fact :("
    return .none
  }
}

E poi finalmente definiamo la view che mostra la caratteristica. Mantiene un Store<AppState, AppAction> in modo che possa osservare tutte le modifiche allo stato e eseguire nuovamente il rendering e possiamo inviare tutte le azioni dell'utente allo store in modo che lo stato cambi. Dobbiamo anche introdurre una struct wrapper per renderlo Identificabile, che il modificatore della vista .alert richiede:

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 notare che abbiamo potuto implementare questa feature senza un avere reale live effect. Questo è molto importante, in quanto le feature possono essere implementate da sole, senza costruire le loro (dipendenze), le quali migliorano i tempi di compilazione.

È anche immediato avere il controller UIKit (al di fuori) dello store. Basta effettuare un subscribe allo store in viewDidLoad per poter aggiornare la UI e mostrare le notifiche. Il codice è più lungo della versione in SwiftUI, quindi viene condensato e riportato di seguito:

Click to expand!
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()

    // Omesso: Subview and constraint...

    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 volta pronti a mostrare questa view, ad esempio nello scene delegate, possiamo scrivere il costrutto dello store. È ora che dobbiamo impostare le dipendenze, ma per il momento possiamo usare un effect che ritorni una finta stringa:

let appView = AppView(
  store: Store(
    initialState: AppState(),
    reducer: appReducer,
    environment: AppEnvironment(
      mainQueue: .main,
      numberFact: { number in Effect(value: "\(number) is a good number Brent") }
    )
  )
)

Questo basta per avere qualcosa sullo schermo con cui giocare. Sicuramente meno immediato della stessa versione in vanilla SwiftUI, ma ci sono alcuni benefici. Ci dà un modo consistente di applicare le mutazioni di stato, invece della logica negli oggetti osservabili e in molte azioni closure dei componenti UI. Ci dà anche un modo conciso per esprimere gli effetti collaterali. Inoltre, questa logica può essere subito testata, effetti collaterali inclusi, senza fare molto altro.

Testing

Per testare, creare TestStore con le stesse informazioni con cui si creerebbe regolarmente uno Store, soltanto che questa volta possiamo fornire delle dipendenze "test-friendly". In particolare, useremo un test scheduler invece del live scheduler DispatchQueue.main, dato che questo ci consentirà di controllare il lavoro mentre viene eseguito, e non dovremo aspettare che la coda venga smaltita.

let scheduler = DispatchQueue.test

let store = TestStore(
  initialState: AppState(),
  reducer: appReducer,
  environment: AppEnvironment(
    mainQueue: scheduler.eraseToAnyScheduler(),
    numberFact: { number in Effect(value: "\(number) is a good number Brent") }
  )
)

Quando il test store sarà creato, potremo usarlo per dichiarare una assertion in modo che in ogni step dovremo confermare che lo stato è cambiato come previsto. Poi, se uno step causa un effetto per cui i dati tornano nello store, dovremo confermare che queste azioni sono state ricevute propriamente. Con il test riportato di seguito, l'utente può incrementare e decrementare il conteggio. Poi, verrà chiesto una validazione, la cui risposta farà mostrare una notifica. Se la notifica verrà ignorata, questa scomparirà.

// Test that tapping on the increment/decrement buttons changes the count
store.send(.incrementButtonTapped) {
  $0.count = 1
}
store.send(.decrementButtonTapped) {
  $0.count = 0
}

// Test that tapping the fact button causes us to receive a response from the effect. Note
// that we have to advance the scheduler because we used `.receive(on:)` in the reducer.
store.send(.numberFactButtonTapped)

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

// And finally dismiss the alert
store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

Queste sono le basi per costruire e testare una feature nella Composable Architecture. Ci sono tantissime altre cose da poter vedere, come la composizione, modularità, adattabilità e gli effetti complessi. La directory Examples contiene tanti progetti da esplorare per vedere più effetti avanzati.

Debugging

La Composable Architecture contiene un gran numero di strumenti che possono venire in aiuto in caso di debug.

  • reducer.debug() migliora il reducer con una print di debug che descrive ogni azione che il reducer riceve e ogni mutazione.

    received action:
      AppAction.todoCheckboxTapped(
        index: 0
      )
      AppState(
        todos: [
          Todo(
    -       isComplete: false,
    +       isComplete: true,
            description: "Milk",
            id: 5834811A-83B4-4E5E-BCD3-8A38F6BDCA90
          ),
          Todo(
            isComplete: false,
            description: "Eggs",
            id: AB3C7921-8262-4412-AA93-9DC5575C1107
          ),
          Todo(
            isComplete: true,
            description: "Hand Soap",
            id: 06E94D88-D726-42EF-BA8B-7B4478179D19
          ),
        ]
      )
  • reducer.signpost() signposts in modo da poter ottenere informazioni su quanto tempo richiedono l'esecuzione delle azioni e quando gli effect sono in esecuzione.

Librerie supplementari

Una delle più importanti regola della Composable Architecture è che gli effetti collaterali non sono mai eseguiti direttamente, ma vengono inclusi nel tipo Effect, vengono ritornati dai Reducer e in seguito lo Store avvia l'effetto. Questo è cruciale per simplificare il flusso dei dati attraverso un'applicazione, e per guadagnare la testabilità sull'intero ciclo end-to-end.

In ogni caso, questo signifca anche che molte librerie e SDK con cui si interagisce ogni giorno devono essere retro-implementate (retrofitted) per essere più friendly allo stile della Composable Architecture. È per questo che preferiamo alleggerire il dolore di usare alcuni dei più popolari framework Apple, fornendo wrapper libraries che mostrano la loro funzionalità in modo da essere più facili da integrare con la nostra libreria. Per adesso supportiamo:

  • ComposableCoreLocation: Un wrapper di CLLocationManager che rende più semplice l'utilizzo da parte di un reducer, e semplice per scrivere test su come la tua logica interagisce con la funzionalità CLLocationManager
  • ComposableCoreMotion: Un wrapper di CMMotionManager che rende più semplice l'utilizzo da parte di un reducer, e semplice per scrivere test su come la tua logica interagisce con la funzionalità CMMotionManager
  • Altre arriveranno a breve, restate connessi 😉

Se siete interessati a contribuire per una wrapper library per un framework che non abbiamo ancora introdotto, sentitevi liberi di aprire un post mostrando il vostro interesse, in modo da poter discutere un programma per l'implementazione.

FAQ

  • Come si confronta la TCA con Elm, Redux e altri?

    Espandi La Composable Architecture (TCA) è costruita su una base di idee rese popolari da Elm Architecture (TEA) e Redux, ma fatta sentire a casa nel linguaggio Swift e sulle piattaforme Apple.

    Per certi versi TCA è un po' più supponente delle altre librerie. Ad esempio, Redux non è prescrittivo su come si eseguono gli effetti collaterali, ma TCA richiede che tutti gli effetti collaterali siano modellati nel tipo "Effect" e restituiti dal Reducer.

    In altri modi TCA è un po' più permissivo rispetto alle altre librerie. Ad esempio, Elm controlla quali tipi di effetti possono essere creati tramite il tipo Cmd, ma TCA consente un'uscita di sicurezza per qualsiasi tipo di effetto poiché Effect è conforme al protocollo Combine Publisher.

    E poi ci sono alcune cose a cui TCA dà la massima priorità che non sono punti di interesse per Redux, Elm o la maggior parte delle altre librerie. Ad esempio, la composizione è un aspetto molto importante del TCA, che è il processo di scomposizione di elementi di grandi dimensioni in unità più piccole che possono essere incollate insieme. Ciò si ottiene con gli operatori "pullback" e "combine" sui riduttori e aiuta nella gestione di funzionalità complesse e nella modularizzazione per una base di codice meglio isolata e tempi di compilazione migliorati.

  • Perché Store non è thread-safe?
    Perché send non è in coda?
    Perché send non viene eseguito sul thread principale?

    Espandi

    Tutte le interazioni con un'istanza di Store (inclusi tutti i suoi ambiti e ViewStore derivati) devono essere eseguite sullo stesso thread. Se lo store sta alimentando una vista SwiftUI o UIKit, tutte le interazioni devono essere eseguite sul thread main.

    Quando un'azione viene inviata allo "Store", viene eseguito un riduttore nello stato corrente e questo processo non può essere eseguito da più thread. Una possibile soluzione consiste nell'utilizzare una coda nell'implementazione di send, ma questo introduce alcune nuove complicazioni:

    1. Se fatto semplicemente con DispatchQueue.main.async incorrerai in un salto di thread anche quando sei già sul thread principale. Questo può portare a comportamenti imprevisti in UIKit e SwiftUI, dove a volte ti viene richiesto di lavorare in modo sincrono, come nei blocchi di animazione.
    2. È possibile creare uno scheduler che svolga il suo lavoro immediatamente quando si trova sul thread principale e altrimenti utilizza DispatchQueue.main.async (es. vedere [CombineScheduler](https://github.com/pointfreeco/combine- schedulers) di UIScheduler). Questo introduce molta più complessità e probabilmente non dovrebbe essere adottato senza avere una buona ragione.

    Questo approccio fa il minor numero di ipotesi su come gli effetti vengono creati e trasformati e previene inutili salti di thread e re-dispatch. Fornisce anche alcuni vantaggi di test. Se i tuoi effects non sono responsabili della propria programmazione, nei test tutti gli effects verranno eseguiti in modo sincrono e immediato. Non saresti in grado di testare come più effects si intersecano tra loro e influiscono sullo stato della tua applicazione. Tuttavia, lasciando la programmazione fuori dallo Store possiamo testare questi aspetti dei nostri effetti se lo desideriamo, o possiamo ignorarli se preferiamo. Abbiamo quella flessibilità.

    Tuttavia, se non sei ancora un fan di nostra scelta, non temere! La TCA è abbastanza flessibile da permetterti di introdurre tu stesso questa funzionalità, se lo desideri. È possibile creare un redux che può forzare tutti gli effetti a fornire il loro output sul thread principale, indipendentemente da dove fa il suo lavoro:

    extension Reducer {
      func receive<S: Scheduler>(on scheduler: S) -> Self {
        Self { state, action, environment in
          self(&state, action, environment)
            .receive(on: scheduler)
            .eraseToEffect()
        }
      }
    }

    Probabilmente vorrai ancora qualcosa come un UIScheduler in modo da non eseguire inutilmente thread hop.

Requisiti

La Composable Architecture dipende dal framework Combine, quindi richiede una versione minima di iOS 13, macOS 10.15, Mac Catalyst 13, tvOS 13 e watchOS 6. Se la tua applicazione deve supportare sistemi operativi precedenti, ci sono fork per [ReactiveSwift](https ://github.com/trading-point/reactiveswift-composable-architecture) e RxSwift che puoi utilizzare!

Installazione

Puoi aggiungere la ComposableArchitecture ad un progetto XCode aggiungendolo come package dependency.

  1. Dal menu File, seleziona Swift Packages › Add Package Dependency…
  2. Inserisci "https://github.com/pointfreeco/swift-composable-architecture" come url
  3. In base a come è strutturato il tuo progetto:
    • Se hai un singolo target aggiungi ComposableArchitecture direttamente alla tua applicazione.
    • Se desideri utilizzare questa libreria da più target Xcode o mischiare target Xcode e target SPM, devi creare un framework condiviso che dipenda da ComposableArchitecture e quindi dipendere da quel framework in tutti i tuoi target. Per un esempio, controlla l'applicazione demo Tic-Tac-Toe, che suddivide molte funzionalità in moduli e utilizza la libreria statica in questo modo utilizzando il framework TicTacToeCommon.

Documentazione

Documentazione disponibile qui.

Aiuto

Se vuoi discutere la Composable Architecture o hai una domanda su come usarla per risolvere un particolare problema, puoi iniziare una [discussione](https://github.com/pointfreeco/swift-composable-architecture/ discussioni) su questo repository, o chiedendo in giro sul [forum Swift] (https://forums.swift.org/c/related-projects/swift-composable-architecture).

Traduzioni

  • Una traduzione Koreana è disponibile qui.
  • Una traduzione Indonesiana è disponibile qui.
  • Una traduzione Italiana è disponibile qui.

Per contribuire con una traduzione apri una PR con un link a Gist!

Crediti e ringraziamenti

Le seguenti persone hanno fornito feedback sulla libreria nelle sue fasi iniziali e hanno contribuito a rendere la libreria quello che è oggi:

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, e a tutti i subscriber di Point-Free 😁.

Un ringraziamento speciale a Chris Liscio che ci ha aiutato con alcune stranezze di SwiftUI e nella rifinutura di alcune API finali.

Grazie aShai Mishali e al progetto CombineCommunity, dove è stata utilizzata l'implementazione della Publishers.Create, utilizzata per il bridge Effect.

Altre Librerie

La Composable Architecture è stata costruita su una base di idee avviate da altre librerie, in particolare Elm e Redux.

Ci sono anche molte librerie di architettura nella comunità Swift e iOS. Ognuno di questi ha il proprio insieme di priorità e compromessi che differiscono dall'Architettura Componibile.

Licenza

Questa libreria è sotto licenza MIT. Clicca LICENSE per dettagli.

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