本篇文章將討論記憶體洩漏 (Memory Leak),並學習如何利用單元測試 (Unit Testing) 來偵測記憶體洩漏。讓我們先看看程式碼:
describe("MyViewController") { describe("init") { it("must not leak") { let vc = LeakTest { return MyViewController() } expect(vc).toNot(leak()) } } }
記憶體洩漏
實際上,記憶體洩漏是開發者最常遇到的問題。我們一直寫程式碼來增加新功能,當 App 越來越大的時候,我們就需要了解甚麼是記憶體洩漏了。
記憶體洩漏就是記憶體的某一部分被永久佔用、而無法再使用的情況;就等同一個會佔用空間、並引致問題的垃圾。
當記憶體被配置在某一位址上,但沒被釋放、亦不再被 App 引用時,我們就稱之為記憶體洩漏。因為沒有被引用,所以沒有辦法釋放記憶體,而且亦無法再使用到它。
無論是入門還是資深的開發人員,我們都一定會在某些情形下引致記憶體洩漏,這與經驗深淺沒有關係。最重要的就是解決問題,讓我們有個乾淨而不會當機的 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 提供的 CALayer
或 UILabel
都有可能發生記憶體洩漏問題。在這些情況下,我們能做的不多,就只可以等待它們的修正升級,或是選擇不用這個 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 循環。
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
。
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) 時,通常都會發生記憶體洩漏問題。你可以閱讀這篇文章,看看有關閉包內的記憶體洩漏問題。
如何解決記憶體洩漏問題?
- 不要製造記憶體洩漏!你需要對記憶體管理有更充足的理解,而且為專案建構並遵循一個強大的程式碼風格。如果你能循序遵守程式碼風格,那麼你就可以輕易發現有沒有使用
weak self
。仔細確認你的程式碼亦相當有幫助。 - 使用 Swift Lint。這是一個很棒的工具,可以強制你遵循程式碼風格和規則,亦可以幫你在編譯時檢測早期問題,例如委託變數不是宣告為 weak 而變成潛在循環引用時。
- 在運行時檢測記憶體洩漏,並將問題展示。如果你知道一個特定物件一次會建立多少個實例,你可以使用 LifetimeTracker,在開發模式下執行這個工具是非常好用的。
- 經常分析你的 App。你可以使用 XCode 內的記憶體分析工具,這是一套非常出色的工具,詳情可參閱本篇文章。Instructment 亦是另一個好用的工具。
- 使用 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 僅在那些框架中添加了一些功能,就可以讓你建置單元測試來檢測物件是否有洩漏問題。
如果你不了解單元測試,下面的截圖或者可以給你一個基本概念:
你可以創建一組測試來實例化物件,並對其進行測試。你可以定義預期結果,如果結果符合預期,則測試將通過,以綠色顯示; 如果結果不合乎預期,測試將以紅色顯示失敗。
測試初始化中的洩漏
要檢測物件是否有洩漏問題,最簡單的測試就是初始化一個實例。有時,如果物件註冊為觀察者、有委託情形、或是設為通知行為,我們所設的測試就可以檢測到一些洩漏問題:
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 🙂!