iOS開(kāi)發(fā) - 老生常談的循環(huán)引用問(wèn)題

debug-like-a-tuner.jpg

內(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 ,releaseautorelease操作。在絕大多數(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引入了weakunowned兩個(gè)修飾關(guān)鍵字。

相對(duì)于強(qiáng)strong引用,weakunowned則稱為弱引用和無(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)大概就是下面兩條原則,

  1. 當(dāng)一個(gè)對(duì)象有可能在它的生命周期內(nèi)被設(shè)置為nil時(shí)候,使用weak關(guān)鍵字;
  2. 當(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)系如下圖所示 ,

weak-reference.png

這是一個(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)系如下圖所示,

unowned-reference.png

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è)列表顯示更多的品牌信息。如下圖所示,

follow-brands.png

按照設(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è)步驟,

  1. 測(cè)試部門(mén)同事反復(fù)、多次的非常規(guī)操作,以及他堅(jiān)持不懈追究到底的決心;
  2. 服務(wù)端和客戶端開(kāi)發(fā)一起看Xcode打印日志,并由服務(wù)端同事發(fā)現(xiàn)過(guò)多請(qǐng)求的異常;
  3. 客戶端同事分析現(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ò)程,大體是這樣的,

  1. self持有了cell,
  2. cell持有了focusButtonClickBlock,
  3. focusButtonClickBlock內(nèi)部持有了self。

3者之間的關(guān)系,如下圖所示,

strong-retain-cycle.png

這樣,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)系如下圖所示,

break-retain-cycle.png

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)制解包。

差不多就是這樣吧。

參考鏈接

公眾號(hào)

微信掃描下方圖片,歡迎關(guān)注本人公眾號(hào)foolishlion,咱們來(lái)談技術(shù)談人生,因?yàn)檫@又不要錢(qián),

foolishlion.jpg
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 20- 枚舉,枚舉原始值,枚舉相關(guān)值,switch提取枚舉關(guān)聯(lián)值 Swift枚舉: Swift中的枚舉比OC中的枚...
    iOS_恒仔閱讀 2,423評(píng)論 1 6
  • 作為一門(mén)現(xiàn)代的高級(jí)編程語(yǔ)言,Swift代替我們進(jìn)行了對(duì)象的創(chuàng)建和銷毀等相關(guān)的內(nèi)存管理。它使用了一個(gè)優(yōu)雅的技術(shù),叫做...
    Maru閱讀 2,296評(píng)論 4 17
  • 明天就是圣誕節(jié)啦,于是,在偏僻的郊區(qū)住了這么久的四只,我們準(zhǔn)備明天進(jìn)城去感受一下圣誕節(jié)的氣氛。 雖然臨近考試,應(yīng)該...
    cuckoo醬閱讀 312評(píng)論 0 2
  • 達(dá)羅的意義在于保存住2014年產(chǎn)生的一些觀點(diǎn)。其中有一些具有寓意。 與簡(jiǎn)書(shū)的這個(gè)專題相比較,建立達(dá)羅的豆列在今天被...
    Cyberpunk閱讀 309評(píng)論 0 1
  • 實(shí)習(xí)的時(shí)光正一天一天的縮短,距離返校也越來(lái)越近。一想到馬上就要畢業(yè)了,要離開(kāi)我生活了多年的學(xué)校,離開(kāi)那個(gè)我曾非常熟...
    SundyZhou閱讀 319評(píng)論 5 1

友情鏈接更多精彩內(nèi)容