從一個內存泄漏復習swift對象的構造過程
XCode 8有非常多的更新,其中的 memory graph 對于內存分析非常有用,十分強大,可以方便的查看對象引用關系以及偵測內存泄漏,近期在使用memory graph進行調試的過程中發現了一些奇怪的現象,這里使用一個簡單的demo工程來說明這個內存泄漏的原因和解決方法。
@0 內存孤島
如下圖是在調試過程中發現的一處內存泄漏:
從圖上看,這里被檢測到有內存泄漏,但是這個對象沒有被任何其它對象引用,也沒有引用其它對象,一般在ARC代碼里面常見的內存泄漏一般都是由于retain cycle引起的,這里看起來沒有任何循環持有,那到底是怎么回事兒呢,從右邊的backtrace可以看到這個對象創建時候的調用堆棧(如果你看不到的話,需要在scheme的Diagnostics下面打開Malloc Stack的開關),從調用棧上可以輕松找到這個對象創建的代碼。
看起來這里應該很容易解決了,畢竟代碼已經找到了。可實際上不是那么回事兒,下面來看一下這里的代碼:
class DerrivedView : LKSuperView {
let config = Config() // <<<--- 報告就是這里創建的對象沒有釋放
override init() {
super.init()
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = config.color
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// 在某個地方用到了這個view
{
let derrivedView = DerrivedView()
}
@1 代碼追查
既然代碼已經定位到了,為什么還不能解決呢,不過這一句的語法實在是簡單到不能再簡單了,完全無法修改。仔細檢查代碼,沒有其它的地方引用這個對象,完全不會存在循環引用的問題,難道是swift最后沒有釋放這個對象,這里來看看LKSuperView有沒有問題:
@interface LKSuperView : UIView
- (instancetype)init;
//...
@end
@implementation LKSuperView
- (instancetype)init {
return [self initWithFrame:CGRectMake(0, 0, 100, 100)];
}
@end
這里是一個OC類,重寫了init方法,給了一個默認的frame,看起來也沒什么問題。
會不會是誤報呢,給Config類加一些log看看
class Config {
let color : UIColor
init() {
print(#function)
color = UIColor.red
}
deinit {
print(#function)
}
}
//output
//init()
//init()
//deinit
從log輸出看確實調用了兩次構造和一次析構,但是DerrivedView也只有一個對象,為什么會調用兩次Config的init呢,打個斷點看一下,好像有些新發現:
從兩次斷點來看,一次來自于DerrivedView::init,一次來自于DerrivedView::initWithFrame,回頭看看LKSuperView::init,確實調用了initWithFrame,最后又回到了DerrivedView::initWithFrame,兩次調用在OC里面看起來是比較正常的寫法了,在swift里面難道會初始化兩遍成員變量嗎,是不是這樣的呢,接下來打開調試時顯示匯編代碼的功能,一看究竟
匯編代碼
果然,在init和initWithFrame的匯編代碼里前面都看到同樣的匯編代碼,天書一樣的匯編可以先忽略,因為反匯編代碼的注釋也很強大,看看框起來的代碼注釋,第一行是調用Config::init創建一個Config對象,第二行就是把生成的Config對象地址直接賦值到DerrivedView.config,這里不存在getter和setter,也不會考慮這里是否被初始化過,最終直接覆蓋了第一次初始化的對象,第一次創建的對象變成了一具尸體,放逐到無盡的深淵。
@2 知識回顧
Swift對象構造過程
Swift構造函數有兩種:指定構造函數和便利構造函數,這里不詳細描述,簡單來說,便利構造函數必須調用本類的其它構造函數,指定構造函數必須調用父類的指定構造函數,便利構造函數最終必須要調用到一個指定構造函數,如圖:
構造函數調用示意圖
實際上還有一個非常重要的區別,指定構造函數都有隱式的進行成員變量的初始化,而便利構造函數沒有,這也是為什么便利構造函數為什么一定要直接或間接調用本類的指定構造函數的原因之一,通過檢查兩種不同構造函數的反匯編代碼,可以很清楚的看到這一結論。
兩步構造法
在Swift里面一個對象的繼承關系鏈和對象的初始化有著對應的關系,按照兩步構造法,第一步從子類向上依次初始化成員變量,當根類初始化完成之后,開始第二階段的調用,第二階段就可以自由調用使用該對象的所有變量和函數,為了保證這兩步構造,編譯器會進行4種安全檢查。這里看一些常見的錯誤例子:
class Base {
convenience init() {
print("base")//錯誤:沒有調用本類的指定構造函數
}
init(name : String) {
print(name)
}
init(name : String, age : Int) {
print("just for demo")
}
}
class Sample : Base {
let value : Int
func sayHello() {
print("Hello")
}
init () {
value = 10
//錯誤:指定構造必須調用super的指定構造函數
}
override init(name : String) {
//錯誤1:必須在init之前對value進行初始化
self.sayHello() //錯誤2:在super.init結束前,或者第一階段構造結束前不能使用self關鍵字
super.init(name : name)
}
convenience init(test : Int) {
super.init()//錯誤:便利構造必須使用self調用本類的構造函數,這樣才有機會對本類的成員進行初始化
}
}
對于一個指定構造函數來講,可以認為一個標準的模板是這樣的
init{
//第一階段:變量初始化
//如果有父類,構造過程傳遞給父類,等待父類初始化
//第二階段:自定義行為
}
兩步構造保證了函數調用的安全性,相對比,C++就沒有這個特性,C++有一個常見的問題就是:在構造函數里面是否可以調用虛函數,C++沒有兩步構造,所以在父類的構造函數調用的時候,子類還完全沒有開始初始化,也就是說虛函數表還沒有準備就緒,所有在父類的構造函數里調用虛函數很可能不能給你想要的結果。
@3 問題分析及預防
繼續回到內存泄漏的問題上,根據前面的分析,OC代碼把構造過程傳遞給了子類,這明顯不符合Swift兩步構造的安全檢查,但是OC并沒有這樣的檢查,OC同樣也是基于兩步構造的,只不過OC的成員變量統一被初始化為0或者nil,OC的構造函數傳遞也是基于消息的,這樣最終導致了開頭的問題出現。
OC的init函數里面調用[self init…]同樣是基于消息發送,最終調用的是子類的方法。
Swift的init函數里面調用self.init(…)是函數調用,一定會調用本類的實現,當然這個調用者必須是便利構造函數。
如何解決這個問題呢,直觀上來看不能夠調用LKSuperView的init函數,因為在init里面調用initWithFrame是不符合兩步構造的原則的,第一個解決方法就是,在DerrivedView里面實現自己的init方法:
convenience override init() {
self.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
}
其實,有經驗的同學也一定清楚UIView::init函數最后也會調用initWithFrame,這里感覺有些坑,所以就算是在LKSuperView里面直接調用 [super init] 也解決不了問題,在Swift代碼繼承OC的類的時候,需要注意以下幾點:
- OC類的init函數盡量正規化,不要修改self的值
- Swift類盡量去檢查OC構造函數鏈,避免發生以上狀況,有時候這種情況會比較隱蔽
- 無論OC還是Swift盡量不去重寫init構造函數,而是重寫標記了 NS_DESIGNATED_INITIALIZER 的構造函數
@4 結語
對于Swift和OC的代碼混用中,一定會存在各種意想不到的問題,畢竟兩種語言的設計思想存在較大的差異,在遇到問題的時候,要善于利用Xcode提供的各種強大的工具來檢查,解決。
?
來自:http://www.jianshu.com/p/e7e110d6dc88