ARC 下的循环引用

本文翻译自 digitalleaves.com,感谢 dopcn 校稿。

ARC 下的循环引用类似于日本的 B 级恐怖片。当你刚成为苹果开发者,你或许不会关心他们的存在。直到某天你的一个 app 因内存泄露而闪退,你才突然意识到他们的存在,并且发现循环引用像幽灵一样存在于代码的各个角落。年复一年,你开始学会如何处理循环引用,检测和避免它们,但是这部片子的恐怖结局还是在那里,随时可能出现。

ARC 令许多开发者(包括我)感到失望的地方之一是苹果保留了用 ARC 来进行内存管理。ARC 很不幸地没有包括一个循环引用检测器,所以很容易就会产生循环引用,因此迫使开发者在写代码的时候采取一些特别的防范措施。

循环引用一直是一些 iOS 开发者感到费解的一个课题。
网上对此也有一些误解[1][2],这些文章给了错误的建议和修复方法,其方法甚至可能引发问题和导致闪退。在这片文章,我想要针对这些问题解释清楚。

##理论简介

内存管理可以追溯到手动内存管理(Manual Retain Release,简称 MRR)。在 MRR,开发者创建的每一个对象,需要声明其拥有权,从而保持对象存在于内存中,当对象不再需要的时候撤销拥有权释放它。MRR 通过引用计数系统实现这套拥有权体系,也就是说每个对象有个计数器,通过计数加一表明被一个对象拥有,减一表明不再持有。当计数为零,对象将被释放。由于手动管理内存实在太烦人,因此苹果推出了自动引用计数(ARC)来解放开发者,不再需要开发者手动添加 retain 和 release 操作,从而可以专注于 App 开发。在 ARC,开发者将会定义一个变量为“strong”或“weak”。一个 weak 弱引用无法 retain 对象,而 strong 引用会 retain 这个对象,并将其引用计数加一。

retainCycle

##我为什么要关心这些

ARC 的问题是循环引用很容易发生。当两个不同的对象各有一个强引用指向对方,那么循环引用便产生了。试想下,一个 book 对象持有多个 page 对象,每个 page 对象又有个属性指向它所属的 book 对象。当你释放了持有 bookpage 对象的变量时,他们仍然还有强引用指向各自,因此你无法释放他们的内存,即使已经没有变量持有他们。

不幸的是,循环引用在实际中并没有那么容易被发现。多个对象之间(A 持有 B,B 持有 C,C 也恰好持有 A)也可以产生循环引用。更糟的是,Objective-C 的 block 和 Swift 的闭包都是独立内存对象,它们会持有其所引用的对象,于是就引发了潜在的循环引用问题。

循环引用对 app 有潜在的危害,会使内存消耗过高,性能变差和 app 闪退等。然而,苹果文档对于可能发生循环引用的场景以及如何避免并没有详细描述,这就容易导致一些误解和不良的编程习惯。

##一些用例模拟

废话不多说,我们一起来分析一些场景中是否会产生循环引用,以及如何避免它。

###父子对象关系

父子对象关系是一个循环引用的典型案例,不幸的是,它也是唯一一个存在于苹果文档中的案例。其实就是前文描述的 Book 与 Page 案例。典型的解决方法就是,在子类定义一个指向父类的变量,声明为 weak 弱引用,从而避免循环引用。

class Parent {
var name: String
var child: Child?
init(name: String) {
self.name = name
}
}
class Child {
var name: String
weak var parent: Parent!
init(name: String, parent: Parent) {
self.name = name
self.parent = parent
}
}

在 swift 中子类指向父对象的变量是一个弱引用,这就迫使我们将该弱引用定义为 optional 类型。如果不使用 optional 可以有另一种做法,将指向父对象的变量声明为“无主引用(unowned)”(表明我们不持有该对象,也不对其进行内存管理)。然而在这种情况下,我们必须非常小心,确保只要还有子对象指向它,父对象不变成 nil,否则会直接闪退。

class Parent {
var name: String
var child: Child?
init(name: String) {
self.name = name
}
}
class Child {
var name: String
unowned var parent: Parent
init(name: String, parent: Parent) {
self.name = name
self.parent = parent
}
}
var parent: Parent! = Parent(name: "John")
var child: Child! = Child(name: "Alan", parent: parent)
parent = nil
child.parent <== possible crash here!

通常有效的做法是,父对象必须持有(强引用)子对象,而子对象只要保持一个弱引用指向他们的父对象。这同样适用于集合对象,它们必须持有它们包含的对象。

###当 Block 和闭包包含在类的成员变量中

另外一个典型的例子,可能不是那么直观。如我们前面解释的,闭包和 block 都是独立的内存对象,会 retain 它们所引用的对象,因此如果我们有个类,里面有个闭包变量,并且这个闭包恰好引用了自身所属对象的一个属性或方法,那么就可能产生循环引用,因为闭包会创建强引用捕获“self”。

class MyClass {
lazy var myClosureVar = {
self.doSomething()
}
}

这个案例的解决方法是定义一个弱版本的 self,然后在闭包或 block 中使用。在 objective-C,我们会定义一个新的变量:

- (id) init() {
__weak MyClass * weakSelf = self;
self.myClosureVar = ^{
[weakSelf doSomething];
}
}

然而在 Swift 我们只需要在闭包的头部声明 [weak self in]:

var myClosureVar = {
[weak self] in
self?.doSomething()
}

用这个方法,当闭包结束的时候,内部的 self 变量不会被强引用,所以它会被释放,打破了循环引用。注意当 self 被声明为 weak,闭包内部的 self 是个可选值。

###GCD: dispatch_async

和我们通常所认为的不同,dispatch_async 自身不会造成循环引用

dispatch_async(queue, { () -> Void in
self.doSomething();
});

在这里,闭包会强引用 self,但是实例化的 self 不会强引用闭包,所以一旦闭包结束,它就会被释放,所以循环引用也不会产生。然而,总有些开发者认为它可能会产生循环引用。有些开发者甚至以为,所有在 block 和闭包里面的 self 都需要弱引用:

dispatch_async(queue, {
[weak self] in
self?.doSomething()
})

在我看来,每种情况都采用这种方法并不是一个好的实践。让我们试想下,如果我们有个对象,用于发送一个后台任务(比如下载数据),并且调用了 self 的一个方法。这时如果我们弱引用 self,该对象的生命周期结束早于闭包结束被释放,因而当我们的闭包调用的 doSomething()方法,该对象可能就不存在了,方法也得不到执行。合适的解决方法是(苹果推荐)在闭包内部,声明一个强引用指向弱引用。

dispatch_async(queue, {
[weak self] in
if let strongSelf = self {
strongSelf.doSomething()
}
})

我觉得这种语法不仅恶心乏味不直观,而且违反了闭包作为一个独立处理实体的原则。学会理解对象的生命周期,明白何时应该声明弱引用,以及对象生存周期的意义,这很重要。但是,这又使得我分心而无法专注于 app 开发的问题本身,如果 Cocoa 不使用 ARC,也就不必要写这些代码。

###本地闭包和 block

函数的闭包和 block 如果没有引用任何实例或类变量,其本身也不会造成循环引用。最常见的一个例子就是 UIViewanimateWithDuration

func myMethod() {
...
UIView.animateWithDuration(0.5, animations: { () -> Void in
self.someOutlet.alpha = 1.0
self.someMethod()
})
}

和 dispatch_async 和其他相关的 GCD 相关方法一样,我们不需要担心局部变量闭包和 block 产生循环引用。

###代理协议

代理协议也是一个典型的场景,需要你使用弱引用来避免循环引用。将代理声明为 weak 是一个即好又安全的做法:

@property (nonatomic, weak) id <MyCustomDelegate> delegate;

在 swift:

weak var delegate: MyCustomDelegate?

在大多数的情况中,一个对象的代理持有一个实例化的对象,或应当生命周期长于该对象(从而响应代理方法),因此一个设计良好的类应该不需要我们考虑任何有关生命周期的问题。

###使用 Instruments 调试循环引用

不管我多努力仔细,我有时还是会忘记声明一个弱引用,然后意外地创建一个新的对象(感谢 ARC 的无所作为!)。幸运的是,XCode 自带了一个很强大的工具 Instruments,用于检测和定位循环引用。一旦你的 app 开发结束,即将提交到 Apple Store,先分析你的 app 是一个好的习惯。Instruments 有很多组件,可以用来分析 app 的不同方面,但是我们现在关心的时 Leak 选项。

instruments

Instruments 一启动,你的应用也应该启动了,然后执行一些交互操作,特别是你想要测试的区域或视图控制器。被检测到的泄露都会以一条红色线显示在 Leaks 区域。Assistant 视图会显示关于泄露的栈追踪,甚至可以直接定位到出问题的代码。

instruments2