iOS开发 - 老生常谈的循环引用问题

debug-like-a-tuner.jpg

内存管理在iOS开发中很重要,在iOS 5之前,开发者需要使用MRC(Manual Reference Count)来进行对象的内存管理;为了方便开发者,从iOS 6开始,苹果引入了ARC(Automatic Reference Count)来进行内存管理,这无疑大大地减少了开发者的工作量。

ARC本质还是MRC,它是在Xcode编译期间,在代码适当的位置添加retain ,releaseautorelease操作。在绝大多数时间,我们可以使用ARC愉快地写代码,但是为了写出更加安全和健壮的代码,开发者还是需要了解内存管理的知识,以防因为内存管理不当导致莫名其妙的问题。

在swift编码过程中,大多数时候开发者过于相信ARC,一不小心还是可能踩到循环引用的坑里面。并且循环引用的问题往往会潜伏起来,在开发的初期,并不会导致异常,但是随着业务复杂度增加、参与的人增多,潜在的循环引用的问题就会出现。而解决循环引用导致的问题,也会消耗不少的人力和时间,回顾循环引用产生的原因,一方面是开发者的经验不足,甚至不知道循环引用的概念,所以写不出高质量的代码;另一方面,有可能是开发过程中并没有引入规范的编码方式,一个人踩了坑,找到了解决方案,并不能推广给团队中的其他成员,导致其他人前赴后继地踩坑。

本篇文章,主要讨论了swift中解决循环引用的方案,并结合一个案例,帮助读者加深理解。

1. Swift中的循环引用

在swift中,对象引用时默认是强strong引用,如果两个对象相互持有时,则会造成循环引用,为了解决这个问题,swift引入了weakunowned两个修饰关键字。

相对于强strong引用,weakunowned则称为弱引用和无主引用,weak和unowned都不会对一个引用对象产生强引用,这在基本原理上来说是因为它们都不会增加对象的引用计数(retain count)。

按照我们OC时代的经验,weak就可以解决循环引用的问题,开发者会感到奇怪,swift有了weak,为什么swift还要引入unowned关键字?以及,unowned和weak有什么区别呢?

1.1 weak, unowned产生背景

之所以有weak和unowned两个关键字,则是与swift语言的可选(optional type)类型相关,关于可选类型的技术点不是本篇探讨的重点,此处不再赘述。

如果,我是说如果,如果swift语言没有optional概念,那么使用weak足以解决循环引用的问题。现在,swift为了保证代码安全性,引入了optional概念。说到底,optional是一个类型,它与Int, String或者诸如自定义的Person类一样;但是optional类型特殊之处在于它可以用来包裹其他的类型,例如var person: Person?, let number: Int?就是用optional类型包裹了Person和Int类型。这样说来,swift中定义属性有两种形式,即optional和non-optional,仅仅一个weak关键字不足以同时解决optional和non-optional两种情况,所以swift引入了另一个关键字unowned。

1.2 weak与optional相关

弱(weak)引用允许引用对象为空,并且我们知道,在swift中只有optional类型定义的变量或对象才可以设置为空,所以如果我们使用weak关键字定义属性,则必须保证该属性是optional类型。

1.3 unowned与non-optional相关

使用unowned时,我们会假定一个对象a的无主引unowned reference用b在其持有者a的生命周期中永远不会被置为nil,同时必须保证一个无主引用对象unowned reference在它的初始化方法中就被赋值,这也意味着该无主引用对象应该定义为non-optional类型,这样我们就可以安全使用该对象,而不必须进行安全检查。

如果因为某些原因,一个无主引用对象从内存中被释放,那么当我们使用该对象的时候,App就会闪退。

1.4 参考Apple官方的建议

上面的内容对于读者来说,可能还是感觉含糊不清,我们来看一下Apple文档中的建议吧,概括起来大概就是下面两条原则,

  1. 当一个对象有可能在它的生命周期内被设置为nil时候,使用weak关键字;
  2. 当你可以确保一个对象在它的生命周期内不会被设置为nil时,使用unowned关键字。

同时,文档中也提供了两个demo,探讨了循环引用的案例和打破循环引用的方法,

先看一下使用weak的demo,如下代码所示,

// weak关键字例子

class Person {
    let name: String
    init(name: String) { self.name = name }
    var apartment: Apartment?
}

class Apartment {
    let number: Int
    init(number: Int) { self.number = number }
    weak var person: Person? // 承租人、占用者
}

这个demo是一个人和公寓的简单模型,Person类有一个Apartment类型的optional属性apartment,同时,Apartment类有一个Person类型的person(承租人)属性,该属性也是optional类型。

Optional在此处表明的意思就是Person对象可能会持有apartment属性,也可能不会;Apartment对象可能会、也可能不会持有person属性。这会造成一个问题,如果Person和Apartment双方都持有了对方,就形成了循环引用,为了打破循环引用,将Apartment类的Person类型的person属性改为weak修饰,经过这样修改之后,两者的关系如下图所示 ,

weak-reference.png

这是一个很好的demo,展示了使用weak关键字的场景。使用weak关键字打破了循环引用之后,两个对象之间不会形成紧密的依赖关系,能够在适当的时机从内存中释放。

再看一下使用unowned的demo,如下代码,


// unowned关键字例子

class Customer {
    let name: String
    var card: CreditCard?
    init(name: String) { self.name = name }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer
    init(number: UInt64, customer: Customer) { 
        self.number = number
        self.customer = customer
    }
}

在这个demo中,是消费者和信用卡的模型,一个消费者可能会拥有、也可能没有一张信用卡;而一张信用卡必定有一个关联的消费者。为了用代码表示两者之间的关系,一个Customer类有一个optional类型的creditCard属性,而一个CreditCard类型有一个non-optional类型、unowned修饰的customer属性。他们之间的关系如下图所示,

unowned-reference.png

2. 闭包引起的循环引用

当然除了两个对象相互持有造成循环引用,还有一种情况也会造成循环引用,那就是闭包(在swift中叫闭包closure,在OC中叫代码块block),并且使用闭包或代码块的场景更多,更容易因为编码疏忽或不规范造成潜在的循环引用。笔者在开发过程中就碰到了这种神坑,下面笔者就回顾一下解决bug的过程,希望能够抛砖引玉,也希望读者在开发过程中能避开这类错误。

2.1 Bug起因

最近的开发周,同事M在接手另一个同事K的工作任务,他在开发过程中遇到一个很奇怪的现象,简单描述一下,在HomeVC页面上是一些品牌信息列表,用户可以点击进入详情,也可以点击关注按钮直接关注;还可以点击导航栏更多品牌按钮,跳转到MoreBrandListVC,这个列表显示更多的品牌信息。如下图所示,

follow-brands.png

按照设计和编码原则,HomeVC的品牌关注状态应该与MoreBrandListVC对应的品牌关注状态保持一致,但测试同事提的比较奇怪的问题就是在HomeVC和MoreBrandListVC之间反复切换,并且多次重复点击关注某个品牌以后,例如关注了“品牌3”,点击“返回”pop回到HomeVC时候,下拉刷新,发现在HomeVC页面,“品牌3”的关注状态是未关注,与MoreBrandListVC关注状态不一致。

刚开始分析该问题时候,以为是客户端多次操作,发送请求太多,服务端处理不及时或者服务端缓存造成的返回结果不一致,准备强行甩锅给服务端开发人员。后来服务端同事说他没有做缓存,而且android是OK的,这就尴尬了。

2.2 多人协作定位bug

既然服务端同事来帮分析问题了,咱们就把他叫过来一块看看Xcode的打印日志,按照测试的操作步骤,iOS同事M不久就重现了测试指出的bug,在Xcode的console控制台中看了看服务端返回的数据,跟客户端显示的是一致的。服务端同事也有点哑口无言,但是他突然看出了异常的情况,就是在Xcode的控制台有很多发送“某品牌关注状态更新的请求”的日志,按照道理,无论是在HomeVC点击关注或下拉刷新,亦或是在MoreBrandListVC点击关注品牌,操作再复杂,最多也就十几条请求吧,但是我们看到的实际情况是Xcode打印了四五十条的请求日志。这一点很让人费解,我们定位了一下发起请求的地方,原来是在MoreBrandListVC里面,更具体地来说,就是MoreBrandListVC观察了某个notification,收到notification之后进行了网络请求。

问题应该就是出现在MoreBrandListVC上面,在回到HomeVC之后,iOS系统会在合适的时机将MoreBrandListVC对象从内存中释放;而现在出现的问题是,在HomeVC页面,MoreBrandListVC对象内部还在接收到notification之后发送网络请求。这说明MoreBrandListVC对象的内存并没有被合理的释放,而是一直存在于内存中,造成MoreBrandListVC不能释放的原因极有可能是循环引用。

请读者再看一下测试重现bug的操作,反复在HomeVC和MoreBrandListVC之间切换,每一次从HomeVC页面push到MoreBrandListVC页面,就创建了一个MoreBrandListVC对象,而因为某部分代码原因,导致MoreBrandListVC对象与另一个对象形成了循环引用。这样,内存中就存在了多个MoreBrandListVC对象,这多个对象再接收到notification之后,就开始进行网络请求,这就是Xcode日志中看到四五十条网络请求的原因。

找到这样一个bug,也是一个挺艰难和复杂的过程,简单描述一下,重现并定位该bug,大概是这样几个步骤,

  1. 测试部门同事反复、多次的非常规操作,以及他坚持不懈追究到底的决心;
  2. 服务端和客户端开发一起看Xcode打印日志,并由服务端同事发现过多请求的异常;
  3. 客户端同事分析现象,定位bug原因是循环引用。

以上,只是简单概括找出bug的过程,实际上中间的坑更多。最麻烦的一点大概就是这一块的bug是之前的版本就已经存在了,只是当时没有测试出来,而现在不知道隔了多久,万年老坑被挖出来,已经不知道当时这块代码的维护者是哪个人。可以说,bug时间越久,就越难排查。

2.3 逐步排查,精确打击

在分析可能造成循环引用的地方,我们进行了一一排查,大体思路是这样的,

  • 首先,MoreBrandListVC的对象创建是在HomeVC内,那么MoreBrandListVC对象有没有反向的持有HomeVC呢,仔细看了看代码,并没有这种情况;
  • 其次,有没有可能是MoreBrandListVC内部与其他block相互持有造成了循环引用呢。我在MoreBrandListVC文件里面搜索了block关键字,搜索到了4个,其中有两个是网络请求的block回调;还有两个是页面CollectionViewCell关注按钮点击的事件回调。

排除了第一种情况,那么着重分析第二种情况,最终我们排查出问题在于CollectionViewCell按钮点击的事件回调与MoreBrandListVC对象造成了循环引用,下面的代码,是因为闭包导致了循环引用,读者可以回顾一下,自己是否写过这样的代码,


func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCellWithReuseIdentifier(kCellReuseIdentify, forIndexPath: indexPath) as? CustomCell
        
        // cell?.focusButtonClickBlock = xxx会导致循环引用
        cell?.focusButtonClickBlock = { (model) -> Void in
          self.sendRequest(withModel: model)
        }
        return cell!
    }

上面的代码,第一眼看起来没有什么问题,但是正如注释所言,cell?.focusButtonClickBlock = xxx会导致循环引用,这段代码中有这样几个角色,self, cell, focusButtonClickBlock,self代表当前的控制器,cell是自定义的UICollectionViewCell,focusButtonClickBlock代表cell上面按钮点击的事件回调,这样划分了角色,就能弄清3者相互持有的过程,大体是这样的,

  1. self持有了cell,
  2. cell持有了focusButtonClickBlock,
  3. focusButtonClickBlock内部持有了self。

3者之间的关系,如下图所示,

strong-retain-cycle.png

这样,3者之间就形成了循环引用,接下来的事情就是打破这个循环引用,使用unowned来打破循环引用,bug也迎刃而解,如下代码所示,


func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCellWithReuseIdentifier(kCellReuseIdentify, forIndexPath: indexPath) as? CustomCell
        
        // 这里添加了[unowned self]
        cell?.focusButtonClickBlock = { [unowned self] (model) -> Void in
          self.sendRequest(withModel: model)
        }
        return cell!
    }

在focusButtonClickBlock内部定义[unowned self]打破了三者之间的循环引用,因为此时闭包持有的self是unowned修饰,它表明闭包并不对self是强引用,而是无主引用,这样当self即ViewController通过点击返回按钮,在NavigationController的堆栈中出栈时,就不会因为循环引用导致无法从内存中释放。打破循环引用之后,三者之间的关系如下图所示,

break-retain-cycle.png

3. 结尾的释疑

这里使用了unowned解决了循环引用,那么为什么不使用weak呢?其实也可以使用weak来解决问题,两者区别就是self是否可能为nil,当我们点击cell上面的按钮时候可以确保self是存在的,而不是nil,所以使用unowned不会有什么问题。

但是,如果在一个网络请求的回调里面使用unowned,那么有可能会导致crash,因为用户有可能在网络请求的过程中等的不耐烦,直接从当前ViewController页面退回前一个页面,这时候self就是nil,使用unowned导致了崩溃;而使用weak则不会有这种问题,因为weak允许被修饰的对象为nil。

这么说来[weak self]相当于self?,是可选解包;而[unowned self]相当于self!,是隐式强制解包。

差不多就是这样吧。

参考链接

公众号

微信扫描下方图片,欢迎关注本人公众号foolishlion,咱们来谈技术谈人生,因为这又不要钱,

foolishlion.jpg

推荐阅读更多精彩内容