[译] Swift 中的内存泄漏

Swift 中的内存泄漏

经过单元测试等方式避免

本篇文章中,咱们将探讨内存泄漏,以及学习如何使用单元测试检测内存泄漏。如今咱们先来快速看一个例子:html

describe("MyViewController"){
    describe("init") {
        it("must not leak"){
            let vc = LeakTest{
                return MyViewController()
            }
            expect(vc).toNot(leak())
        }
    }
}
复制代码

这是 SpecLeaks 中的一个测试。前端

重点:我将要解释什么是内存泄漏,讨论循环引用以及一些其余你可能早已知道的事情。若是你仅仅想阅读有关对泄漏进行单元测试的部分,直接跳到最后一章便可。android

内存泄漏

在实际中,内存泄漏是咱们开发者最常面临的问题。随着 app 的成长,咱们为 app 开发了一个又一个的功能,却也同时带来了内存泄漏的问题。ios

内存泄漏就是指内存片断再也不会被使用,却被永久持有。它是内存垃圾,不只占据空间也会致使一些问题。git

某个时刻被分配过,但又未被释放,而且也再也不被你的 app 持有的内存,就是被泄漏的内存。由于它再也不被引用,因此如今没有办法释放掉它,它也没有办法被再次使用。github

苹果官方文档数据库

不论咱们是新人仍是老手,咱们总会在某个时间点创造内存泄漏,这无关咱们的经验多少。为了打造一个干净、不崩溃的应用,消除内存泄漏十分重要,由于它们十分危险swift

内存泄漏很危险

内存泄漏不只会增长 app 的内存占用,也会引入有害的的反作用甚至崩溃后端

为何内存占用会不断增加?它是对象没有被释放掉的直接后果。这些对象彻底就是内存垃圾,当建立这些对象的操做不断被执行,它们占据的内存就会不断增加。太多的内存垃圾!这可能致使内存警告的状况,而且最终 app 会崩溃。api

解释有害的反作用须要更详细一点的细节。

假设有一个对象在被建立时的 init 方法中开始监听一个通知。它每次监听到通知后的动做就是将一些东西存入数据库中,播放视频或者是对一个分析引擎发布一个事件。因为对象须要被平衡,咱们必需要在它被释放时中止监听通知,这在 deinit 中实现。

若是这样一个对象泄漏了,会发生什么?

这个对象永远不会被释放,它永远不会中止监听通知。每一次通知被发布,该对象就会响应。若是用户反复执行操做,建立这个有问题的对象,那么就会有多个重复对象存在。全部这些对象都会响应这个通知,而且会彼此影响。

在这种状况下,崩溃多是发生的最好状况

大量泄漏的对象重复响应了 app 通知,改变数据库、用户界面,使得整个 app 的状态出错。你能够经过 The Pragmatic Programmer 这篇文章中的 Dead Programs tell no lies 了解这类问题的重要性。

内存泄漏毫无疑问会致使很是差的用户体验以及 App Store 上的低分。

内存泄漏于何处产生?

好比第三方 SDK 或者框架均可能产生内存泄漏,甚至也包括 Apple 创造的某些类诸如 CALayer 或者 UILabel。在这些状况下,咱们除了等待 SDK 更新或者弃用 SDK 以外别无他法。

但内存泄漏更可能的是由咱们自身的代码致使的。内存泄漏的头号缘由则是循环引用

为了不内存泄漏,咱们必须理解内存管理和循环引用。

循环引用

循环这个词来源于 Objective-C 使用手动引用计数的时期。在可以使用自动引用计数和 Swift,以及咱们如今针对值类型所能作的一切方便的事情以前,咱们使用的是 Objective-C 和手动引用计数。你能够经过 这篇文章 了解手动引用计数和自动引用计数。

在那段时期,咱们须要对内存处理了解更多。理解分配、拷贝、引用的含义,以及如何平衡这些操做(好比释放)是很是重要的。基本规则是不论你什么时候创造了一个对象,你就拥有了它而且你须要负责释放掉它。

如今的事情简单不少,可是仍然须要学习一些概念。

Swift 中当一个对象对强关联了另外一个对象,就是引用了它。这里说的对象指的是引用类型,基本上就是类。

结构体和枚举都是值类型。仅有值类型的话不太可能产生循环引用。当捕获和存储值类型(结构体和枚举)时,并不会有以前说的关于引用的种种问题。值都是被拷贝的,而不是被引用,尽管值也能持有对对象的引用。

当一个对象引用了第二个对象,那么就拥有了它。第二个对象将会一直存在直到它被释放。这被称做强引用。直到当你将对应属性设置为 nil 时第二个对象才会被销毁。

class Server {
}

class Client {
    var server : Server //Strong association to a Server instance
    
    init (server : Server) {
        self.server = server
    }
}
复制代码

强关联。

A 持有 B 而且 B 持有 A 那么就形成了循环引用。

A 👉 B + A 👈 B = 🌀

class Server {
    var clients : [Client] // 由于这里是强引用
    
    func add(client:Client){
        self.clients.append(client)
    }
}

class Client {
    var server : Server // 而且这里也是强引用
    
    init (server : Server) {
        self.server = server
        
        self.server.add(client:self) // 这一行产生了循环引用 -> 内存泄漏
    }
}
复制代码

循环引用。

在这个例子中,不论 client 仍是 server 都将没法被释放内存。

为了从内存中释放,对象必须首先释放其全部的依赖关系。因为对象自己也是依赖项,所以没法释放。一样,当一个对象存在循环引用时,它不会被释放

当循环引用中的一个引用是**弱引用(weak)或者无主引用(unowned)**的时候,循环引用就能够被打破。有时候因为咱们正在编写的代码须要相互关联,所以循环必须存在。但问题就在于不能全部的关联关系都是强关联,其中至少必须有一个是弱关联。

class Server {
    var clients : [Client] 
    
    func add(client:Client){
        self.clients.append(client)
    }
}

class Client {
    weak var server : Server! // 此处为弱引用
    
    init (server : Server) {
        self.server = server
        
        self.server.add(client:self) // 如今不存在循环引用了
    }
}
复制代码

弱引用能够打破循环引用。

如何打破循环引用

Swift 提供了两种方式用以解决使用引用类型时致使的的强引用循环:Weak 和 Unowned。

在循环引用中使用 Weak 以及 Unowned,能让一个实例引用另外一个实例时再也不保持强持有。这样实例之间可以互相引用而不会产生强引用循环。

Apple’s Swift Programming Language

Weak: 一个变量可以可选地不持有其引用的对象。当变量并不持有其引用对象时,就是弱引用。弱引用能够为 nil

Unowned: 和弱引用类似,无主引用也不会强持有其引用的实例。但与弱引用不一样的是,无主引用必须是一直有值的。正因如此,无主引用始终被定义为非可选类型。无主引用不能为 nil

两者的使用时机

当闭包和它捕获的实例互相引用时,将闭包中的捕获值定义为无主引用,这样他们老是会同时被释放出内存。

相反的,将闭包中捕获的实例定义为弱引用时,这个捕获的引用有可能在将来变成 nil。弱引用始终是一个可选类型,当引用的实例被释放出内存时它就会自动变成 nil

Apple’s Swift Programming Language

class Parent {
    var child : Child
    var friend : Friend
    
    init (friend: Friend) {
        self.child = Child()
        self.friend = friend
    }
    
    func doSomething() {
        self.child.doSomething( onComplete: { [unowned self] in  
              //The child dies with the parent, so, when the child calls onComplete, the Parent will be alive
              self.mustBeAlive() 
        })
        
        self.friend.doSomething( onComplete: { [weak self] in
            // The friend might outlive the Parent. The Parent might die and later the friend calls onComplete.
              self?.mightNotBeAlive()
        })
    }
}
复制代码

对比弱引用和无主引用。

写代码时忘记使用 weak self 的状况并不稀奇。咱们常常在写闭包时引入内存泄漏,好比在使用 flatMapmap 这样的函数式代码时,或者是在写消息监听、代理的相关代码时。这篇文章 里你能够读到更多关于闭包中内存泄漏的内容。

如何消灭内存泄漏?

  1. 不要创造出内存泄漏。对内存管理有更深入的认识。为项目定义完善的 代码风格,而且严格遵照。若是你足够严谨,而且遵循你的代码风格,那么缺乏 weak self 也将容易被发现。代码审查也能提供很大帮助。
  2. 使用 Swift Lint。这是一个一个很棒的工具,可以强制你遵循一种代码风格,遵循第一条规则。它可以帮你早在编译期就发现一些问题,好比代理变量声明时并无被声明为弱引用,这本来可能致使循环引用。
  3. 在运行期间检测内存泄漏,并将它们可视化。若是你清楚某个特定的对象在特定时刻有多少实例存在,那么你可使用 LifetimeTracker。这是一个能在开发模式下运行的好工具。
  4. 常常评测 app。Xcode 中的 内存分析工具 很是有用,能够参考 这篇文章. 不久以前 Instruments 也是一种方法,这也是很是棒的工具。
  5. 使用 SpecLeaks 对内存泄漏进行单元测试。这个第三方库使用 Quick 和 Nimble 让你方便地对内存泄漏进行测试。你能够在接下来的章节中更多地了解到它。

对内存泄漏进行单元测试

一旦咱们知道循环和弱引用是怎么一回事,咱们就能为循环引用编写测试,方法就是弱引用去检测循环。只须要对某个对象进行弱引用,咱们就能测试出该对象是否有内存泄漏。

由于弱引用并不会持有其引用的实例,因此当实例被释放出内存时,极可能弱引用仍然指向该实例。所以,当弱引用引用的对象被释放后,自动引用计数会将弱引用设置为 nil

假设咱们想知道 x 是否发生了内存泄漏,咱们建立了一个指向它的弱引用,叫作 leakReference。若是 x 被从内存中释放,ARC 会将 leakReference 设置为 nil。因此,若是 x 发生了内存泄漏,leakReference 永远不会被设置为 nil。

func isLeaking() -> Bool {
   
    var x : SomeObject? = SomeObject()
  
    weak var leakReference = x
  
    x = nil
    
    if leakReference == nil {
        return false // 没发生内存泄漏
    }
    else{
        return true // 发生了内存泄漏
    }
}
复制代码

测试一个对象是否发生内存泄漏。

若是 x 真的发生了内存泄漏,弱引用 leakReference 会指向这个发生内存泄漏的实例。另外一方面,若是该对象没发生内存泄露,那么在该对象被设置为 nil 以后,它将再也不存在。这样的话,leakReference 将会为 nil。

”Swift by Sundell” 在 这篇文章 中详细阐述了不一样内存泄漏的区别,对我写本文以及 SpecLeaks 都有极大的帮助。另外 一篇佳做 也采用了相似的方式。

基于这些理论,我写出了 SpecLeacks,一个基于 Quick 和 Nimble、可以检测内存泄漏的拓展。核心就是编写单元测试来检测内存泄漏,不须要大量冗余的样板代码。

SpecLeaks

结合使用 Quick 和 Nimble 能更好地编写更人性化、可读性更强的单元测试。SpecLeaks 只是在这两个框架的基础之上增长了一点点功能,使其可以让你更方便地编写单元测试,来检测是否有对象发生了内存泄漏。

若是你对单元测试并不了解,那么这张截图也许可以给你一个提示,告诉你单元测试作了些什么:

你能够写单元测试来实例化一些对象,并在基于它们作一些尝试。你定义指望的结果,以及怎样的结果才算符合预期,才能经过测试,让测试结果呈现绿色。若是最终结果并不符合最开始定义的预期,那么测试将会失败并呈现出红色。

测试初始化阶段的内存泄漏

这是检测内存泄漏的测试中,最简单的一个,只须要初始化一个实例并看它是否发生了内存泄漏。有时,这个对象注册了监听事件,或者是有代理方法,或者注册了通知,这些状况下,这类测试就能检测出一些内存泄漏:

describe("UIViewController"){
    let test = LeakTest{
        return UIViewController()
    }

    describe("init") {
        it("must not leak"){
            expect(test).toNot(leak())
        }
    }
}
复制代码

测试初始化阶段。

测试 viewController 中的内存泄漏

一个 viewController 可能在它的子视图加载完成后开始发生内存泄漏。在此以后,会发生大量的事情,可是使用这个简单的测试你就能保证在 viewDidLoad 方法中不存在内存泄漏。

describe("a CustomViewController") {
    let test = LeakTest{
        let storyboard = UIStoryboard.init(name: "CustomViewController", bundle: Bundle(for: CustomViewController.self))
        return storyboard.instantiateInitialViewController() as! CustomViewController
    }

    describe("init + viewDidLoad()") {
        it("must not leak"){
            expect(test).toNot(leak())
            //SpecLeaks will detect that a view controller is being tested 
            // It will create it's view so viewDidLoad() is called too } } } 复制代码

对一个 viewController 的 init 和 viewDidLoad 进行测试。

使用 SpecLeaks 你不须要为了使 viewDidLoad 方法被调用而手动调用 viewController 上的 view。当你测试 UIViewController 的子类时 SpecLeaks 将会替你作这些。

测试方法被调用时的内存泄漏

有时候初始化一个实例并不能判断是否发生了内存泄漏,由于内存泄漏有可能在某个方法被调用的时候发生。在这种状况下,你能够在操做被执行的时候测试是否有内存泄漏,像这样:

describe("doSomething") {
    it("must not leak"){
        
        let doSomething : (CustomViewController) -> () = { vc in
            vc.doSomething()
        }

        expect(test).toNot(leakWhen(doSomething))
    }
}
复制代码

检测自定义 viewController 是否在 doSomething 方法被调用时发生内存泄漏。

总结一下

内存泄漏能产生大量问题,他们会致使极差的用户体验、崩溃和 App Store 中的差评,咱们必需要消除它们。良好的代码风格、良好的实践、对内存管理透彻的理解以及单元测试都能起到有效的帮助。

可是单元测试并不能保证内存测试彻底不发生,你并不能覆盖全部的方法调用和状态,测试每个存在与其余对象相互做用的东西是不太可能的。另外,有时候必需要模拟依赖,才能发现原始的依赖可能发生的内存泄漏。

单元测试确实能下降发生内存泄漏的可能性,使用 SpeakLeaks 能够很是方便的检测、发现出闭包中的内存泄漏,就好比 flatMap 或者是其余持有了 self 的逃逸闭包。若是你忘记将代理声明为弱引用也是一样的道理。

我大量地使用了 RxSwift,以及 faltMap、map、subscribe 和一些其余须要传递闭包的函数。在这些状况下,缺乏 weak 或 unowned 常常会致使内存泄漏,而使用 SpecLeaks 就能轻易的检测出来。

就我的而言,我始终尝试在个人全部类之中增长这样的测试。例如每当我创造一个 viewController,我就会为它创造一份 SpecLeaks 代码。有时候 viewController 会在加载视图时发生内存泄漏,用这类测试就能垂手可得地发现。

那么你意下如何?你会为检测内存泄漏而写单元测试吗?你会写测试吗?

我但愿你喜欢阅读本文,若是你有任何的建议和疑问均可以给我回复!请尽情尝试 SpeckLeaks :)


感谢 Flawless App


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索