作为一门现代的高级编程语言,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
对象就会被自动销毁。
现在你可以在侧边栏看到init
和deinit
两个print
语句的输出了。这意味着,在该对象从内存中销毁之前,该对象在作用域结束的时候调用了析构方法。
Swift中的对象生命周期拥有五个阶段:
- 分配 (从栈内存或者堆内存中分配空间)
- 初始化(调用
init
构造方法) - 活动 (对象的使用)
- 析构 (调用
deinit
方法) - 回收 (从栈内存或者堆内存中释放占用空间)
虽然Swift中没有直接的hooks
函数给内存的分配和回收,但是你可以使用print
语句作为代理在init
和deinit
中监控这些生命周期。有的时候,“分配”和“析构”的过程是可以互换的,但是他们是生命周期中完全不同的两个阶段。
引用计数是一个当对象不再被需要的时候自动被回收的机制。现在我们有一个问题:“你是如何确定一个对象在未来永远不被需要了的呢?“,自动引用计数会为每一个对象持有一个使用的计数,也就是我们所说的引用计数
。
这个计数意味着有多少东西引用了该对象。当一个对象的引用计数变成了0,那么意味着没有对象持有它,那么这个对象就可以被析构和回收了。
当你初始化了一个User
对象,ARC就从1开始了对该对象的引用计数。在do
语句的闭包末端,user1
脱离了作用域,引用计数递减为0。结果,user1
执行析构方法并且从内存中回收。
循环引用
在大多数的情况下,ARC非常稳定的运作着;作为一名开发者,你不需要担心哪些对象在不确定的情况之下会发生内存泄漏。
但是这并不是绝无可能的!内存泄漏还是有可能发生!
那么内存泄漏时如何发生的呢?想象一下一种情况,当两个对象不再需要,但是又互相引用着对方。那么这两个对象的引用计数都不可能为0,内存回收也就永远不会发生了。
这种情况就叫做循环引用。它玩弄了ARC阻止了正常的内存清理。正如你所见,引用计数最后不会变成0,因此object1
和object2
永远不会被销毁。
为了重现该问题,我们将下列的代码添加在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
的时候,phone
的owner
被赋值。
此时,我们可以在侧边看到Phone
和User
对象都被正确的释放了。
但是当我们的do
语句执行如下的操作的时候:
do {
let user1 = User(name: "John")
let iPhone = Phone(model: "iPhone 6s Plus")
user1.add(phone: iPhone)
}
在这里,你给user1
添加了一台iPhone
。这自动将user1
赋值给了iPhone
的owner
。这时一个循环引用就产生了,并且user1
和iPhone
将永远不会被销毁。
弱引用
为了打破这种循环引用,你可以指定对象的引用关系为弱引用。除非有明确的说明,否者所有的引用都是强引用。弱引用和强引用相比的区别是,弱引用并不会导致引用计数增加,并且当弱引用指向的对象销毁的时候自动将其置为nil。
上面的图片中,虚线代表了弱引用。值得注意的是,object1
的引用计数为1是因为variable1
引用了它。object2
的引用计数为2,是因为variable2
以及object1
都引用了它。虽然object2
引用了object1
,但是这是弱引用,意味着这不会影响对object1
的引用计数。
当variable1
和variable2
都销毁的时候,object1
引用计数将降为0,deinit
方法就会被调用。接着,它就取消了对object2
的强引用,随后object2
也就被销毁了。
现在我们回到playgroud,将owner
属性用weak
来修饰以达到打破User-Phone
的循环引用,就像这样:
class Phone {
weak var owner: User?
// other code...
}
现在user1
和iPhone
都会被正确的释放掉了,我们也可以在侧边栏看到相关的打印显示。
无主引用
其实还有另外一种不会增加引用计数的引用修饰:unowned(无主引用)。那么unowned
和weak
之间有什么区别呢?一个弱引用永远都是可选类型的,并且当它所指向的对象被销毁的时候,该引用会被自动置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
,iPhone
和subscription1
都没有被销毁。你能看出来问题在哪里么?
user1
对subscription1
的引用或者subscription1
对user1
的引用应当用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())
你会注意到user1
和iPhone
被成功的销毁了,但是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
其实是self
的unowned
拷贝。在闭包的作用域之外,self
任然指向之前的引用。然而在闭包的作用域之内,self
所指向的引用其实是一个对于self
的一个新的变量。
所以,在闭包中,self
和completePhoneNumber
的关系就是非拥有的关系了。只要你可以保证闭包中的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
对象。这样循环引用就很明显了:Contact
和Number
互相强引用对方造成了内存泄漏。
思考一下,Contact
可以脱离Number
存在,但是Number
却不能脱离于Contact
存在。那么你应该怎么解决循环引用呢?使用weak
或者unowned
,但是应该修饰在Number
对Contact
还是Contact
对Number
呢?
这里给你一些不错的建议,如果你需要的话
解决方案
这里有两种解决方案:要么,Contact
对Number
弱引用,要么,Number
对Contact
无主引用。
苹果官方文档建议我们父对象应当对子对象强引用--不要违背这个原则。这意味着,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
,导致ernie
和bert
互相引用而无法释放。如果你企图将数组标记为unowned
,那么Xcode会显示错误:unowned
只能用来修饰类。
为了在这里打破循环引用,你将不得不创建一个泛型的包装类然后使用它来讲实例对象添加到数组中,如果说你不知道什么是泛型,或者不知道怎么使用它,那你可以看看这篇文章。
在定义Person
类之前添加下列的代码:
class Unowned<T: AnyObject> {
unowned var value: T
init (_ value: T) {
self.value = value
}
}
然后改变Person
中friends
属性的定义:
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,现在ernie
和bert
已经被正确的释放掉了~
friends
数组已经不再是Person
对象的集合了,而是一个Unowned
对象的集合,该对象封装了Person
对象。
为了访问Person
,我们可以这么做:
let firstFriend = bert.friends.first?.value // get ernie
鸣谢
本文出自raywenderlich,感谢17岁的年轻作者Maxime Defauw带来这么好的教程,希望这篇文章可以让大家更好的了解Swift!