浅谈swift中闭包修饰符 weak?unowned? 或什么都不用
平常的开发中,clourse是我们iOSr绕不过去的坎儿。
苹果本身也很重视闭包,像之前的一些老的target-action类型的api(例如:NSTimer的初始化方法),都增加了对应的clourse版本。
而在swift中,更是将闭包作为了一等函数,享受和对象同等的待遇,可以作为函数的返回值。
在wwdc2019上,横空出世的SwfitUI,其声明式的语法,终结了UIKit中UIControl、手势等复杂的target-action用户交互响应方式。
也许,过不了几年,如果我们苹果仍然坚挺,我们的代码中的闭包就会像王者小学生一样无处不在了吧。
我的闭包的忠实舔狗——weak
刚毕业去面试时,经常遇见的面试场景如下
面试官:平常使用闭包时,需要注意什么?
我: 注意循环引用
面试官:闭包中的循环引用是怎么造成的
我:闭包会对其内部的所有的对象增加一个强引用,如果其内部的对象也对闭包本身有一个强引用的 话,就会造成 这个对象强引用了闭包, 而闭包又强引用了这个对象,两者引用计数均无法清0,也就无法被释放,造成内存泄漏。
面试官:有什么办法解决这个问题吗?
我:可以再闭包中使用weak修饰这个对象,这样一来,该对象虽然强引用了闭包,但是闭包却是弱引用了这个对象,弱引用是无法增加对象的引用计数的。所以当对象的其他强引用都被移除后,对象引用计数为0,对象正常销毁,循环应用问题也迎刃而解。
面试官: 很好,明天来公司上班吧!
哈哈,上面展示的只是一部分,现在郑州这找工作可不好找。
言归正传,刚使用闭包时,确实如上方,虽然也知道除了weak还有unowned修饰符,甚至有些clourse可以直接使用对象本身,而不使用任何修饰符。
但是自己摸不清楚什么场景使用什么修饰符,为了防止意外,我把所有的闭包都用上了[weak self]来修饰。这种处理方式,
虽然可耻,但是有用!
不过随着接触的项目越来越多,就会发现[weak self]在使用中带来的不便,如下是一个简单的例子
这个例子只是一个用来展示的示例,不涉及任何实际场景
AlertView是一个弹窗,其clickButton闭包传递点击弹窗上的一个按钮
self.alertView = AlertView(...)
alertView.clickButton = {
self.view.doSomeThing(to: self.someView)
}
相信眼睛雪亮的大家已经看出上述代码的问题了。没错就和上方的面试例子中说的一样,这个例子中的alertView和self产生了循环引用。
改进一下代码,在clourse中添加[weak self]来解决循环引用问题
self.alertView = AlertView(...)
alertView.clickButton = {[weak self] in
guard let self = self else {
return
}
self.view.doSomeThing(to: self.someView)
}
可以看到,weak虽然能解决问题,但是它将修饰的self转换成了一个可选值(Optional),相信用swift的小伙伴们能体会到在使用可选值时各种解包的困扰。就如同上面的例子,如果我不用guard else去解包的话,就会写成这样。
self.alertView = AlertView(...)
alertView.clickButton = {[weak self] in
self?.view.doSomeThing(to: self!.someView)
}
我对self使用了!进行了强制解包。
我本人很讨厌这种写法,实际上,我在项目中极少使用!去处理可选值,除非我非常非常非常确信,使用!时,不会造成解包失败问题。我认为!解包它是非常不安全的。如上面的例子,正常情况下上面的写法是没有问题的。但也许clickButton闭包是在延时3秒后调用呢,这时候你还能确定self还存在吗?
这里我直接使用guard else解决了这个问题,但在实际项目中,我们不一定喜欢看到每次使用闭包都要去解包,而且随着使用的闭包越来越多,就会感觉到越来越烦躁。
swift在4.2版本修复了 guard let `self` = self else {return}的编译器bug,
并且允许我们直接使用
guard let self = self else {return}
所以,虽然本人懒的一比,但是还是决定去深入了解下使用clourse时,各种修饰符的使用场景。
最重要的是,什么时候能不用修饰符
实战
先创建一个项目ClourseBigFight(闭包大作战)
在StoryBoard中新建NextViewController
目前逻辑为 ViewController 可以点击红色按钮跳转到NextViewController,因为包括在导航控制器中,所以在NextViewController中,也可以通过左上角的Back按钮返回。
添加NextViewController.swift文件并连接SB中对应的控制器
实际项目演示如下,很简单push 和 pop2个步骤
现在,在NextViewController.swift中添加代码
override func viewDidLoad() {
super.viewDidLoad()
NSLog("NextViewController viewDidLoad")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
NSLog("视图消失了")
}
deinit {
NSLog("视图被销毁了")
}
仅仅简单的描述了NextViewController的声明周期,方便看到结果。运行项目,进入NextViewController,然后退出
控制台打印如下:
可以看到,几乎是在NextViewController 被pop后消失的瞬间,便触发了销毁方法deinit
好了,上面只是准备,接下来才是正戏!
weak unowned 和无修饰符在clourse中的表现
weak
继续在NextViewController的ViewDidLoad中添加如下代码, 忽视NSlog中self可选值带来的警告⚠️
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {[weak self] in
self?.view.backgroundColor = .white
NSLog("延时结束,现在的NextViewController:(self)")
}
我在NextViewController中添加在一个延时操作,这个操作可以在控制台打印“NextViewController viewDidLoad”后触发,并且在3秒后执行将控制器的view的背景色变为红色
重复上面的操作,在延时操作结束前退出NextViewController,控制台打印如下
可以看到,在43秒的时候进入了NextViewController,在45秒时退出来控制器,并且在退出控制器的瞬间,NextViewController的实例便已经被销毁了,在3秒延时结束时,打印的self=nil,也验证了self在延时结束前已经被销毁。
这个实例也证实了我们经常背的面试题中的weak的作用——弱引用。
所以这个闭包并没有对self产生强引用,而在我们退出NextViewController的瞬间,它便失去了所有强引用而被销毁。
思考下weak的作用
- 修饰对象的时候,对象被转换成可选值 ==> 修饰的对象有可能在某个时刻为nil(这个时刻是对象被销毁的时候)
- 修饰的对象在其他强引用都被移除后便被销毁 ==> 没有对修饰的对象施加强引用
unowned
更换上面的weak为unowned,如下
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {[unowned self] in
self.view.backgroundColor = .white
NSLog("延时结束,现在的NextViewController:(self)")
}
unowned不会像weak一样把修饰的对象转为可选值,所以clourse中的self能正常使用,没有了解包的困扰,似乎能解决上面我的烦恼?
不管其他,来看下它在上面例子中的表现
重复上述进出动作
居然崩溃了,果然看似好用的东西都不靠谱。
来看控制台的输出,
可以看到,在33秒时,进入了NextViewController,并且在34秒的时候退了出来,在退出来的时候控制器被销毁了。这似乎和上面使用weak时的情况一样,不过在延时结束时,2个修饰符产生了完全不一样的结果。weak可以正常使用,而unowned在这里报错
self.view.backgroundColor = .white
看下报错内容
Attempted to read an unowned reference but the object was already deallocated
翻译下来意思是
尝试去读取一个无主的引用,但是这个对象已经被释放了
嗯嗯,似乎跟上面控制台打印的正好一致了,在退出NextViewController的时候,它已经被销毁了。
所以在延时结束时,再次去访问self会报错,因为这时候的self已经被销毁了呀。
思考下unowned的作用
- 修饰对象的时候,对象没有被转换成可选值 ==> 保证修饰的对象一定不会为nil
- 修饰的对象在其他强引用都被移除后便被销毁 ==> 没有对修饰的对象施加强引用
之前在网上找为什么unowned会造成这样的问题,它真正的作用是什么,但是似乎没有什么有用的收获。
其实从上面的例子,可以推断出来,unowned修饰的对象就算被销毁了,仍然可以去访问,只不过是,访问的内存块已经被回收了,只剩下了供访问对象的指针。也就是说被unowned修饰的对象,如果被销毁了去访问,就会产生野指针。
为了验证得出的结论,重新运行项目,重复了之前的操作,不过这一次,先不退出NextViewController,直到延时3s结束后,再退出。来看控制台
可以看到,因为在延时结束后,我们没有退出NextViewController,它没有被销毁,所以结束时,延时闭包中的内容正常被打印。而在退出之后,控制器也顺利的被销毁了。
我得出一下结论
unowned只能被使用在闭包的生命周期小于或者等于其修饰的对象的声明周期的场景。 |
无修饰符场景
修改上面的代码
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.view.backgroundColor = .white
NSLog("延时结束,现在的NextViewController:(self)")
}
执行进出操作
这里先卖个关子,我在下面先放个大图,不让大家看到结论,大家可以现在心中思考下,控制台会打出什么样的输出。
来看控制台打印
05秒即将结束时进入NextViewController
07秒退出了控制器,注意,这里控制器消失后没有被直接被销毁
09秒的时候延时操作执行完毕,因为控制器没有被销毁,所以正常打印了NextViewController的信息
同时间,在打印控制器信息结束后,也就是延时闭包执行结束后,NextViewController控制器被销毁
怎么样,和自己想的有出入吗?
是不是惊奇的发现自己一直担心的循环引用问题居然没有触发。
再回顾下循环引用的概念,2个对象相互强引用而造成无法被释放。
上面的例子,控制器是在闭包执行完成后被销毁的,也就是说现在 控制器被闭包强引用了。在闭包执行完成后,闭包失去了对控制器的强引用,而控制器被销毁了。 所以这种情况下,仅仅是闭包强引用了NextViewController,而NextViewController并没有强引用闭包。
也就是说
ble data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">当然,如果你有意向使用修饰符的话,需要记清一下规则
ble data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal"> draft-type="table" data-size="normal" data-row-style="normal"> weak可以适用于大部分情况,除非你想主动引起循环引用unowned修饰符使用时要确保闭包的声明周期在其修饰的对象的生命周期内able data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">
上面简单演示了修饰符的作用,下面会处理一些实际项目中常见的闭包的使用场景。
闭包(clourse)
swift中,闭包和闭包之间是有区别的。
按特性来分
比如,逃逸闭包、非逃逸闭包、自动闭包
这里只谈逃逸和非逃逸闭包
非逃逸闭包
常见的有sort, filter, map等函数,或者不同页面间使用闭包进行传递用户交互事件(例如经常使用的cell中的按钮点击事件传递给控制器)。
他们的共同特征是
able data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">实例
override func viewDidLoad() {
super.viewDidLoad()
NSLog("NextViewController viewDidLoad")
doSomeThing {
NSLog("Hello world")
}
}
func doSomeThing(_ action: () -> ()) {
action()
}
简单写一个doSomeThing的方法,执行后去打印”Hello world“
运行,控制台如下
可以看到闭包的内容被立刻执行了。
逃逸闭包
常见的场景,GCD的延迟执行事件, 网络请求的回调,计时器等方法内作为属性。
它们的共同特征是
able data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">这个延迟的时间可能是1毫秒,也可能是1小时甚至更久。因为这个闭包没有被立刻执行,所以说该闭包从当前域中逃逸了。
实例,改编上面的方法
func doSomeThing(delay: TimeInterval, action: @escaping () -> ()) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
action()
}
NSLog("方法执行了")
}
方法本身没有什么解释的,延时后执行某些操作。
需要注意的是action后新增的@escaping,如果不添加,编译器将报错
意思是,逃逸闭包内捕获了非逃逸属性的闭包action。
先看结果,再解释
注意时间,执行doSomeThing方法时,NSLog("方法执行了")被立刻执行了,但是闭包内的内容被延时了2秒,等NSLog("Hello world")被执行时,doSomeThing方法早已经执行结束。闭包内的内容执行时已经脱离了doSomeThing的执行域。
所以,这种脱离原本执行域的闭包我们称之为逃逸闭包。
上述例子中的action因为延时操作,所以其在执行时脱离了原本的方法doSomeThing的执行域,所以它属于逃逸闭包。
able data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">不过有一种情况例外,如果闭包是可选的,那么不需要添加@escaping也可使用,效果和添加之后一样。
也就是说:
able data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">按使用场景来分
闭包可以作为函数体内的参数和返回值,也可以作为类的属性.
这里不讨论作为返回值的情况,本身比较少见。而且使用规则和其他场景类似。
- 作为函数参数
闭包作为参数时为局部变量,不会被类强引用。而逃逸和非逃逸闭包的概念,也仅限于函数参数中
- 作为属性
会被类强引用
分析与总结
- 非逃逸闭包
作为形参,闭包绝对不会被该对象强引用。
所以绝对不会造成循环引用问题,使用不需要使用weak和unowned修饰符。
- 逃逸闭包,闭包内强引用了对象
作为形参,闭包绝对不会被该对象强引用。所以不会造成循环引用问题。
逃逸闭包可能会造成被闭包强引用的对象延迟释放内存,甚至完全不释放内存(如一个一直不停止的计时器)。
无法保证闭包被调用时,被强引用的对象是否已经被销毁了。因此unowned是绝对不能使用的,它认为对象一定存在,如果对象被销毁了,就会因为野指针而崩溃。
针对负影响——延迟释放内存。可以根据自己的需求确定使用weak还是不使用修饰符。
如果不介意延迟释放内存,可以不使用修饰符,被强引用的对象会在闭包执行完成后被销毁
如果使用weak,则闭包对对象的引用变为了弱引用,对象可能会在闭包内容执行前被销毁(置为nil)
- 作为属性(非weak修饰情况)
作为属性,闭包必定会被其实例对象强引用。
如果闭包中强引用了持有该闭包的实例对象,则会造成循环引用问题,而造成内存泄漏。
如果闭包中强引用的实例对象对持有该闭包的对象强持有,那么会造成下面的循环,这里比较绕,所以画图来表达。当然这中强引用链不仅限于2个3个对象,可能有更长的。
上述情况也会造成循环引用,实际上这种情况非常常见,比如cell的点击回调.
作为属性的闭包比较特殊,因为他既可以在耗时操作中使用(如:网络请求,计时器),又可以再非耗时操作中使用(如:cell的点击回调)。
所以在对待属性类型的闭包时,我们要确定它是否使用在耗时场景中。
如果使用在耗时操作中,并且闭包中强引用了持有闭包的对象,那么,和逃逸闭包类似,需要使用[weak self],
如果使用在非耗时操作中,那么,我们使用[unowned self]即可。
当然,上面两种场景的前提是该闭包会引起循环引用。否则,我们完全没必要使用修饰符。
实践
下面会列举一些日常工作中常用的使用闭包的场景。
1. GCD,网络请求
如最开始我们举的例子
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.view.backgroundColor = .white
NSLog("延时结束,现在的NextViewController:(self)")
}
这里无论如何都不会引起循环引用,原因可以用上方总结来推断,我们先⌘+单击,查看asyncAfter函数:
public func asyncAfter(deadline: DispatchTime, qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = [], execute work: @escaping @convention(block) () -> Void)
可以看到,这里的闭包是一个逃逸闭包,按照上方的结论,在函数中的闭包是形式参数,不会被对象强引用,所以我们不用担心循环引用问题。
唯一需要关注的是,使用的时候是否在意被闭包强引用的self延迟释放带来的问题,如果不介意self被延迟释放内存,那么我们完全可以不使用修饰符,如果介意或者延迟释放self会产生什么难以预料的问题,就可以使用[weak self]来提前释放self。
实际上,网络请求也是一样的,我们可以根据自己的需要选择是否添加[weak self]。但是,记清楚,逃逸闭包中,我们绝对不要使用[unowned self]。
2.动画
平常开发我们可能会经常使用动画,如
UIView.animate(withDuration: 2) {
self.view.alpha = 0
}
上面例子中的闭包是逃逸闭包,但是,不像我们之前得出的结论。动画这里比较奇怪,在使用的时候,完全不需要使用修饰符,但是也不会产生延迟释放的负影响。
目前我发现的闭包中,只有动画这里会有这种情况。
在我测试中,在动画所在视图从屏幕被移除的一瞬间,其闭包中对象的引用就会消失,这里我也没有找到原因,如果有知道的大佬,可以指点一下。
3. 子页面点击事件的回调
我们经常会遇到这样的场景:
一个cell中包含1-多个按钮,点击按钮需要我们去做一下事情,但是这个事情我们需要回到tableview或者collectionview所在的控制器中去处理。比如,点击cell的点赞按钮,我们回到控制器去请求数据,并在更新数据结束后,重新加载列表视图来展示新的点赞数。
我这里简单的重现了一个类似的cell,其创建cell的代码如下
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//1.cell的数据源
var data = datas[indexPath.row]
//2.获取到cell
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell") as! CustomCell
//3.配置cell的数据
cell.configUI(data)
//4.cell的点赞按钮回调
cell.clickTapZanClourse = {
self.reuqestTapZan(for: indexPath)
}
return cell
}
///模拟请求点赞接口
func reuqestTapZan(for indexPath: IndexPath) {
let data = self.datas[indexPath.row]
//5.模拟一个3秒的网络请求
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
//6.网络请求结束后,更新数据
data.zanUp()
self.datas[indexPath.row] = data
//7.刷新cell的UI
self.tableView.performBatchUpdates({
self.tableView.reloadRows(at: [indexPath], with: .automatic)
}, completion: nil)
}
}
cell内容如下
var clickTapZanClourse: (() -> ())?
@IBOutlet weak var bgView: UIView!
@IBOutlet weak var zanNumLb: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
bgView.layer.cornerRadius = 10
bgView.layer.masksToBounds = true
}
func configUI(_ model: CellModel) {
bgView.backgroundColor = UIColor.init(named: model.cellBgColor)
zanNumLb.text = "点赞数:(model.zanNum)"
}
//点击点赞按钮
@IBAction func clickTapZan(_ sender: UIButton) {
clickTapZanClourse?()
}
cell所在控制器的声明周期相关代码如下:
override func viewDidLoad() {
super.viewDidLoad()
NSLog("NextViewController viewDidLoad")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
NSLog("视图消失了")
}
deinit {
NSLog("视图被销毁了")
}
这里我先不使用任何的修饰符。
下一步的操作和最开始的例子一样,我们从一个控制器点击按钮进入该列表所在的控制器,然后接着pop出去。
控制器打印如下:
嗯,视图消失后没有被销毁。
这里可以分析一下,
首先,控制器没有被释放,应该是控制器还被其他对象强引用引起了循环引用。
那么其实我们上面已经说了很多了,问题应该是出在cellForRow中的几个闭包这里。
cellForRow里面的闭包共3个,分别是4,5,7这里
先看4
- 4这里用一个循环引用 控制器——>UITableView——>UITableViewCell——>clickTapZanClourse——>控制器
- 4这里使用了cell的一个储存为属性的闭包
- 4这里cell的点击点赞闭包使用在cell的点击点赞按钮方法中,也就是说,使用在非延时的情况下
- cell的生命周期一定在控制器的生命周期之内
根据我们上面的结论
1可以确认 我们必须使用weak 或 unowned来解除上面的循环引用。
2,3,4可以确认,我们完全可以使用unowned来修饰闭包中的self,当然使用weak也是可以的,不过就需要处理烦人的可选值问题了
更改后代码如下
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//1.cell的数据源
let data = datas[indexPath.row]
//2.获取到cell
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell") as! CustomCell
//3.配置cell的数据
cell.configUI(data)
//4.cell的点赞按钮回调
cell.clickTapZanClourse = {[unowned self] in
self.reuqestTapZan(for: indexPath)
}
return cell
}
///模拟请求点赞接口
func reuqestTapZan(for indexPath: IndexPath) {
var data = self.datas[indexPath.row]
//5.模拟一个3秒的网络请求
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
//6.网络请求结束后,更新数据
data.zanUp()
self.datas[indexPath.row] = data
//7.刷新cell的UI
self.tableView.performBatchUpdates({
self.tableView.reloadRows(at: [indexPath], with: .automatic)
}, completion: nil)
}
}
那么,继续看步骤5和步骤6中的闭包
这里的闭包只有在点击cell上的点赞按钮后才会运行,所以,先不点击点赞按钮再次运行程序
看控制台打印
可以看到,再次重复上方步骤,视图确实被释放了。
继续分析步骤5和步骤6中的闭包
这两个闭包的情况其实可以使用
步骤5 这里用上方 1. GCD 中的方法来判断, asyncAfter是不会引起循环引用的,只是多了一个延迟释放self的负影响,可以根据自己的需求来决定是否使用weak来修饰
步骤6 performBatchUpdates是一个非逃逸闭包,所以完全不需要使用修饰符
那么,先不修改步骤5中的闭包, 我们点击cell的点赞按钮后在延时操作结束前退出控制器
看控制台打印
注意时间,在19秒进入了控制器,并点击了cell上的点赞按钮后,
在21秒时推出了控制器,在23秒左右,演示操作执行,报了⚠️,原因是此时UITableView所在控制器已经不再视图层级中了,所以无法去更新cell。然后在最后,视图被销毁。
确实和之前分析一致,控制器在逃逸闭包 asyncAfter中,导致控制器被延迟释放了。
这里如果不想看到烦人的[TableView]警告的话,我们可以使用[weak self]来使控制器提前释放
///模拟请求点赞接口
func reuqestTapZan(for indexPath: IndexPath) {
var data = self.datas[indexPath.row]
//5.模拟一个3秒的网络请求
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {[weak self] in
guard let self = self else {return}
//6.网络请求结束后,更新数据
data.zanUp()
self.datas[indexPath.row] = data
//7.刷新cell的UI
self.tableView.performBatchUpdates({
self.tableView.reloadRows(at: [indexPath], with: .automatic)
}, completion: nil)
}
}
控制台打印如下
完美。
看到这里,我的这一块的知识已经被掏空了。
不过在实际使用过程中基本上已经可以处理绝大多数日常开发中的闭包问题了。
第一次写技术类的文章,自己的文采也不好,写的不算特别理想,改了很久,总想把每一个点描述清楚,但是实际写的过程中发现,真的是太难了,所以如果看到文章中有比较多的重复的概念,没错,相信你,就是我自己太絮叨了 ,总想说清楚每一个东西。
如果文章能对你有帮助就太好了。