已处理

GitHub release (with filter) GitHub

Processed 是一个轻量级的 SwiftUI 自动加载状态处理器,减少了重复的样板代码并提高了代码可读性。它通过两个属性包装器 (@Loadable@Process) 在视图中使用,也可以通过 LoadableSupportProcessSupport 协议在任意类中使用。 它还支持完全手动的状态控制,以应对默认设置无法满足需求的情况。

struct DemoView: View {
  @Loadable<[Int]> var numbers
  
  @MainActor func loadNumbers() {
    $numbers.load {
      try await Task.sleep(for: .seconds(2))
      return [0, 1, 2, 42, 73]
    }
  }
  
  var body: some View {
    List {
      Button("Load Numbers") {
        loadNumbers()
      }
      switch numbers {
      case .absent: 
        EmptyView()
      case .loading: 
        ProgressView()
      case .error(let error): 
        Text("\(error.localizedDescription)")
      case .loaded(let numbers):
        ForEach(numbers, id: \.self) { number in
          Text(String(number))
        }
      }
    }
  }
}

内容

  1. 安装
  2. 文档
  3. 背景
  4. Loadable
  5. Process
  6. 高级功能
  7. 示例
  8. 许可证

安装

Processed 支持 iOS 15+, macOS 13+, watchOS 8+ and tvOS 15+ 以及 visionOS 1+。

Swift Package

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

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

Xcode 项目

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

文档

你可以在这里找到文档。

背景

应用程序需要在很多地方处理加载、错误和成功状态,以执行诸如登录、保存或删除某些内容之类的通用过程,或者获取和准备用户的数据。因此,定义某种驱动 UI 的 enum 是有用的

enum LoadingState<Value> {
  case absent
  case loading
  case error(Error)
  case loaded(Value)
}

// Optionally, you could define a similar ProcessState enum for generic processes without a return value

然后你可以像这样在 SwiftUI 视图中使用它(或者在视图模型内部,如果你希望将状态保持在视图之外)

struct DemoView: View {
  @State var numbers: LoadingState<[Int]> = .absent
  var body: some View {
    List {
      switch numbers {
        /* Loading, Error and Success UI */
      }
  }
}

这对于确保你的 UI 与数据的当前状态保持一致非常有用。但是,在几乎任何情况下,这样的加载过程都与异步任务紧密耦合,该异步任务实际上在不阻塞 UI 的情况下运行该过程。因此,你需要在视图或视图模型中设置另一种状态

struct DemoView: View {
  @State var numbers: LoadingState<[Int]> = .absent
  @State var loadingTask: Task<Void, Never>?

  var body: some View {
    List {
      Button("Reload") { loadNumbers() }
      switch numbers {
        /* Loading, Error and Success UI */
      }
  }
  
  func loadNumbers() {
    /* Reload data */
  }
}

loadNumbers 方法可能如下所示

func loadNumbers() {
  loadingTask?.cancel()
  loadingTask = Task {
    numbers = .loading
    do {
      try await Task.sleep(for: .seconds(2))
      numbers = .loaded([0, 1, 2, 42, 73])
    } catch {
      numbers = .error(error)
    }
  }
}

这里有趣的是,方法内部的几乎所有内容都是样板代码。你总是必须取消任何先前的加载任务,创建一个新任务,设置 .loading 状态,并且总是必须以 .loaded 状态或 .error 状态结束。实际上加载数据的部分才是这个特定情况独有的。

这正是 Processed 可以提供帮助的地方。它将样板代码隐藏在一组易于使用的类型和属性包装器后面。让我们看看它是如何工作的。

Loadable

Processed 定义了一个 LoadableState 枚举,可用于表示某些数据的加载状态。它还附带了许多方便的属性和方法,例如 .isLoading.setLoading().data 等。

enum LoadableState<Value> {
  case absent
  case loading
  case error(Error)
  case loaded(Value)
}

在这些类型的基础上,Processed 定义了 @Loadable 属性包装器,你可以在 SwiftUI 视图中使用它来自动化加载状态和任务处理。

struct DemoView: View {
  @Loadable<[Int]> var numbers // Default state .absent
  // ...
}

在这里,numbers 的类型为 LoadableState<[Int]>,这意味着你可以像使用任何其他 enum 一样对其进行切换和交互。视图也会在状态更改时更新。如果需要,这使你可以完全手动控制所有状态和行为。

/* DemoView */
var body: some View {
  List {
    switch numbers {
    case .absent: /* ... */
    case .loading: /* ... */
    case .error(let error): /* ... */
    case .loaded(let numbers): /* ... */
    }
  }
}

但是,真正的好处在于带 $ 前缀的属性 $numbers。它公开了一些方法,这些方法可以处理我们上面讨论的重复样板代码

/* DemoView */
@MainActor func loadNumbers() {
  $numbers.load {
    try await Task.sleep(for: .seconds(2))
    return [42]
  }
}

在这里,调用 $numbers.load { ... } 会执行以下操作

  1. 它取消任何先前的加载 Tasks
  2. 它启动并存储一个新的 Task
  3. 它将状态设置为 .loading(除非 runSilently 参数设置为 true
  4. 它调用闭包并等待返回值或错误。
    • 如果返回一个值,它会将状态设置为 .loaded(theValue)
    • 如果抛出一个错误,它会将状态设置为 .error(theError)

一切都隐藏在这一简单的调用后面,因此你只需专注于实际加载数据即可。

你还可以随时间 yield 多个值

/* DemoView */
@MainActor func loadNumbers() {
  $numbers.load { yield in
    var numbers: [Int] = []
    for await number in [42, 73].publisher.values {
      try await Task.sleep(for: .seconds(1))
      numbers.append(number)
      yield(.loaded(numbers))
    }
  }
}

此外,你可以从 async 上下文中调用 load。发生这种情况时,加载过程不会在内部创建自己的 Task,而是简单地使用调用 Task,从而使你可以完全控制它

/* DemoView */
@MainActor func loadNumbers() {
  self.numbersTask = Task {
    await $numbers.load { // await the loading process
      try await Task.sleep(for: .seconds(2))
      return [42]
    }
    // At this point, the loading process has finished
  }
}

最后,@Loadable 还支持从外部和内部取消(除了尊重父 Task 取消之外)

$numbers.cancel() // Cancel internal Task
$numbers.reset() // Cancel internal Task and reset state to .absent

// Throw this in the `load` closure, to cancel a loading process from the inside:
throw CancelLoadable()

// Throw this in the `load` closure, to reset a loading process from the inside:
throw ResetLoadable()

在类中使用 LoadableState

如果你希望将状态保留在视图模型中,或者希望完全在 SwiftUI 外部使用 Processed,你也可以在类内部执行上述所有操作。但是,由于 SwiftUI 属性包装器的性质(它们在内部保存 @State 属性,这些属性在 SwiftUI 环境之外不起作用),因此语法略有不同。

但是,这仍然非常容易:你必须使你的类符合 LoadableSupport 协议,该协议实现与 @Loadable 属性包装器相同的 loadcancelreset 方法,但这次是在类本身上定义的

@MainActor final class ViewModel: ObservableObject, LoadableSupport {
  // Define the LoadableState enum as a normal @Published property
  @Published var numbers: LoadableState<[Int]> = .absent

  func loadNumbers() {
    // Call the load method from the LoadableSupport protocol
    load(\.numbers) {
      try await Task.sleep(for: .seconds(2))
      return [42]
    }
  }
  
  func loadStreamedNumbers() {
    // Call the load method that yields results from the LoadableSupport protocol
    load(\.numbers) { yield in
      var numbers: [Int] = []
      for await number in [42, 73].publisher.values {
        try await Task.sleep(for: .seconds(1))
        numbers.append(number)
        yield(.loaded(numbers))
      }
    }
  }

  func cancelLoading() {
    cancel(\.numbers)
  }
}

Process

Processed 还定义了一个 ProcessState 枚举,可用于表示通用过程的状态,例如登录、保存某些内容或删除。与 LoadableState 一样,它附带了许多方便的属性和方法,例如 .isRunning.setFinished().error 等。

enum ProcessState<ProcessKind> {
  case idle
  case running(ProcessKind)
  case failed(process: ProcessKind, error: Swift.Error)
  case finished(ProcessKind)
}

在这些类型的基础上,Processed 定义了 @Process 属性包装器,你可以在 SwiftUI 视图中使用它来自动化过程状态和任务处理。

struct DemoView: View {
  @Process var saveData // Compiler infers Process<SingleProcess> for single-purpose process states
  // ...
}

在这里,saveData 的类型为 ProcessState<SingleProcess>,这意味着你可以像使用任何其他 enum 一样对其进行切换和交互。视图也会在状态更改时更新。如果需要,这使你可以完全手动控制所有状态和行为。

/* DemoView */
var body: some View {
  List {
    switch saveData {
    case .idle: /* ... */
    case .running: /* ... */
    case .failed(_, let error): /* ... */
    case .finished: /* ... */
    }
  }
}

但是,与 @Loadable 一样,真正的好处在于带 $ 前缀的属性 $saveData。它还公开了一些方法,这些方法可以处理我们上面讨论的重复样板代码

/* DemoView */
@MainActor func save() {
  $saveData.run {
    try await saveToDisk()
  }
}

在这里,调用 $saveData.run { ... } 会执行以下操作,几乎与 @Loadable 执行的操作相同,只是语义更适合没有返回值的通用过程

  1. 它取消任何先前的加载 Tasks
  2. 它启动并存储一个新的 Task
  3. 它将状态设置为 .running(除非 runSilently 参数设置为 true
  4. 它调用闭包并等待返回或错误。
    • 如果闭包返回,它会将状态设置为 .finished
    • 如果抛出一个错误,它会将状态设置为 .failed(theError)

一切都隐藏在这一简单的调用后面,因此你只需专注于实际加载数据即可。

你还可以通过相同的状态管理多种类型的流程。如果你有多个不并行运行的进程,这将非常有用。在上面的示例中,ProcessState 枚举的泛型参数会自动推断为 SingleProcess,这是一个辅助类型,可以更轻松地处理只有一个目的的进程。指定你自己的 ProcessKind 也很容易!让我们稍微修改一下示例,添加一个删除选项

enum ProcessKind {
  case save
  case delete
}

struct DemoView: View {
  @Process<ProcessKind> var process // Specify multiple purposes of this process state

  @MainActor func save() {
    $process.run(.save) { // Run a save process
      try await saveToDisk()
    }
  }
  
  @MainActor func delete() {
    $process.run(.delete) {  // Run a delete process
      try await deleteFromDisk()
    }
  }

  var body: some View {
    List {
      switch saveData {
      case .idle: /* ... */
      case .running(let process): /* ... */ // Identify the process
      case .failed(let process, let error): /* ... */ // Identify the process
      case .finished(let process): /* ... */ // Identify the process
      }
    }
  }
}

此外,你可以从 async 上下文中调用 run。发生这种情况时,@Process 不会在内部创建自己的 Task,而是简单地使用调用 Task,从而使你可以完全控制它

/* DemoView */
@MainActor func save() {
  self.processTask = Task {
    await $process.run(.save) { // await the process
      try await saveToDisk()
    }
    // At this point, the process has finished
  }
}

最后,@Process 还支持从外部和内部取消(除了尊重父 Task 取消之外)

$process.cancel() // Cancel internal Task
$process.reset() // Cancel internal Task and reset state to .absent

// Throw this in the `run` closure, to cancel a process from the inside:
throw CancelProcess()

// Throw this in the `run` closure, to reset a process from the inside:
throw ResetProcess()

在类中使用 ProcessState

LoadableState 一样,你也可以在类内部执行上述所有操作。你只需使你的类符合 ProcessSupport 协议,该协议实现与 @Process 属性包装器相同的 runcancelreset 方法,但这次是在 self 上定义的

enum  ProcessKind {
  case save
  case delete
}

@MainActor final class ViewModel: ObservableObject, ProcessSupport {
  // Define the Process enum as a normal @Published property
  @Published var process: Process<ProcessKind> = .idle

  func save() {
    // Call the run method from the ProcessSupport protocol
    run(\.process, as: .save) {
      try await save()
    }
  }
  
  func delete() {
    // Call the run method from the ProcessSupport protocol
    run(\.process, as: .delete) {
      try await delete()
    }
  }
 
  func cancelLoading() {
    cancel(\.process)
  }
}

高级功能

中断

上面的所有 loadrun 方法都重载了,以添加所谓的“中断”。中断是一个闭包,在(加载)过程中并行调用,允许你根据该过程花费的时间运行逻辑。这使得在进程花费太长时间时添加超时或淡入一个显示“加载时间比预期长”的视图变得容易。这是一个例子

@Loadable var numbers: LoadableState<[Int]>
@State var showLoadingDelay = false
// ...
@MainActor func loadWithTimeout() {
 $numbers.load(interrupts: [.seconds(2), .seconds(3)]) {
  try await Task.sleep(for: .seconds(10))
  return [42]
 } onInterrupt: { accumulatedDelay in
  switch accumulatedDelay {
  case .seconds(5):
   throw TimeoutError()
  default:
   showLoadingDelay = true
  }
 }
}

示例

你可以在此存储库的 Examples 文件夹中找到一个演示项目。在那里,你将找到一系列可以用来入门的示例和演示。

App 简单流程演示 基本 Loadable 演示
RocketSim_Screenshot_iPhone_15_Pro_2023-11-21_23 05 12 RocketSim_Screenshot_iPhone_15_Pro_2023-11-21_23 05 23 RocketSim_Screenshot_iPhone_15_Pro_2023-11-21_23 05 36

许可证

MIT 许可证

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

特此授予任何获得本软件及相关文档文件(“软件”)副本的人免费许可,以处理本软件,不受限制,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或出售本软件副本的权利,并允许向其提供本软件的人员按照以下条件进行操作

上述版权声明和本许可声明应包含在本软件的所有副本或重要部分中。

本软件按“原样”提供,不提供任何形式的明示或暗示的保证,包括但不限于适销性、特定用途的适用性和不侵权的保证。在任何情况下,作者或版权持有人均不对任何索赔、损害或其他责任承担责任,无论是在合同诉讼、侵权诉讼或其他方面,由软件或软件的使用或其他交易引起或与之相关。