RxComposableArchitecture

CI GitHub Release GitHub contributors GitHub License

RxComposableArchitecture 是 Composable Architecture 的一个分支,并进行了调整以使其与 UIKit 配合使用。

RxComposable 和 TCA 之间有什么区别

Composable Architecture

Composable Architecture (简称 TCA) 是一个用于以一致且易于理解的方式构建应用程序的库,它考虑了组合、测试和人体工程学。这个库基于 PointFree 的 Swift Composable Architecture。

什么是 Composable Architecture?

这个库提供了一些核心工具,可以用于构建各种目的和复杂度的应用程序。它提供了引人入胜的方案,您可以遵循这些方案来解决在构建应用程序时遇到的许多日常问题,例如

了解更多

Composable Architecture 是在 Point-Free 的许多剧集中设计的,Point-Free 是一个探索函数式编程和 Swift 语言的视频系列,由 Brandon WilliamsStephen Celis 主持。

您可以在这里观看所有剧集,以及从头开始的架构的专门多部分导览:第一部分第二部分第三部分第四部分

video poster image

示例

此仓库附带示例,演示如何使用 RxComposableArchitecture 解决常见问题。查看目录以查看所有示例

正在寻找更实质性的内容?查看 isowords 的源代码,这是一个用 SwiftUI 和 Composable Architecture 构建的 iOS 单词搜索游戏。

基本用法

要使用 Composable Architecture 构建功能,您需要定义一些类型和值来模拟您的领域

这样做的好处是您将立即解锁功能的可测试性,并且您能够将大型复杂功能分解为可以粘合在一起的更小的域。

作为一个基本示例,考虑一个 UI,它显示一个数字以及用于增加和减少数字的“+”和“−”按钮。为了使事情更有趣,假设还有一个按钮,当点击该按钮时,会发出 API 请求以获取关于该数字的随机事实,然后在警报中显示该事实。

此功能的状态将包含当前计数的整数,以及表示我们要显示的警报标题的可选字符串(可选,因为 nil 表示不显示警报)

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

接下来我们有功能中的动作。有一些明显的动作,例如点击减少按钮、增加按钮或事实按钮。但也有一些稍微不那么明显的动作,例如用户关闭警报的动作,以及当我们收到来自事实 API 请求的响应时发生的动作

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

struct ApiError: Error, Equatable {}

接下来,我们为该功能完成其工作所需的依赖项环境建模。特别是,为了获取数字事实,我们需要构造一个 Effect 值,该值封装了网络请求。因此,该依赖项是从 IntEffect<String> 的函数,其中 String 表示来自请求的响应。此外,effect 通常会在后台线程上执行其工作(URLSession 就是这种情况),因此我们需要一种在主队列上接收 effect 值的方法。我们通过主队列调度器来实现这一点,这是一个重要的依赖项,以便我们可以编写测试。我们必须使用 AnyScheduler,以便我们可以在生产中使用来自 RxSwift 的实时 MainScheduler.instance,并在测试中使用测试调度器。

struct AppEnvironment {
  var mainQueue: SchedulerType
  var numberFact: (Int) -> Effect<String>
}

接下来,我们实现一个 reducer,它实现此域的逻辑。它描述了如何将当前状态更改为下一个状态,并描述了需要执行哪些 effect。某些动作不需要执行 effect,它们可以返回 .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
  }
}

然后最后我们定义显示该功能的视图。它持有 Store<AppState, AppAction>,以便它可以观察状态的所有更改并重新渲染,并且我们可以将所有用户动作发送到 store,以便状态更改。我们还必须在事实警报周围引入一个结构体包装器,使其成为 Identifiable,这是 .alert 视图修饰符所要求的

  class AppViewController: UIViewController {
    private let store: Store<AppState, AppAction>
    private let disposeBag = DisposeBag()

    init(store: Store<AppState, AppAction>) {
      self.store = 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...

      store.subscribe(\.number)
        .map { "\($0.count)" }
        .subscribe(onNext: { [numberLabel] in
            countLabel.text = String($0)
        })
        .disposed(by: disposeBag)

	  store.subscribe(\.numberFactAlert)
        .subscribe(onNext: { [weak self] numberFactAlert in
          let alertController = UIAlertController(
            title: numberFactAlert, message: nil, preferredStyle: .alert
          )
          alertController.addAction(
            UIAlertAction(
              title: "Ok",
              style: .default,
              handler: { _ in self?.store.send(.factAlertDismissed) }
            )
          )
          self?.present(alertController, animated: true, completion: nil)
        })
        .disposed(by: disposeBag)
    }

    @objc private func incrementButtonTapped() {
      store.send(.incrementButtonTapped)
    }
    @objc private func decrementButtonTapped() {
      store.send(.decrementButtonTapped)
    }
    @objc private func factButtonTapped() {
      store.send(.numberFactButtonTapped)
    }
  }

重要的是要注意,我们能够在没有实际的实时 effect 的情况下实现整个功能。这很重要,因为它意味着可以在隔离状态下构建功能,而无需构建其依赖项,这可以帮助缩短编译时间。

一旦我们准备好显示此视图,例如在场景委托中,我们可以构造一个 store。这是我们需要提供依赖项的时刻,现在我们可以只使用一个立即返回模拟字符串的 effect

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

这足以在屏幕上显示一些东西来玩。这肯定比您以原始 SwiftUI 方式执行此操作要多几个步骤,但有一些好处。它为我们提供了一种一致的方式来应用状态突变,而不是将逻辑分散在一些可观察对象和 UI 组件的各种动作闭包中。它还为我们提供了一种简洁的方式来表达副作用。我们可以立即测试此逻辑,包括 effect,而无需进行太多额外的工作。

测试

要进行测试,您首先创建一个 TestStore,其中包含与您创建常规 Store 相同的信息,但这次我们可以提供测试友好的依赖项。特别是,我们使用测试调度器而不是实时 DispatchQueue.main 调度器,因为这允许我们控制工作何时执行,并且我们不必人为地等待队列赶上。

let scheduler = TestScheduler(initialClock: 0)

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

创建测试 store 后,我们可以使用它来断言用户步骤的整个流程。在每一步中,我们都需要证明状态如何按照我们的预期变化。此外,如果某个步骤导致 effect 被执行,从而将数据反馈到 store 中,我们必须断言这些动作已正确接收。

下面的测试是用户增加和减少计数,然后他们请求一个数字事实,并且该 effect 的响应触发显示警报,然后关闭警报会导致警报消失。

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

这就是在 Composable Architecture 中构建和测试功能的基础知识。还有很多更多的事情需要探索,例如组合、模块化、适应性和复杂 effect。示例目录包含一堆项目供您探索,以查看更高级的用法。

调试

Composable Architecture 附带了许多工具来帮助调试。

要求

RxComposableArchitecture 依赖于 RxSwift 框架。它需要 iOS 11 的最低部署目标。

安装

Swift Package Manager

您可以通过将 ComposableArchitecture 添加为包依赖项来将其添加到 Xcode 项目。

  1. 文件菜单中,选择添加包...
  2. 在包存储库 URL 文本字段中输入“https://github.com/tokopedia/RxComposableArchitecture
  3. 取决于您的项目结构
    • 如果您有一个需要访问该库的应用程序目标,则将 ComposableArchitecture 直接添加到您的应用程序。
    • 如果您想从多个 Xcode 目标中使用此库,或者混合 Xcode 目标和 SPM 目标,则必须创建一个依赖于 ComposableArchitecture 的共享框架,然后在您的所有目标中依赖于该框架。有关此示例,请查看 Tic-Tac-Toe 演示应用程序,该应用程序将许多功能拆分为模块,并以这种方式使用 tic-tac-toe Swift 包中的静态库。

Cocoapods

将其添加到您的 Podfile 中

pod "RxComposableArchitecture", :git => 'https://github.com/tokopedia/RxComposableArchitecture', :tag => '0.50.3'

由于几个 RxComposableArchitecture deps 不支持 cocoapods,因此您需要在 Podfile 上手动添加它。

  1. 下载目录 development-podspecs 并将其放在您的代码库中。
  2. 在您的 Podfile 上,添加此条目
pod 'CasePaths', :podspec => './development-podspecs/CasePaths.podspec.json'
pod 'Clocks', :podspec => './development-podspecs/swift-clocks.podspec.json'
pod 'CombineSchedulers', :podspec => './development-podspecs/combine-schedulers.podspec.json'
pod 'CustomDump', :podspec => './development-podspecs/CustomDump.podspec.json'
pod 'Dependencies', :podspec => './development-podspecs/swift-dependencies.podspec.json'
pod 'XCTestDynamicOverlay', :podspec => './development-podspecs/xctest-dynamic-overlay.podspec.json'

许可证

此库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE