文章

在使用闭包时防止时序问题

了解对闭包的不同 API 调用可能对您的 app 造成怎样的影响。

概览

您在 Swift 中使用的许多 API 都将闭包 (即以实例形式传递的函数) 视为参数。由于闭包可能包含与 app 中多个部分交互的代码,因此您务必要了解闭包所传递到的 API 调用闭包的不同方式。您传递给 API 的闭包可被同步 (立即) 或异步 (一段时间之后) 调用,并可能被调用一次或多次,也可能永不被调用。

了解同步和异步调用的结果

在将闭包传递给 API 时,您需要考虑相对于代码中其他代码调用闭包的时间。在同步 API 中,闭包的调用结果会在您传递闭包后立即可用。在异步 API 中,该结果需要等待一段时间后才会可用;这一差别影响闭包“内”代码和闭包“后”代码的编写方式。

以下示例定义了两个函数:now(_:)later(_:)。您可以用相同的方式调用这两个函数:利用后置闭包而不使用其他参数。now(_:)later(_:) 都接受闭包并调用它,但 later(_:) 则会等几秒才调用其闭包。

import Dispatch
let queue = DispatchQueue(label: "com.example.queue")

func now(_ closure: () -> Void) {
    closure()
}

func later(_ closure: @escaping () -> Void) {
    queue.asyncAfter(deadline: .now() + 2) {
        closure()
    }
}

now(_:)later(_:) 函数代表着您在 app 框架内接受闭包的方法中最常遇到的两种 API 类别:类似于 now(_:) 的同步 API,和类似于 later(_:) 的异步 API。

由于调用闭包可能会改变 app 的局部和全局状态,因此在编写传递闭包后的代码时需要仔细考量闭包被调用的时间。即便是打印一组字母这样简单的任务,也可能会受到闭包调用时机的影响:

later {
    print("A") // Eventually prints "A"
}
print("B") // Immediately prints "B"

now {
    print("C") // Immediately prints "C"
}
print("D") // Immedately prints "D"

// Prevent the program from exiting immediately if you're running this code in Terminal.
let semaphore = DispatchSemaphore(value: 0).wait(timeout: .now() + 10)

运行上例中的代码通常会按照以下顺序打印字母:BCDA。尽管打印 A 的那一行位于代码的最前面,但在输出中排在后面。出现顺序差异的原因在于 now(_:)later(_:) 函数的定义方式。如果您编写的代码依赖于特定的执行顺序,您需要知道每个函数调用其闭包的方式。

在使用接受闭包的 API 时,您经常需要考虑这种基于时间的执行问题。在许多情形中,只有一种调用顺序对您的 app 来说是正确的,因此务必要根据您所使用的 API,仔细思考您的 app 将处于的状态。您可以结合使用 API 名称、参数名称以及相关的文档来判断 API 是同步还是异步的。

一个常见的时序错误是预计异步调用的结果可在同步调用代码中使用。例如,上文中的 later(_:) 方法与 URLSession (英文) 类的 dataTask(with:completionHandler:) (英文) 方法相当,后者也是异步的。您应当避免下面这样的时序场景:在 app 的 viewDidLoad() (英文) 方法中调用 dataTask(with:completionHandler:) 方法,并试图在作为完成处理程序传递的闭包外面使用其结果。

在会被多次调用的闭包中,不要编写代码来进行一次性更改

如果您要将一个闭包传递给可能要多次调用它的 API,请去掉旨在对外部状态进行一次性更改的代码。

以下示例会创建一个 FileHandle (英文) 以及一组要写入该句柄所引用的文件的数据行:

import Foundation

let file = FileHandle(forWritingAtPath: "/dev/null")!
let lines = ["x,y", "1,1", "2,4", "3,9", "4,16"]

要将每一行写入文件,应将闭包传递给 forEach(_:) (英文) 方法:

lines.forEach { line in
    file.write("\(line)\n".data(using: .utf8)!)
}

FileHandle 使用完毕后,应使用 closeFile() (英文) 来结束它。调用 closeFile() 的正确位置是在闭包外面:

lines.forEach { line in
    file.write("\(line)\n".data(using: .utf8)!)
}

file.closeFile()

如果您误解了 closeFile() (英文) 的要求,可能会将调用放在闭包内。这样会导致您的 app 崩溃:

lines.forEach { line in
    file.write("\(line)\n".data(using: .utf8)!)
    file.closeFile() // Error
}

不要将重要代码放在可能不会被调用的闭包内

如果传递给 API 的闭包有可能不会被调用,请不要将对 app 继续运行至关重要的代码放在该闭包内。

以下示例定义了一个 Lottery 枚举,该枚举会随机挑选一个中奖号码并在正确数字被猜中时调用完成处理程序:

enum Lottery {
    static var lotteryWinHandler: (() -> Void)?
    
    @discardableResult static func pickWinner(guess: Int) {
        print("Running the lottery.")
        if guess == Int.random(in: 0 ..< 100_000_000), let winHandler = lotteryWinHandler {
            winHandler()
            return true
        }
        
        return false
    }
}

编写的代码不应依赖于要调用的完成处理程序,否则会很不可靠。因为随机猜测无法保证一定正确,如果将诸如支付账单等重要行为安排在中奖之后才发生,这些行为可能永远不会发生。

func payBills() {
    amountOwed = 0
}

var amountOwed = 25
let myLuckyNumber = 42

Lottery.lotteryWinHandler = {
    print("Congratulations!")
    payBills()
}

// You get 10 chances at winning.
for _ in 1..10 {
    Lottery.pickWinner(guess: myLuckyNumber)
}

if amountOwed > 0 {
    fatalError("You need to pay your bills before proceeding.")
}

// Code placed below this line runs only if the lottery was won.

另请参阅

数据流和控制流

在 App 中维护状态

利用枚举捕捉和跟踪 app 的状态。