1. Introduction

现在让我们继续构建剩下的应用程序。

幸运的是,应用程序是完全模块化的,所以我们可以从最简单、依赖最少的模块开始,然后返回到主应用目标。同样幸运的是,我们已经在代码中使用了environment技术,这将使转换到这种新样式变得非常容易。

2. Using the architecture’s environment

让我们从PrimeModal模块开始。它实际上没有任何effects,因此不使用environment,但我们仍然需要更新它的reducer以使用新的签名。 那么它应该有什么样的environment呢?

public func primeModalReducer(
  state: inout PrimeModalState,
  action: PrimeModalAction,
  environment: ???
) {

因为这个特性不需要环境,所以我们可以使用Void。这表示一个不保存任何有意义的东西的环境,因此不需要任何依赖来完成它的工作:

public func primeModalReducer(state: inout PrimeModalState, action: PrimeModalAction, environment: Void) {

现在PrimeModal模块正在构建。

让我们跳转到下一个复杂的模块,FavoritePrimes模块。这确实有影响,但总的来说这个特性非常简单。我们首先需要把reducer修好:

public func favoritePrimesReducer(state: inout [Int], action: FavoritePrimesAction) {

这一次我们确实需要一个environment,而且实际上我们已经创建了一个包含所有必要依赖项的environment。它叫FavoritePrimesEnvironment,现在它只包含一个FileClient,但未来它可以容纳更多。所以让我们用它作为我们的reducer的环境:

public func favoritePrimesReducer(
  state: inout [Int],
  action: FavoritePrimesAction,
  environment: FavoritePrimesEnvironment
) {

我们现在有个小错误:

🛑 Constant cannot be declared public because its type uses an internal type

这是因为FavoritePrimesEnvironment是内部的,所以我们需要让它公开:

public struct FavoritePrimesEnvironment {
  var fileClient: FileClient
}

从技术上讲,这个模块正在构建中,但我们实际上并没有利用reducer中给出的environment。我们仍在呼唤全局Current。所以让我们更新reducer:

case .saveButtonTapped:
  return [
    environment.fileClient.save("favorite-primes.json", try! JSONEncoder().encode(state))
      .fireAndForget()
  ]

case .loadButtonTapped:
  return [
    environment.fileClient.load("favorite-primes.json")
      .compactMap { $0 }
      .decode(type: [Int].self, decoder: JSONDecoder())
      .catch { error in Empty(completeImmediately: true) }
      .map(FavoritePrimesAction.loadedFavoritePrimes)
      .eraseToEffect()
  ]

现在我们可以删除我们的Current值,因为我们完全依赖于传递给reducerenvironment:

//var Current = FavoritePrimesEnvironment.live

现在这个模块正在构建,但在继续下一个模块之前,让我们利用我们的应用程序是模块化的这一事实。我们现在可以直接在playground中测试这个视图,以确保一切都正常工作。

这是唯一可能的,因为我们已经做了模块化的应用程序。我们不需要得到整个应用程序构建来测试这个变化。我们只需要建立这个模块。这在大型项目中非常有用。它为你提供了大量的灵活性来进行潜在的重构和修改api,因为你可以只在应用的一小部分进行测试,而无需转换整个应用。

我们已经有了这个模块的playground,但它不打算编译,因为它使用了旧的Current环境技术。 与其改变全局的Current值,不如让我们创建一个小的本地环境来玩:

var environment = FavoritePrimesEnvironment.mock
environment.fileClient.load = { _ in
  Effect.sync { try! JSONEncoder().encode(Array(1...10)) }
}

然后当我们构建这个store让视图在这个playground上运行时我们可以传递这个environment:

PlaygroundPage.current.liveView = UIHostingController(
  rootView: NavigationView {
    FavoritePrimesView(
      store: Store<[Int], FavoritePrimesAction>(
        initialValue: [2, 3, 5, 7, 11],
        reducer: favoritePrimesReducer,
        environment: environment
      )
    )
  }
)

它的行为和以前是一样的。

再次,我们只是想重申,能够如此迅速地做出这样的改变,并在孤立的情况下迭代这些想法是多么强大。如果我们不得不更新整个应用程序,只是为了测试这个变化,我们甚至可能不鼓励尝试。毕竟,也许我们发现这个改变不是很好,我们最终会恢复它。如果把所有的工作都扔到整个应用程序构建中去,那将是一件令人沮丧的事情。

现在让我们进入Counter模块。这个模块不仅有一些副作用,而且还有一些我们在之前的模块中没有看到的新东西。 让我们从counterReducer开始。我们知道我们需要引入一个环境,我们已经准备好了一个,CounterEnvironment,它具有计算第n个素数的n素数效应。

public func counterReducer(
  state: inout CounterState,
  action: CounterAction,
  environment: CounterEnvironment
) -> [Effect<CounterAction>] {

就像之前一样,我们需要将environment公开化,我们可以去掉全局的Current变量:

public struct CounterEnvironment {
  var nthPrime: (Int) -> Effect<Int?>
}

//var Current = CounterEnvironment.live

最后,我们需要使用我们传递到reducerenvironment而不是Current

case .nthPrimeButtonTapped:
  state.isNthPrimeButtonDisabled = true
  return [
    environment.nthPrime(state.count)
      .map(CounterAction.nthPrimeResponse)
      .receive(on: DispatchQueue.main)
      .eraseToEffect()
]

接下来我们要修正pullback。现在我们只是沿着reducer的状态和作用往回拉,但是我们还需要结合它们的环境,以便我们能够描述如何将局部counter和素模态reducer嵌入到包含它们两种功能的更大reducer中。

要做到这一点,我们必须描述如何将父模式环境转化为每一个counterprime modal 环境。让我们先给counterViewReducer一个类型,这样我们就知道我们到底在转换什么:

public let counterViewReducer: Reducer<CounterViewState, CounterViewAction, ???> = combine(
  pullback(
    counterReducer,
    value: \CounterViewState.counter,
    action: /CounterViewAction.counter,
    environment: { ??? }
  ),
  pullback(
    primeModalReducer,
    value: \.primeModal,
    action: /CounterViewAction.primeModal,
    environment: { ??? }
  )
)

我们应该用什么environment? 它需要具有计数器reducer和初始模态reducer的所有依赖项。但是,prime modal reducer有一个Void环境,所以我们可以对这个组合reducer使用CounterEnvironment:

public let counterViewReducer: Reducer<CounterViewState, CounterViewAction, CounterEnvironment>

然后,对于每个回调,我们需要描述如何将这个CounterEnvironment转换为我们正在回调的reducer的各自环境。对于counter reducer,我们可以只取整个环境,而对于prime模态reducer,我们不想取任何东西,所以我们可以忽略counter environment,只返回void:

public let counterViewReducer = combine(
  pullback(
    counterReducer,
    value: \CounterViewState.counter,
    action: /CounterViewAction.counter,
    environment: { $0 }
  ),
  pullback(
    primeModalReducer,
    value: \.primeModal,
    action: /CounterViewAction.primeModal,
    environment: { _ in () }
  )
)

就像这样Counter模块正在构建。

我们只剩下需要修复的应用目标,但在此之前,让我们确保我们的playground中一切正常。为了让playground编译,我们只需要停止使用Current变量,并将一个环境传递给store:

var environment = CounterEnvironment.mock
environment.nthPrime = { _ in .sync { 7236893748932 }}

PlaygroundPage.current.liveView = UIHostingController(
  rootView: CounterView(
    store: Store<CounterViewState, CounterViewAction>(
      initialValue: CounterViewState(
        alertNthPrime: nil,
        count: 0,
        favoritePrimes: [],
        isNthPrimeButtonDisabled: false
      ),
      reducer: logging(counterViewReducer),
      environment: environment
    )
  )
)

一切似乎都和之前一样。

我们到了重构的最后一步:the app target。这个目标负责获取所有特性模块中定义的所有reducers和views,并将它们组合在一起形成完整的应用程序。只有几个编译器错误,第一个是回调,这是可以理解的,因为我们需要描述如何转换环境。

让我们从回调开始。现在看起来是这样的:

let appReducer = combine(
  pullback(
    counterViewReducer,
    value: \AppState.counterView,
    action: /AppAction.counterView
  ),
  pullback(
    favoritePrimesReducer,
    value: \.favoritePrimes,
    action: /AppAction.favoritePrimes
  )
)

为了解决这个问题,让我们看看我们希望在应用程序级别拥有什么样的environment。它需要包含counterViewReducerfavoritePrimesReducer的所有依赖项。我们可以创建一个结构体来保存这些environments:

struct AppEnvironment {
  var counter: CounterEnvironment
  var favoritePrimes: FavoritePrimesEnvironment
}

然后当我们拉回每个特性reducers时我们只需要描述如何将应用程序环境转换为各自特性的环境。这就像取出我们需要的struct字段一样简单:

let appReducer: Reducer<AppState, AppAction, AppEnvironment> = combine(
  pullback(
    counterViewReducer,
    value: \AppState.counterView,
    action: /AppAction.counterView,
    environment: { $0.counter }
  ),
  pullback(
    favoritePrimesReducer,
    value: \.favoritePrimes,
    action: /AppAction.favoritePrimes,
    environment: { $0.favoritePrimes }
  )
)

现在pullback正在编译。在实践中看到这种转换是很酷的。我们允许父特性包含其子特性的所有依赖关系,然后当我们将一堆子特性组合在一起时,我们只需要切掉它所关心的环境的一部分。最重要的是,所有这3个转换都是静态检查的,因此我们可以在一定程度上确信,如果编译的话,我们的特性可以正确地插入到一起。

下一个编译器错误是在activity feed的高阶reducer中。为了让它再次编译,我们只需要引入环境泛型和reducer参数,并将其传递给我们正在转换的reducer:

func activityFeed(
  _ reducer: @escaping Reducer<AppState, AppAction, AppEnvironment>
) -> Reducer<AppState, AppAction, AppEnvironment> {

  return { state, action, environment in
    …

    return reducer(&state, action, environment)
  }
}

最后一个编译器错误是在创建主内容视图时,它需要创建一个store,现在需要提供一个环境。我们可以通过提供所有依赖项的实时版本来实现:

window.rootViewController = UIHostingController(
  rootView: ContentView(
    store: Store(
      initialValue: AppState(),
      reducer: with(
        appReducer,
        compose(
          logging,
          activityFeed
        )
      ),
      environment: AppEnvironment(
        counter: .live,
        favoritePrimes: .live
      )
    )
  )
)

这是这么久以来的第一次,我们有了一个完整的构建应用,如果我们运行它,一切都会像之前一样继续工作。看到模块化在架构的这一系列重构中是如何帮助我们的,真是太酷了! 我们做了一些相当全面的更改,然后能够一次更新和测试每个模块,而不需要一次重构所有内容。


3. Tuplizing the environment

在继续之前,我认为有必要问问environments结构体是否发挥了作用。为每个特性模块提供环境结构体是不必要的相互依赖。每个结构体保存到自己的版本的依赖,甚至将可能为两个不同的功能要访问相同的依赖,我们被迫把它复制在父环境,如果FavoritePrimes模块也需要Wolfram Alpha”**nth prime“**端点。

实际上,我们并不需要环境结构体的任何特性。我们不需要给它添加方法,或者改变它,或者让它符合协议。真正需要的是我们能够一次传递多个依赖项,因此类型别名和元组可能是更简单的工具来帮助实现这一点,它可能帮助我们解决我们正在看到的嵌套依赖项问题。

prime modal模块中,我们在环境中使用了Void,它实际上是空元组的类型别名。

public typealias Void = ()

看来我们已经被元组化了。

FavoritePrimes模块中,我们不用struct来包装文件客户端,而是输入别名FavoritePrimesEnvironment作为FileClient:

//public struct FavoritePrimesEnvironment {
//  var fileClient: FileClient
//}
public typealias FavoritePrimesEnvironment = FileClient

如果将来这个模块需要更多依赖,我们将把这个类型别名升级为一个带命名参数的元组。我们也可以去掉所有这些代码:

//extension FavoritePrimesEnvironment {
//  public static let live = FavoritePrimesEnvironment(fileClient: .live)
//}

为了使用这个新的环境定义,我们只需要更新我们的reducer来直接使用environment,而不是访问fileClient字段:

case .saveButtonTapped:
  return [
    environment.save("favorite-primes.json", try! JSONEncoder().encode(state))
    …
  ]

case .loadButtonTapped:
  return [
    environment.load("favorite-primes.json")
    …
  ]

代替为整个FavoritePrimesEnvironment创建一个mock,让我们为FileClient创建一个mock,它可以在测试中用于创建一个FavoritePrimesEnvironment:

#if DEBUG
extension FileClient {
  static let mock = FileClient(
    load: { _ in Effect<Data?>.sync {
      try! JSONEncoder().encode([2, 31])
      } },
    save: { _, _ in .fireAndForget {} }
  )
}
#endif

我们也可以对CounterEnvironment做同样的事。它有一个单一的nthPrime依赖项,我们将在类型别名中直接赋值。

//public struct CounterEnvironment {
//  var nthPrime: (Int) -> Effect<Int>
//}
public typealias CounterEnvironment = (Int) -> Effect<Int?>

在将来,如果这个环境增长,我们将把它转换为一个包含每个依赖项字段的元组。

我们可以注释掉“实时”环境。

//extension CounterEnvironment {
//  public static let live = CounterEnvironment(nthPrime: Counter.nthPrime)
//}

然后在counterReducer中,我们将直接调用环境:

case .nthPrimeButtonTapped:
  state.isNthPrimeButtonDisabled = true
  return [
    environment(state.count)
    ...
  ]

最后,在ContentView中,我们不会在应用环境中使用嵌套的结构体,那样会使多个下游特性之间难以共享依赖关系,我们将创建一个平面元组来保存每个特性需要的所有依赖:

typealias AppEnvironment = (
  fileClient: FileClient,
  nthPrime: (Int) -> Effect<Int?>
)

然后,当回调时,我们只需要从应用环境中取出我们关心的任何依赖,并将它们传递给各自的特性:

let appReducer: Reducer<AppState, AppAction, AppEnvironment> = combine(
  pullback(
    counterViewReducer,
    value: \AppState.counterView,
    action: /AppAction.counterView,
    environment: { $0.nthPrime }
  ),
  pullback(
    favoritePrimesReducer,
    value: \.favoritePrimes,
    action: /.AppActionfavoritePrimes,
    environment: { $0.fileClient }
  )
)

最后,在sceneddelegate中,当构建store时,我们可以创建一个漂亮的扁平环境元组来传递:

window.rootViewController = UIHostingController(
  rootView: ContentView(
    store: Store(
      initialValue: AppState(),
      reducer: with(
        appReducer,
        compose(
          logging,
          activityFeed
        )
      ),
      environment: AppEnvironment(
        fileClient: .live,
        nthPrime: Counter.nthPrime
      )
    )
  )
)

在根目录中有一个平坦的依赖列表要好得多,然后每当我们拉回reducer时,我们就可以决定要传递哪些依赖。

在移动到元组的过程中,我们失去了一个小东西,那就是自动补全工具。结构体初始化器在自动完成时出现,但元组类型别名不会出现。希望有一天这种情况会改变并得到支持。


4. Testing with the environment

现在我们的整个应用程序终于建立起来了,它和之前一样工作,但是我们已经从我们的特性中删除了对全局环境的依赖,取而代之的是显式地传递环境。最重要的是,环境的传递直接融入到体系结构的定义中,因此使用它并不困难。当我们执行回调时,我们必须描述如何将父环境转换为子环境。

虽然应用程序正在构建中,但它的测试还没有。所以让我们快速解决这些问题,这样我们就可以得到一个完整的工作项目,并了解我们已经完成了什么。

如果我们从PrimeModal模块开始,我们会发现没有太多事情要做,因为这个特性甚至不需要环境,我们只使用了Void。因此,为了让这个测试编译,我们需要传递一个void值给我们的reducer:

class PrimeModalTests: XCTestCase {
  func testSaveFavoritesPrimesTapped() {
    var state = (count: 2, favoritePrimes: [3, 5])
    let effects = primeModalReducer(state: &state, action: .saveFavoritePrimeTapped,environment: ())

    let (count, favoritePrimes) = state
    XCTAssertEqual(count, 2)
    XCTAssertEqual(favoritePrimes, [3, 5, 2])
    XCTAssert(effects.isEmpty)
  }

  func testRemoveFavoritesPrimesTapped() {
    var state = (count: 3, favoritePrimes: [3, 5])
    let effects = primeModalReducer(state: &state, action: .removeFavoritePrimeTapped, environment: ())

    let (count, favoritePrimes) = state
    XCTAssertEqual(count, 3)
    XCTAssertEqual(favoritePrimes, [5])
    XCTAssert(effects.isEmpty)
  }
}

这些测试现在建立并通过了。

接下来,我们将跳转到FavoritePrimesTests模块,该模块测试确实需要环境的特性。它使用FileClient依赖项来从磁盘保存和加载数据。我们可以注释掉我们的测试设置,因为我们不再有一个当前环境来模拟。

class FavoritePrimesTests: XCTestCase {
//  override func setUp() {
//    super.setUp()
//    Current = .mock
//  }

第一个测试可以通过将一个模拟文件客户端直接传递给reducer来修复:

func testDeleteFavoritePrimes() {
  var state = [2, 3, 5, 7]
  let effects = favoritePrimesReducer(&state, .deleteFavoritePrimes([2]), .mock)

  XCTAssertEqual(state, [2, 3, 7])
  XCTAssert(effects.isEmpty)
}

下一个测试使用一个特别设计的environment。它想要进入文件客户端的save端点,以便我们可以确保它实际上是从reducer中调用的。我们可以构造一个新的文件客户端来做我们想做的事情,而不是改变Current的值:

func testSaveButtonTapped() {
  var didSave = false
  var environment = FileClient.mock
  environment.save = { _, data in
    .fireAndForget {
      didSave = true
    }
  }

然后使用这个environment,我们将它传递给reducer:

 var state = [2, 3, 5, 7]
  let effects = favoritePrimesReducer(&state, .saveButtonTapped, environment)

  XCTAssertEqual(state, [2, 3, 5, 7])
  XCTAssertEqual(effects.count, 1)

  effects[0].sink { _ in XCTFail() }

  XCTAssert(didSave)
}

该文件中的最终测试还需要一个特别设计的environment,但这一次它希望模拟FileClient中的加载端点加载某些特定数据的情况。

func testLoadFavoritePrimesFlow() {
  var environment = FileClient.mock
  environment.load = { _ in .sync { try! JSONEncoder().encode([2, 31]) } }

然后我们将这个environment传递给测试中的reducer:

  var state = [2, 3, 5, 7]
  var effects = favoritePrimesReducer(&state, .loadButtonTapped, environment)

  XCTAssertEqual(state, [2, 3, 5, 7])
  XCTAssertEqual(effects.count, 1)

  var nextAction: FavoritePrimesAction!
  let receivedCompletion = self.expectation(description: "receivedCompletion")
  effects[0].sink(
    receiveCompletion: { _ in
      receivedCompletion.fulfill()
  },
    receiveValue: { action in
      XCTAssertEqual(action, .loadedFavoritePrimes([2, 31]))
      nextAction = action
  })
  self.wait(for: [receivedCompletion], timeout: 0)

  effects = favoritePrimesReducer(&state, nextAction, environment)

  XCTAssertEqual(state, [2, 31])
  XCTAssert(effects.isEmpty)
}

现在这个测试目标正在构建,所有测试都通过了。

接下来是Counter模块,该模块有一个环境,该环境保存了我们为了从副作用中加载第n个素数而点击的端点。

现在这还不是编译,因为assert helper需要修复。 现在它还没有意识到environment问题。为了解决这个问题,我们需要引入另一个泛型的环境,以便我们可以使用它的environment:

func assert<Value: Equatable, Action: Equatable, Environment>(
  initialValue: Value,
  reducer: Reducer<Value, Action, Environment>,
  environment: Environment,

然后在helper的主体中,我们调用这个reducer,这意味着它需要一个环境来工作。

func assert<Value: Equatable, Action: Equatable, Environment>(
  initialValue: Value,
  reducer: Reducer<Value, Action, Environment>,
  environment: Environment,
  steps: Step<Value, Action>...,
  file: StaticString = #file,
  line: UInt = #line
) {
  …
  effects.append(contentsOf: reducer(&state, action, environment))

现在我们的counter测试出现了一些错误。我们可以从注释之前为模拟全局环境所做的设置开始。

class CounterTests: XCTestCase {
//  override func setUp() {
//    super.setUp()
//    Current = .mock
//  }

接下来我们将进行快照测试,这将贯穿整个用户脚本并在整个过程中进行快照,以确保UI能够按照我们所期望的方式进行改变。我们需要在构建store时提供一个环境来解决这个问题:

let store = Store(
  initialValue: CounterViewState(),
  reducer: counterViewReducer,
  environment: { _ in .sync { 17 } }
)

该文件中的其他测试与我们迄今为止看到的其他测试略有不同,因为这是我们创建的assert helper,它使测试大型reducer变得超级容易。

例如,要测试递增和递减逻辑,我们可以简单地这样做:

func testIncrDecrButtonTapped() {
  assert(
    initialValue: CounterViewState(count: 2),
    reducer: counterViewReducer,
    steps:
    Step(.send, .counter(.incrTapped)) { $0.count = 3 },
    Step(.send, .counter(.incrTapped)) { $0.count = 4 },
    Step(.send, .counter(.decrTapped)) { $0.count = 3 }
  )
}

既然断言助手是环境敏感的,我们就可以修复counter测试了。 对于第一个,我们可以传递一个nthPrime效应,它直接返回17:

func testIncrDecrButtonTapped() {
  assert(
    initialValue: CounterViewState(count: 2),
    reducer: counterViewReducer,
    environment: { _ in .sync { 17 } },
    steps:
    Step(.send, .counter(.incrTapped)) { $0.count = 3 },
    Step(.send, .counter(.incrTapped)) { $0.count = 4 },
    Step(.send, .counter(.decrTapped)) { $0.count = 3 }
  )
}

我们甚至可以对下一个做同样的操作:

func testNthPrimeButtonHappyFlow() {
  assert(
    …
    environment: { _ in .sync { 17 } },
    …
  )
}

下一个测试与前一个类似,除了它的环境需要一个失败的nthPrime端点:

func testNthPrimeButtonUnhappyFlow() {
  assert(
    …
    environment: { _ in .sync { nil } },
    …
  )
}

这个文件中的最后一个测试,它的目的是演示如何为reducer编写一种“集成测试”,在其中,我们同时在一个测试中练习组合的reducer的许多部分。为了编译它,我们需要给它一个模拟环境:

func testPrimeModal() {
  assert(
    …
    environment: { _ in .sync { 17 } },
    …
  )
}

现在这个目标正在编译,让我们运行测试!

❌ failed - Assertion failed to handle 1 pending effect(s)

哎呀,这出乎我的意料! 这只是发生了,因为不久前我们添加了一个effect到我们的counter reducer,以显示添加复杂的effect是多么容易。Let’s go remove that effect:

case .decrTapped:
  state.count -= 1
  let count = state.count
  return [
//    .fireAndForget {
//      print(count)
//    },
//
//    Just(.incrTapped)
//      .delay(for: 1, scheduler: DispatchQueue.main)
//      .eraseToEffect()
  ]

可组合架构能够自动地为我们捕捉这种回归,这是非常令人惊奇的!

现在测试通过了!


5. Conclution

所以我们最终完成了应用程序的构建和所有测试…

那么,我们真的解决了我们所说的环境技术所带来的问题了吗? 在一天结束的时候,这真的会帮助我们的应用程序吗?

答案当然是肯定的! 这种环境技术的改进解决了我们在本系列节目开始时描述的所有问题:

  • 我们遇到了多个环境的问题:如果每个特性模块都有自己的环境,那么就很难知道如何同时控制所有的特性模块,并且会失去一些静态保证。

  • 还有本地依赖的概念。由于每个模块只有一个环境,因此不可能在不同的环境中重用一个屏幕。

  • 然后还有共享依赖关系的问题。我们的每个特性都可能具有具有共同依赖关系的环境,而模块的“全局”环境使共享这些共同依赖关系变得困难。

需要注意的是,我们之所以能够对环境技术进行调整,是因为我们采用了可组合架构(Composable Architecture),它为我们提供了构建特性和解决这些问题的单一、一致的方式。

为了证明这一点,让我们看一遍每个问题,并准确地演示它是如何解决的……