【51CTO.com快译】
简介
作为一种现代化高级编程语言,Swift为您的应用程序中的分配、释放等内存管理需求提供强有力的支持。它使用的是一种称为自动化引用计数(ARC)的技术。通过本文的学习,你将通过以下内容进一步提升你的Swift开发中的ARC编程水平:
- 了解ARC的工作原理。
- 何谓引用循环以及如何消除这种循环。
- 通过一个实例展示引用循环,并通过最新的Xcode可视化工具了解如何检测这种循环。
- 如何处理值类型和引用类型混合应用情形。
入门
打开Xcode并单击命令「File\New\Playground…」。然后,选择iOS平台,并将其命名为「MemoryManagement」,然后选择【Next】命令。最后,把工程保存到你想要存储的目标位置,然后删除其中的样板代码并保存工程。
接下来,将下面的代码添加到您的工程文件中:
- 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,并创建它的一个实例。该类有一个属性是name,定义了一个init方法(刚好在内存分配之后进行调用)和一个deinit方法(刚好在内存回收后调用)。打印语句print用于及时输出你想看到的所发生的事情。
从输出结果中,你会发现在侧边栏中显示出“User John is initialized\n”;此信息是通过在初始化方法init中的打印语句print输出的。但是,你会发现,deinit方法内的print语句永远不会被调用。这意味着,对象永远不会被析构。当然,这也就意味着它永远不会被释放。这是因为,它被初始化的范围永远不会关闭——工程本身永远不会走出这个范围——因此,对象不会从内存中删除。
现在,我们改变上面的初始化方法,像如下这样:
- do {
- let user1 = User(name: "John")
- }
此语句创建了一个范围,此围绕包含了user1对象的初始化。于是,在作用域结束后,我们希望user1对象会被释放。
现在,你看到对应于初始化和析构方法中的两个print语句都在侧边栏中输出了内容。这表明,该对象在上面定义的作用域结束后,也就是恰好在它被从内存中删除之前被析构。
归纳起来,一个Swift对象的生命周期包括五个阶段:
1. 分配(从堆栈或堆中分配内存)
2. 初始化(init代码运行)
3. 使用(使用对象)
4. 析构(deinit代码运行)
5. 释放(内存又回到了堆栈或堆)
虽然没有直接的钩子技术埋伏到内存分配和内存回收中,但是您可以在init和deinit方法中 使用print语句作为代理手段来监控上述过程。注意,尽管上面过程4和5中的释放和析构两个方法常常交替使用,但实际上它们是在一个对象的生命周期中的两个不同的阶段。
引用计数是当不再需要对象时被释放的机制。这里的问题是:“你何时可以肯定未来不会需要这个对象?”通过保持一个使用次数的统计计数,即“引用计数”即可实现这一管理目的。引用计数存储于每一个对象实例内部。
上面的计数能够确定,有多少“东西”引用了对象。当一个对象的引用计数降为零时,也就是说对象的客户端不再存在;于是,对象被析构和解除内存分配;请参考下图示意。
当你初始化User对象时,开始时该对象的引用计数为1,因为常量user1引用该对象。在do语句块的结束,user1超出范围,计数减1,并且引用计数递减到零。这样一来,user1被析构,并且随后取消内存分配。
引用循环
在大多数情况下,ARC就像一个魔法一样起作用。作为开发人员,您通常不必担心内存泄露,例如不必担心未使用的对象是否还存活于内存中。
但事情并不总是一帆风顺!内存泄漏也可能发生!
泄漏是怎样发生的?让我们设想有这样的情况,某两个对象不再需要使用它们,但它们各自引用了对方。既然每一个对象都有一个非零的引用计数;那么,这两个对象的释放就永远不会发生。
这就是所谓的强引用循环。它愚弄了ARC,并防止被从内存中清理掉。正如你所看到的,在最后的引用计数并不为零,因而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的新类,并创建此新类的一个实例。
这个新的类是相当简单的:拥有两个属性,一个用于模型存储和一个用于拥有者,还有一个初始化方法init和析构方法deinit。其中,owner属性是可选的,因为Phone可以不需要User而存在。
接下来,将下面的代码添加到User类中,正好位于name属性后面:
- private(set) var phones: [Phone] = []
- func add(phone: Phone) {
- phones.append(phone)
- phone.owner = self
- }
这部分代码将增加一个phones数组属性来保存一个用户所拥有的所有电话号码。而且,这个setter方法是私有的,这样客户端会被强制使用add(phone:)方法。此方法可确保当你添加新号码时owner设置正确。
目前,如你可以在侧边栏中看到的,无论是Phone还是User对象都会按预期释放。
但现在,你如果把do语句块修改成如下这样:
- do {
- let user1 = User(name: "John")
- let iPhone = Phone(model: "iPhone 6s Plus")
- user1.add(phone: iPhone)
- }
在这里,你把iPhone添加到user1。这会自动将iPhone的owner设置为user1。在这两个对象之间的一个强引用循环防止ARC重新分配它们。这样一来,无论是user1还是iPhone从未被释放。
弱引用
为了打破引用循环,您可以将引用计数的对象之间的关系指定为weak。除非另有说明,所有引用都是强引用。相比之下,弱引用并不会增加对象的强引用计数。
换句话说,弱引用并不参加对象的生命周期管理。此外,弱引用总是被声明为optional类型。这意味着,当引用计数变为零时,引用可被自动设置为nil。
在上图中,虚线箭头表示弱引用。注意,图中的object1的引用计数是1,因为变量variable1引用了它。Object2的引用计数为2,因为variable2和object1都引用了它。但是,object2弱引用object1,这意味着它不会影响object1的强引用计数。
当两个变量(即变量variable1和变量variable2)销毁后,object1的引用计数为零并将调用deinit。这将消除对object2的强引用;当然,随后object2也被析构。
现在,请再打开上面的示例工程,通过使owner成为弱引用,从而打破User和Phone间的引用循环,代码如下所示:
- class Phone {
- weak var owner: User?
- // other code...
- }
相应的图示如下:
现在,user1和iphone这两个变量在do语句块的最后都能够正确释放内存。你可以从侧边栏的输出结果中观察到这一点。
无主引用
Swift语言中还引入了另一种不增加引用计数的引用修饰符:unowned。
那么,unowned和weak引用之间的区别是什么?弱引用始终是可选的,并且当引用对象析构时自动变为nil。这就是为什么为了使你的代码进行编译(因为变量需要改变)而必须把弱属性定义为可选的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和一个到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属性和两个新的函数。
接下来,添加以下代码到CarrierSubscription类的初始化方法init中,正好位于打印语句之前:
user.subscriptions.append(self)
这将确保CarrierSubscription被添加到用户的订阅数组中。
最后,修改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的引用都应当是无主引用,从而打破这种循环。现在的问题是:这两个应选择哪一种?要解决这个问题,需要你有一点关于域(domain)的知识作为帮助。
用户拥有一个订阅,而订阅并不拥有用户。此外,没有拥有它的用户的CarrierSubscription是没有任何存在意义的。这就是为什么你在最开始的位置把它声明为一个不可改变的let类型属性的原因。
由于没有CarrierSubscription的用户可以存在,但没有用户的CarrierSubscription没有存在必要;因此,user引用应当是无主类型(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的值将是对应于此时的任何可能的取值,而不是对应于捕获点处原来的值。
因此,捕捉列表用于在闭包内部定义弱引用对象或无主引用对象之间的关系。在上述例子中,unowned引用就是一个不错的选择,因为在CarrierSubscription的实例消失后闭包是不可能存在的。
现在,请把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之间的关系应当是无主(unowned)引用。如果您确信闭包中的一个引用对象将永远不会释放,那么你可以使用unowned引用。如果这个对象确定要释放内存,那么就存在麻烦了。
请把下面的代码添加到上面示例工程文件的结尾:
- 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!
程序运行时将引发一个运行时异常,因为闭包期望self.who仍然有效,但是当mermaid变量脱离其范围时会被释放。这个例子似乎有些做作,但在现实开发中很容易发生这种情况——例如,当您使用闭包要很晚时候才运行某些东西的时候(譬如在异步网络调用完成后)。
好,下面请把WWDCGreeting中的greetingMaker变量更改成如下这样:
- lazy var greetingMaker: () -> String = {
- [weak self] in
- return "Hello \(self?.who)."
- }
这段代码中,你对原来的greetingMaker作出两处修改。首先,使用weak替换unowned。其次,由于self成为weak类型,所以你需要使用self?.who来访问who属性。
再次运行示例工程时系统不再崩溃了,但你在侧边栏中得到一个奇怪的输出结果:“Hello, nil.”。也许,这是可以接受的,但更多的情况下当对象已经一去不复返时你往往想做一些完全与此不同的事情。Swift的guard let语句使得实现这一目的非常容易。
让我们最后一次重新修改闭包吧,使其看起来像下面这样:
- lazy var greetingMaker: () -> String = {
- [weak self] in
- guard let strongSelf = self else {
- return "No greeting available."
- }
- return "Hello \(strongSelf.who)."
- }
guard语句绑定一个来自于weak welf的新变量strongSelf。如果self是nil,闭包将返回“No greeting available.”另一方面,如果self不是nil,strongSelf将进行强引用;这样一来,对象将被确保一直有效,直到闭包末端处。
上述这一术语,有时也被称为强弱舞蹈(strong-weak dance),它是Swift语言中处理闭包中这种行为的一种强有力的模式。
一个引用循环的完整例子
现在,你已经明白了Swift语言中的ARC原则了,你也理解了什么是引用循环,以及如何打破它们。接下来,让我们来看看一个真实世界的例子。
首先,请下载我提供的一个启动项目(https://koenig-media.raywenderlich.com/uploads/2016/08/ContactsStarterProject-1.zip),并在Xcode 8(或更新版本)中打开,因为Xcode 8添加了你要使用的一些有趣的新功能。
之后,构建并运行这个项目,你会看到显示以下内容:
这是一个简单的联系人应用程序。你可以随意点击一个联系人以获取更多信息,或者使用右上角的【+】按钮添加一个联系人。
现在,我们来概述一下关键代码的作用:
- ContactsTableViewController:显示数据库所有联系人对象。
- DetailViewController:显示每一个具体联系人的详细信息。
- NewContactViewController<:允许用户添加一个联系人。
- ContactTableViewCell:一个用于显示联系人详细信息的表格单元格。
- Contact:对应于数据库中的联系人。
- Number:用于存储电话号码。
然而,这个工程中存在一些可怕的错误:代码中存在引用循环!在相当一段时间内,您的用户不会注意到这一点,因为这个问题中存在的泄漏对象很小——它们的尺寸使得它更难追查。幸运的是,Xcode 8中提供了一个新的内置工具来帮助你找到哪怕是最小的泄漏。
生成并再次运行应用程序。尝试着删除三或四个联系人。看起来,他们已经完全消失了,对吧?
当应用程序仍在运行时,移动到Xcode的底部,然后单击【Debug Memory Graph】按钮:
请观察图中Xcode 8引入的新的问题类型:Runtime Issues。它们看起来像是在一个紫色方框中放上了一个白色的感叹号一样的图标,请参考显示在下面这个截图中选择的部分:
在导航器中,选择某一个有问题的联系人对象。则循环引用现在清晰可见:Contact和Number对象保持彼此存活——通过彼此相互引用。请参考下图:
这种类型图表提供了你寻找代码中错误的一种形象标志。请考虑一下:一个联系人在没有号码情况下能够正常存在,但一个号码在没有联系人时是不应当存在的。那么,你将如何解决这个循环问题呢?
强烈建议读者先自己尝试解决一下这个问题。然后,再对照下面的解决方案。
其实,有两种可能的解决办法:你可以使从Contact到Number的关系成为弱引用类型,也可以使从Number到Contact的关系成为unowned类型。这两种方案都能够有效地解决循环引用问题。
【注意】苹果官方文档(https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmPractical.html)中推荐一个父对象应当强引用一个子对象。这意味着,应当在Contact中强引用Number,而使Number无主引用Contact。请参考下面的代码答案:
- class Number {
- unowned var contact: Contact
- // Other code...
- }
- class Contact {
- var number: Number?
- // Other code...
- }
循环引用与值类型和引用类型
Swift类型分为引用类型(如类)和值类型(如结构或枚举)。主要的区别是,值类型在传来传去时被复制,而引用类型共享引用信息的一个副本。
这是否意味着,使用值类型时就不存在循环问题?是的:如果一切都使用值类型复制的话,就不会存在循环引用关系,因为不会创建真正的引用。你至少需要两个引用才构成一个循环,是吧?
不妨回到刚才的工程代码中,在结尾处加上以下内容:
- struct Node { // Error
- var payload = 0
- var next: Node? = nil
- }
运行一下,你会注意到编译器无法正常通过编译。原因在于,一个结构(值类型)不能是递归的或使用它自己的一个实例;否则,这种类型的结构将有无限的大小。现在,我们将其更改为像下面这样的一个类:
- class Node {
- var payload = 0
- var next: Node? = nil
- }
自我引用对于类(即引用类型)来说不是问题,所以编译器错误消失了。
现在,再添加下列代码到您的上述文件中:
- 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
- }
这里的例子提供了一个值类型和引用类型混合形成引用循环的例子。
ernie和bert正常存活——通过在他们的friends数组中保持互相引用,虽然数组本身是一个值类型。如果把这个数组改成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))
- }
现在,ernie和bert都能够正常释放了!
在此,friends数组不再是Person对象的一个集合了,而是成为无主对象的集合——此对象用作Person实例的包装器。
为了从Unowned对象中访问Person对象,我们可以使用value属性,像这样:
- let firstFriend = bert.friends.first?.value // get ernie
小结
完整的示例工程下载地址是https://koenig-media.raywenderlich.com/uploads/2016/08/MemoryManagement.playground.zip。
通过本文学习,你对Swift的内存管理应当有了一个很好的了解,并知道ARC是如何工作的。
如果你想更深入地了解Swift是如何实现弱引用的,请参考一下迈克的博客文章“Swift弱引用”(https://www.mikeash.com/pyblog/friday-qa-2015-12-11-swift-weak-references.html)。
【51CTO译稿,合作站点转载请注明原文译者和出处为51CTO.com】