[譯] Swift 中的記憶體洩漏

RickeyBoy發表於2018-05-27

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 只是在這兩個框架的基礎之上增加了一點點功能,使其能夠讓你更方便地編寫單元測試,來檢測是否有物件發生了記憶體洩漏。

如果你對單元測試並不瞭解,那麼這張截圖也許能夠給你一個提示,告訴你單元測試做了些什麼:

[譯] Swift 中的記憶體洩漏

你可以寫單元測試來例項化一些物件,並在基於它們做一些嘗試。你定義期望的結果,以及怎樣的結果才算符合預期,才能通過測試,讓測試結果呈現綠色。如果最終結果並不符合最開始定義的預期,那麼測試將會失敗並呈現出紅色。

測試初始化階段的記憶體洩漏

這是檢測記憶體洩漏的測試中,最簡單的一個,只需要初始化一個例項並看它是否發生了記憶體洩漏。有時,這個物件註冊了監聽事件,或者是有代理方法,或者註冊了通知,這些情況下,這類測試就能檢測出一些記憶體洩漏:

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章