原生 SwiftUI 架构

GitHub release (latest by date) GitHub

Define dependencies, inject them into observable data providers, build your generic UI components and integrate everything into the screens of your app

Puddles 是一种 SwiftUI 应用的架构,专注于尽可能多地使用原生机制和模式,只在绝对必要时才添加抽象和自定义类型。

内容

安装

Puddles 支持 iOS 15+、macOS 12+、watchOS 8+ 和 tvOS 15+。

Swift Package

将以下行添加到你的 Package.swift 文件中的依赖项中

.package(url: "https://github.com/SwiftedMind/Puddles", from: "2.0.0")

Xcode 项目

转到 File > Add Packages... 并在右上角的搜索字段中输入 URL "https://github.com/SwiftedMind/Puddles"。 Puddles 应该会出现在列表中。选择它,然后单击右下角的“Add Package”。

文档

Puddles 的文档可以在这里找到:文档

Puddles 架构

Puddles 将您的项目分为 4 个不同的层级:模块 (Modules)组件 (Components)提供者 (Providers)核心 (Core)

》模块 (Modules)

〉应用的结构

Puddles 中的应用由模块组成,通常可以将其视为单个屏幕 - 例如,Home 是一个模块,负责显示主屏幕,而 NumbersExample 负责显示有关随机数字的事实的屏幕。 模块是 SwiftUI 视图,因此它们可以以自然而熟悉的方式组合在一起,形成应用的整体结构。

/// The Root Module - the entry point of a simple example app.
struct Root: View {

  /// A global router instance that centralizes the app's navigational states for performant and convenient access across the app.
  @ObservedObject var rootRouter = Router.shared.root

  var body: some View {
    Home()
      .sheet(isPresented: $rootRouter.isShowingLogin) {
          Login()
      }
      .sheet(isPresented: $rootRouter.isShowingNumbersExample) {
          NumbersExample()
      }
  }
}

〉组合用户界面

模块通过组合简单、通用的组件来定义应用的屏幕和行为。 它们可以访问环境,在那里它们可以访问受控的、抽象的接口,该接口驱动应用与外部数据和其他框架的交互。

/// A Module rendering a screen where you can fetch and display facts about random numbers.
struct NumbersExample: View {

  /// A Provider granting access to external data and other business logic around number facts.
  @EnvironmentObject var numberFactProvider: NumberFactProvider

  /// A local state managing the list of already fetched number facts.
  @State private var numberFacts: [NumberFact] = []

  // The Module's body, composing the UI and UX from various generic view components.
  var body: some View {
    NavigationStack {
      List {
        Button("Add Random Number Fact") { addRandomFact() }
        Section {
          ForEach(numberFacts) { fact in
            NumberFactView(numberFact: fact)
          }
        }
      }
      .navigationTitle("Number Facts")
    }
  }

  private func addRandomFact() {
    Task {
      let number = Int.random(in: 0...100)
      try await numberFacts.append(.init(number: number, content: numberFactProvider.factAboutNumber(number)))
    }
  }
}

〉模块不是组件

模块描述了应用的整体结构,因此它们是不可重用的。 它们在应用中具有固定和预定的位置,因此可以在其中硬编码特定的行为和导航操作。 您可以在视图层次结构的不同位置定义多个模块,这些模块使用相同的底层组件,但对其应用不同的行为。

/// A (slightly contrived) example of a Module similar to NumbersExample, rendering a screen where you can shuffle all the number facts provided by a parent module.
struct ShuffleNumbersExample: View {

  /// A list of number facts that can be passed in
  @Binding var numberFacts: [NumberFact] = []

  // The Module's body, composing the UI and UX from various generic view components.
  var body: some View {
    NavigationStack {
      List {
        Button("Shuffle Everything") { shuffleFacts() }
        Section {
          ForEach(numberFacts) { fact in
            NumberFactView(numberFact: fact)
          }
        }
      }
      .navigationTitle("Shuffle Your Facts")
    }
  }

  private func shuffleFacts() {
    numberFacts = numberFacts.shuffled()
  }
}

》组件 (Components)

〉通用 SwiftUI 视图

组件层由许多小的、通用的 SwiftUI 视图组成,这些视图组合在一起形成应用的 UI。 它们不拥有任何数据,也无法访问外部业务逻辑。 它们的唯一目的是获取信息并描述应如何显示它们。

/// A simple component that displays a number fact.
struct NumberFactView: View {
  var numberFact: NumberFact // Data model
  var body: some View {
    if let content = numberFact.content {
      VStack(alignment: .leading) {
        Text("Number: \(numberFact.number)")
          .font(.caption)
          .fixedSize()
        Text(content)
          .fixedSize(horizontal: false, vertical: true)
          .multilineTextAlignment(.leading)
          .frame(maxWidth: .infinity, alignment: .leading)
      }
    } else {
      ProgressView()
        .frame(maxWidth: .infinity)
        .padding(.vertical, 10)
    }
  }
}

〉它们是构建块

视图组件是基本的构建块,它们通过允许您以不同的方式组合它们来自然地产生强大的模块化,从而在模块中创建各种可能的用户界面和体验。

〉它们不做假设

视图组件不对其使用的上下文做任何假设。 理想情况下,它们的构建方式应使其可在任何上下文中重用,方法是让其父视图提供数据和用户交互的解释。

〉构建交互式预览

Puddles 附带了一组工具,可轻松将完全交互式的预览添加到您的视图组件。

private struct PreviewState {
  var numberFact: NumberFact = .init(number: 5, content: Mock.factAboutNumber(5))
}

struct NumberFactView_Previews: PreviewProvider {
  static var previews: some View {
    StateHosting(PreviewState()) { $state in // Binding to the preview state
      List {
        NumberFactView(numberFact: state.numberFact)
        Section {/* Debug Controls ... */}
      }
    }
  }
}

》提供者 (Providers)

〉控制数据访问和交互

提供者通过向模块公开受控且稳定的接口来驱动应用与外部数据和其他框架的交互。 这完全隐藏了特定于所提供数据的性质和来源的任何实现细节和逻辑,使您可以交换依赖项,而无需接触依赖于它们的模块。

/// Provides access to facts about numbers.
@MainActor final class NumberFactProvider: ObservableObject {
  struct Dependencies {
    var factAboutNumber: (_ number: Int) async throws -> String
  }

  private let dependencies: Dependencies
  init(dependencies: Dependencies) {/* ... */}

  // The views only ever use the public interface and know nothing about the dependencies
  func factAboutNumber(_ number: Int) async throws -> String {
    try await dependencies.factAboutNumber(number)
  }
}

〉在初始化期间注入依赖项

提供者使用依赖项注入来启用对提供者向应用分发的数据的完全控制。 您可以使用真实数据定义实时应用的变体,并使用模拟数据定义测试和预览目的的变体。

extension NumberFactProvider {
  static var mock: NumberFactProvider = {/* Provide mocked data */}()
  static var live: NumberFactProvider = {
    let numbers = Numbers() // From the Core Swift package
    return .init(
      dependencies: .init(factAboutNumber: { number in
        try await numbers.factAboutNumber(number)
      })
    )
  }()
}

〉通过 SwiftUI 环境分发

提供者通过 SwiftUI 环境分发,允许您在视图层次结构的任何点注入它们,甚至使用模拟变体覆盖它的某些部分。

struct YourApp: App {
  var body: some Scene {
    WindowGroup {
      Root()
        .environmentObject(NumberFactProvider.live)
    }
  }
}
struct Root: View {
  var body: some View {
    List {
      SectionA() // SectionA will interact with real data
      SectionB()
        .environmentObject(NumberFactProvider.mock) // SectionB will interact with mocked data
    }
  }
}

〉释放预览的力量

这种使用业务逻辑和外部数据访问的方式使您可以轻松地为应用中的每个视图构建完全交互式和功能齐全的 SwiftUI 预览,只需将模拟数据注入预览提供程序即可。

struct Root_Previews: PreviewProvider {
  static var previews: some View {
    Root().withMockProviders()
  }
}

》核心 (Core)

〉隔离业务逻辑

核心层构成了 Puddles 的骨干。 它被实现为一个本地 Swift 包,其中包含应用的整个业务逻辑,以(大部分)隔离的组件的形式,分为单独的目标。 与 UI 没有直接关系的所有内容都应放在此处,从而鼓励构建易于独立修改和替换的模块化类型。

let package = Package(
  name: "Core",
  dependencies: [/* ... */],
  products: [/* ... */],
  targets: [
    .target(name: "Models"), // App Models
    .target(name: "Extensions"), // Useful extensions and helpers
    .target(name: "MockData"), // Mock data
    .target(name: "BackendConnector", dependencies: ["Models"]), // Connects to a backend
    .target(name: "LocalStore", dependencies: ["Models"]), // Manages a local database
    .target(name: "CultureMinds", dependencies: ["MockData"]), // Data Provider for Iain Banks's Culture book universe
    .target(name: "NumbersAPI", dependencies: ["MockData", "Get"]) // API connector for numbersAPI.com
  ]
)

〉连接外部依赖项

构建连接到你的后端、本地数据库或任何外部框架依赖项的目标,并为应用提供连接到它们的接口。

import Get // https://github.com/kean/Get

/// Fetches random facts about numbers from https://numbersapi.com
public final class Numbers {
  private let client: APIClient
  public init() {/* ... */}

  public func factAboutNumber(_ number: Int) async throws -> String {
    let request = Request<String>(path: "/\(number)")
    return try await client.send(request).value
  }
}

〉定义应用模型

应用的数据模型也在此包中定义,以便每个功能组件都可以使用和公开它们,而不是以 DTO 对象或类似形式泄漏实现细节。

public struct NumberFact: Identifiable, Equatable {
  public var id: Int { number }
  public var number: Int
  public var content: String?

  public init(number: Int, content: String? = nil) {
    self.number = number
    self.content = content
  }
}

》导航

〉全局可访问的路由器

由于模块锚定在应用的固定和预定位置,因此可以将导航硬编码到其中。 因此,全局可访问的 Router 单例使您可以轻松地通过简单的调用从应用中的一个位置跳转到任何其他位置。

/// The home Module.
struct Home: View {

  var body: some View {
    List {
      Button("Login") {
        // Easy access to a globally shared router
        Router.shared.showLogin()
      }
      Button("Numbers Example") {
        Router.shared.navigate(to: .numbersExample)
      }
    }
  }
}

〉集中式导航状态

Router` 类是一个单例,负责管理应用每个部分的整个导航状态。 这使其可以导航到视图层次结构中的任何点,并公开简单方便的方法供模块执行此操作。

/// An object that holds the entire navigational state of the app.
@MainActor final class Router {
  static let shared: Router = .init()

  /// An observable object holding all the navigational state of the root Module.
  var root: RootRouter = .init()
  var home: HomeRouter = .init()

  /// An enum that represents all the possible destinations in the app.
  enum Destination: Hashable {
    case root
    case numbersExample
  }

  /// Navigates to a destination.
  func navigate(to destination: Destination) {
    switch destination {
    case .root:
      root.reset()
      home.reset()
    case .numbersExample: // Shows the numbers example after resetting the app's navigation state.
      root.reset()
      home.reset()
      showNumbersExample()
    }
  }

  /// Presents the login modally over the current context.
  func showLogin() {
    root.isShowingLogin = true
  }

  /// Dismisses the login.
  func dismissLogin() {
    root.isShowingLogin = false
  }

  /// Presents the numbers example modally over the current context.
  func showNumbersExample() {
    root.isShowingNumbersExample = true
  }

  /// Dismisses the numbers example.
  func dismissNumbersExample() {
    root.isShowingNumbersExample = false
  }
}

〉仅在需要时观察

将 Router 标记为 @ObservedObject 的唯一位置是在模块内部,这些模块实现由 Router 的已发布状态驱动的视图修饰符。 这样,更改导航状态只会更新实际受更改影响的模块。

/// The Root Module - the entry point of a simple example app.
struct Root: View {

  /// A global router instance that centralizes the app's navigational states for performant and convenient access across the app.
  @ObservedObject var rootRouter = Router.shared.root

  var body: some View {
    Home()
      .sheet(isPresented: $rootRouter.isShowingLogin) {
        Login()
      }
      .sheet(isPresented: $rootRouter.isShowingNumbersExample) {
        NumbersExample()
      }
  }
}

示例应用

Puddles 示例 - 一个简单的应用,演示了 Puddles 的基本模式,包括用于导航的全局共享路由器。

关于 Puddles 的一些话

我围绕一些关键理念设计和构建了 Puddles,这些理念从根本上塑造了该架构及其所有优点和缺点。

  1. 使用 Puddles 应该需要最小的承诺。 它必须易于集成到现有项目中,并且如果不起作用也易于删除。
  2. 它应该永远不会限制你。 必须可以偏离建议的模式和技术。
  3. 它应该感觉像原生 SwiftUI,并尽可能减少抽象。
  4. 它应该能够轻松地模拟和预览应用的每个部分。

有可能为这些想法中的每一个找到(主观的)完美解决方案。 但是,要找到一个满足所有这些的想法却出奇的困难。 Puddles 是我尝试找到一种折衷方案,建议一种尽可能接近我个人理想解决方案的架构。

我也不想过度设计任何东西。 虽然通过在 Swift 和 SwiftUI 已经提供的功能上构建一层又一层来解决许多问题和权衡肯定是可能的——并且绝对有效——但我希望尽可能地接近原生生态系统,不仅是为了允许更大的灵活性和自由度,而且为了保持一切尽可能轻量级。 现在,您可以轻松地 fork 该存储库并自行修改或维护它。 代码不多,而且大部分应该相当简单。 我希望尽可能地保持这种状态。

Puddles 设计的另一个关键点是我不想基于传统的 MVVM 模式构建,这种模式在 SwiftUI 中已经变得非常流行。 我知道这非常有主观性,但我们所知的 SwiftUI 中严格的 MVVM 对我来说根本感觉不对。 它在很多方面限制了你,并使 SwiftUI 提供的许多令人惊叹的工具几乎无法使用,或者至少使它们使用起来非常乏味。 将所有视图的逻辑提取到 View 结构之外感觉就像在与框架作对。 我对此的看法可能会随着时间的推移而改变,但好处是,如果需要,应该相对容易地调整 Puddles。 这也是我将其设计为灵活和轻量级的原因。

Puddles 的设计方式有一些缺点。 最重要的是:单元测试。 虽然你可以测试 Core 层中的组件以及 Providers 的实现,但很难正确且彻底地测试模块,因为它们是 SwiftUI 视图,并且目前无法在 SwiftUI 环境之外访问视图的状态。 这是你在决定尝试使用 Puddles 构建应用程序时必须愿意接受的权衡。

说了这么多,我想强调的是,Puddles 可能不是构建你的 SwiftUI 应用程序的最佳方式,你甚至可能轻微或强烈地不喜欢它。 这是一种尝试提出传统 MVVM 替代方案的尝试。 你应该始终考虑你的需求、约束以及尝试新事物和可能存在风险的意愿。 不过,如果你确实决定尝试一下 Puddles,那么我真诚地希望你成功构建一个模块化且可维护的应用程序 - 并且一路享受乐趣。

- Dennis

许可证

MIT 许可证

版权所有 (c) 2023 Dennis Müller 和所有合作者

特此授予任何获得本软件及其相关文档文件(“软件”)副本的人免费许可,以便处理本软件而无限制,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或销售本软件副本的权利,并允许向其提供本软件的人员这样做,但须符合以下条件

以上版权声明和本许可声明应包含在所有副本或本软件的实质性部分中。

本软件按“现状”提供,不提供任何形式的明示或暗示的保证,包括但不限于适销性、适用于特定用途和不侵权的保证。在任何情况下,作者或版权持有者均不对任何索赔、损害或其他责任负责,无论该等索赔、损害或其他责任是因合同、侵权或其他原因引起的,或因本软件或使用本软件或其他行为而引起的。