TCA Composer

CI

TCA Composer 是一个 Swift 宏框架,用于在基于 TCA 的应用程序中生成样板代码。 Composer 提供了一系列 Swift 宏,允许您声明式地构造一个 Reducer,并自动生成全部或部分的 StateActionbody 声明。 Composer 还可以自动生成整个 Reducer,用于导航目标和堆栈。 Composer 鼓励使用简单的设计模式来构建您的代码,同时仍然允许您完全灵活地构建代码和应用程序。

重要提示

Composer 需要 Composable Architecture 的 1.7.0 版本(或更高版本)。 Composer 还要求在您的 Reducer 中采用 ObservableState。 如果您要将现有的 Reducer 迁移到 Composer,强烈建议您首先按照迁移指南更新您的 Reducer 以使用 ObservableState,以便更平滑地过渡到使用 Composer。

示例

此存储库包含来自 TCA 存储库 的几个已转换为使用 Composer 宏框架的示例,包括

这些示例是开始使用和体验 Composer 的绝佳方式。

基本用法

入门指南

让我们从创建典型的 TCA 示例 Counter 开始。 要使用 Composer,请将该软件包添加到您的项目并导入 TCAComposer 模块。 然后,在 Counter 声明中将 @Reducer 宏替换为 @Composer 宏,如下所示。 这就是开始创作所需的一切。 实际上,以下代码已经可以编译。

 import ComposableArchitecture
+import TCAComposer

-@Reducer
+@Composer
 struct Counter {
 }

让我们看看生成了什么代码。 点击展开 @Composer

 import ComposableArchitecture
 import TCAComposer

 @Composer
 struct Counter {
+  @ObservableState 
+  struct State: Equatable {
+  }
+
+  @CasePathable
+  enum Action {
+  }
+
+  @ReducerBuilder<State, Action>
+  var body: some ReducerOf<Self> {
+    EmptyReducer()
+  }
}

使用 @Composer 宏已经创建了一个功能齐全的 Reducer。 当然,它现在还什么都不做。 所以让我们改变它。

为简单计数器创建 State

让我们继续构建 TCA 中常用的简单 Counter 示例。 为此,我们只需添加

@Composer
struct Counter {
  struct State {
    var count = 0
  }
}

让我们看看生成了什么代码。 点击展开 @Composer

 @Composer
 struct Counter {
+  @ObservableState 
   struct State {
     var count = 0
   }
+
+  @CasePathable
+  enum Action {
+  }
+
+  @ReducerBuilder<State, Action>
+  var body: some ReducerOf<Self> {
+    EmptyReducer()
+  }
}

如果缺少 @ObservableState,Composer 会自动将其应用于您的所有 State 声明。

为简单计数器创建 Action

现在我们已经实现了 State,让我们实现 Action。 在这里,使用 Composer 创建 Reducer 开始变得有趣。 Composer 旨在完全负责生成 ReducerAction。 Composer 鼓励您将 Action 分解为更小的特定于域的动作,而不是在代码中创建一个大的 Action 枚举。 这是 TCA 社区 中使用的常见设计模式。

注意:如果您想自己管理 Action,您仍然可以使用 Composer。 但是,您需要在 Action 中添加一些样板代码才能充分利用 Composer 的功能。 该文档提供了有关如何完成此操作的更多详细信息。

现在,让我们通过创建两个动作 decrementButtonTappedincrementButtonTapped 来实现递增和递减计数的能力。 在普通的 TCA 应用程序中,您将创建一个 Action 枚举并添加两个 case。 在 Composer 中,我们将改为将我们的枚举命名为 ViewAction

注意:名称 ViewAction 是按照惯例选择的。 您可以自由选择任何名称,并使用 Composer 以您喜欢的任何方式构建您的代码。 Composer 确实有一些首选的约定,如果您采纳它们,您将获得一些额外的好处,例如自动将 @CasePathable 添加到 action 枚举,但您没有义务这样做。

 @Composer
 struct Counter {
   struct State {
     var count = 0
   }

-  enum Action {
+  enum ViewAction {
     case decrementButtonTapped
     case incrementButtonTapped
   }
 }

让我们看看生成了什么代码。 点击展开 @Composer

 @Composer
 
 struct Counter {
+  @ObservableState 
   struct State {
     var count = 0
   }

+  @CasePathable
   enum ViewAction {
     case decrementButtonTapped
     case incrementButtonTapped
   }
+
+  @CasePathable
+  enum Action {
+  }
+
+  @ReducerBuilder<State, Action>
+  var body: some ReducerOf<Self> {
+    EmptyReducer()
+  }
}

Composer 已经自动将 @CasePathable 应用于我们的 ViewAction 枚举。 默认情况下,Composer 会自动将 @CasePathable 应用于在您的 reducer 中定义的任何名称中带有 Action 后缀的枚举。 但是,Composer 尚未对 Action 执行任何有趣的操作。 让我们继续创建 body,看看有什么不同。

为简单计数器创建 Reducer body

通常,Reducer 的所有有趣工作都发生在 body 声明中。 应用程序具有非常大且复杂的 body 声明是很常见的。 与 Action 一样,Composer 鼓励您将 reducer 分解为更小的部分,并完全负责生成 Reducerbody。 为此,Composer 需要您提供一些指导,形式为指令。 指令是附加到 reducer 中部分代码的宏,用于指导 Composer 如何组合 reducer 的 bodyAction。 所有指令都以 @Compose... 开头,并且影响 body 组合的指令以 @ComposeBody... 开头。

注意:@Compose... 指令不生成任何代码,也无法在 XCode 中展开。 它们只是 @Composer 宏读取的注释,以确定要生成的代码。

继续我们的 Counter 示例。 我们将把编写 Reducerbody 的工作委托给 Composer。 相反,我们将添加一个函数来直接减少 ViewAction,并且我们将指示 Composer 如何使用 @ComposeBodyActionCase 指令将其组合到我们的 reducer 中。

 @Composer
 struct Counter {
   struct State {
     var count = 0
   }

-  enum Action {
+  enum ViewAction {
     case decrementButtonTapped
     case incrementButtonTapped
   }
 }

-  var body: some ReducerOf<Self> {
-    Reduce { action, state in
+  @ComposeBodyActionCase
+  func view(state: inout State, action: ViewAction) {
     switch action {
       case .decrementButtonTapped:
        state.count -= 1
-        return .none
      case .incrementButtonTapped:
        state.count += 1
-        return .none
   }

让我们看看生成了什么代码。 点击展开 @Composer

 @Composer
 
 struct Counter {
+  @ObservableState 
   struct State {
     var count = 0
   }

+  @CasePathable
   enum ViewAction {
     case decrementButtonTapped
     case incrementButtonTapped
   }

   @ComposeBodyActionCase
   func view(state: inout State, action: ViewAction) {
     switch action {
       case .decrementButtonTapped:
        state.count -= 1
      case .incrementButtonTapped:
        state.count += 1
   }
+
+  @CasePathable
+  enum Action: ComposableArchitecture.ViewAction {
+    view(ViewAction)
+  }
+
+  @ReducerBuilder<State, Action>
+  var body: some ReducerOf<Self> {
+    TCAComposer.ReduceAction(\.view) { state, action in
+      self.view(state: &state, action: action)
+      return .none
+    }
+  }
}

Composer 现在已经自动生成了一个功能性的 body,并在 Action 中添加了一个具有 ViewAction 关联类型的 view case。 这都归功于 @ComposeBodyActionCase 指令的魔力。 它有两个目的

组合 Reducers

Composer 的真正威力来自将子 reducer 组合到父 reducer 中。 让我们创建一个由两个 Counter reducer 组成的 TwoCounters reducer。 为了实现这一点,我们将使用附加到我们的顶级 reducer 声明的 @ComposeReducer 宏。 此宏允许您在一个地方声明您的所有子 reducer,包括导航目标和导航堆栈的 reducer。 它还允许一些额外的自定义,例如使 Action 符合 BindableAction,以便允许从 ViewState 进行绑定访问。 在下面的示例中,指定了 .bindable 选项给 @ComposeReducer 以启用绑定,并添加了两个名为 counter1counter2 的子项。

 @ComposeReducer(
   .bindable,
   children: [
     .reducer("counter1", of: Counter.self, initialState: .init()),
     .reducer("counter2", of: Counter.self, initialState: .init())
   ]
 )
 @Composer
 struct TwoCounters {
   struct State {
     var isDisplayingSum = false
   }
  
   enum ViewAction {
     case resetCountersTapped
   }
   
   @ComposeBodyActionCase
   func view(state: inout State, action: ViewAction) {
     switch action {
     case .resetCountersTapped:
       state.counter1.count = 0
       state.counter2.count = 0
     }
   }
 }

让我们看看生成了什么代码。 点击展开 @Composer

@ComposeReducer(
  .bindable,
  children: [
    .reducer("counter1", of: Counter.self, initialState: .init()),
    .reducer("counter2", of: Counter.self, initialState: .init())
  ]
)
@Composer
struct TwoCounters {
  
+ @_ComposerScopePathable
+ @_ComposedStateMember("counter1", of: Counter.State.self, initialValue: .init())
+ @_ComposedStateMember("counter2", of: Counter.State.self, initialValue: .init())
+ @ObservableState
  struct State {
    var isDisplayingSum = false
  }
  
+ @CasePathable
  enum ViewAction {
    case resetCountersTapped
  }
  
  @ComposeBodyActionCase
  func view(state: inout State, action: ViewAction) {
    switch action {
    case .resetCountersTapped:
      state.counter1.count = 0
      state.counter2.count = 0
    }
  }
  
+ @CasePathable
+ enum Action: ComposableArchitecture.BindableAction, ComposableArchitecture.ViewAction {
+   case binding(BindingAction<State>)
+   case counter1(Counter.Action)
+   case counter2(Counter.Action)
+   case view(ViewAction)
+ }
+
+ @ComposableArchitecture.ReducerBuilder<Self.State, Self.Action>
+ var body: some ReducerOf<Self> {
+   ComposableArchitecture.BindingReducer()
+   ComposableArchitecture.Scope(state: \.counter1, action: \Action.Cases.counter1) {
+     Counter()
+   }
+   ComposableArchitecture.Scope(state: \.counter2, action: \Action.Cases.counter2) {
+     Counter()
+   }
+   ComposableArchitecture.CombineReducers {
+     TCAComposer.ReduceAction(\Action.Cases.view) { state, action in
+       self.view(state: &state, action: action)
+         return .none
+     }
+   }
+ }
+
+ struct AllComposedScopePaths {
+   var counter1: TCAComposer.ScopePath<TwoCounters.State, Counter.State, TwoCounters.Action, Counter.Action> {
+     get {
+       return TCAComposer.ScopePath(state: \State.counter1, action: \Action.Cases.counter1)
+     }
+   }
+   var counter2: TCAComposer.ScopePath<TwoCounters.State, Counter.State, TwoCounters.Action, Counter.Action> {
+     get {
+       return TCAComposer.ScopePath(state: \State.counter2, action: \Action.Cases.counter2)
+     }
+   }
+ }
}

哇,很多代码! 感谢 .bindable 选项,Composer 自动生成了一个包含 BindingAction 一致性的 ActionAction 还包含了我们两个 reducer 子项的 case 以及来自 @ComposeBodyActionCase 宏的 view action。 自动生成的 body 调用 BindingReducer,作用域两个子 reducer,然后最终调用我们的 view 函数来 reduce ViewAction

您还会注意到,以带有下划线的宏开始的新宏附加到了 State。 这些是 Composer 用来在您的 Reducer 代码部分中生成代码的内部宏,是 swift 宏系统工作方式的副产品。 内部宏并非供您使用,并且可能会在发布版本之间发生变化。 这是完全展开后 State 的样子

 @ObservableState
 struct State {
   var isDisplayingSum = false
    
+  static var allComposedScopePaths: AllComposedScopePaths {
+    AllComposedScopePaths()
+  }
+ 
+  @ObservationStateTracked
+  var counter1: Counter.State = .init()
+  @ObservationStateIgnored
+  private var _counter1: Counter.State
+ 
+  @ObservationStateTracked
+  var counter2: Counter.State = .init()
+  @ObservationStateIgnored
+  private var _counter2: Counter.State
 }

这些宏自动为我们的子 reducer 向 State 添加了新成员,包括对 @ObservableState 的所需支持。 @_ComposerScopePathable 宏与生成的 AllComposedScopePaths 结构相结合,通过为每个子 reducer 生成一个 ScopePath 来改进视图人体工程学,以便您可以使用 store.scopes.counter1 来作用域一个子 reducer,而不是更冗长的 store.scope(state: \.counter1, action: \.counter1)。 很酷,不是吗?

更多内容即将推出....

未来几天将推出更多使用 Composer 的示例。 与此同时,请查看 示例 以了解更复杂的使用方法。

改进的视图人体工程学

ScopePaths

Composer 引入了一个新的 ScopePath 概念,简化了 TCA 应用程序中作用域的创建。 ScopePath 由 Composer 自动创建,并且是一种更简洁的方式,可以使用单个指向 ScopedPathKeyPath 在您的应用程序中作用域 store,而不是单独的状态和动作 KeyPath。 例如,现在可以编写如下代码

- ChildView(store: store.scope(state: \.child, action: \.child))
+ ChildView(store: store.scopes.child)

- ForEach(store.scope(state: \.children, action: \.children)) {
+ ForEach(store.scopes.children) {
  }

- .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
+ .alert($store.scopes(\.destination.alert))

文档

发行版和 main 的文档可在此处获得

已知问题

Xcode 宏扩展

当同一源行上有多个宏时,Xcode 当前不会在源代码编辑器中扩展宏。 当 Composer 向现有的 StateAction 声明添加成员时,这是一种常见情况,并且会阻止您看到正在生成的代码。 但是,如果生成的代码产生编译器错误,Xcode 将扩展宏并向您显示该错误。

SwiftUI 预览

使用新的 #Preview 表达式宏时,由于宏扩展错误,预览可能在某些情况下无法加载。 当同一源文件中的多个宏引用另一个宏的扩展内容时,这是一个常见问题。 如果您遇到此问题,您可以通过使用 pre-macro PreviewProvider 或将您的 Reducer 定义移动到单独的源文件来解决此问题。

Swift 编译器

在开发 Composer 时,发现了 Swift 编译器中的许多错误。 其中许多已通过 Composer 的设计和实现中的更改得到缓解。 但是,使用 Composer 时可能仍然会遇到一些编译器问题(尽管在经验中,大多数问题都可以解决)。 如果您遇到麻烦的编译器错误,请提交问题或开始讨论。

鸣谢

特别感谢 Brandon Williams 和 Stephen Celis 在 Point-Free 所做的出色工作,包括 Composable ArchitectureCasePathsSwift Macro Testing 项目,这些项目使本项目成为可能。

许可证

本库以 MIT 许可证发布。详情请参见 LICENSE 文件。