要对本地 Swift 计算进行防抖,请在输入每次变化时取消之前的延迟任务,然后在短暂停顿后执行计算。本 macOS 教程会构建一个合成方块计数器,只有在最新的有效输入稳定后才更新。
明确计算器应执行的操作
读者要完成的任务很简单:在 Mac 应用的数字输入框中添加延迟预览。设想一幅由正方形图层构成的图形:第一层包含一个方块,第二层包含四个,第三层包含九个。输入图层数后,计算这些图层中的方块总数。所有数据都保存在内存中,并使用合成数字。
在此示例中,输入规则如下:
- 接受从
0到1000(含边界)的十进制整数。 - 解析前移除首尾空白字符。
- 输入为空时立即显示提示,输入无效时显示错误。
- 输入有效时,清除之前的答案,并等待
300毫秒后再计算。 - 每当有新的编辑输入或视图消失时,取消待处理的计算。
结果是从 1 到指定图层数的平方和。公式为 n * (n + 1) * (2 * n + 1) / 6。对于 3 层,示例计算为 1 + 4 + 9 = 14。对于 0,结果为 0。这些是数学示例,并非实测的应用结果。
300 毫秒的间隔是一个示例性的交互选择。它会产生尾沿防抖:每次新编辑都会重新开始静默期。持续编辑可能会无限期推迟计算。适合在输入稳定后显示预览的场景可以采用这种行为;如果命令必须响应每次单独的编辑,就需要采用不同的规则。
为什么取消必须终止延迟执行路径
任务句柄让应用能够取消之前安排的工作。Swift 任务创建后可以立即开始运行,丢弃句柄并不会停止任务。应保存句柄,以便下一次编辑可以取消该任务。Apple 的 Task 文档介绍了这两种行为。
延迟使用 try await Task.sleep(for: .milliseconds(300))。Apple 说明,这会挂起任务而不阻塞其底层线程;如果在延迟结束前取消任务,则会抛出 CancellationError。此时长重载可用于 macOS 13 及更高版本。Task.sleep 文档
取消仍然是协作式的:调用 cancel() 不会强制中断任意计算代码。延迟执行路径必须在 sleep 抛出错误时返回,并且应在计算前再次检查是否已取消。Task.cancel 文档、Task.checkCancellation 文档
一个常见错误是对 sleep 使用 try?,然后直接继续计算。这会丢弃抛出的取消错误,并允许后续语句继续执行。这里捕获错误后会退出任务,避免产生过时的答案。
构建完整的 macOS 示例
在 Xcode 中创建一个 macOS 应用项目,选择 SwiftUI 界面和 Swift 语言。使用 Swift 6 工具链,并将部署目标设为 macOS 14 或更高版本。此示例使用现代的无参数 onChange(of:initial:_:) 重载,Apple 列出的支持版本从 macOS 14 开始。SwiftUI onChange 文档
将生成的应用入口文件替换为下面的完整程序。从目标中移除生成的 ContentView.swift 文件,或者替换其内容,确保只保留一个 ContentView 声明。目标中只能有一个标记为 @main 的应用声明。
import SwiftUI
import Combine
import Foundation
@MainActor
final class TileCalculator: ObservableObject {
@Published private(set) var result: Int?
@Published private(set) var status = "Enter a layer count."
private var pending: Task<Void, Never>?
func schedule(_ text: String) {
pending?.cancel()
pending = nil
result = nil
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else {
status = "Enter a layer count."
return
}
guard let layers = Int(cleaned, radix: 10),
(0...1000).contains(layers) else {
status = "Use a whole number from 0 to 1000."
return
}
status = "Waiting for typing to pause..."
pending = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: .milliseconds(300))
try Task.checkCancellation()
} catch {
return
}
guard let self else { return }
let total = layers * (layers + 1) * (2 * layers + 1) / 6
self.result = total
self.status = "Calculated for \(layers) layers."
self.pending = nil
}
}
func stop() {
pending?.cancel()
pending = nil
result = nil
status = "Calculation paused."
}
}
@MainActor
struct ContentView: View {
@State private var input = "3"
@StateObject private var calculator = TileCalculator()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Square-layer tile counter")
.font(.headline)
TextField("Layers: 0 to 1000", text: $input)
.textFieldStyle(.roundedBorder)
Text(calculator.status)
if let total = calculator.result {
Text("Total tiles: \(total)")
.font(.title2)
}
}
.padding(24)
.frame(minWidth: 360, minHeight: 180)
.onAppear {
calculator.schedule(input)
}
.onChange(of: input) {
calculator.schedule(input)
}
.onDisappear {
calculator.stop()
}
}
}
@main
@MainActor
struct LocalTileCalculatorApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
视图使用 @StateObject 持有计算器。Apple 文档说明,这会在容器输入发生变化时保留模型实例,而视图标识发生变化时则会创建新实例。已发布的模型属性会驱动状态和答案的显示。StateObject 文档
模型和任务闭包都显式标记为 @MainActor。Apple 将此执行器定义为等同于主调度队列。在此实现中,任务调度、取消和结果赋值因此都在同一个 actor 上进行。sleep 会挂起任务;等待结束后,简短的算术表达式会在该 actor 上运行。MainActor 文档
输入和结果规则如何避免显示过时的预览
取消操作发生在验证之前。如果用户将有效数字改为空字段或无效值,之前待处理的计算仍会被取消。验证时提前返回,也不会让旧的延迟任务继续运行并显示结果。
程序会在每次编辑时清除 result。这是有意设定的显示规则:新值等待处理期间,答案会消失,因此界面不会把旧总数显示成当前文本对应的答案。另一种设计可以保留答案,并清楚标注为上一条结果,但这需要采用不同的显示规则。
Foundation 的 trimmingCharacters(in:) 会从字符串两端移除匹配的字符。使用 .whitespacesAndNewlines 后,像 3 这样的输入会变成 3;字符串内部的字符保持不变。Foundation 字符串修剪文档
Swift 的整数解析器默认使用 10 进制,接受可选的前导符号,并在值无效或无法表示时返回 nil。因此,此处接受 +3 和 003,而拒绝 3.5、1,000 和 3 0。解析得到的负值会由单独的范围检查拒绝。Int.init 文档
上限也能约束算术运算。在 1000 层时,分子为 2,003,001,000,总数为 333,833,500。所有允许的输入都处于这些非负中间值的范围内。提高上限时,必须同时检查中间乘法和最终结果;事后再除以六,无法修复已经发生的溢出。
每个延迟任务都会捕获该次编辑经过验证的 layers 值,而不会在 sleep 后重新读取输入框。任务通过取消检查后,在赋值结果之前不会发生 await。其他主 actor 调度调用无法插入这段同步的计算与赋值过程。Swift 语言指南介绍了 actor 隔离和显式标记的挂起点。并发 — Swift 编程语言
这种推理依赖于计算始终保持同步且十分简短。如果之后加入异步操作,应在该操作返回后再次检查取消状态以及请求是否仍为当前请求。修订标识符可以区分连续的请求,包括用户返回到先前输入值的情况。
使用合成输入检查行为
构建并运行应用,然后按以下验收步骤操作。预期结果依据程序规则推导;具体唤醒时间取决于调度。
| 操作 | 预期行为 |
|---|---|
使用初始输入 3 打开窗口 |
显示等待状态,然后显示 Total tiles: 14。 |
将输入替换为 4 |
清除之前的总数,然后显示 Total tiles: 30。 |
输入 1,并在第一次延迟结束前输入 10 |
重新开始延迟;输入稳定后显示 Total tiles: 385。 |
输入 4,并在延迟结束前将其删除 |
显示 Enter a layer count.,且不显示总数。 |
输入 3.5 或 1001 |
立即显示范围提示,且不显示总数。 |
输入 0 |
延迟后显示 Total tiles: 0。 |
输入 1000 |
延迟后显示 Total tiles: 333833500。 |
要检查取消行为,请在延迟到期前留出充足时间进行下一次编辑。到达时间边界时,旧结果可能已经在处理下一次编辑之前完成。下一次编辑仍会清除该结果并开始新请求。延迟并非精确的执行时间预约;Swift 语言指南说明,sleep 会至少挂起请求的时长。Swift 并发文档
以上算术示例已通过整数计算独立核验。研究环境中没有 Swift 编译器,因此只检查了 macOS 程序,未在该环境中编译或执行。
将取消操作与视图生命周期绑定
视图消失处理器会显式调用 stop()。Apple 文档说明,onDisappear 会在视图消失后运行,具体时机取决于视图类型。应将其视为视图的清理钩子,而不是精确的窗口事件截止时间。视图出现处理器会在视图每次出现时重新安排当前输入的计算。SwiftUI onDisappear 文档
取消错误的捕获处理会有意直接返回,不更改共享状态,也不清除已保存的句柄。一个较早取消的任务可能会在新任务已保存后恢复;如果允许旧任务无条件清理,就可能清除新任务的句柄。在此程序中,调度和停止方法会清除已取消的句柄,而只有成功完成的任务才会清除自己的活动句柄。
防抖控制的是工作何时开始。它无法让耗时的同步计算保持响应,也无法撤销已经赋值的结果。对于这个范围受限的公式,实用模式很简洁:取消之前的句柄,验证并捕获输入,挂起一个静默周期,在取消时退出,然后在主 actor 上提交简短的计算结果。这样,Mac 界面就能提供可预测的输入稳定后预览。
来源
- Task — Apple。
- sleep(for:tolerance:clock:) — Apple。
- cancel() — Apple。
- checkCancellation() — Apple。
- onChange(of:initial:_:) — Apple。
- StateObject — Apple。
- MainActor — Apple。
- trimmingCharacters(in:) — Apple。
- init(_:radix:) — Apple。
- Concurrency — The Swift Programming Language — Swift.org。
- onDisappear(perform:) — Apple。