
內(nèi)存管理在iOS開(kāi)發(fā)中很重要,在iOS 5之前,開(kāi)發(fā)者需要使用MRC(Manual Reference Count)來(lái)進(jìn)行對(duì)象的內(nèi)存管理;為了方便開(kāi)發(fā)者,從iOS 6開(kāi)始,蘋(píng)果引入了ARC(Automatic Reference Count)來(lái)進(jìn)行內(nèi)存管理,這無(wú)疑大大地減少了開(kāi)發(fā)者的工作量。
ARC本質(zhì)還是MRC,它是在Xcode編譯期間,在代碼適當(dāng)?shù)奈恢锰砑?code>retain ,release或autorelease操作。在絕大多數(shù)時(shí)間,我們可以使用ARC愉快地寫(xiě)代碼,但是為了寫(xiě)出更加安全和健壯的代碼,開(kāi)發(fā)者還是需要了解內(nèi)存管理的知識(shí),以防因?yàn)閮?nèi)存管理不當(dāng)導(dǎo)致莫名其妙的問(wèn)題。
在swift編碼過(guò)程中,大多數(shù)時(shí)候開(kāi)發(fā)者過(guò)于相信ARC,一不小心還是可能踩到循環(huán)引用的坑里面。并且循環(huán)引用的問(wèn)題往往會(huì)潛伏起來(lái),在開(kāi)發(fā)的初期,并不會(huì)導(dǎo)致異常,但是隨著業(yè)務(wù)復(fù)雜度增加、參與的人增多,潛在的循環(huán)引用的問(wèn)題就會(huì)出現(xiàn)。而解決循環(huán)引用導(dǎo)致的問(wèn)題,也會(huì)消耗不少的人力和時(shí)間,回顧循環(huán)引用產(chǎn)生的原因,一方面是開(kāi)發(fā)者的經(jīng)驗(yàn)不足,甚至不知道循環(huán)引用的概念,所以寫(xiě)不出高質(zhì)量的代碼;另一方面,有可能是開(kāi)發(fā)過(guò)程中并沒(méi)有引入規(guī)范的編碼方式,一個(gè)人踩了坑,找到了解決方案,并不能推廣給團(tuán)隊(duì)中的其他成員,導(dǎo)致其他人前赴后繼地踩坑。
本篇文章,主要討論了swift中解決循環(huán)引用的方案,并結(jié)合一個(gè)案例,幫助讀者加深理解。
1. Swift中的循環(huán)引用
在swift中,對(duì)象引用時(shí)默認(rèn)是強(qiáng)strong引用,如果兩個(gè)對(duì)象相互持有時(shí),則會(huì)造成循環(huán)引用,為了解決這個(gè)問(wèn)題,swift引入了weak和unowned兩個(gè)修飾關(guān)鍵字。
相對(duì)于強(qiáng)strong引用,weak和unowned則稱為弱引用和無(wú)主引用,weak和unowned都不會(huì)對(duì)一個(gè)引用對(duì)象產(chǎn)生強(qiáng)引用,這在基本原理上來(lái)說(shuō)是因?yàn)樗鼈兌疾粫?huì)增加對(duì)象的引用計(jì)數(shù)(retain count)。
按照我們OC時(shí)代的經(jīng)驗(yàn),weak就可以解決循環(huán)引用的問(wèn)題,開(kāi)發(fā)者會(huì)感到奇怪,swift有了weak,為什么swift還要引入unowned關(guān)鍵字?以及,unowned和weak有什么區(qū)別呢?
1.1 weak, unowned產(chǎn)生背景
之所以有weak和unowned兩個(gè)關(guān)鍵字,則是與swift語(yǔ)言的可選(optional type)類型相關(guān),關(guān)于可選類型的技術(shù)點(diǎn)不是本篇探討的重點(diǎn),此處不再贅述。
如果,我是說(shuō)如果,如果swift語(yǔ)言沒(méi)有optional概念,那么使用weak足以解決循環(huán)引用的問(wèn)題。現(xiàn)在,swift為了保證代碼安全性,引入了optional概念。說(shuō)到底,optional是一個(gè)類型,它與Int, String或者諸如自定義的Person類一樣;但是optional類型特殊之處在于它可以用來(lái)包裹其他的類型,例如var person: Person?, let number: Int?就是用optional類型包裹了Person和Int類型。這樣說(shuō)來(lái),swift中定義屬性有兩種形式,即optional和non-optional,僅僅一個(gè)weak關(guān)鍵字不足以同時(shí)解決optional和non-optional兩種情況,所以swift引入了另一個(gè)關(guān)鍵字unowned。
1.2 weak與optional相關(guān)
弱(weak)引用允許引用對(duì)象為空,并且我們知道,在swift中只有optional類型定義的變量或?qū)ο蟛趴梢栽O(shè)置為空,所以如果我們使用weak關(guān)鍵字定義屬性,則必須保證該屬性是optional類型。
1.3 unowned與non-optional相關(guān)
使用unowned時(shí),我們會(huì)假定一個(gè)對(duì)象a的無(wú)主引unowned reference用b在其持有者a的生命周期中永遠(yuǎn)不會(huì)被置為nil,同時(shí)必須保證一個(gè)無(wú)主引用對(duì)象unowned reference在它的初始化方法中就被賦值,這也意味著該無(wú)主引用對(duì)象應(yīng)該定義為non-optional類型,這樣我們就可以安全使用該對(duì)象,而不必須進(jìn)行安全檢查。
如果因?yàn)槟承┰?,一個(gè)無(wú)主引用對(duì)象從內(nèi)存中被釋放,那么當(dāng)我們使用該對(duì)象的時(shí)候,App就會(huì)閃退。
1.4 參考Apple官方的建議
上面的內(nèi)容對(duì)于讀者來(lái)說(shuō),可能還是感覺(jué)含糊不清,我們來(lái)看一下Apple文檔中的建議吧,概括起來(lái)大概就是下面兩條原則,
- 當(dāng)一個(gè)對(duì)象有可能在它的生命周期內(nèi)被設(shè)置為nil時(shí)候,使用weak關(guān)鍵字;
- 當(dāng)你可以確保一個(gè)對(duì)象在它的生命周期內(nèi)不會(huì)被設(shè)置為nil時(shí),使用unowned關(guān)鍵字。
同時(shí),文檔中也提供了兩個(gè)demo,探討了循環(huán)引用的案例和打破循環(huán)引用的方法,
先看一下使用weak的demo,如下代碼所示,
// weak關(guān)鍵字例子
class Person {
let name: String
init(name: String) { self.name = name }
var apartment: Apartment?
}
class Apartment {
let number: Int
init(number: Int) { self.number = number }
weak var person: Person? // 承租人、占用者
}
這個(gè)demo是一個(gè)人和公寓的簡(jiǎn)單模型,Person類有一個(gè)Apartment類型的optional屬性apartment,同時(shí),Apartment類有一個(gè)Person類型的person(承租人)屬性,該屬性也是optional類型。
Optional在此處表明的意思就是Person對(duì)象可能會(huì)持有apartment屬性,也可能不會(huì);Apartment對(duì)象可能會(huì)、也可能不會(huì)持有person屬性。這會(huì)造成一個(gè)問(wèn)題,如果Person和Apartment雙方都持有了對(duì)方,就形成了循環(huán)引用,為了打破循環(huán)引用,將Apartment類的Person類型的person屬性改為weak修飾,經(jīng)過(guò)這樣修改之后,兩者的關(guān)系如下圖所示 ,

這是一個(gè)很好的demo,展示了使用weak關(guān)鍵字的場(chǎng)景。使用weak關(guān)鍵字打破了循環(huán)引用之后,兩個(gè)對(duì)象之間不會(huì)形成緊密的依賴關(guān)系,能夠在適當(dāng)?shù)臅r(shí)機(jī)從內(nèi)存中釋放。
再看一下使用unowned的demo,如下代碼,
// unowned關(guān)鍵字例子
class Customer {
let name: String
var card: CreditCard?
init(name: String) { self.name = name }
}
class CreditCard {
let number: UInt64
unowned let customer: Customer
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
}
在這個(gè)demo中,是消費(fèi)者和信用卡的模型,一個(gè)消費(fèi)者可能會(huì)擁有、也可能沒(méi)有一張信用卡;而一張信用卡必定有一個(gè)關(guān)聯(lián)的消費(fèi)者。為了用代碼表示兩者之間的關(guān)系,一個(gè)Customer類有一個(gè)optional類型的creditCard屬性,而一個(gè)CreditCard類型有一個(gè)non-optional類型、unowned修飾的customer屬性。他們之間的關(guān)系如下圖所示,

2. 閉包引起的循環(huán)引用
當(dāng)然除了兩個(gè)對(duì)象相互持有造成循環(huán)引用,還有一種情況也會(huì)造成循環(huán)引用,那就是閉包(在swift中叫閉包closure,在OC中叫代碼塊block),并且使用閉包或代碼塊的場(chǎng)景更多,更容易因?yàn)榫幋a疏忽或不規(guī)范造成潛在的循環(huán)引用。筆者在開(kāi)發(fā)過(guò)程中就碰到了這種神坑,下面筆者就回顧一下解決bug的過(guò)程,希望能夠拋磚引玉,也希望讀者在開(kāi)發(fā)過(guò)程中能避開(kāi)這類錯(cuò)誤。
2.1 Bug起因
最近的開(kāi)發(fā)周,同事M在接手另一個(gè)同事K的工作任務(wù),他在開(kāi)發(fā)過(guò)程中遇到一個(gè)很奇怪的現(xiàn)象,簡(jiǎn)單描述一下,在HomeVC頁(yè)面上是一些品牌信息列表,用戶可以點(diǎn)擊進(jìn)入詳情,也可以點(diǎn)擊關(guān)注按鈕直接關(guān)注;還可以點(diǎn)擊導(dǎo)航欄更多品牌按鈕,跳轉(zhuǎn)到MoreBrandListVC,這個(gè)列表顯示更多的品牌信息。如下圖所示,

按照設(shè)計(jì)和編碼原則,HomeVC的品牌關(guān)注狀態(tài)應(yīng)該與MoreBrandListVC對(duì)應(yīng)的品牌關(guān)注狀態(tài)保持一致,但測(cè)試同事提的比較奇怪的問(wèn)題就是在HomeVC和MoreBrandListVC之間反復(fù)切換,并且多次重復(fù)點(diǎn)擊關(guān)注某個(gè)品牌以后,例如關(guān)注了“品牌3”,點(diǎn)擊“返回”pop回到HomeVC時(shí)候,下拉刷新,發(fā)現(xiàn)在HomeVC頁(yè)面,“品牌3”的關(guān)注狀態(tài)是未關(guān)注,與MoreBrandListVC關(guān)注狀態(tài)不一致。
剛開(kāi)始分析該問(wèn)題時(shí)候,以為是客戶端多次操作,發(fā)送請(qǐng)求太多,服務(wù)端處理不及時(shí)或者服務(wù)端緩存造成的返回結(jié)果不一致,準(zhǔn)備強(qiáng)行甩鍋給服務(wù)端開(kāi)發(fā)人員。后來(lái)服務(wù)端同事說(shuō)他沒(méi)有做緩存,而且android是OK的,這就尷尬了。
2.2 多人協(xié)作定位bug
既然服務(wù)端同事來(lái)幫分析問(wèn)題了,咱們就把他叫過(guò)來(lái)一塊看看Xcode的打印日志,按照測(cè)試的操作步驟,iOS同事M不久就重現(xiàn)了測(cè)試指出的bug,在Xcode的console控制臺(tái)中看了看服務(wù)端返回的數(shù)據(jù),跟客戶端顯示的是一致的。服務(wù)端同事也有點(diǎn)啞口無(wú)言,但是他突然看出了異常的情況,就是在Xcode的控制臺(tái)有很多發(fā)送“某品牌關(guān)注狀態(tài)更新的請(qǐng)求”的日志,按照道理,無(wú)論是在HomeVC點(diǎn)擊關(guān)注或下拉刷新,亦或是在MoreBrandListVC點(diǎn)擊關(guān)注品牌,操作再?gòu)?fù)雜,最多也就十幾條請(qǐng)求吧,但是我們看到的實(shí)際情況是Xcode打印了四五十條的請(qǐng)求日志。這一點(diǎn)很讓人費(fèi)解,我們定位了一下發(fā)起請(qǐng)求的地方,原來(lái)是在MoreBrandListVC里面,更具體地來(lái)說(shuō),就是MoreBrandListVC觀察了某個(gè)notification,收到notification之后進(jìn)行了網(wǎng)絡(luò)請(qǐng)求。
問(wèn)題應(yīng)該就是出現(xiàn)在MoreBrandListVC上面,在回到HomeVC之后,iOS系統(tǒng)會(huì)在合適的時(shí)機(jī)將MoreBrandListVC對(duì)象從內(nèi)存中釋放;而現(xiàn)在出現(xiàn)的問(wèn)題是,在HomeVC頁(yè)面,MoreBrandListVC對(duì)象內(nèi)部還在接收到notification之后發(fā)送網(wǎng)絡(luò)請(qǐng)求。這說(shuō)明MoreBrandListVC對(duì)象的內(nèi)存并沒(méi)有被合理的釋放,而是一直存在于內(nèi)存中,造成MoreBrandListVC不能釋放的原因極有可能是循環(huán)引用。
請(qǐng)讀者再看一下測(cè)試重現(xiàn)bug的操作,反復(fù)在HomeVC和MoreBrandListVC之間切換,每一次從HomeVC頁(yè)面push到MoreBrandListVC頁(yè)面,就創(chuàng)建了一個(gè)MoreBrandListVC對(duì)象,而因?yàn)槟巢糠执a原因,導(dǎo)致MoreBrandListVC對(duì)象與另一個(gè)對(duì)象形成了循環(huán)引用。這樣,內(nèi)存中就存在了多個(gè)MoreBrandListVC對(duì)象,這多個(gè)對(duì)象再接收到notification之后,就開(kāi)始進(jìn)行網(wǎng)絡(luò)請(qǐng)求,這就是Xcode日志中看到四五十條網(wǎng)絡(luò)請(qǐng)求的原因。
找到這樣一個(gè)bug,也是一個(gè)挺艱難和復(fù)雜的過(guò)程,簡(jiǎn)單描述一下,重現(xiàn)并定位該bug,大概是這樣幾個(gè)步驟,
- 測(cè)試部門(mén)同事反復(fù)、多次的非常規(guī)操作,以及他堅(jiān)持不懈追究到底的決心;
- 服務(wù)端和客戶端開(kāi)發(fā)一起看Xcode打印日志,并由服務(wù)端同事發(fā)現(xiàn)過(guò)多請(qǐng)求的異常;
- 客戶端同事分析現(xiàn)象,定位bug原因是循環(huán)引用。
以上,只是簡(jiǎn)單概括找出bug的過(guò)程,實(shí)際上中間的坑更多。最麻煩的一點(diǎn)大概就是這一塊的bug是之前的版本就已經(jīng)存在了,只是當(dāng)時(shí)沒(méi)有測(cè)試出來(lái),而現(xiàn)在不知道隔了多久,萬(wàn)年老坑被挖出來(lái),已經(jīng)不知道當(dāng)時(shí)這塊代碼的維護(hù)者是哪個(gè)人??梢哉f(shuō),bug時(shí)間越久,就越難排查。
2.3 逐步排查,精確打擊
在分析可能造成循環(huán)引用的地方,我們進(jìn)行了一一排查,大體思路是這樣的,
- 首先,MoreBrandListVC的對(duì)象創(chuàng)建是在HomeVC內(nèi),那么MoreBrandListVC對(duì)象有沒(méi)有反向的持有HomeVC呢,仔細(xì)看了看代碼,并沒(méi)有這種情況;
- 其次,有沒(méi)有可能是MoreBrandListVC內(nèi)部與其他block相互持有造成了循環(huán)引用呢。我在MoreBrandListVC文件里面搜索了
block關(guān)鍵字,搜索到了4個(gè),其中有兩個(gè)是網(wǎng)絡(luò)請(qǐng)求的block回調(diào);還有兩個(gè)是頁(yè)面CollectionViewCell關(guān)注按鈕點(diǎn)擊的事件回調(diào)。
排除了第一種情況,那么著重分析第二種情況,最終我們排查出問(wèn)題在于CollectionViewCell按鈕點(diǎn)擊的事件回調(diào)與MoreBrandListVC對(duì)象造成了循環(huán)引用,下面的代碼,是因?yàn)殚]包導(dǎo)致了循環(huán)引用,讀者可以回顧一下,自己是否寫(xiě)過(guò)這樣的代碼,
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(kCellReuseIdentify, forIndexPath: indexPath) as? CustomCell
// cell?.focusButtonClickBlock = xxx會(huì)導(dǎo)致循環(huán)引用
cell?.focusButtonClickBlock = { (model) -> Void in
self.sendRequest(withModel: model)
}
return cell!
}
上面的代碼,第一眼看起來(lái)沒(méi)有什么問(wèn)題,但是正如注釋所言,cell?.focusButtonClickBlock = xxx會(huì)導(dǎo)致循環(huán)引用,這段代碼中有這樣幾個(gè)角色,self, cell, focusButtonClickBlock,self代表當(dāng)前的控制器,cell是自定義的UICollectionViewCell,focusButtonClickBlock代表cell上面按鈕點(diǎn)擊的事件回調(diào),這樣劃分了角色,就能弄清3者相互持有的過(guò)程,大體是這樣的,
- self持有了cell,
- cell持有了focusButtonClickBlock,
- focusButtonClickBlock內(nèi)部持有了self。
3者之間的關(guān)系,如下圖所示,

這樣,3者之間就形成了循環(huán)引用,接下來(lái)的事情就是打破這個(gè)循環(huán)引用,使用unowned來(lái)打破循環(huán)引用,bug也迎刃而解,如下代碼所示,
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(kCellReuseIdentify, forIndexPath: indexPath) as? CustomCell
// 這里添加了[unowned self]
cell?.focusButtonClickBlock = { [unowned self] (model) -> Void in
self.sendRequest(withModel: model)
}
return cell!
}
在focusButtonClickBlock內(nèi)部定義[unowned self]打破了三者之間的循環(huán)引用,因?yàn)榇藭r(shí)閉包持有的self是unowned修飾,它表明閉包并不對(duì)self是強(qiáng)引用,而是無(wú)主引用,這樣當(dāng)self即ViewController通過(guò)點(diǎn)擊返回按鈕,在NavigationController的堆棧中出棧時(shí),就不會(huì)因?yàn)檠h(huán)引用導(dǎo)致無(wú)法從內(nèi)存中釋放。打破循環(huán)引用之后,三者之間的關(guān)系如下圖所示,

3. 結(jié)尾的釋疑
這里使用了unowned解決了循環(huán)引用,那么為什么不使用weak呢?其實(shí)也可以使用weak來(lái)解決問(wèn)題,兩者區(qū)別就是self是否可能為nil,當(dāng)我們點(diǎn)擊cell上面的按鈕時(shí)候可以確保self是存在的,而不是nil,所以使用unowned不會(huì)有什么問(wèn)題。
但是,如果在一個(gè)網(wǎng)絡(luò)請(qǐng)求的回調(diào)里面使用unowned,那么有可能會(huì)導(dǎo)致crash,因?yàn)橛脩粲锌赡茉诰W(wǎng)絡(luò)請(qǐng)求的過(guò)程中等的不耐煩,直接從當(dāng)前ViewController頁(yè)面退回前一個(gè)頁(yè)面,這時(shí)候self就是nil,使用unowned導(dǎo)致了崩潰;而使用weak則不會(huì)有這種問(wèn)題,因?yàn)閣eak允許被修飾的對(duì)象為nil。
這么說(shuō)來(lái)[weak self]相當(dāng)于self?,是可選解包;而[unowned self]相當(dāng)于self!,是隱式強(qiáng)制解包。
差不多就是這樣吧。
參考鏈接
- http://stackoverflow.com/questions/24011575/what-is-the-difference-between-a-weak-reference-and-an-unowned-reference
- https://realm.io/news/hector-matos-memory-management/
- http://swifter.tips/retain-cycle/
公眾號(hào)
微信掃描下方圖片,歡迎關(guān)注本人公眾號(hào)foolishlion,咱們來(lái)談技術(shù)談人生,因?yàn)檫@又不要錢(qián),
