ARC 下的迴圈引用類似於日本的 B 級恐怖片。當你剛成為蘋果開發者,你或許不會關心他們的存在。直到某天你的一個 app 因記憶體洩露而閃退,你才突然意識到他們的存在,並且發現迴圈引用像幽靈一樣存在於程式碼的各個角落。年復一年,你開始學會如何處理迴圈引用,檢測和避免它們,但是這部片子的恐怖結局還是在那裡,隨時可能出現。
ARC 令許多開發者(包括我)感到失望的地方之一是蘋果保留了用 ARC 來進行記憶體管理。ARC 很不幸地沒有包括一個迴圈引用檢測器,所以很容易就會產生迴圈引用,因此迫使開發者在寫程式碼的時候採取一些特別的防範措施。
迴圈引用一直是一些 iOS 開發者感到費解的一個問題。 網上有許多誤導資訊[1][2],這些文章給了錯誤的建議和修復方法,其方法甚至可能引發問題和導致 app 閃退。在這片文章,我想要針對這些問題解釋清楚。
理論簡介
記憶體管理可以追溯到手動記憶體管理(Manual Retain Release,簡稱 MRR)。在 MRR,開發者建立的每一個物件,需要宣告其擁有權,從而保持物件存在於記憶體中,當物件不再需要的時候撤銷擁有權釋放它。MRR 通過引用計數系統實現這套擁有權體系,也就是說每個物件有個計數器,通過計數加1表明被一個物件擁有,減1表明不再持有。當計數為零,物件將被釋放。由於手動管理記憶體實在太煩人,因此蘋果推出了自動引用計數(ARC)來解放開發者,不再需要開發者手動新增 retain 和 release 操作,從而可以專注於 App 開發。在 ARC,開發者將會定義一個變數為“strong”或“weak”。一個 weak 弱引用無法 retain 物件,而 strong 引用會 retain 這個物件,並將其引用計數加一。
我為什麼要關心這些?
ARC 的問題是迴圈引用很容易發生。當兩個不同的物件各有一個強引用指向對方,那麼迴圈引用便產生了。試想下,一個 book 物件持有多個 page 物件,每個 page 物件又有個屬性指向它所屬的 book 物件。當你釋放了持有 book 和 page 物件的變數時,他們仍然還有強引用指向各自,因此你無法釋放他們的記憶體,即使已經沒有變數持有他們。
不幸的是,迴圈引用在實際中並沒有那麼容易被發現。多個物件之間(A 持有 B,B 持有 C,C 也恰好持有 A)也可以產生迴圈引用。更糟的是,Objective-C block 和 Swift 閉包都是獨立記憶體物件,它們會持有其所引用的物件,於是就引發了潛在的迴圈引用問題。
迴圈引用對 app 有潛在的危害,會使記憶體消耗過高,效能變差和 app 閃退等。然而,蘋果文件對於可能發生迴圈引用的場景以及如何避免並沒有詳細描述,這就容易導致一些誤解和不良的程式設計習慣。
一些用例模擬
廢話不多說,我們一起來分析一些場景中是否會產生迴圈引用,以及如何避免它。
父子物件關係
父子物件關係是一個迴圈引用的典型案例,不幸的是,它也是唯一一個存在於蘋果文件中的案例。其實就是前文描述的 Book 與 Page 案例。典型的解決方法就是,在子類定義一個指向父類的變數,宣告為 weak 弱引用,從而避免迴圈引用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Parent { var name: String var child: Child? init(name: String) { self.name = name } } class Child { var name: String weak var parent: Parent! init(name: String, parent: Parent) { self.name = name self.parent = parent } } |
在 swift 中子類指向父物件的變數是一個弱引用,這就迫使我們將該弱引用定義為 optional 型別。如果不使用 optional 可以有另一種做法,將指向父物件的變數宣告為“無主引用(unowned)”(表明我們不持有該物件,也不對其進行記憶體管理)。然而在這種情況下,我們必須非常小心,確保只要還有子物件指向它,父物件不變成 nil,否則會直接閃退。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Parent { var name: String var child: Child? init(name: String) { self.name = name } } class Child { var name: String unowned var parent: Parent init(name: String, parent: Parent) { self.name = name self.parent = parent } } var parent: Parent! = Parent(name: "John") var child: Child! = Child(name: "Alan", parent: parent) parent = nil child.parent <== possible crash here! |
通常有效的做法是,父物件必須持有(強引用)子物件,而子物件只要保持一個弱引用指向他們的父物件。這同樣適用於集合物件,它們必須持有它們包含的物件。
當 Block 和閉包包含在類的成員變數中
另外一個典型的例子,可能不是那麼直觀。如我們前面解釋的,閉包和 block 都是獨立的記憶體物件,會 retain 它們所引用的物件,因此如果我們有個類,裡面有個閉包變數,並且這個閉包恰好引用了自身所屬物件的一個屬性或方法,那麼就可能產生迴圈引用,因為閉包會建立強引用捕獲“self”。
1 2 3 4 5 |
class MyClass { lazy var myClosureVar = { self.doSomething() } } |
這個案例的解決方法是定義一個弱版本的 self,然後在閉包或 block 中使用。在 objective-C,我們會定義一個新的變數:
1 2 3 4 5 6 |
class="lang-objc">- (id) init() { __weak MyClass * weakSelf = self; self.myClosureVar = ^{ [weakSelf doSomething]; } } |
然而在 Swift 我們只需要在閉包的頭部宣告 “[weak self in]”:
1 2 3 4 |
var myClosureVar = { [weak self] in self?.doSomething() } |
用這個方法,當閉包結束的時候,內部的 self 變數不會被強引用,所以它會被釋放,打破了迴圈引用。注意當 self 被宣告為 weak,閉包內部的 self 是個可選值。
GCD: dispatch_async
和我們通常所認為的不同,dispatch_async 自身不會造成迴圈引用
1 2 3 |
dispatch_async(queue, { () -> Void in self.doSomething(); }); |
在這裡,閉包會強引用 self,但是例項化的 self 不會強引用閉包,所以一旦閉包結束,它就會被釋放,所以迴圈引用也不會產生。然而,總有些開發者認為它可能會產生迴圈引用。有些開發者甚至以為,所有在 block 和閉包裡面的 self 都需要弱引用:
1 2 3 4 |
dispatch_async(queue, { [weak self] in self?.doSomething() }) |
在我看來,每種情況都採用這種方法並不是一個好的實踐。讓我們試想下,如果我們有個物件,用於傳送一個後臺任務(比如下載資料),並且呼叫了 self 的一個方法。這時如果我們弱引用 self,該物件的生命週期結束早於閉包結束被釋放,因而當我們的閉包呼叫的 doSomething()方法,該物件可能就不存在了,方法也得不到執行。合適的解決方法是(蘋果推薦)在閉包內部,宣告一個強引用指向弱引用。
1 2 3 4 5 6 |
dispatch_async(queue, { [weak self] in if let strongSelf = self { strongSelf.doSomething() } }) |
我覺得這種語法不僅噁心乏味不直觀,而且違反了閉包作為一個獨立處理實體的原則。學會理解物件的生命週期,明白何時應該宣告弱引用,以及物件生存週期的意義,這很重要。但是,這又使得我分心而無法專注於 app 開發的問題本身,如果 Cocoa 不使用 ARC,也就不必要寫這些程式碼。
本地閉包和 block
函式的閉包和 block 如果沒有引用任何例項或類變數,其本身也不會造成迴圈引用。最常見的一個例子就是 UIView
的 animateWithDuration
。
1 2 3 4 5 6 7 |
func myMethod() { ... UIView.animateWithDuration(0.5, animations: { () -> Void in self.someOutlet.alpha = 1.0 self.someMethod() }) } |
和 dispatch_async 和其他相關的 GCD 相關方法一樣,我們不需要擔心區域性變數閉包和 block 產生迴圈引用。
代理協議
代理協議也是一個典型的場景,需要你使用弱引用來避免迴圈引用。將代理宣告為 weak 是一個即好又安全的做法:
@property (nonatomic, weak) id <MyCustomDelegate> delegate;
在 swift:
weak var delegate: MyCustomDelegate?
在大多數的情況中,一個物件的代理持有一個例項化的物件,或應當生命週期長於該物件(從而響應代理方法),因此一個設計良好的類應該不需要我們考慮任何有關生命週期的問題。
使用 Instruments 除錯迴圈引用
不管我多努力仔細,我有時還是會忘記宣告一個弱引用,然後意外地建立一個新的物件(感謝 ARC 的無所作為!)。幸運的是,XCode 自帶了一個很強大的工具 Instruments,用於檢測和定位迴圈引用。一旦你的 app 開發結束,即將提交到 Apple Store,先分析你的 app 是一個好的習慣。Instruments 有很多元件,可以用來分析 app 的不同方面,但是我們現在關心的時 Leak 選項。
Instruments 一啟動,你的應用也應該啟動了,然後執行一些互動操作,特別是你想要測試的區域或檢視控制器。被檢測到的洩露都會以一條紅色線顯示在 Leaks 區域。Assistant 檢視會顯示關於洩露的棧追蹤,甚至可以直接定位到出問題的程式碼。