阅读 2921

[译] Swift 中的内存泄漏

Swift 中的内存泄漏

通过单元测试等方式避免

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

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

这是 SpecLeaks 中的一个测试。

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

内存泄漏

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

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

某个时刻被分配过,但又未被释放,并且也不再被你的 app 持有的内存,就是被泄漏的内存。因为它不再被引用,所以现在没有办法释放掉它,它也没有办法被再次使用。

苹果官方文档

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

内存泄漏很危险

内存泄漏不仅会增加 app 的内存占用,也会引入有害的的副作用甚至崩溃

为什么内存占用会不断增长?它是对象没有被释放掉的直接后果。这些对象完全就是内存垃圾,当创建这些对象的操作不断被执行,它们占据的内存就会不断增长。太多的内存垃圾!这可能导致内存警告的情况,并且最终 app 会崩溃。

解释有害的副作用需要更详细一点的细节。

假设有一个对象在被创建时的 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏