Skip to content

Instantly share code, notes, and snippets.

@sh3l6orrr
Last active October 9, 2024 03:22
Show Gist options
  • Save sh3l6orrr/10c8f7c634a892a9c37214f3211242ad to your computer and use it in GitHub Desktop.
Save sh3l6orrr/10c8f7c634a892a9c37214f3211242ad to your computer and use it in GitHub Desktop.
TCA

The Composable Architecture (可组装架构)

The Composable Architecture (简写为TCA) 让你用统一、便于理解的方式来搭建应用程序,它兼顾了组装,测试,以及功效。你可以在 SwiftUI,UIKit,以及其他框架,和任何苹果的平台(iOS、macOS、tvOS、和 watchOS)上使用 TCA。

什么是TCA

TCA提供了用于搭建适用于各种目的、复杂度的app的一些核心工具,你可以一步步地跟随它去解决很多你在日常开发中时常会碰到的问题,比如:

  • 状态管理(State Management)
    用简单的值类型来管理应用的状态,以及在不同界面调用这些状态,使一个界面内的变化可以立刻反映在另一个界面中。

  • 组装(Composition)
    将庞大的功能拆散为小的可以独立运行的组件,然后再将它们重新组装成原来的功能。

  • 副作用(Side Effects)
    用最可测试和便于理解的方式来让app的某些部分与外界沟通。

  • 测试(Testing)
    除了测试某个功能,还能集成测试它与其他功能组合成为的更复杂的功能,以及用端到端测试来了解副作用如何影响你的应用。这样就可以有力地保证业务逻辑和预期相符。

  • 功效(Ergnomics)
    用一个有最少概念和可动部分,且简单的API来做到上面的一切。

更多

TCA的设计过程可以在 Point-Free,一个探索 Swift 和函数式编程的视频系列中找到。Point-free 由 Brandon WilliamsStephen Celis 主持。

这里看所有剧集。

深入浅出、面面俱到的TCA资源:第一部分第二部分第三部分第四部分

video poster image

示例

示例图片

这个仓库有 很多 用TCA来解决常见和复杂问题的例子,其中有:

想看看更棒的吗?查看 isowords 的源代码。isowords 是一个iOS平台上的拼字游戏,它用 SwiftUI 和 TCA 搭建。

基本用法

用这些类和实例来为状态管理模型建模:

  • State:即状态,是一个用于描述某个功能的执行逻辑,和渲染界面所需的数据的类。
  • Action:一个代表在功能中所有可能的动作的类,如用户的行为、提醒,和事件源等。
  • Environment:一个包含功能的依赖的类,如API客户端,分析客户端等。
  • Reducer:一个用于描述触发「Action」时,如何从当前状态(state)变化到下一个状态的函数,它同时负责返回任何需要被执行的「Effect」,如API请求(通过返回一个「Effect」实例来完成)。
  • Store:用于驱动某个功能的运行时(runtime)。将所有用户行为发送到「Store」中,令它运行「Reducer」和「Effects」。同时从「Store」中观测「State」,以更新UI。

这样做的好处是功能立刻就易于测试了。同时,你也可以把大的功能拆散为一个个小的功能、再重新组装他们。

一个基本的例子是一个显示一个数字、两个按钮 「+ :增加数字」 和 「- :减小数字」 的小app。 为了让它更有意思,界面上还有一个「获取关于这个数字的一个小知识」按钮。点击它会发送一个API请求,并获取一个任意的关于当前数字的常识,然后将它在弹窗中显示。

这个app的状态(state)包含:当前的数字、一个包含弹窗将显示的内容的字符串(属于「String?」类是因为nil表示不显示弹窗):

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

接下来考虑这个app中会发生的「Action」。有一些很显见:如用户点击「 + 」、「 - 」或「获取关于这个数字的一个小知识」按钮。还有一些不是那么明显的「Action」,如用户关闭弹窗、接收API请求的答复:

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

struct ApiError: Error, Equatable {}

接下来我们为这个app的依赖所需的环境来建模。尤其是,我们需要建造一个「Effect」实例来封装API请求。因此我们要依赖是一个「Int -> Effect<String, ApiError>」类的函数。其中「String」代表了API请求的答复。还有一点,「Effect」会在一个后台线程中运行(像「URLSession」中那样),因此我们需要一种能在主队列上接收「Effect」值的方法。通过依赖「main queue scheduler」,我们可以实现这个目的。控制「main queue scheduler」对写测试很重要,所以我们使用一个「AnyScheduler」,这样就可以在开发中使用实时的「DispatchQueue」、在测试时使用「test schedule」。

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

接下来,我们用一个「Reducer」实现逻辑。它描述了如何将当前状态(state)变化到下一个状态,以及描述了什么样的「Effect」将被执行。不需要执行「Effect」的「Action」会返回「.none」:

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

最后写「View」来展示这个app。它包含一个「Store<AppState, AppAction>」,以便观察状态(state)变化和更新UI。我们要把所有的用户动作发送到「Store<AppState, AppAction>」里,使状态变化。我们还得用一个「struct」代表弹窗,并让它遵循「alert」方法所需求的「Identifiable」协议:

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

很重要的一点是,我们实现了app所有的功能,但是没有直接用到任何「Effect」。这很重要,因为这使得其中的小功能可以单独搭建,互不依赖,从而减少编译次数。

用「UIKit」来驱动这个app也可以:通过在「viewDidLoad」函数中观察「store」的变化并更新UI、展示弹窗。这样做代码会比「SwiftUI」版冗长,所以具体内容折叠在了下面:

点击展开
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()

    // 忽略:添加子场景和设置限制

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

一旦我们准备好展示这个场景了(比如在「scene delegate」中),就可以创建一个「store」。这时需要提供依赖,我们就用个简单的「Effect」:返回一个有意思的「String」。

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

这就足够让屏幕显示些什么我们可以试着玩玩的东西了。这样当然会比用纯 SwiftUI 来的麻烦些,但是也有好处。我们可以用一个统一的方式来改变「State」,而不是把逻辑随意地分散到一些「Observable」类、各种「Action」的闭包和UI组件中。另一个好处是我们得到了一个简洁明了的表示副作用的方式。还有一个好处:我们可以立即测试逻辑(包括「Effect」),而不需要太多额外的工作。

测试

要测试,创建一个「TestStore」并给它提供创建「Store」所需的同样信息,但不同的是我们可以提供些更有助于测试的依赖。尤其是我们会用到一个「test scheduler」,而非「DispatchQueue.main scheduler」,因为这样可以控制事件执行的时间,而且我们不需要人工地等待队列完成。

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

当我们创建了「test store」,我们就可以用它来测试一系列用户的动作。在其中的每一步,我们都需要证明状态(State)如预期一样改变了。再者,如果有一步导致了「Effect」并将其传送回了「Store」,我们得测试它们确实被「Store」所接收了。

下面的测试包含了用户增减数字、获取关于数字的小知识以及它的应答所导致弹窗的显示,和关闭弹窗。

// 测试用户增减数字
store.send(.incrementButtonTapped) {
  $0.count = 1
}
store.send(.decrementButtonTapped) {
  $0.count = 0
}

// 测试获取关于数字的小知识以及它的应答所导致弹窗的显示。注意我们得「advance」「scheduler」,
  因为ruducer中使用了 `.receive(on:)`
store.send(.numberFactButtonTapped)

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

// 测试关闭弹窗
store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

这就是在 TCA 中搭建和测试一个app的基础知识。还有_很多_值得探索东西,如组装、模块化、适应性、以及复杂的「Effect」。这个示例 文件夹有很多用了更高级功能的项目。

Debug

TCA 包含很多帮助debug的工具。

  • reducer.debug() 让「reducer」可以打印每个它接收到的「Action」的描述,以及每个它改变的状态(State)。

    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() 使「reducer」可以「signpost」,即报告它的「Action」花了多久执行,以及哪些「Effect」在执行。

补充性的库

TCA 最重要的原则之一是副作用永远不会直接执行,而是包装在「Effect」类中从「reducer」返回,然后 「Store」稍后执行它。这对于简化数据在app中的流动方式,以及在从用户操作到执行效果的端到端循环中获得可测试性至关重要。

但这也意味着对于很多你每天都会接触到的 SDK,你需要为它们加些适配,以使他们能更好地适应 TCA 的风格。这就是为什么我们为苹果很多热门的库加了一层包裹,这样它们的功能就可以更好地适应 TCA了。目前支持的库有:

  • ComposableCoreLocation: 包裹 CLLocationManager,让它更方便地在「reducer」中使用,以及更方便地测试逻辑和CLLocationManager的交互。
  • ComposableCoreMotion: 包裹 CMMotionManager,,让它更方便地在「reducer」中使用,以及更方便地测试逻辑和`CMMotionManager的交互。
  • 持续关注更多 😉

如果你对提供一个库来包裹 TCA 感兴趣,欢迎「open an issue」,这样我们就可以一起讨论它的可行性。

常见问题

  • 和 Elm、Redux,以及其他框架比起来,TCA怎么样?

    展开看回答 TCA 基于 Elm Architecture (TEA) 和 Redux 的理念,但是更适用于 Swift 和苹果的各个平台。

    某些地方上,TCA 比其他框架更有执念。比如 Redux 没有很好解决副作用的问题,但是 TCA 要求所有副作用被「Effect」类建模,并被「reducer」所返回。

    某些地方上,TCA 没有其他框架的限制严格。比如 Elm 通过「Cmd」类来控制允许被创建的「Effect」类型,但 TCA 让任何类型的「Effect」都能偷渡上船,因为「Effect」遵循「Publisher」协议。

    在某些方面,TCA 非常重视,但并非 Elm、Redux,以及其他框架的焦点。例如,TCA 很在乎组装,即将大的功能拆散为一个个可以重新拼装小单位。之所以能做到这点,是因为 TCA 在「reducer」上用了「pullback」和「combine」运算符。这样做可以帮助复杂问题简单化,以及模块化出一个分离性好的代码库,从而减少编译次数。

  • 为什么「Store」不是线程安全的?
    为什么「send」不在队列中?
    为什么不在主线程上运行「send」?

    展开看回答

    一切与一个「Store」实例的交互(包括它的所有「scope」和衍生的「ViewStore」)必须在同一个线程上完成。如果这个「Store」在驱动某个 SwiftUI 或 UIKit 的「view」,所有的交互必须在_主_线程完成。

    当某个「Action」被传入「Store」,会有一个「reducer」改变当前的状态(state),这个过程不能同时在多个线程进行。一个可能的解决办法是在「send」的实现中使用一个队列,但这会引出一些新的疑难杂症:

    1. 如果仅仅是用了 DispatchQueue.main.async,即使是在住线程上,你也会引发一个线程跳跃。这会在 SwiftUI 和 UIKit 中导致出乎意料的问题,如在动画块中就没法同步式地进行任务。

    2. 创造一个在主线程上立刻执行,但在其他情况下使用 DispatchQueue.main.async 的「scheduler」是可能的(参见 CombineSchedulerUIScheduler)。但这会让事情变复杂,除非有很好的原因,否则不应该采用。

    这就是为什么我们要求所有的「Action」被从同一个线程发送。这个要求和「URLSession」和其他苹果的API的设计理念相同。这类的API趋向把输出传送到对它们最方便的线程上,然后是否将它们发送到主线程就是你的责任了。在 TCA 中,决定是否将「Action」分派到主线程是你的责任。如果你在使用一个可能将输出发送到非主线程的「Effect」,你必须要用 .receive(on:) 来迫使它到主线程上。

    这样的设计对于有多少「Effect」会被创造和改变没有预设,并预防了不必要的线程跳跃和重分派。它同时提供了一些测试上的好处。如果你的「Effect」没有对它们自己的「schedule」负责,那么在测试中所有「Effect」会同步、立刻进行。这样就没法测试多个同时发生的「Effect」如何交叉并影响app的状态(state)。然而,通过把「schedule」放在「Store」外面,我们就可以只在需要的情况下去测试这些方面,灵活性更高了。

    然而,如果你还是没有被以上说服,不用怕!TCA 很灵活,甚至允许你来添加这些功能。无论「Effect」在哪工作,创建一个更高阶的「reducer」来迫使所有「Effect」在主线程输出都是可能的。

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

    你可能还是需要一个类似 UIScheduler 的东西,这样就不必执行线程跳跃了。

配置要求

TCA以「Combine」为基础,所以需要最低目标版本iOS 13,macOS 10.15,Mac Catalyst 13,tvOS 13,和watchOS 6。若你的应用需要支持更老版本,可以采用这些forks:ReactiveSwiftRxSwift

安装

在XCode工程中将TCA加入到依赖:

  1. File 菜单选择 Add packages...
  2. 在URL文本框中输入"https://github.com/pointfreeco/swift-composable-architecture" 。
  3. 如果:
    • 你只有一个单独的app需要使用TCA, 将 ComposableArchitecture 直接加入到app中。
    • 你要在多个 Xcode targets 中使用TCA,或者有混合的 Xcode targets 和 SPM targets,创建一个共享的框架 ComposableArchitecture,并在所有target中将其添加为依赖。这里有一个示例app:Tic-Tac-Toe ,它将许多功能分散到多个模块,并通过tic-tac-toe这个 Swift package 来使用了TCA。

参考文档

最新的API参考文档在这

获取帮助

如果你想讨论可组合架构或对如何使用它来解决特定问题有疑问,可以在discussions开始一个话题,或在Swift 论坛发起提问。

翻译

下面的翻译由社区成员贡献:

如果你想贡献翻译,可以 open a PR 同时提供一个链接到 Gist

鸣谢

在开发早期,他们为TCA提供了建议,帮助它变得更好: 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, 以及所有的 Point-Free 订阅者😁。

特别感谢 Chris Liscio 帮助我们克服了关于 SwiftUI 的许多难题,并完善了最终的API。

同时感谢 Shai MishaliCombineCommunity 项目。我们用他们的「Publishers.Create」来在 「Effect」中沟通委托和以回调函数为基础的API,这样和第三方API适配起来就方便很多了。

其他库

TCA 的开发受到了很多其他库的影响,尤其是 ElmRedux。 在Swift和iOS社区中还有很多其他有关架构的库。相较于TCA,每个都有独特的优劣。

开源协议

此库在 MIT 协议下开源。参见 LICENSE

@lexrus
Copy link

lexrus commented Oct 18, 2022

Ergnomics 在 TCA 里是指人机工效,建议把“功效“改成”工效“。

@fandongtongxue
Copy link

great

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