SwiftUI的导航和路由

1,437 阅读6分钟

20 Aug 2021SwiftUI导航和路由

发布者: Adrian Stoyles

在WWDC 2019上,苹果向苹果开发社区宣布了SwiftUI。SwiftUI见证了苹果平台上应用开发的未来,从UIKit命令式风格转向越来越流行的数据驱动的声明式风格。在这篇文章中,我们将讨论和探索SwiftUI中的导航流和视图构建方法。特别是,我们将探索一种将这两种活动从视图层中抽象出来的方法。

在我们开始之前

在我们开始之前,让我们回顾一下UIKit和SwiftUI在导航方面的一些区别。

UIKit是一个强制性的UI框架,提供了多种管理导航的方法。下面列举几个:

  • A [ViewController](https://developer.apple.com/documentation/uikit/view_controllers)可以在内部呈现或推送另一个ViewController
  • ViewController 的外部对象可以请求它进行导航。
  • A [NavigationController](https://developer.apple.com/documentation/uikit/uinavigationcontroller)的堆栈可以通过各种方式进行突变(popToRoot,setViewControllers, 等等)
  • 故事板片段

有了这样的自由度,在一个代码库中发现混合的导航做法是很正常的。各种架构和框架(CoordinatorVIPERRIBs)通过将导航和流控制整合到一个专门的组件(如路由器或协调器)中来解决这个问题。将导航从ViewController 层中抽象出来,可以提高ViewController 的可重用性,并为整个代码库提供更大的可测试性。

另一方面,SwiftUI是一个声明式的UI框架,其中导航和流程控制被严格限制在视图层中。虽然这种方法确实给应用程序的导航灌输了更高层次的可预测性,但在View 可重用性和可测试性方面存在一些缺陷。苹果公司的导航例子倾向于将呈现的View 与呈现的View

一个例子

下面是SwiftUI中一个常见的程序化导航的例子,其中ViewA ,将ViewB 推到导航栈中:

struct ViewA: View {

  @State var navigateToViewB: Bool = false

  var body: some View {
    NavigationView {
      VStack {
        Button("Go to ViewB") {
          doSomethingAsync() {
            self.navigateToViewB = true
          }
        }

        NavigationLink(
          destination: ViewB(
            viewModel: .init(
              userRepo: .init()
            )
          ),
          isActive: $navigateToViewB,
          label: {
            EmptyView()
          }
        )
      }
    }
  }
}

我们马上就能看到这种方法的一些问题:

  • ViewA 需要与依赖关系一起构建 。ViewB
  • ViewA 绑定后只能导航到 ,不能在另一个上下文中重复使用。ViewB
  • ViewB 不能在 测试中被模拟。View
  • ViewA 管理导航目的地的状态。

已经有一些框架在SwiftUI的导航系统之上工作,以提供一种类似于UIKit的方法(例如SwiftUIRouter的基于路径的路由)。在这篇文章中,我们想要实现的是创建一个模式,在以类型和null-safe的方式保留SwiftUI的声明式导航方法的同时,还能实现以下目标:

  • ViewA 移除ViewB 结构和相关的依赖关系。
  • ViewA 中注入一个路由对象,它可以在导航被触发时提供一个View
  • 允许路由对象的范围从应用/全局域下放到本地域,以实现模块化。
  • 启用业务逻辑层来决定导航流。

为了让大家熟悉,我们将看一下使用MVVM架构的解决方案。然而,这些原则也可以很好地适用于其他模式。

视图构建

ViewB 第一个目标是移除构建ViewA 的责任,并将此任务委托给另一个对象。我们将这个对象称为Router 。为了将Router 的具体实现从View ,我们将创建一个协议,它将符合这个协议。我们还将添加一个名为Route相关类型,这样我们就可以在其他类型上创建其他的Router

protocol Routing {
  associatedtype Route
  associatedtype View: SwiftUI.View

  @ViewBuilder func view(for route: Route) -> Self.View
}

在这种情况下,Route 将是一个枚举,我们的两个屏幕作为案例viewAviewB

enum AppRoute {
  case viewA
  case viewB
}

在具体实现中,称为AppRouter ,我们需要提供一个Environment 对象。这包含了建立一个View 所需的依赖关系。同时注意,我们需要更新我们的View ,以需要一个Router 的属性:

struct AppRouter: Routing {
  let environment: Environment

  func view(for route: AppRoute) -> some View {
    switch route {
    case .viewA:
      ViewA(router: self)

    case .viewB:
      ViewB(
        router: self,
        viewModel: .init(
          userRepo: environment.userRepo
        )
      )
    }
  }
}

现在我们的Router 已经创建,我们可以把它注入到ViewA ,并委托构建ViewB 。鉴于Routing 包含相关的类型,我们需要使我们的视图在协议类型上通用,并将Route 类型限制在AppRoute

struct ViewA<Router: Routing>: View where Router.Route == AppRoute {
  let router: Router
  @State var navigateToViewB: Bool = false

  var body: some View {
    NavigationView {
      VStack {
        Button("Go to ViewB") {
          doSomethingAsync() {
            self.navigateToViewB = true
          }
        }

        NavigationLink(
          destination: router.view(for: .viewB),
          isActive: $navigateToViewB,
          label: {
            EmptyView()
          }
        )
      }
    }
  }
}

尽管对ViewA 结构的改变不大,但我们在这里实现了一些事情:

  • ViewA 不再需要知道如何构建 或它的任何依赖关系。ViewB
  • ViewA 不受约束地只呈现 。ViewB
  • Router 是通用的,因此ViewA 可以通过不同的Router 实现进行初始化。
  • ViewA 可以在其他情况下使用,并在完成 时展示其他 。doSomethingAsync View

创建和分发路由器

正如上面的例子所展示的,Router 是通过视图层次结构从视图到视图向下传递的。在一个应用程序的上下文中,Router 可以在@main View 上初始化,并从该点开始向下传递视图层次结构。根据应用程序的规模,将Router分成更多的模块化组件可能是有益的。不是每个视图都需要知道每个Route 。例如,你可能有一个专门用于认证模块的路由器:

enum AuthRoute {
  case signIn
  case signUp
  case forgotPassword
}

struct AuthRouter: Routing {
  let environment: AuthEnvironment

  func view(for route: AuthRoute) -> some View {
    switch route {

    case .signIn:
      SignInView(
        viewModel: AuthViewModel(
          userRepo: environment.userRepo
        ),
        router: self
      )

    case .signUp:
      SignUpView(
        viewModel: AuthViewModel(
          userRepo: environment.userRepo),
        router: self
      )

    case .forgotPassword:
      ForgotPasswordView(
        viewModel: AuthViewModel(
          userRepo: environment.userRepo),
        router: self
      )
    }
  }
}

导航流程

到目前为止,这个例子只说明了从ViewAViewB 的单一导航情况。如果我们要添加另一个NavigationLinkViewA ,我们应该避免进一步添加@State Bool 的变量来表示导航状态。例如,在一个时间点上,应该只有一个导航是活跃的。

如果我们以某种方式结束了一个不止一个导航处于激活状态,会发生什么?幸好还有一个NavigationLink ,我们可以使用。它需要一个与任何Optional Hashable 类型的绑定。当绑定的值被设置为tag ,导航就被激活了:

public init<V>(
  destination: Destination,
  tag: V,
  selection: Binding<V?>,
  @ViewBuilder label: () -> Label
) where V : Hashable

使用这种激活方法,navigateToViewB 可以用一个可选的AppRoute 来代替,并改名为更通用的activeNavigation 。在这种情况下,适应性很简单,因为我们的枚举AppRoute 是自动的Hashable 。请注意,包含有关联类型的枚举将需要进一步的工作来使类型符合Hashable

struct ViewA<Router: Routing>: View where Router.Route == AppRoute {
  let router: Router
  @State var activeNavigation: AppRoute?

  var body: some View {
    NavigationView {
      VStack {
        Button("Go to ViewB") {
          doSomethingAsync() {
            self.activeNavigation = .viewB
          }
        }

        NavigationLink(
          destination: router.view(for: .viewB),
          tag: .viewB,
          selection: $activeNavigation,
          label: { EmptyView() }
        )
      }
    }
  }
}

现在,ViewA 可以指定AppRoute 类型的活动导航,为新的案例添加更多的NavigationLinks 也很简单。此外,如果路线是CaseIterable ,就有可能在一个ForEach 视图中创建多个NavigationLinks。例如,如果你在整个应用中使用一个单一的Router

ForEach(
  Array(Router.Route.allCases),
  id: \.self
) {
  NavigationLink(
    destination: router.view(for: $0),
    tag: $0,
    selection: $activeNavigation,
    label: { EmptyView() }
  )
}

然而,这在很多情况下可能并不理想。

接下来,我们可以看看如何将activeNavigation 从视图层移到ViewModel

为了做到这一点,我们定义一个基本的ViewModel 协议。

protocol ViewModel: ObservableObject {
  associatedtype Route

  var activeNavigation: Route? { get set }
}

在一个ViewModel 的实现中,activeNavigation 可以被设置为任何Route 的类型之一。当一个流程需要偏离时,这是很有用的,也许是由于服务请求的特殊响应或其他一些状态。

把它绑在一起

现在我们已经定义了模式中每个对象的角色和职责,我们可以指定一些规则和协议,以便为应用程序执行一致的导航模式:

  • View:注册并执行导航
  • ViewModel:通过发布一个活动的导航属性来驱动流状态
  • Router:为给定的环境构建具有依赖性的视图

Routing 已经被定义为:

protocol Routing {
  associatedtype Route: RouteType
  associatedtype Body: View

  @ViewBuilder func view(for route: Route) -> Self.Body
}

我们将Route 限制在以下符合性上:

typealias RouteType = Hashable & Identifiable & CaseIterable

接下来,我们可以为我们的ViewModels定义一个协议来遵循。请注意,对于ViewModel 的实现,navigationRoute 将需要包裹在@Published 属性包装器中:

protocol ViewModel: ObservableObject {
  associatedtype Route: RouteType

  var navigationRoute: Route? { get set }
}

最后,我们为我们的Views 定义一个协议。这就把我们所有的部分整合在一起,并强制要求ViewModelViewRouter 都是同一个Route 类型上的泛型。

protocol AppView: View {
  associatedtype VM: ViewModel
  associatedtype Router: Routing where Router.Route == VM.Route

  var viewModel: VM { get }
  var router: Router { get }
}

结语

在这篇文章中,我介绍了一种模式,用于在整个SwiftUI应用中实现一致的导航方法。该模式需要依赖性注入,并且是可测试的。它还支持下探到更小的领域,这意味着功能可以模块化,不需要绑定到应用程序领域。

虽然如此,但一如既往地有进一步的考虑。这些例子是孤立的、简单的,并不涉及复杂的流程,其中ViewViewModel 的外部状态可能会影响流程。我们将继续探索更高级的用例,并报告其进展情况。然而,到目前为止,这个模式是朝着正确方向迈出的令人鼓舞的一步。