1. Cross-platform SwiftUI views

让我们看看,如果我们天真地改变一些views,让它对Mac友好,会发生什么。

首先,我们需要做一些工作来让Xcode项目成型,以便它能够同时为iOS和macOS构建框架。特别地,我们在项目中有这些xcconfig文件,并且它们已经被添加到项目中的所有框架中。

一旦完成,我们可以尝试为Mac构建Counter模块,但我们会立即得到一个错误:

.navigationBarTitle("Counter demo")
🛑 Value of type ‘some View’ has no member ‘navigationBarTitle’

原来macOS应用程序没有“navigation bar titles”的概念,因为这不是Mac应用程序的UI外观。只有iOS、watchOS和tvOS应用程序能够使用这个视图修饰符。对于macOS,我们需要特殊处理。

我们可以这样做的一种方法是为视图提取一个小助手属性,它在这个特殊的逻辑中烘焙:

extension View {
  var counterNavBarTitle: some View {
    #if os(macOS)
    return self
    #else
    return self.navigationBarTitle("Counter demo")
    #endif
  }
}

然后在视图中我们可以这样做:

.counterNavBarTitle

如果我们在macOS平台上,它什么都不会做。

但这只是冰山一角。有很多api只适用于单个平台或平台的一个子集。例如,动作表单不在macOS上,所以如果您计划在跨平台应用程序中使用这些api,那么您将需要执行更多特例操作。也有一些api是在iOS和macOS上,但不是在watchOS或tvOS上,如RotationGesture和magnationgesture

但除了这个小问题,我们还有这段代码,每当你点击“is this prime”按钮时,它会显示一个模式:

.sheet(
  item: .constant(self.viewStore.value.isPrimeModal),
  onDismiss: { self.viewStore.send(.primeModalDismissed) }
) { isPrimeDetail in
  IsPrimeModalView(
    store: self.store.scope(
      value: { (isPrimeDetail.count, $0.favoritePrimes) },
      action: { .primeModal($0) }
    )
  )
}

对于这个应用程序的macOS版本,我们实际上想使用一个弹窗而不是一个模态。我们想要popovers是一种超轻量的方式来显示一些额外的UI与其他UI相关,popovers甚至不能在iphone上工作,只能在ipad上。

popovers的API非常类似于模态,但我们仍然需要做额外的特殊的外壳逻辑,以便根据平台显示弹窗或模态。

我们可以尝试在视图扩展助手中做那个特殊的加工,比如:

extension View {
  var primeDetail: some View {
    #if os(iOS)
    // modal
    #elseif os(macOS)
    // popover
    #endif
  }
}

2. Dedicated platform SwiftUI views

所有这些微小的差异真的会累积起来。在开发这个功能的几个月里,我们可能会在这个视图中进行几十个小调整,这取决于我们运行的是iOS应用程序还是Mac应用程序。

幸运的是,SwiftUI对于我们在这里看到的问题有一个很好的故事。SwiftUI的立场是,UI的构造应该是应用程序中最简单的部分。毕竟,它只是一个简单的函数,它将视图的状态映射到SwiftUI类型的层次结构,包括堆栈、按钮、文本视图、列表等等。 而且,由于UI的构造可以变得如此简单,所以当您想要支持的每个平台的UI显著偏离时,为它们复制视图代码可能是可行的。

我们完全同意SwiftUI的这一立场。通常情况下,Mac视图的惯例和设计与iOS视图有很大不同,所以最好是为每个平台从头创建视图,而不是抽象它们的共性并统一它们的差异。

这就是我们要做的。我们将提取出我们的CounterView到一个名为CounterView_iOS.swift的新文件中,我们将把它的定义包装在:

#if os(iOS)
import ComposableArchitecture
import PrimeAlert
import PrimeModal
import SwiftUI
…
#endif

然后我们要复制这个文件,将其命名为CounterView_macOS.swift,并将条件更改为:

#if os(macOS)
…
#endif

比到处都是#if条件语句更好的方法是创建所有新的框架,只保存iOS或macOS特定的内容。然后Counter模块可以只关注业务逻辑中不可知的部分,与特定于平台的实现无关。

现在我们已经有了一个专门为macOS创建的视图,我们可以开始对其进行修改,使其能够编译,并且更适合macOS。首先,让我们去掉导航栏的标题:

// .navigationBarTitle("Counter demo")

接下来,我们要解决模态表单的问题,我们希望在macOS上以弹出窗口的形式呈现它。我们只需要从sheetAPI切换到popoverAPI,并从我们的存储中构造一个绑定。

.popover(
  isPresented: Binding(
    get: { self.viewStore.value.isPrimeModalShown },
    set: { _ in self.viewStore.send(.primeModalDismissed) }
  )
) {
  IsPrimeModalView(
    store: self.store.scope(
      value: { ($0.count, $0.favoritePrimes) },
      action: { .primeModal($0) }
    )
  )
}

但我们的state和actions的命名并不理想。几个月后,当我们遇到这段代码时,我们可能会感到困惑,因为我们正在显示一个popover,即使状态清楚地告诉我们它是一个modal

我们可以做得更好。我们将使我们的核心业务逻辑对UI更加不可知,并允许我们的每个视图将这种不可知状态转换为对它们有意义的状态。

因此,与其以将要显示的UI类型来命名状态,当你问当前计数是否为质数时我们会显示一些细节屏幕,它可能是modal, a popover, an alert 等等。我们将它命名为isPrimeDetailShown:

public typealias CounterState = (
   …
   isPrimeDetailShown: Bool
 )

而不是调用操作isPrimeModalDismissed,我们将通过调用isPrimeDetailDismissed使它更加不可知:

public enum CounterAction: Equatable {
  …
  case primeDetailDismissed
}

这意味着在reducer中,我们现在需要与新的状态和动作一起工作:

case .isPrimeButtonTapped:
  state.isPrimeDetailShown = true
  return []

case .primeDetailDismissed:
  state.isPrimeDetailShown = false
  return []

然后我们需要更新CounterFeatureState,这是一个包含计数器UI和the prime modal的所有数据的结构体:

public struct CounterFeatureState: Equatable {
  …
  public var isPrimeDetailShown: Bool

  public init(
    …
    isPrimeDetailShown: Bool = false
  ) {
    …
    self.isPrimeDetailShown = isPrimeDetailShown
  }

  var counter: CounterState {
    get { (self.alertNthPrime, self.count, self.isNthPrimeRequestInFlight, self.isPrimeDetailShown) }
    set { (self.alertNthPrime, self.count, self.isNthPrimeRequestInFlight, self.isPrimeDetailShown) = newValue }
  }

  …
}

这就是我们需要在这个文件中更新的,让我们跳转到counterview_macos.swift,构建我们的Mac应用程序。它的本地域是不对的,因为它提到了modal,我们想为macOS应用程序使用popovers,所以让我们相应地重命名:

struct State: Equatable {
  …
  let isPrimePopoverShown: Bool
}
enum Action {
  …
  case primePopoverDismissed
}

还要记住,我们已经把modal换成了popovers,代码看起来是这样的:

.popover(
  isPresented: Binding(
    get: { self.viewStore.value.isPrimePopoverShown },
    set: { _ in self.viewStore.send(.primePopoverDismissed) }
  )
  ) {
    IsPrimeModalView(
      store: self.store.scope(
        value: { ($0.count, $0.favoritePrimes) },
        action: { .primeModal($0) }
      )
    )
}

然后我们需要更新转换,使我们的状态和动作为view store的形状:

extension CounterView.State {
  init(counterFeatureState state: CounterFeatureState) {
    …
    self.isPrimePopoverShown = state.isPrimeDetailShown
  }
}

extension CounterFeatureAction {
  init(counterViewAction action: CounterView.Action) -> CounterFeatureAction {
    switch action {
    …
    case .primePopoverDismissed:
      return .counter(.primeDetailDismissed)
    …
    }
  }
}

现在一切都在构建中,但仍有一些领域特定的命名我们可以满足。首先,我们可以看到IsPrimeModalView名称可能有点误导人。它可能也应该推广到IsPrimeDetailView,但它的功能可能需要在将来的某一天被分成macOS和iOS版本。

我们还想从Mac应用中删除一些逻辑。我们不想支持双击手势,所以我们可以将其注释掉。

// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
// .background(Color.white)
// .onTapGesture(count: 2) {
//   self.viewStore.send(.doubleTap)
// }

这意味着我们也可以在action enum中去掉case doubleTap:

enum Action {
  …
//  case doubleTap
}

…

extension CounterFeatureAction {
  init(counterViewAction action: CounterView.Action) -> CounterFeatureAction {
    switch action {
    …
//    case .doubleTap:
//      return .counter(.requestNthPrime)
    }
  }
}

虽然我们正在为Mac创造内容,但我们却在改变作用域时破坏了iOS实现。让我们跳转到CounterView_iOS.swift文件,在那里我们需要做两个小的改变,我们构建了view store状态和动作:

extension CounterView.State {
  init(counterFeatureState state: CounterFeatureState) {
    …
    self.isPrimeModalShown = state.isPrimeDetailShown
  }
}

extension CounterFeatureAction {
  init(counterViewAction action: CounterView.Action) -> CounterFeatureAction {
    switch action {
    …
    case .primeModalDismissed:
      return .counter(.primeDetailDismissed)
    }
  }
}

这很酷。这里我们得到了isPrimeDetailShown的不可知值,我们将它映射到一个更特定于领域的isPrimeModalShown的值,因为这是iOS UI所关心的。然后我们得到了更特定于作用域的primeModalDismissed值,并将它映射到不可知的.counter(.primeDetailDismissed),这是驱动业务逻辑的reducer所理解的。

就像这样,我们有一个为iOS和macOS编译的框架,其中包含两个平台之间共享的核心业务逻辑,以及为每个平台适当调整的视图。每个平台视图都处理对其平台有意义的状态和操作,并且没有任何需要解释的未知域,或者不适用于视图的额外域。


3. Conclusion

这就是为什么我们认为为了支持共享业务逻辑,我们可以在视图中加入一点复制。我们将在两个为不同平台量身定制的视图之间分享我们功能的真正大脑。事实上,支持iOS应用的effects能够无缝地应用于Mac应用是一件令人惊讶的事情。请记住,我们对这些effects进行了很好的测试覆盖,所以我们可以放心,在iOS和macOS上运行时,事情都能像我们预期的那样运行。

我们已经开启了使我们的核心业务逻辑不受UI中用例影响的能力,这是我们应用程序中所有真正困难的工作发生的地方,同时仍然允许我们将这种不受影响的特性应用到特定于我们的视图的领域中。

我们同时得到了多层次的好处。我们一方面通过一般化处理业务逻辑的方式来简化,另一方面通过专门化从应用程序状态构造视图的方式来简化。

我们已经到了将可组合架构(**Composable Architecture)**引入生产就绪状态的最后阶段。