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


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

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

注意:我將會解釋甚麼是記憶體洩漏、循環引用、以及其他你可能已經知道的事。如果你只想知道如何對記憶體洩漏進行單元測試的話,請跳到下一個章節。

記憶體洩漏

實際上,記憶體洩漏是開發者最常遇到的問題。我們一直寫程式碼來增加新功能,當 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 時,第二個物件才會被銷毀。

如果 A 引用 B,B 也引用 A,那就是一個循環引用。

A 👉 B + A 👈 B = 🌀

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

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

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

如何破壞循環引用?

當你選擇類別型態時,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 程式語言

編寫程式碼時,我們很常會忘記要加入 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。

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

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

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

SpecLeaks

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

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

Screenshot

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

測試初始化中的洩漏

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

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

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

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

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

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

總結

記憶體洩漏是很麻煩的,會造成不好的使用者體驗、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
訂閲電子報

訂閲電子報

AppCoda致力於發佈優質iOS程式教學,你不必每天上站,輸入你的電子郵件地址訂閱網站的最新教學文章。每當有新文章發佈,我們會使用電子郵件通知你。

已收你的指示。請你檢查你的電郵,我們已寄出一封認證信,點擊信中鏈結才算完成訂閱。

Shares
Share This