Intro und Ergebnisse
Ich habe gerade meine App RemafoX migriert, die auf The Composable Architecture (TCA) Version 0.35.0 aufgebaut war, auf den neuen API-Stil von 1.0. Obwohl zwischen dem Release von 0.35.0 und der aktuellen Beta von 1.0 weniger als ein Jahr liegt, ist es wichtig zu beachten, dass in diesem kurzen Zeitraum nicht weniger als 27 Feature-Releases mit teilweise erheblichen Aenderungen veroeffentlicht wurden. Das Point-Free-Team arbeitet wirklich auf Hochtouren daran, die App-Entwicklung fuer Swift-Entwickler zu verbessern, und TCA ist die Kulmination des Grossteils ihrer Arbeit. Nahezu jedes Thema, das sie in ihrer grossartigen Advanced-Swift-Videoreihe besprechen, hat Auswirkungen auf TCA. Obwohl sie es geschafft haben, alle Aenderungen bis zur aktuellen Version 0.52.0 weitgehend quellcode-kompatibel zu halten, wird das 1.0-Release viele aeltere APIs entfernen, die schon seit einiger Zeit als deprecated markiert sind.
Weil ich es sehr ineffizient finde, staendig jede Entscheidung bezueglich der Architektur oder der Konventionen meines App-Codes zu hinterfragen, habe ich in den letzten Monaten nicht viel Zeit investiert, um alle Verbesserungen an TCA nachzuholen – auch wenn ich immer ein Auge darauf hatte, um die allgemeine Richtung mitzubekommen. Aber das nahende Release der Meilenstein-Version 1.0 der Library und die Tatsache, dass ich als Naechstes an einigen groesseren Features fuer RemafoX arbeiten will, machen es zu einem guten Zeitpunkt, um neu zu ueberlegen und zu lernen, wie ich meine Apps kuenftig am besten strukturiere.
Gluecklicherweise hat sich das grundlegende Konzept von TCA ueberhaupt nicht geaendert. Aber die APIs zur Beschreibung, wie Features verbunden werden, wie Navigation funktionieren soll, wie asynchrone Arbeit deklariert wird und sogar wie Dependencies weitergegeben werden – all das hat seitdem erhebliche Aenderungen erfahren, alles zum Besseren, unter Nutzung der neuesten Swift-Features. Es gab also viel herauszufinden und zu migrieren, und ich habe alle diese Aenderungsbereiche auf einmal angegangen. Um es aber handhabbar zu halten, habe ich die Aenderungen Modul fuer Modul auf alle 33 UI-Features meiner mit SwiftPM modularisierten App angewendet.
Hier sind die wichtigsten Erkenntnisse des Migrationsprozesses vorab:
Es hat mich eine volle Arbeitswoche (~5 Tage) gekostet, die Migration abzuschliessen.
Meine Codebasis ist um 2.500 Zeilen Code geschrumpft, was einer Reduktion von ~7% entspricht.
Einige Navigations-Bugs, Threading-Probleme und SwiftUI-Glitches sind jetzt behoben.
Mein Code ist deutlich einfacher zu verstehen, zu navigieren und nachzuvollziehen.
Was die Teststory meiner App angeht: Alle meine Tests bestehen tatsaechlich weiterhin und ich musste keinerlei Aenderungen am Testcode vornehmen. Der Grund dafuer ist, dass ich derzeit nur Tests fuer Nicht-UI-Features habe, wie das Parsen von Daten, das Suchen nach Dateien oder das Vornehmen von Aenderungen an Strings-Dateien – und in einigen Teilen recht umfangreiche Tests. Als ich aber auch Tests fuer mein UI in Betracht zog, lag ich bereits Monate hinter meiner urspruenglichen Timeline fuer das App-Release, und zusaetzlich war TCA noch einige Wochen davon entfernt, nicht-erschoepfendes Testen zu unterstuetzen. Also entschied ich mich gegen das Hinzufuegen von UI-Tests, da ich nicht wirklich zufrieden damit war, wie oft man Tests aendern musste, nur wegen eines Refactorings auf der UI-Ebene, das das allgemeine Verhalten eigentlich nicht aenderte, aber gefuehlt ein Umschreiben der zugehoerigen Tests erforderte, weil sie so erschoepfend waren. Aber mit dem jetzt verfuegbaren nicht-erschoepfenden Testen plane ich, Schritt fuer Schritt UI-Tests fuer meine App zu schreiben, angefangen mit den wichtigsten: Mein Feature mit der meisten Business-Logik und alle meine Onboarding-Features. Moeglicherweise schreibe ich darueber in einem zukuenftigen Artikel.
Aber fuer jetzt konzentrieren wir uns darauf, wie ich die Migration meiner App-Codebasis angegangen bin.
Vor der Migration (Praxisbeispiel)
Ich denke, der beste Weg zu erklaeren, welche Aenderungen noetig waren und welche weiteren Aenderungen ich zur Vereinfachung vorgenommen habe, ist, echten Code zu zeigen. Im Folgenden zeige ich dir also, wie der tatsaechliche Code des einfachsten Features meiner App vor der Migration aussah und wie ich ihn zum neuen TCA-1.0-Stil weiterentwickelt habe.
Das Feature heisst in meiner Codebasis AppInfo und sieht in der App so aus:

Der “About RemafoX”-Screen (Cmd+I).
Vor der Migration war der Feature-Code auf 7 verschiedene Dateien aufgeteilt:

Feature-Teile: Action, ActionHandler, Error, Event, Reducer, State und View.
AppInfoState und AppInfoAction definieren die Daten und moeglichen Interaktionen:
import AppFoundation
import AppUI
public struct AppInfoState: Equatable {
public typealias Action = AppInfoAction
public typealias Error = AppInfoError
@BindingState
var showEnvInfoCopiedToClipboard: Bool = false
var selectedAppIcon: AppIcon
var errorHandlingState: ErrorHandlingState?
public init() {
self.selectedAppIcon = Defaults[.selectedAppIcon]
}
}AppInfoState.swift
import AppFoundation
import AppUI
public enum AppInfoAction: Equatable, BindableAction {
public typealias State = AppInfoState
public typealias Error = AppInfoError
case onAppear
case onDisappear
case selectedAppIconChanged
case copyEnvironmentInfoPressed
case binding(BindingAction<State>)
case errorOccurred(error: Error)
case setErrorHandling(isPresented: Bool)
case errorHandling(action: ErrorHandlingAction)
}AppInfoAction.swift
Beachte, dass ich immer Typealiases fuer verwandte Teile des Features definiert habe, auf die ich irgendwo innerhalb der Typen verweisen koennte – auch wenn ich sie nicht wirklich genutzt habe. Ausserdem hatte ich eine zusaetzliche Action set<Name des Childs>(isPresented:), wann immer ich eine Child-View hatte, die ich spaeter per Sheet praesentieren wollte. Falls du dich fragst, was diese Imports von AppFoundation und AppUI sind – das habe ich in diesem Artikel erklaert. Sie helfen, die Anzahl der Imports in meiner App zu reduzieren.
Als Naechstes schauen wir uns an, wie die AppInfoView-Datei aussieht:
import AppFoundation
import AppUI
public struct AppInfoView: View {
public typealias State = AppInfoState
public typealias Action = AppInfoAction
let store: Store<State, Action>
public init(store: Store<State, Action>) {
self.store = store
}
public var body: some View {
WithViewStore(self.store) { viewStore in
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .center, spacing: 10) {
viewStore.selectedAppIcon.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 128, height: 128)
.onChange(of: Defaults[.selectedAppIcon]) { newValue in
viewStore.send(.selectedAppIconChanged)
}
Text(Constants.appDisplayName)
.font(.system(size: 33, weight: .light, design: .rounded))
Text("Copyright © 2022 Cihat Gündüz")
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
Divider()
VStack(alignment: .center, spacing: 10) {
Text("Environment Info")
.font(.headline)
Text("Provide these info when reporting bugs or use Help menu.")
.frame(maxWidth: .infinity, alignment: .leading)
.font(.subheadline)
.padding(.bottom, 5)
HStack {
Text("App Version:").foregroundColor(.secondary)
Spacer()
Text(Bundle.main.versionInfo)
}
HStack {
Text("System Version:").foregroundColor(.secondary)
Spacer()
Text(ProcessInfo.processInfo.operatingSystemVersionString.replacingOccurrences(of: "Version ", with: ""))
}
HStack {
Text("System CPU:").foregroundColor(.secondary)
Spacer()
Text(KernelState.getStringValue(for: .cpuBrandString))
}
HStack {
Text("Tier:").foregroundColor(.secondary)
Spacer()
Text(Plan.loadCurrent().tier.displayName)
}
Button {
viewStore.send(.copyEnvironmentInfoPressed)
} label: {
Label("Copy", systemSymbol: .docOnClipboard)
}
.padding(.top, 10)
.popover(isPresented: viewStore.binding(\.$showEnvInfoCopiedToClipboard), arrowEdge: Edge.top) {
Text("Copied!").padding(10)
}
}
}
.frame(width: 320)
.padding()
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
.sheet(
isPresented: viewStore.binding(
get: { $0.errorHandlingState != nil },
send: Action.setErrorHandling(isPresented:)
)
) {
IfLetStore(
self.store.scope(state: \State.errorHandlingState, action: Action.errorHandling(action:)),
then: ErrorHandlingView.init(store:)
)
}
}
}
}
#if DEBUG
struct AppInfoView_Previews: PreviewProvider {
static let store = Store(
initialState: .init(),
reducer: appInfoReducer,
environment: .mocked
)
static var previews: some View {
AppInfoView(store: self.store)
}
}
#endifAppInfoView.swift
Beachte, dass ich fuer das Praesentieren eines Sheets nicht weniger als 11 Zeilen Code brauche und die Action setErrorHandling(isPresented:) manuell zurueck ins System senden muss. Erfahrene Entwickler werden auch bemerken, dass ich tatsaechlich globale Dependencies in meinem View-Code verwende, zum Beispiel mit Plan.loadCurrent(), was meinen UI-Code nicht besonders testbar macht. Aber ich werde sie als richtige Dependencies einfuehren, sobald ich mit dem Schreiben von UI-Tests beginne – ignorieren wir das fuer jetzt also.
Das letzte fehlende Puzzleteil eines Features in TCA ist der AppInfoReducer:
import AppFoundation
import AppUI
public let appInfoReducer = AnyReducer.combine(
errorHandlingReducer
.optional()
.pullback(
state: \AppInfoState.errorHandlingState,
action: /AppInfoAction.errorHandling(action:),
environment: { $0 }
),
AnyReducer<AppInfoState, AppInfoAction, AppEnv> { state, action, env in
let actionHandler = AppInfoActionHandler(env: env)
switch action {
case .onAppear, .onDisappear:
return .none // for analytics only
case .selectedAppIconChanged:
return actionHandler.selectedAppIconChanged(state: &state)
case .copyEnvironmentInfoPressed:
return actionHandler.copyEnvironmentInfoPressed(state: &state)
case .binding:
return .none // assignment handled by `.binding()` below
case .errorOccurred, .setErrorHandling, .errorHandling:
return actionHandler.handleErrorAction(state: &state, action: action)
}
}
.binding()
.recordAnalyticsEvents(eventType: AppInfoEvent.self) { state, action, env in
switch action {
case .onAppear:
return .init(event: .onAppear)
case .onDisappear:
return .init(event: .onDisappear)
case .copyEnvironmentInfoPressed:
return .init(event: .copyEnvironmentInfoPressed)
case .errorOccurred(let error):
return .init(event: .errorOccurred, attributes: ["errorCode": error.errorCode])
case .binding, .setErrorHandling, .errorHandling, .selectedAppIconChanged:
return nil
}
}
)Beachte zuerst, dass der appInfoReducer auf globaler Ebene definiert ist, was sich schon falsch anfuehlt. Dann werden 7 Zeilen benoetigt, um das Child-Feature ErrorHandling mit diesem Feature zu verbinden. Und dir wird auffallen, dass ich einen weiteren Typ namens AppInfoActionHandler eingefuehrt habe, der die eigentliche Logik des Reducers enthaelt. Der Grund dafuer ist, dass ein Teil meiner Reducer-Logik recht lang ist und wenn ich die gesamte Logik im switch-case behalten wuerde, haette ich viele Cases mit viel Code darin. Xcode bietet aber keine Features, um innerhalb von switch-Cases zu navigieren. Also habe ich die Logik in Funktionen eines anderen Typs ausgelagert. Schliesslich wirst du bemerken, dass ich eine Extension auf dem AnyReducer-Typ selbst fuer Analytics-Zwecke definiert habe:
import ComposableArchitecture
extension AnyReducer {
/// Returns a `Result` where each action coming to the store first attempts to record an analytics event.
/// In the implementation, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
public func recordAnalyticsEvents<Event: AnalyticsEvent>(
eventType: Event.Type,
event toAttributedEvent: @escaping (State, Action, Environment) -> Analytics.AttributedEvent<Event>?
) -> Self {
.init { state, action, env in
guard let attributedEvent = toAttributedEvent(state, action, env) else { return self.run(&state, action, env) }
return .concatenate(
.fireAndForget { Analytics.shared.record(attributedEvent: attributedEvent) },
self.run(&state, action, env)
)
}
}
}AnyReducerExt.swift
Das Einzige, was das tut, ist ein Event in meiner Analytics-Engine zu erfassen, die von TelemetryDeck angetrieben wird, fuer die Actions, die ich aufzeichnen moechte. Ich finde das sehr nuetzlich als Erinnerung, bei jeder neuen Action, die ich zum AppInfoAction-Enum hinzufuege, immer zu ueberlegen, ob ich das neue Event auf voellig anonymisierte Weise analysieren moechte. Damit das richtig funktioniert, muss ich auch fuer jedes Feature einen weiteren Typ definieren, hier AppInfoEvent:
import AppFoundation
enum AppInfoEvent: String {
case onAppear
case onDisappear
case copyEnvironmentInfoPressed
case errorOccurred
}
extension AppInfoEvent: AnalyticsEvent {
var idComponents: [String] {
["AppInfo", self.rawValue]
}
}AppInfoEvent.swift
Dieses Enum definiert alle Events, die ich erfassen moechte, und die idComponents-Property hilft beim automatischen Erstellen eines Strings, wenn ein Event-Name an meinen Analytics-Anbieter uebergeben wird. Das AnalyticsEvent-Protokoll ist etwas am Thema vorbei, aber falls es dich interessiert – es ist einfach das hier:
import Foundation
public protocol AnalyticsEvent: Identifiable where ID == String {
var idComponents: [String] { get }
}
extension AnalyticsEvent {
public var id: String {
self.idComponents.joined(separator: ".")
}
}AnalyticsEvent.swift (Teil eines Hilfsmoduls namens Analytics)
Dir ist vielleicht auch ein AppEnv-Typ aufgefallen, den ich fuer die Environment verwende. Das ist eigentlich ein gemeinsamer Typ, den ich ueberall wiederverwendet habe, wo ich nur einen einfachen Environment-Typ mit einer mainQueue brauchte und der ueberall in meiner Anwendung herumgereicht wird:
import CombineSchedulers
import Defaults
import Foundation
public struct AppEnv {
public let mainQueue: AnySchedulerOf<DispatchQueue>
public init(mainQueue: AnySchedulerOf<DispatchQueue>) {
self.mainQueue = mainQueue
}
}
#if DEBUG
extension AppEnv {
public static var mocked: AppEnv {
.init(mainQueue: DispatchQueue.main.eraseToAnyScheduler())
}
}
#endifNun, die letzte der 7 Dateien ist AppInfoError und dieser Typ ist fuer dieses sehr einfache Feature tatsaechlich leer. Aber ich werde seinen Zweck in einem spaeteren Artikel erklaeren, in dem ich meinen Ansatz zur Fehlerbehandlung im Detail beschreibe. Alles, was du fuer diesen Artikel wissen musst: Wenn etwas Unerwartetes passiert, moechte ich ein Sheet mit hilfreichen Informationen direkt im Kontext eines Features anzeigen.
Point-Free neigt dazu, alle ihre Typen in einer einzigen Datei zu behalten, was fuer ein kleines Feature wie AppInfo funktionieren mag. Aber ein typisches Feature von mir umfasst etwa 500 bis 1.500 Zeilen Code mit allen Typen zusammen. Ich neige dazu, meine Dateien klein zu halten, mit einem Soft Cap von 400 Zeilen und einem Hard Cap von 1.000 Zeilen (siehe SwiftLint-Regelstandards). Mit 314 Zeilen wuerde selbst dieses sehr einfache Feature schon nahe an das Soft Cap kommen, und einige meiner Features wuerden sogar ueber das Hard Cap hinausgehen. Alles in einer Datei zu halten ist also fuer mich ein No-Go. Deshalb habe ich stattdessen jeden Typ in eine eigene Datei gelegt. Aber ich war auch nie 100% zufrieden damit, weil alles etwas verstreut wirkte. Im Idealfall waere zusammengehoeriger Code immer noch beieinander, aber das Feature waere trotzdem gleichmaessig auf weniger als 7 Dateien verteilt. Schauen wir also, wie die Dinge nach der Migration aussehen.
Moechtest du hier deine Werbung sehen? Kontaktiere mich unter [email protected].
Nach der Migration (Praxisbeispiel)
In der TCA-1.0-Beta hat Point-Free das Konzept eingefuehrt, ein spezielles struct zu erstellen, das als Scope oder Namespace fuer ein Feature dient, und Hilfstypen wie State und Action als Subtypen in diesen Namespace-Typ zu legen. Sie haben diesem Namespace den Namen Feature gegeben und ihn Reducer-konform gemacht, was in etwa so aussieht:
struct Feature: Reducer {
struct State: Equatable { … }
enum Action: Equatable { … }
func reduce(into state: inout State, action: Action) -> Effect<Action> { … }
}Fuer mich ist diese Struktur aber aus zwei Gruenden verwirrend:
Den Namespace mit dem Suffix
Featurezu benennen, waehrend erReducer-konform ist, erscheint mir unstimmig. Ueberall, wo einreducer-Parameter uebergeben werden muss, wuerden wir einFeatureuebergeben – was verwirrend waere. Der Namespace muesste dann eherReducerheissen, aber dann haetten wir Subtypen ueberReducer.StateundReducer.Action, was auch nicht korrekt ist.Wenn man die Idee eines Feature-Namespaces voll annimmt, wuerde ich einen Typ
Reducerinnerhalb desFeature-Typs der Konsistenz halber erwarten.
Stattdessen habe ich mich dafuer entschieden, das Feature tatsaechlich als Namespace zu verwenden und auch einen Reducer-Subtyp hineinzusetzen, der Reducer-konform ist. Nun, das wuerde zu struct Reducer: Reducer fuehren – ein Namenskonflikt. Loesen wir das mit einem Typealias:
import ComposableArchitecture
public typealias FeatureReducer: ReducerJetzt koennten wir einen Reducer: FeatureReducer-Subtyp in unserem Feature-Namespace definieren. Und wo wir schon dabei sind – ich vergesse tatsaechlich oft, einen public Initializer fuer meine Reducer zu schreiben (was noetig ist, da die App modularisiert ist), also definieren wir stattdessen ein neues oeffentliches Protokoll, das einen public Initializer verlangt:
import ComposableArchitecture
public protocol FeatureReducer: Reducer {
init()
}Tatsaechlich gibt es noch mehr Dinge, die ich bei anderen TCA-Feature-Typen gerne vergesse. Machen wir das alles zu einer klaren Anforderung, indem wir Protokolle wie FeatureReducer fuer alle Arten von Subtypen innerhalb eines Feature implementieren:
import Analytics
import ComposableArchitecture
import ErrorHandling
import SwiftUI
public protocol FeatureState: Equatable {
var childErrorHandling: ErrorHandlingFeature.State? { get set }
}
public protocol FeatureAction: Equatable {
associatedtype ErrorType: FeatureError
static func errorOccurred(_ error: ErrorType) -> Self
static func childErrorHandling(_ action: PresentationAction<ErrorHandlingFeature.Action>) -> Self
}
public protocol FeatureEvent: AnalyticsEvent {}
public protocol FeatureError: HelpfulError {}
public protocol FeatureReducer: Reducer {
init()
}
public protocol FeatureView: View {
associatedtype Action: FeatureAction
}Auszug aus Feature.swift (Teil eines Hilfsmoduls)
Beachte, dass ich verlange, dass FeatureState und FeatureAction Equatable sind, was in TCA immer eine gute Idee ist, um sie testbar zu machen – und alle meine State- und Action-Typen konformieren bereits dazu. Zusaetzlich habe ich FeatureView entsprechend definiert, plus die zwei zusaetzlichen Typen, die ich fuer meine Analytics- und Error-Handling-Beduerfnisse brauche. Beachte auch, dass ich mich entschieden habe, statt dem Suffix State bei allen Child-Features (wie bei errorHandlingState im Feature zuvor), das Praefix child zu verwenden – wie in childErrorHandling. Das erleichtert das Finden von Child-Features beim Scannen der Attribute von oben nach unten.
Mit diesen Protokollen koennen wir dem Compiler jetzt sogar beibringen, was ein “Feature” eigentlich ist, indem wir ein weiteres Protokoll definieren, das alle Subtypen verlangt:
/// A namespace for a TCA feature with extra requirements for Analytics and Error Handling.
public protocol Feature {
associatedtype State: FeatureState
associatedtype Action: FeatureAction
associatedtype Event: FeatureEvent
associatedtype Error: FeatureError
associatedtype Reducer: FeatureReducer
associatedtype View: FeatureView
}
/// A helper to declare a `Store` of a `Feature` type.
public typealias FeatureStore<F: Feature> = Store<F.State, F.Action>Auszug aus Feature.swift (Teil eines Hilfsmoduls)
Ich habe auch einen Typealias zum Definieren des store in unseren Views erstellt, aehnlich dem neuen StoreOf-Typealias von Point-Free, aber spezifisch fuer ein Feature.
Gut, mit dieser Vorarbeit schauen wir uns an, wie das migrierte Feature aussieht:
import AppFoundation
import AppUI
public enum AppInfoFeature: Feature {
public struct State: FeatureState {
// see below
}
public enum Action: FeatureAction, BindableAction {
// see below
}
public enum Event: String, FeatureEvent {
// see below
}
public enum Error: FeatureError {
// ...
}
public struct Reducer: FeatureReducer {
// see below
}
public struct View: FeatureView {
// see below
}
}
extension AppInfoFeature.Event: AnalyticsEvent {
public var idComponents: [String] {
["AppInfo", self.rawValue]
}
}Ueberblick ueber AppInfoFeature.swift
Beachte, dass ich ein enum statt eines struct fuer das Feature definiert habe, um zu verdeutlichen, dass es sich lediglich um einen Namespace handelt. Ausserdem konformieren alle Subtypen genau zu dem, was ihr Name ist, mit Feature als Praefix – z. B. State: FeatureState. Das macht es wirklich einfach, sich zu merken, wozu man konformieren muss, und den Code konsistenter.
Hier ist der State-Body, den ich im obigen Code-Beispiel fuer einen besseren Ueberblick ausgelassen habe:
public struct State: FeatureState {
@BindingState
var showEnvInfoCopiedToClipboard: Bool = false
var selectedAppIcon: AppIcon
@PresentationState
public var childErrorHandling: ErrorHandlingFeature.State?
public init() {
self.selectedAppIcon = Defaults[.selectedAppIcon]
}
}Das sieht dem urspruenglichen AppInfoState recht aehnlich, aber diesmal ist das Child von errorHandlingFeature in childErrorHandling umbenannt. Und weil ich auch das Child-Feature selbst migriert habe, hat sich der Typ von ErrorHandlingState? zu ErrorHandlingFeature.State? geaendert. Ausserdem habe ich das @PresentationState-Attribut fuer den neuen Navigationsstil in TCA 1.0 hinzugefuegt, der Dismissal von innerhalb des Childs unterstuetzt, ueber @Dependency(\.dismiss) und den Aufruf von self.dismiss() im Reducer.
Als Naechstes schauen wir uns unseren Action-Subtyp an:
public enum Action: FeatureAction, BindableAction {
case onAppear
case onDisappear
case selectedAppIconChanged
case copyEnvironmentInfoPressed
case binding(BindingAction<State>)
case errorOccurred(Error)
case childErrorHandling(PresentationAction<ErrorHandlingFeature.Action>)
}Das ist auch weitgehend eine Kopie der urspruenglichen AppInfoAction, aber beachte, dass die Child-Action jetzt einen anderen Typ hat. Sie hat sich von HelpfulErrorAction zu PresentationAction<HelpfulErrorFeature.Action> geaendert – ein Wrapper, der alle Child-Actions in einen Case namens .presented legt. Der andere Case .dismiss meldet zurueck, dass das Child geschlossen wurde, falls das Parent darauf reagieren muss. Dank PresentationAction konnte ich die Action setErrorHandling(isPresented:) komplett entfernen, da dies jetzt in TCA-eigenen Typen gekapselt ist.
Schauen wir uns jetzt an, wie unsere View aussieht:
public struct View: FeatureView {
let store: FeatureStore<AppInfoFeature>
public init(store: FeatureStore<AppInfoFeature>) {
self.store = store
}
}Wie du siehst, verwende ich den FeatureStore-Typealias anstelle des alten Stils Store<State, Action> oder des TCA-1.0-Stils StoreOf<Feature>. Aber wo ist alles andere, das eine SwiftUI-View definiert, wie die body-Property? Nun, die Implementierung einer View ist typischerweise einer der laengsten Teile eines Features, also habe ich mich dafuer entschieden, die strukturellen Teile aller Subtypen an einem Ort zu behalten, waehrend Konformitaeten zu Protokollen, die viel Code erfordern, in Extension-Dateien ausgelagert werden.
Die Implementierung der View ist als Extension in einer separaten Datei:
import AppFoundation
import AppUI
extension AppInfoFeature.View: View {
public typealias Action = AppInfoFeature.Action
public var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
VStack(alignment: .leading, spacing: 20) {
// same code as before
}
.frame(width: 320)
.padding()
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
.sheet(store: self.store.scope(state: \.$childErrorHandling, action: Action.childErrorHandling)) { childStore in
HelpfulErrorFeature.View(store: childStore)
}
}
}
}
#if DEBUG
struct AppInfoView_Previews: PreviewProvider {
static let store = Store(initialState: AppInfoFeature.State(), reducer: AppInfoFeature.Reducer())
static var previews: some View {
AppInfoFeature.View(store: self.store).previewVariants()
}
}
#endifAppInfoFeatureView.swift*
Die Implementierung der body-Property ist so ziemlich die gleiche wie vorher. Aber beachte, dass der 11-zeilige .sheet-Modifier auf nur 3 Zeilen geschrumpft ist. Das verdanken wir den neuen Navigationstools mit @PresentationState und PresentationAction. Eine weitere Aenderung ist beim static let store im PreviewProvider passiert: Es gibt keinen Environment-Parameter mehr, der an den Store uebergeben werden muss!
Schauen wir uns den Reducer-Subtyp an, um zu erfahren warum:
public struct Reducer: FeatureReducer {
@Dependency(\.mainQueue)
var mainQueue
@Dependency(\.continuousClock)
var clock
public init() {}
}Beachte die Verwendung des @Dependency-Attributs. Es erinnert dich vielleicht an das @Environment-Attribut in SwiftUI, und es funktioniert tatsaechlich genauso. Dieses neue Attribut ist der Grund, warum in TCA 1.0 kein Environment-Typ mehr benoetigt wird. Stattdessen werden alle Dependencies mit dem @Dependency-Attribut deklariert. Das erlaubt mir, den AppEnv-Typ, den ich vorher herumgereicht habe, komplett zu entfernen.
Auch hier vermisst du vielleicht die eigentliche Implementierung des Reducer-Protokolls. Nun, die Implementierung des Protokolls ist der zweite Teil eines Features, der recht lang werden kann, also habe ich auch das in eine eigene Datei ausgelagert:
import AppFoundation
import AppUI
extension AppInfoFeature.Reducer: Reducer {
public typealias State = AppInfoFeature.State
public typealias Action = AppInfoFeature.Action
enum ShowEnvInfoCopiedId {}
public var body: some ReducerOf<Self> {
AnalyticsEventRecorderOf<AppInfoFeature> { state, action in
switch action {
case .onAppear:
return .init(event: .onAppear)
case .onDisappear:
return .init(event: .onDisappear)
case .copyEnvironmentInfoPressed:
return .init(event: .copyEnvironmentInfoPressed)
case .errorOccurred(let error):
return .init(event: .errorOccurred, attributes: ["errorCode": error.errorCode])
case .binding, .childErrorHandling, .selectedAppIconChanged:
return nil
}
}
BindingReducer()
Reduce<State, Action> { state, action in
switch action {
case .onAppear, .onDisappear:
return .none // for analytics only
case .selectedAppIconChanged:
return self.selectedAppIconChanged(state: &state)
case .copyEnvironmentInfoPressed:
return self.copyEnvironmentInfoPressed(state: &state)
case .binding:
return .none // assignment handled by `BindingReducer()` above
case .errorOccurred, .childErrorHandling:
return self.handleHelpfulErrorAction(state: &state, action: action)
}
}
.ifLet(\.$childErrorHandling, action: /Action.childErrorHandling) {
HelpfulErrorFeature.Reducer()
}
}
private func selectedAppIconChanged(...)
private func copyEnvironmentInfoPressed(state: inout State) -> Effect<Action> {
Pasteboard.string = Constants.GitHub.environmentInfo
state.showEnvInfoCopiedToClipboard = true
return .run { send in
try await self.clock.sleep(for: Constants.toastMessageDuration)
try Task.checkCancellation()
await send(.set(\.$showEnvInfoCopiedToClipboard, false))
}
.cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)
}
private func handleHelpfulErrorAction(...)
}AppInfoFeatureReducer.swift*
Beachte zuerst die voellig andere Struktur. Es werden keine globalen reducer-Variablen mehr benoetigt. Stattdessen wird eine body-Property implementiert, ganz aehnlich wie beim View-Protokoll in SwiftUI. Und die Analogie endet nicht dort – die Struktur ist auch sehr SwiftUI-aehnlich, mit einer einfachen Liste verschiedener Reducer, die zusammen den AppInfoFeature.Reducer bilden, einschliesslich eines namens BindingReducer(), der .binding() ersetzt. Beachte auch, dass die 7 Zeilen Code fuer die Verbindung des Child-Features auf nur 3 Zeilen mit der neuen .ifLet-API geschrumpft sind. Zusaetzlich konnte ich, anstatt einen eigenen ActionHandler-Typ definieren zu muessen, in dem ich die Logik fuer die Reaktion auf Actions untergebracht habe, diese Funktionen einfach in den Reducer-Typ selbst verschieben – weil wir uns jetzt in einem Typ befinden und nicht auf globaler Ebene. Ausserdem nutzt die Implementierung von copyEnvironmentInfoPressed die neuen async-APIs. Vorher war sie im weniger lesbaren Combine-Stil implementiert:
Pasteboard.string = Constants.GitHub.environmentInfo
state.showEnvInfoCopiedToClipboard = true
return .init(value: .set(\.$showEnvInfoCopiedToClipboard, false))
.delay(for: Constants.toastMessageDuration, scheduler: env.mainQueue)
.eraseToEffect()
.cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)Auszug aus dem alten AppInfoActionHandler.swift
Schliesslich musste ich durch den neuen SwiftUI-artigen Function-Builder-Stil meine AnyReducer-Extension-Funktion recordAnalyticsEvents in einen einfachen Reducer umwandeln, der die Ausfuehrungslogik als Property speichert:
import ComposableArchitecture
import Foundation
/// Returns a `Reducer` where each action coming to the store attempts to record an analytics event.
public struct AnalyticsEventRecorder<State, Action, Event: AnalyticsEvent>: Reducer {
let toAttributedEvent: (State, Action) -> Analytics.AttributedEvent<Event>?
/// In the event closure, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
public init(event toAttributedEvent: @escaping (State, Action) -> Analytics.AttributedEvent<Event>?) {
self.toAttributedEvent = toAttributedEvent
}
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
if let attributedEvent = self.toAttributedEvent(state, action) {
Analytics.shared.record(attributedEvent: attributedEvent)
}
return .none
}
}
/// Convenient way to declare an `AnalyticsEventRecorder`, but requires `Reducer` to conform to `Feature`.
public typealias AnalyticsEventRecorderOf<F: Feature> = AnalyticsEventRecorder<F.State, F.Action, F.Event>AnalyticsEventRecorder.swift (aus einem Hilfsmodul)
Der Body des Analytics-Helpers im Reducer oben hat sich ueberhaupt nicht geaendert – ich habe ihn einfach von der vorherigen Hilfsfunktion in den Initializer dieses neuen Reducers kopiert.
Und das war’s – das gesamte AppInfo-Feature ist auf TCA 1.0 migriert. Die gesamte Dateistruktur sieht jetzt so aus, mit nur 3 Dateien statt 7:

Beachte, dass ich * als Trennzeichen verwende, um zu signalisieren, dass die Datei den Hauptteil eines Subtyps enthaelt. Natuerlich koennten wir . als Trennzeichen verwenden, sodass der Name sich wie AppInfoFeature.Reducer.swift liest. Aber weil .R von .Reducer vor .s von .swift sortiert wird, wuerden die Subtyp-Dateien ueber der Haupt-Feature-Datei AppInfoFeature.swift erscheinen – also habe ich mich fuer ein Trennzeichen entschieden, das aehnlich wie ein Punkt aussieht, aber niedrigere Prioritaet als . hat, was zu * fuehrte.
Die SwiftLint-Regel file_name, die ich aktiviert habe, zeigte mir mit diesem Benennungsstil eine Warnung. Aber ich konnte das einfach anpassen, indem ich dies zur Konfigurationsdatei hinzufuegte:
file_name:
nested_type_separator: '*'Fazit
Die Migration meiner mittelgrossen App auf den neuen API-Stil von TCA 1.0 war viel Arbeit, aber der Grossteil bestand aus dem Einrichten von Dateistrukturen, Suchen und Ersetzen und dem Verschieben von bestehendem Code an andere Stellen. Und ich habe einige Zeit investiert, um eine gute Struktur herauszufinden, die mir gefaellt. Ich denke, wenn ich es nochmal fuer eine andere App mit meinen Erkenntnissen machen muesste, waere ich wahrscheinlich in 2–3 Tagen statt 5 fertig.
Nur an wenigen Stellen musste ich tatsaechlich Code anpassen, hauptsaechlich bei der Migration von Combine-artigem Effect-Code in meinen Reducern zu async/await-artigem Code. Aber dank grossartiger Dokumentation und Warnungen war immer ziemlich klar, was zu tun ist. Fuer alle, die eine aehnliche Migration durchfuehren, hier sind die 3 Links, die ich am nuetzlichsten fand:
Und wenn es eine Episode gibt, die einen guten Ueberblick ueber die Fortschritte in TCA 1.0 bietet, dann sind es die ersten ~35 Minuten von Episode #222, die Composable Navigation einfuehrt. Schau sie dir an, um schnell eine Idee davon zu bekommen, wie sich die Dinge im letzten Jahr veraendert haben.
Du fandest diesen Artikel hilfreich? Hol dir meinen Expertenrat!

