Memory Leaks (記憶體洩漏)可以導致 App 閃退?用單元測試就可輕鬆減少洩漏!


本篇原文(標題: Memory Leaks in Swift)刊登於作者 Medium,由 Leandro Pérez 所著,並授權翻譯及轉載。

本篇文章將討論記憶體洩漏 (Memory Leak),並學習如何利用單元測試 (Unit Testing) 來偵測記憶體洩漏。讓我們先看看程式碼:

describe("MyViewController") {
    describe("init") {
        it("must not leak") {
            let vc = LeakTest {
                return MyViewController()
            }
            expect(vc).toNot(leak())
        }
    }
}
注意:我將會解釋甚麼是記憶體洩漏、循環引用、以及其他你可能已經知道的事。如果你只想知道如何對記憶體洩漏進行單元測試的話,請跳到下一個章節。

記憶體洩漏

實際上,記憶體洩漏是開發者最常遇到的問題。我們一直寫程式碼來增加新功能,當 App 越來越大的時候,我們就需要了解甚麼是記憶體洩漏了。

記憶體洩漏就是記憶體的某一部分被永久佔用、而無法再使用的情況;就等同一個會佔用空間、並引致問題的垃圾。

當記憶體被配置在某一位址上,但沒被釋放、亦不再被 App 引用時,我們就稱之為記憶體洩漏。因為沒有被引用,所以沒有辦法釋放記憶體,而且亦無法再使用到它。

Apple 官方說明

無論是入門還是資深的開發人員,我們都一定會在某些情形下引致記憶體洩漏,這與經驗深淺沒有關係。最重要的就是解決問題,讓我們有個乾淨而不會當機的 App。為甚麼呢?因為它們很危險

記憶體洩漏很危險

這個問題不僅會增加 App 的記憶體用量,也會引致有害的副作用,甚至造成 App 閃退。

為何記憶體用量會愈來愈大呢?這是物件未被釋放。那些物件其實都是垃圾,隨著我們重覆會創造垃圾的動作,記憶體用量就會一直增長。垃圾太多,就會導致記憶體不足而造成 App 閃退

讓我們再說說甚麼是有害的副作用

試想像,當我們建立了一個物件於 init 內,物件就會開始監管通知功能,它會回應通知、將資料存於資料庫中、播放影片、或是將特定事件放到分析引擊等。由於物件有建立也需關閉,當它在 deinit 中被釋放時,就會停止監管通知功能。

如果這個物件發生記憶體洩漏,會發生甚麼事情呢?

它將永遠會佔用記憶體,並會一直監管通知功能。每次有通知時,這個物件就會作出回應。如果使用者重覆會建立有問題的物件的動作,就會有很多實例 (instance) 存在,而所有實例都會對通知作出回應,並進一步互相影響。

在這樣狀況下,讓 App 閃退反而是件好事.

多個記憶體洩漏的物件會回應 App 的通知、更改資料庫和 UI,最後損毀整個 App。你可以在 The Pragmatic Programmer 中拜讀這篇文章 “Dead programs tell no lies”,了解這些問題的嚴重性。

記憶體洩漏無疑會導致非常差的使用者體驗 (UX),亦會影響 App 在 App Store 的評價。

記憶體洩漏是從哪裡來的?

有時,記憶體洩漏可能來自於第三方套件 SDK 或是範例中的框架,即使是 Apple 提供的 CALayerUILabel 都有可能發生記憶體洩漏問題。在這些情況下,我們能做的不多,就只可以等待它們的修正升級,或是選擇不用這個 SDK。

然而,問題大多數都是我們的程式碼所造成的,而且最普遍的原因就是循環引用 (retain cycles)

為了避免記憶體洩漏問題,我們必須了解記憶體管理與循環引用。

循環引用

Retain 這個字來自 Objective-C 的手動參考計數 (Manual Reference Counting, MRC)。在 ARC、Swift、以及現在可以用值類型 (Value Type) 做到的所有事情之前,我們就是用 Objective-C 和 MRC 的。你可以從這篇文章了解 MRC 和 ARC。

在過去,我們需要更為了解記憶體控管,了解甚麼是 alloc、copy、retain,以及如何平衡一些如釋放 (release) 的相對性動作,是非常重要的。基礎的概念就是不論你在何時建立了一個物件,就是擁有了它,也代表你有責任要去釋放它。

現在事情聽起來簡單多了,但還是有些概念需要再學習。

在 Swift,當一個物件與另一個物件有強烈的關聯 (association) 時,兩者之間就會形成循環。如我目前所說的物件就是指參考類型 (Reference Types) 和類別 (Classes)。

結構 (Struct) 和列舉 (Enum) 是值類型,我們不能僅靠值類型建立循環引用。當取得和存取值類型時(如結構和列舉),就沒有引用這回事了。雖然值類型可以保存對物件的引用,但是它只是複製,而不是引用。

當一個物件引用第二個物件,就代表擁有它。第二個物件會一直存在著,直到它被釋收,這就是 Strong Reference。只有當你將屬性設置為 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] //Because this reference is strong

    func add(client: Client) {
        self.clients.append(client)
    }
}

class Client {
    var server: Server //And this one is also strong

    init (server: Server) {
        self.server = server

        self.server.add(client: self) //This line creates a Retain Cycle -> Leak!
    }
}

在這個範例中,我們不能 dealloc (銷毀)客戶端和伺服器。

為了釋放記憶體,物件首先要釋放所有的依賴項目。因為物件本身就是個依賴項目,它就無法被釋放。再一次聲明,當一個物件有循環引用的情況,就永遠不會消失。

要破壞循環引用,就需要其中一個引用物件的設定屬性為 weak 或是 unowned。循環之所以必須存在,是因為我們在編寫程式碼時建構的關聯性質。問題就是,要破壞循環引用,就是不能讓關聯性質為 strong,其中一個物件變數屬性必須為 weak。

class Server {
    var clients: [Client]

    func add(client: Client) {
        self.clients.append(client)
    }
}

class Client {
    weak var server: Server! //This one is weak

    init (server: Server) {
        self.server = server

        self.server.add(client: self) //Now there is no retain cycle
    }
}

如何破壞循環引用?

當你選擇類別型態時,Swift 提供了兩種方法來解決 strong reference:weak reference 和 unowned reference。

Weak 和 unowned reference 容許一個實例在循環引用中引用另一個實例,而不使用 Strong reference。這樣實例就可以互相引用,而不會建立 Strong reference 循環。

Apple’s Swift 程式語言

Weak:變數可以選擇不取得所引用物件的擁有權,weak reference 就是指變數選擇不取得物件的擁有權。Weak reference 可以設為 nil。

Unowned:與 weak references 很相似,一個 unowned reference 不會牢牢持有引用的實例。但與 weak reference 不同,unowned reference 是永遠有值的。因此,unowned reference 會定義為非可選類型 (non-optional type)。Unowned reference 不能設為 nil

兩者何時使用?

當閉包 (closure) 和它所取得的實例一直相互引用、並總是會同時取消配置時,請定義閉包內的實例為 unowned reference。
相反地,當未來的引用有可能會得到 nil 值時,就要定義為 weak reference。Weak reference 永遠都是可選類型,當所引用的實例取消配置時,就會自動變成 nil

Apple’s Swift 程式語言

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。在編寫如 flatMap、 內有互動的程式碼的 map、觀察者模式 (Observer)、或是委託 (Delegate) 的區塊閉包 (block closure) 時,通常都會發生記憶體洩漏問題。你可以閱讀這篇文章,看看有關閉包內的記憶體洩漏問題。

如何解決記憶體洩漏問題?

  1. 不要製造記憶體洩漏!你需要對記憶體管理有更充足的理解,而且為專案建構並遵循一個強大的程式碼風格。如果你能循序遵守程式碼風格,那麼你就可以輕易發現有沒有使用 weak self。仔細確認你的程式碼亦相當有幫助。
  2. 使用 Swift Lint。這是一個很棒的工具,可以強制你遵循程式碼風格和規則,亦可以幫你在編譯時檢測早期問題,例如委託變數不是宣告為 weak 而變成潛在循環引用時。
  3. 在運行時檢測記憶體洩漏,並將問題展示。如果你知道一個特定物件一次會建立多少個實例,你可以使用 LifetimeTracker,在開發模式下執行這個工具是非常好用的。
  4. 經常分析你的 App。你可以使用 XCode 內的記憶體分析工具,這是一套非常出色的工具,詳情可參閱本篇文章。Instructment 亦是另一個好用的工具。
  5. 使用 SpecLeaks 對記憶體洩漏進行單元測試。這個 pod 使用 Quick 和 Nimble,讓你輕鬆為洩漏問題建立測試。我們會在下一節中詳述。

對記憶體洩漏進行單元測試

現在我們知道了循環和 weak references 的運作方法,就可以開始編寫程式碼來測試循環引用了。這個方法是利用 weak reference 來測試循環。我們已經在一個物件中建立了 weak reference,就可以測試物件有沒有記憶體洩漏的問題。

因為 weak reference 不會牢牢持有引用的實例,所以即使在 weak reference 持續引用實例的情況下,仍可以釋放實例。也因此,當引用的實例被解除配置時,ARC 會自動將 weak reference 設為 nil

這樣說好了,假設我們想看看物件 x 有沒有洩漏問題,我們可以設置一個名為 leakReferece 的 weak reference。如果從記憶體釋放了 x,ARC 會將 leakReference 設為 nil。換句話說,如果 x 真的有洩漏問題,leakReferece 就永遠都不會是 nil。

func isLeaking() -> Bool {

    var x: SomeObject? = SomeObject()

    weak var leakReference = x

    x = nil

    if leakReference == nil {
        return false //Not leaking
    } else {
        return true //Leaking
    }
}

如果 x 真的有記憶體洩漏問題,weak 變數 leakReference 就會指向洩漏的實例。另一方面,如果物件沒有洩漏問題的話,在將其設為 nil 之後,它就應該不會再存在了。在這種情況下,leakReference 將會是 nil。

“Swift by Sundel” 所寫的文章,詳細解釋了不同形式的記憶體洩漏問題,這篇文章對我寫這篇教學、以及創建 SpecLeaks 亦很有幫助。我亦推薦你閱讀另一篇類似的文章

基於這個概念,我創建了 SpecLeaks,它是 Quick 和 Nimble 的擴展型態,可允許我們來測試洩漏問題。這個方法是對記憶體洩漏進行程式碼單元測試,而無需編寫太多樣板程式碼。

SpecLeaks

Quick 和 Nimble 是一個絕佳組合,以用高可讀性的方式來編寫單元測試。SpecLeaks 僅在那些框架中添加了一些功能,就可以讓你建置單元測試來檢測物件是否有洩漏問題。

如果你不了解單元測試,下面的截圖或者可以給你一個基本概念:

Screenshot

你可以創建一組測試來實例化物件,並對其進行測試。你可以定義預期結果,如果結果符合預期,則測試將通過,以綠色顯示; 如果結果不合乎預期,測試將以紅色顯示失敗。

測試初始化中的洩漏

要檢測物件是否有洩漏問題,最簡單的測試就是初始化一個實例。有時,如果物件註冊為觀察者、有委託情形、或是設為通知行為,我們所設的測試就可以檢測到一些洩漏問題:

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

    describe("init") {
        it("must not leak") {
            expect(test).toNot(leak())
        }
    }
}

在視圖控制器中測試記憶體洩漏問題

在視圖控制器讀取視圖時,亦有可能產生記憶體洩漏問題,接著可能會有百萬種事情發生。但若使用這個範例測試,你就可以確保你的 viewDidLoad 不會有記憶體洩漏問題。

describe("a CustomViewController") {
    let test = LeakTest {
        let bundle = Bundle(for: CustomViewController.self)
        let storyboard = UIStoryboard.init(name: "CustomViewController", bundle: bundle)
        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
        }
    }
}

使用 SpecLeaks 時,你不必為了調用 viewDidLoad,在視圖控制器中來手動呼叫 view。當你測試 UIViewController 的子類別時,SpecLeaks 會替你呼叫 view

在調用方法時測試記憶體洩漏問題

有時,實例化一個物件是不足以確認洩漏問題的,它可能會在調用方法時才開始洩漏。對於這種情況,你也可以執行一個動作來測試,就像這樣:

describe("doSomething") {
    it("must not leak") {

        let doSomething: (CustomViewController) -> Void = { vc in
            vc.doSomething()
        }

        expect(test).toNot(leakWhen(doSomething))
    }
}

總結

記憶體洩漏是很麻煩的,會造成不好的使用者體驗、App 閃退、並影響 App Store 的評價。我們需要徹底解決這個問題,就要有穩健的程式碼風格、以及良好的做法,並了解記憶體管理和單元測試。

單元測試雖無法保證記憶體洩漏不再存在,因為你永遠無法覆蓋所有的方法調用與狀態,所以也不可能測試物件的整個交互範圍。另外,我們常常要模擬依賴關係 (dependency),這樣的話原始的依賴關係就有可能是漏洞。

單元測試可以減少記憶體洩漏的機會,這是一種相當簡單的測試,使用 SpecLeaks 可以方便地檢測閉包中的記憶體洩漏,像是在 flatMap 與其他包含 self 的逃逸閉包 (Escaping Closures),又或是如果你忘記將一個 delegate 宣告為 weak,也會發生同樣狀況。

我會大量使用 RxSwift,以及 flatMap、map、subscribe、和其他需要通過閉包的函數。在這些狀況下,因為沒有使用 weak/unowned 而產生的記憶體洩漏問題,就可以由 SpecLeaks 輕易測試出來。

就個人而言,我正在嘗試將這些測試加入到所有類別中,每當我建立了一個視圖控制器,就會加入一個 Spec。有時候視圖控制器就在讀取視圖時產生洩漏,這樣的話我加入的測試就會馬上把偵測到這個問題了。

你覺得怎樣?你會編寫單元測試來偵測記憶體洩漏問題嗎?你平常會編寫測試嗎?

希望你喜歡本篇文章,若你有任何建議與問題,歡迎在下面留言!也請記得試看看 SpecLeaks 🙂!

本篇原文(標題: Memory Leaks in Swift)刊登於作者 Medium,由 Leandro Pérez 所著,並授權翻譯及轉載。
作者簡介: Leandro,一位獨立軟體建築師/開發者,從 2001 開始撰寫程式,並在 2009 開始創建 iOS apps。對品質和維護性非常有要求,非常熱愛建立強大而可擴展的軟體。最近醉心於用 RxSwift 創建 iOS App。
譯者簡介:Oliver Chen-工程師,喜歡美麗的事物,所以也愛上 Apple,目前在 iOS 程式設計上仍是新手,正研讀 Swift 與 Sketch 中。生活另一個身份是兩個孩子的爸,喜歡和孩子一起玩樂高,幻想著某天自己開發的 App,可以讓孩子覺得老爸好棒!。聯絡方式:電郵[email protected]

此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This