Processed 是一个轻量级的 SwiftUI 自动加载状态处理器,减少了重复的样板代码并提高了代码可读性。它通过两个属性包装器 (@Loadable
和 @Process
) 在视图中使用,也可以通过 LoadableSupport
和 ProcessSupport
协议在任意类中使用。 它还支持完全手动的状态控制,以应对默认设置无法满足需求的情况。
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))
}
}
}
}
}
Processed 支持 iOS 15+, macOS 13+, watchOS 8+ and tvOS 15+ 以及 visionOS 1+。
将以下行添加到你的 Package.swift
文件中的依赖项中
.package(url: "https://github.com/SwiftedMind/Processed", from: "2.0.0")
前往 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 可以提供帮助的地方。它将样板代码隐藏在一组易于使用的类型和属性包装器后面。让我们看看它是如何工作的。
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 { ... }
会执行以下操作
Tasks
Task
.loading
(除非 runSilently
参数设置为 true
).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()
如果你希望将状态保留在视图模型中,或者希望完全在 SwiftUI 外部使用 Processed,你也可以在类内部执行上述所有操作。但是,由于 SwiftUI 属性包装器的性质(它们在内部保存 @State
属性,这些属性在 SwiftUI 环境之外不起作用),因此语法略有不同。
但是,这仍然非常容易:你必须使你的类符合 LoadableSupport
协议,该协议实现与 @Loadable
属性包装器相同的 load
、cancel
和 reset
方法,但这次是在类本身上定义的
@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)
}
}
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
执行的操作相同,只是语义更适合没有返回值的通用过程
Tasks
Task
.running
(除非 runSilently
参数设置为 true
).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()
与 LoadableState
一样,你也可以在类内部执行上述所有操作。你只需使你的类符合 ProcessSupport
协议,该协议实现与 @Process
属性包装器相同的 run
、cancel
和 reset
方法,但这次是在 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)
}
}
上面的所有 load
和 run
方法都重载了,以添加所谓的“中断”。中断是一个闭包,在(加载)过程中并行调用,允许你根据该过程花费的时间运行逻辑。这使得在进程花费太长时间时添加超时或淡入一个显示“加载时间比预期长”的视图变得容易。这是一个例子
@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 演示 |
---|---|---|
MIT 许可证
版权所有 (c) 2023 Dennis Müller 和所有合作者
特此授予任何获得本软件及相关文档文件(“软件”)副本的人免费许可,以处理本软件,不受限制,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或出售本软件副本的权利,并允许向其提供本软件的人员按照以下条件进行操作
上述版权声明和本许可声明应包含在本软件的所有副本或重要部分中。
本软件按“原样”提供,不提供任何形式的明示或暗示的保证,包括但不限于适销性、特定用途的适用性和不侵权的保证。在任何情况下,作者或版权持有人均不对任何索赔、损害或其他责任承担责任,无论是在合同诉讼、侵权诉讼或其他方面,由软件或软件的使用或其他交易引起或与之相关。