美文网首页小斑程序员iOS Developer
【译】深入浅出Swift中的内存管理和循环引用

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

作者: Maru | 来源:发表于2017-04-23 01:14 被阅读358次

    作为一门现代的高级编程语言,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!

    相关文章

      网友评论

      • XIAODAO:唉,外国大神才17岁,让我这个大叔情何以堪:joy:
        XIAODAO:我86后,你还年轻,真的:smile: :+1:
        Maru:@XIAODAO 同大叔,总有更高的山,做好自己就可以了吧:smile:
      • KarenLoo:ContactsStarterProject-1.zip下载不了

      本文标题:【译】深入浅出Swift中的内存管理和循环引用

      本文链接:https://www.haomeiwen.com/subject/jqorattx.html