【译】深入浅出Swift中的内存管理和循环引用

作为一门现代的高级编程语言,Swift代替我们进行了对象的创建和销毁等相关的内存管理。它使用了一个优雅的技术,叫做自动引用技术(Automatic Reference Counting)或ARC。在本篇教程中,你会学习到在Swift中的ARC和内存管理技术。

随着深入了解这一整套系统,你会理解堆对象的生命周期。Swift运用ARC使得在资源有限的环境下做到可预期和高效--比如在iOS系统下。

因为ARC是"自动"的,你不需要明确的参与到对象的引用计数上面来。但是你需要考虑对象之间的引用关系,防止出现内存泄漏。这对于新人开发来说是非常重要的一点。

在本篇文章中,你可以通过学习一下的四点来提升你的Swift和ARC的相关技能:

  • ARC是如何工作的。
  • 什么是循环引用以及如何打破循环应用。
  • 通过一个具体的循环引用的例子,使用最新版本Xcode的可视化工具来检测问题。
  • 如何区别对待值类型和引用类型。

开篇

打开Xcode,然后点击File\New\Playground…,选择iOS Platform,把它命名为MemoryManagement并且点击Next

接下来,将下列的代码添加到你的playgroud中去:


class User {
  var name: String
 
  init(name: String) {
    self.name = name
    print("User \(name) is initialized")
  }
 
  deinit {
    print("User \(name) is being deallocated")
  }
}
 
let user1 = User(name: "John")

这里定义了一个叫做User的类,然后创建了一个该类的示例对象。这个User类拥有一个属性name、一个init的构造方法(在开辟内存空间之后调用)和一个deinit的析构方法(在回收内存空间之前调用),print方法是用来打印当前的生命周期事件,以便我们观察。

你会注意到,在playgroud旁边显示了"User John is initialized\n",这个是在init方法中的打印输出,但是我们会发现,在deinit中的print方法却一直没有被调用,这意味着该对象没有一直没有被销毁。这是因为当前的作用域没有闭合 -- playgroud一直没有脱离当前的作用域 -- 所以该对象就不会从内存中销毁。

我们试着将user1对象包裹在do语句的作用域中,就像这样:

do {
  let user1 = User(name: "John")
}

这里创建了一个作用域给初始化之后的user1对象。在该对用域结束的时候,该user1对象就会被自动销毁。

现在你可以在侧边栏看到initdeinit两个print语句的输出了。这意味着,在该对象从内存中销毁之前,该对象在作用域结束的时候调用了析构方法。

Swift中的对象生命周期拥有五个阶段:

  1. 分配 (从栈内存或者堆内存中分配空间)
  2. 初始化(调用init构造方法)
  3. 活动 (对象的使用)
  4. 析构 (调用deinit方法)
  5. 回收 (从栈内存或者堆内存中释放占用空间)

虽然Swift中没有直接的hooks函数给内存的分配和回收,但是你可以使用print语句作为代理在initdeinit中监控这些生命周期。有的时候,“分配”和“析构”的过程是可以互换的,但是他们是生命周期中完全不同的两个阶段。

引用计数是一个当对象不再被需要的时候自动被回收的机制。现在我们有一个问题:“你是如何确定一个对象在未来永远不被需要了的呢?“,自动引用计数会为每一个对象持有一个使用的计数,也就是我们所说的引用计数

这个计数意味着有多少东西引用了该对象。当一个对象的引用计数变成了0,那么意味着没有对象持有它,那么这个对象就可以被析构和回收了。

当你初始化了一个User对象,ARC就从1开始了对该对象的引用计数。在do语句的闭包末端,user1脱离了作用域,引用计数递减为0。结果,user1执行析构方法并且从内存中回收。

循环引用

在大多数的情况下,ARC非常稳定的运作着;作为一名开发者,你不需要担心哪些对象在不确定的情况之下会发生内存泄漏。

但是这并不是绝无可能的!内存泄漏还是有可能发生!

那么内存泄漏时如何发生的呢?想象一下一种情况,当两个对象不再需要,但是又互相引用着对方。那么这两个对象的引用计数都不可能为0,内存回收也就永远不会发生了。

这种情况就叫做循环引用。它玩弄了ARC阻止了正常的内存清理。正如你所见,引用计数最后不会变成0,因此object1object2永远不会被销毁。

为了重现该问题,我们将下列的代码添加在User类的定义之下,但是再
do闭包之前:

class Phone {
  let model: String
  var owner: User?
 
  init(model: String) {
    self.model = model
    print("Phone \(model) is initialized")
  }
 
  deinit {
    print("Phone \(model) is being deallocated")
  }
}

然后改变do语句做的事情:

do { 
  let user1 = User(name: "John")
  let iPhone = Phone(model: "iPhone 6s Plus")
}

这里添加了一个新的类,叫做Phone,然后创建了一个Phone类的实例对象。

这个新的类非常简单:拥有两个属性,一个是Model(手机型号),一个是owner(拥有者),一个init方法和一个deinit方法。Phone可以独立于User存在,所以owner属性是可选的。

接下来,添加下列的代码到User类:

private(set) var phones: [Phone] = []
func add(phone: Phone) {
  phones.append(phone)
  phone.owner = self
}

这里添加了一个phones的数组来存储当前用户所拥有的所有手机,该方法的setter方法是私有的,所以我们无法直接通过对phones的添加方法来添加手机,我们只能使用add方法来对用户的手机进行添加。这个方法确保了当你添加phone的时候,phoneowner被赋值。

此时,我们可以在侧边看到PhoneUser对象都被正确的释放了。

但是当我们的do语句执行如下的操作的时候:

do { 
  let user1 = User(name: "John")
  let iPhone = Phone(model: "iPhone 6s Plus")
  user1.add(phone: iPhone)
}

在这里,你给user1添加了一台iPhone。这自动将user1赋值给了iPhoneowner。这时一个循环引用就产生了,并且user1iPhone将永远不会被销毁。

弱引用

为了打破这种循环引用,你可以指定对象的引用关系为弱引用。除非有明确的说明,否者所有的引用都是强引用。弱引用和强引用相比的区别是,弱引用并不会导致引用计数增加,并且当弱引用指向的对象销毁的时候自动将其置为nil

上面的图片中,虚线代表了弱引用。值得注意的是,object1的引用计数为1是因为variable1引用了它。object2的引用计数为2,是因为variable2以及object1都引用了它。虽然object2引用了object1,但是这是弱引用,意味着这不会影响对object1的引用计数。

variable1variable2都销毁的时候,object1引用计数将降为0,deinit方法就会被调用。接着,它就取消了对object2的强引用,随后object2也就被销毁了。

现在我们回到playgroud,将owner属性用weak来修饰以达到打破User-Phone的循环引用,就像这样:

class Phone {
  weak var owner: User?
  // other code...
}

现在user1iPhone都会被正确的释放掉了,我们也可以在侧边栏看到相关的打印显示。

无主引用

其实还有另外一种不会增加引用计数的引用修饰:unowned(无主引用)。那么unownedweak之间有什么区别呢?一个弱引用永远都是可选类型的,并且当它所指向的对象被销毁的时候,该引用会被自动置nil,这就是为什么当你定义一个weak属性的时候,必须要使用var来通过编译器的检查(因为这个变量需要被改变)。

相比之下,无主引用永远都不能为可选类型。如果你尝试访问一个无主引用所修饰的一个已经被释放的对象,那么你就会触发错误!

是时候来一些unowned的使用练习了。在do语句�之前添加一个叫做CarrierSubscription的类:

class CarrierSubscription {
  let name: String
  let countryCode: String
  let number: String
  let user: User
 
  init(name: String, countryCode: String, number: String, user: User) {
    self.name = name
    self.countryCode = countryCode
    self.number = number
    self.user = user
 
    print("CarrierSubscription \(name) is initialized")
  }
 
  deinit {
    print("CarrierSubscription \(name) is being deallocated")
  }
}

CarrierSubscription拥有四个属性:订单名称(name),国家编码(countryCode),订单手机号码(phone number)以及一个对User对象的引用。

接下来,在User类的name属性之后添加如下的代码:

var subscriptions: [CarrierSubscription] = []

这里增加了一个subscriptions的数组,这个数组保存着所有的CarrierSubscrition对象:

同样的,在Phone类中的owner属性之后增加如下的代码:

var carrierSubscription: CarrierSubscription?
 
func provision(carrierSubscription: CarrierSubscription) {
  self.carrierSubscription = carrierSubscription
}
 
func decommission() {
  self.carrierSubscription = nil
}

这里增加了可选类型的CarrierSubscription属性,以及一个provision方法和一个decommission方法,分别用来指定一个订单和撤销一个订单。

接下来,我们可以在CarrierSubscription类的init方法的打印语句之前增加下列的代码:

user.subscriptions.append(self)

这确保了CarrierSubscription被添加到了用户的subscriptions数组当中去。

最后,我们的do作用域是这样的:

do { 
  let user1 = User(name: "John")
  let iPhone = Phone(model: "iPhone 6s Plus")
  user1.add(phone: iPhone)
  let subscription1 = CarrierSubscription(name: "TelBel", countryCode: "0032", number: "31415926", user: user1)
  iPhone.provision(carrierSubscription: subscription1)
}

注意侧边栏的输出。再一次我们发现出现了循环引用:user1,iPhonesubscription1都没有被销毁。你能看出来问题在哪里么?

user1subscription1的引用或者subscription1user1的引用应当用unowned修饰来打破循环引用。现在的问题是,哪一方需要被修饰呢?

用户对订单存在拥有关系,相反的,订单对用户是不存在拥有关系的。此外,一个运输订单如果没有目标用户,那么这个订单就是没有意义的。这也是为什么在声明user属性的时候,我们使用不可变的let来声明。一个用户可以脱离订单存在,但是订单无法脱离用户存在,所以订单中所指向的用户需要使用unowned来修饰。

现在我们给CarrierSubscription类的user属性通过unowned来修饰:

class CarrierSubscription {
  let name: String
  let countryCode: String
  let number: String
  unowned let user: User
  // Other code...
}

这打破了循环引用,使得每一个对象都得到了正确的销毁。

闭包中的循环引用

对象之间的循环引用发生在属性互相强引用对方的时候。与对象类似,闭包也是一种引用类型并且会造成循环引用。闭包会捕获它所需要进行操作的对象。

举一个例子,当一个闭包被赋值给一个对象的属性,并且该闭包也是用了该对象的引用,那么就会发生循环引用。换句话说,该对象通过一个存储属性强引用该闭包;而该闭包则通过捕获self的值来保持对该对象的强引用。

将下列的代码添加到CarrierSubscription类的User属性之下:

lazy var completePhoneNumber: () -> String = {
  self.countryCode + " " + self.number
}

这个闭包计算并且返回了一个完整的手机号码。该属性被标记为lazy,意味着该属性直到第一次被访问才进行赋值运算。这样做是必要的,因为如果你想要计算出完整的手机号码,那么你必须首先直到它的self.countryCode(国家编码)以及它的self.number(手机号码),而这两个属性只有在被初始化之后才是可用的,所以我们需要“惰性计算”这个特性。

接着,我们在do语句的末尾添加上如下的代码:

print(subscription1.completePhoneNumber())

你会注意到user1iPhone被成功的销毁了,但是CarrierSubscription却没有被成功的销毁,因为在该对象和闭包之间产生了循环引用:

Swift拥有一种简单优雅的方式来在闭包中打破循环引用。你需要声明一个定义闭包和捕获对象的关系的捕获列表。

为了说明该捕获列表是如何工作的,我们可以先来思考一下以下的代码:

var x = 5
var y = 5
 
let someClosure = { [x] in
  print("\(x), \(y)")
}
 
x = 6
y = 6
 
someClosure()        // Prints 5, 6
print("\(x), \(y)")  // Prints 6, 6

变量x在捕获列表中,所以当闭包被定义的时候一份x的拷贝就会被创建。这也就是说,闭包只是捕获了值而没有捕获引用。而与之相反的,y并没有在捕获列表中,所以闭包便捕获了y的引用。

使用捕获列表来定义闭包和其中所捕获的对象的weak或者unowned关系将变得十分有优势。如果CarrierSubscription一旦销毁,那么闭包就会不存在,在这种情况之下,unowned将会十分的适合。

改变CarrierSubscription类中的completePhoneNumber闭包:

lazy var completePhoneNumber: () -> String = {
  [unowned self] in
  return self.countryCode + " " + self.number
}

这里添加了[unowned self]到闭包的捕获列表中。这意味着,被捕获的self由原先的强引用改变成了“无主引用”。

这样我们就解决了循环引用。

在这里我们使用的其实是一种初次引进的捕获语法的简写,思考一下一列的完整写法:

var closure = {
  [unowned newID = self] in
  // Use unowned newID here...
}

在这里newID其实是selfunowned拷贝。在闭包的作用域之外,self任然指向之前的引用。然而在闭包的作用域之内,self所指向的引用其实是一个对于self的一个新的变量。

所以,在闭包中,selfcompletePhoneNumber的关系就是非拥有的关系了。只要你可以保证闭包中的self对象不会被销毁,那么尽管使用unowned吧。但是如果销毁了,那么你的程序就会Crash掉。

添加下列的代码到你的Playground:

// A class that generates WWDC Hello greetings.  See http://wwdcwall.com
class WWDCGreeting {
  let who: String
 
  init(who: String) {
    self.who = who
  }
 
  lazy var greetingMaker: () -> String = {
    [unowned self] in
    return "Hello \(self.who)."
  }
}
 
let greetingMaker: () -> String
 
do {
  let mermaid = WWDCGreeting(who: "caffinated mermaid")
  greetingMaker = mermaid.greetingMaker
}
 
greetingMaker() // TRAP!

playground会因为self而遭遇一个runtime异常,在闭包当中,who变量任然是有效的,但是其实当mermaid超出作用域的时候,mermaid已经被销毁了,那么这个时候访问self就会出现异常。这个例子可能看起来有一些做作,但是其实在日常的编程中它是很有可能发生的,比如闭包的滞后执行,又或者是某些异步工作之后执行。

我们把greetingMaker变成这样:

lazy var greetingMaker: () -> String = {
  [weak self] in
  return "Hello \(self?.who)."
}

这里我们对原来的闭包进行了两处的改动。首先我们把unowned关键字改成了weak,其次我们需要把访问who属性时候的代码改成self?.who

playground不再Crash了,但是你在闭包的旁边看到了这样的输出:"Hello, nil.",很多时候,这样的输出并不是我们所期待的,这个时候guard let该出场了。

重写之后,我们的代码变成了这样:

lazy var greetingMaker: () -> String = {
  [weak self] in
  guard let strongSelf = self else {
    return "No greeting available."
  }
  return "Hello \(strongSelf.who)."
}

guard语法将weak self绑定到了strongSelf这个新的变量中,如果self是一个nil那么闭包就会返回"No greeting available.",相反的,如果self不是一个nil,那么strongSelf就是一个强引用,所以直到闭包结束之前都可以保证正确的运行。

使用Xcode8找到循环引用

现在你已经明白了ARC的主要内容,什么是循环引用以及如何打破循环引用,现在是时候来看一个真实的例子了。

下载这个项目,并且使用Xcode8打开。你必须使用Xcode8或者Xcode8之后的版本,因为Xcode8增加了一些我们待会儿会用到的新特性。

打开运行这个项目之后你会看到这个界面:

这是一个简单的通讯录App。随便点击一个联系人就可以看到这个人的详细信息,点击右上角的+可以添加联系人。

让我们来看一下代码:

  • ContactsTableViewController: 展示数据库中的所有Contact对象。
  • DetailViewController: 展示一个指定的Contact对象的详细信息。
  • NewContactViewControllerdsa: 允许用户添加新的联系人。
  • ContactTableViewCell: 一个自定义的Cell来展示详细信息。
  • Contact:数据库中联系人的模型。
  • Number: 联系人联系电话的模型。

然而这个项目有一些很大的缺陷:因为这里存在着循环引用。你的用户也不会注意到由细小的内存泄漏而引发的问题--而且这个问题将很难被发现。幸运的是,Xcode8有了新的内建工具来找到这些细小的内存泄漏。

再次运行这个项目。侧滑联系人点击删除,我们删除三四个联系人,这样看起来他们全部被删除了,嗯,没问题...

当App仍在运行的时候,我们来到Xcode的下方,点击Debug Memory Graph按钮。

在Xcode中观察新的几种问题(警告⚠️,错误❌,等等):Runtime issues。他们看起来像是一个紫色的正方形,里面有一个白色的惊叹号,比如下图中选中的那样:

在导航栏中选择其中有问题的Contact对象。这样循环引用就很明显了:ContactNumber互相强引用对方造成了内存泄漏。

思考一下,Contact可以脱离Number存在,但是Number却不能脱离于Contact存在。那么你应该怎么解决循环引用呢?使用weak或者unowned,但是应该修饰在NumberContact还是ContactNumber呢?

这里给你一些不错的建议,如果你需要的话

解决方案

这里有两种解决方案:要么,ContactNumber弱引用,要么,NumberContact无主引用。

苹果官方文档建议我们父对象应当对子对象强引用--不要违背这个原则。这意味着,Contact应当强引用Number对象,而Number应当对Contact保持无主引用,这是当前最适合的解决方案:

class Number {
  unowned var contact: Contact
  // Other code...
}
class Contact {
  var number: Number?
  // Other code...
}

再次运行工程,我们会发现问题被解决了!

PS:值类型和引用类型的循环

Swift的类型可以分为值类型(比如结构体,枚举)和引用类型(比如类)两种。这两者的一个主要的区别是,值类型在进行赋值传递的时候会拷贝一份该值返回,而引用类型在进行赋值传递的时候则是返回一个该对象引用的拷贝。

那么这是不是意味着值类型永远不存在循环引用呢?是的:对值类型的赋值都是拷贝操作,没有引用的创建那么也就不会存在循环引用一说了。你至少需要有两个引用才能引发循环引用。

回到我们的playgroud,添加下列的代码:

struct Node { // Error
  var payload = 0
  var next: Node? = nil
}

看起来,编译器会报错,一个结构体(值类型)不能够被递归或者使用自身的值。否则这个结构体将会变得无穷大。我们将它改变成类:

class Node {
  var payload = 0
  var next: Node? = nil
}

self的引用在类中没有问题,所以编译错误也就消失了。
接着我们添加下列的代码:

class Person {
  var name: String
  var friends: [Person] = []
  init(name: String) {
    self.name = name
    print("New person instance: \(name)")
  }
 
  deinit {
    print("Person instance \(name) is being deallocated")
  }
}
 
do {
  let ernie = Person(name: "Ernie")
  let bert = Person(name: "Bert")
 
  ernie.friends.append(bert) // Not deallocated
  bert.friends.append(ernie) // Not deallocated
}

这里是一个混合类型(值类型 + 引用类型)的循环引用的例子。

虽然friends是一个值类型的数组,但是由于friends数组的装载了对方的引用类型的Person,导致erniebert互相引用而无法释放。如果你企图将数组标记为unowned,那么Xcode会显示错误:unowned只能用来修饰类。

为了在这里打破循环引用,你将不得不创建一个泛型的包装类然后使用它来讲实例对象添加到数组中,如果说你不知道什么是泛型,或者不知道怎么使用它,那你可以看看这篇文章

在定义Person类之前添加下列的代码:

class Unowned<T: AnyObject> {
  unowned var value: T
  init (_ value: T) {
    self.value = value
  }
}

然后改变Personfriends属性的定义:

var friends: [Unowned<Person>] = []

最后,改变do中所做的事情:

do {
  let ernie = Person(name: "Ernie")
  let bert = Person(name: "Bert")
 
  ernie.friends.append(Unowned(bert))
  bert.friends.append(Unowned(ernie))
}

OK,现在erniebert已经被正确的释放掉了~

friends数组已经不再是Person对象的集合了,而是一个Unowned对象的集合,该对象封装了Person对象。

为了访问Person,我们可以这么做:

let firstFriend = bert.friends.first?.value // get ernie

鸣谢

本文出自raywenderlich,感谢17岁的年轻作者Maxime Defauw带来这么好的教程,希望这篇文章可以让大家更好的了解Swift!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容