要对本地 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 界面就能提供可预测的输入稳定后预览。

来源