Swift 中的闭包捕获

这篇文章整理了我对「闭包捕获」的了解。

1. 捕获

闭包可以从定义它的上下文中捕获常量和变量,并修改被捕获的常量和变量的值——即使定义这些常量和变量的原始范围已经不再存在。

一个例子解释捕获

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var total = 0
    
    let incrementer: () -> Int = {
        total += amount
        return total
    }
    
    return incrementer
}
    
let incrementByTen = makeIncrementer(forIncrement: 10)
    
print(incrementByTen())
// print "10"
print(incrementByTen())
// print "20"
print(incrementByTen())
// print "30"
    
let alsoIncrementByTen = incrementByTen
print(alsoIncrementByTen())
// print "40"
    
let incrementByOne = makeIncrementer(forIncrement: 1)
print(incrementByOne())
// print "1"

makeIncrementer 中定义的 incrementer 即捕获了两个「量」:变量 total 和常量 amount

makeIncrementer 返回后,totalamount 的作用域本该结束,但因为被 incrementer 捕获,它们仍将继续存在。

每次执行 incrementByTen 时,其修改的都是被捕获的 total,所以执行三次的输出为:102030

闭包是引用传递的,所以 alsoIncrementByTen 中的 total 其实就是 incrementByTen 中的,这解释了为什么执行 alsoIncrementByTen 的输出为:40

incrementByOne 捕获了自己的 total 变量,它与 incrementByTen 没有关系,于是它的输出为:1

2. 捕获与逃逸闭包

捕获常常在使用逃逸闭包时发生,拿构造一个斐波那契数列举例:

struct Fib: Sequence {
    
    typealias Element = Int
    
    func makeIterator() -> AnyIterator<Int> {
        var x = 1
        var y = 1
        
        return AnyIterator {
            
            defer {
                (x, y) = (y, x + y)
            }
            
            return x
        }
    }
}
    
let fib = Fib()
    
for pair in zip(fib, 0..<10) {
    print(pair.0)
    // print "1", "1", "2", "3", "5", "8", "13", "21", "34", "55"
}

AnyIterator 的构造参数就是一个逃逸闭包,它捕获了 xy,并在每次执行时修改这两个变量的值。

3. 捕获与循环引用

然而与捕获打的更多的交道,是消除它的副作用——循环引用。

引用类型的捕获很容易造成循环引用,如果没有适时地打破循环,内存泄漏就会发生。一个常见场景:

class ViewModel {
    
    private var callback: (() -> Void)?
    
    func whenDataLoaded(_ callback: @escaping () -> Void) {
        self.callback = callback
    }
    
    func loadData() {
    	 // 模拟异步加载数据
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            if let cb = self.callback { cb() }
        }
    }
    
    deinit {
        print("ViewModel deinit")
    }
}
    
class Controller {
    
    private let viewModel = ViewModel()

    func bind1() {
        viewModel.whenDataLoaded {
            self.reloadUI()
        }
    }
    
    func reloadUI() {
    }
    
    deinit {
        print("Controller deinit")
    }
}
    
let controller = Controller()
controller.bind1()

闭包会强持有被捕获的变量,于是 controller 持有 viewModelviewModel 持有 callbackcallback 捕获了 self——即 controller。Boom! 内存泄漏了。

这时我们要用 weakunowned 变量捕获 self,从而打破循环引用,即:

func bind2() {
    viewModel.whenDataLoaded { [weak self] in
        guard let self = self else { return }
        self.reloadUI()
    }
}

4. 隐式捕获与显式捕获

我们看到了写在闭包头部的 [weak self],它叫作捕获列表。捕获分隐式捕获与显式捕获,使用捕获列表的即显式捕获。

let dog1 = Dog()
let dog2 = Dog()
    
let closure = { [dog1, dog2] in  // 捕获列表
    dog1.name = "小白"
    dog2.name = "欧弟"
}

它们之间最大的区别就是,显式捕获在定义捕获列表时对要捕获的值进行取值,而隐式捕获则等到执行时才会取值。

显式捕获有点像下边的语法糖:

let capturedDog = dog
DispatchQueue.main.async { 
    capturedDog.doSomething()
}

事实上,显示捕获的另一种写法的确是这样:

DispatchQueue.main.async { [capturedDog = dog] in
    capturedDog.doSomething()   
}

观察下边的代码:

class Cat {
    let name: String
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("Good bye, \(name).")
    }
}
    
var cat = Cat(name: "加菲")

DispatchQueue.main.async { [cat] in
    print("[Explicit Capture] Cat's name is \(cat.name).")
}
    
DispatchQueue.main.async {
    print("[Implicit Capture] Cat's name is \(cat.name).")
}
    
cat = Cat(name: "哆啦A梦")
print("Cat's name is \(cat.name).")

它的输出是:

Cat's name is 哆啦A梦.
[Explicit Capture] Cat's name is 加菲.
Good bye, 加菲.
[Implicit Capture] Cat's name is 哆啦A梦.
Good bye, 哆啦A梦.

因为显式捕获在定义捕获列表时就对「加菲」进行了取值,所以第一个闭包输出「加菲」。当该闭包执行结束,「加菲」的作用域完全结束,Good bye, 加菲. 被输出。

与显式捕获不同,隐式捕获等到执行时才会取值,此时,cat 已经被重新赋值成「哆啦A梦」了。

5. weak 与 unowned

为了打破循环引用,我们常用 weakunowned 修饰捕获的变量,就像上边的:

func bind2() {
    viewModel.whenDataLoaded { [weak self] in
        guard let self = self else { return }
        self.reloadUI()
    }
}

它就像:

func bind2() {
	weak var weakSelf = self
	viewModel.whenDataLoaded {
		weakSelf?.reloadUI()
	}
}

weakunowned 都不增加引用计数,其中,unowned 更加激进。

unowned 总是假定其修饰的引用不为空。所以,你应该只在确认闭包执行时该对象还在时使用 unowned,比如:

let timer = DispatchSource.makeTimerSource()
    
var count = 0
timer.setEventHandler { [unowned timer] in // 等于 [unowned capturedTimer = timer]
    if count == 3 {
        timer.cancel()
    }
    
    count += 1
}
    
timer.schedule(wallDeadline: .now(), repeating: 1)
timer.activate()

但更多时候是无法确认的,weak 相比之下更加包容,它修饰的引用总是可空的,这无疑会让人更加安心,除了你不时地要写 guard let self = self else { return } 外。

6. 捕获的注意点

是否需要显式捕获在大多数情况下都很容易判断。但有个别情况也很容易被忽略:

DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
    guard let self = self else { return }
    self.reloadUI()
}

这里主队列并没有被 self 持有,也就是说循环引用没有发生,但显式捕获仍然是必须的。

试想一下:如果控制器被 popdismiss,理该被立即销毁,可因为 self 被闭包捕获,控制器反而会继续存在一小会儿,直到定时结束,reloadUI 仍会被执行。这通常没有什么现实意义,有时还可能会造成意想不到的数据混乱。

但一股脑地使用 weak self 也并不明智,例如我们需要延时地将数据 flush 到数据库中:

self.dbQueue.asyncAfter(deadline: .now() + 3) {
    self.flush()
}

self.dbQueue 同样不持有入队的闭包,隐式捕获 self 只在闭包还没有被执行之前的一小会儿造成循环引用——一旦闭包执行结束,循环引用就会被打破。

如果使用 weak 显式捕获的话,当 self 被释放,flush() 便不会得到执行了。

精准捕获能让代码更健壮:

viewModel.whenDataLoaded { [navigationItem] data in
    navigationItem.title = data.title
}

如果我们还是捕获 self 的话则需要 [weak self]guard let self = self else { return }self.navigationItem 等等。精准地捕获 navigationItem 能让你在轻松地避免循环引用。

总之,在逃逸闭包里使用外部量时,是否需要显式捕获,是否需要 weak/unowned 捕获,要根据其上下文进行判断。

7. API 设计的一小步

使用带有逃逸闭包的 API 或多或少会增加开发者的心智负担,再熟练的程序员也要花上一点时间考虑怎么捕获更好。

我们可以优化一下 API, 减少使用者犯错的风险。

例如上边的 viewModel.whenDataLoaded,我们把它改成:

func whenDataLoaded1<T: AnyObject>(_ t: T, _ callback: @escaping (T?) -> Void) {
    self.callback = { [weak t] in
        callback(t)
    }
}

这样,开发者在调用时就可以直接:

viewModel.whenDataLoaded1(self) { (controller) in
    
}

不用再考虑捕获啦!

Swift 中的闭包捕获大概就是这样了,如果有帮到你的话,欢迎留言让我知道!😉


#swift

Comments