iOS 編寫高質量Objective-C程式碼(五)

QiShare發表於2018-09-05

級別: ★★☆☆☆
標籤:「iOS」「記憶體管理」「Objective-C」
作者: MrLiuQ
審校: QiShare團隊

前言: 這幾篇文章是小編在鑽研《Effective Objective-C 2.0》的知識產出,其中包含作者和小編的觀點,以及小編整理的一些demo。希望能幫助大家以簡潔的文字快速領悟原作者的精華。 在這裡,QiShare團隊向原作者Matt Galloway表達誠摯的敬意。

文章目錄如下:
iOS 編寫高質量Objective-C程式碼(一)
iOS 編寫高質量Objective-C程式碼(二)
iOS 編寫高質量Objective-C程式碼(三)
iOS 編寫高質量Objective-C程式碼(四)
iOS 編寫高質量Objective-C程式碼(五)
iOS 編寫高質量Objective-C程式碼(六)
iOS 編寫高質量Objective-C程式碼(七)
iOS 編寫高質量Objective-C程式碼(八)


本篇的主題是iOS中的 “記憶體管理機制”

說到iOS記憶體管理,逃不過iOS的兩種記憶體管理機制:MRC & ARC
先簡單介紹一下:
MRC(manual reference counting): “手動引用計數” ,由開發者管理記憶體。 ARC(automatic reference counting):“自動引用計數”,從iOS 5開始支援, 由編譯器幫忙管理記憶體。

蘋果引入ARC機制的原因猜測:

iOS 4之前,所有iOS開發者必須要手動管理記憶體,即手動管理物件的記憶體分配和釋放。首先,不斷插入retainrelease等記憶體管理語句,大大加大了工作量和程式碼量。其次,在面對一些多執行緒併發操作時,開發者手動管理記憶體並不簡單,還可能會帶來很多無法預知的問題。
所以,蘋果從iOS 5開始引入ARC機制,由編譯器幫忙管理記憶體。在編譯期,編譯器會自動加上記憶體管理語句。這樣,開發者可以更加關注業務邏輯。

下面進入正題:編寫高質量Objective-C程式碼(五)——記憶體管理篇

一、理解引用計數

  • 引用計數工作原理:

這裡引入《Objective-C 高階程式設計 iOS與OSX多執行緒和記憶體管理》這本書的例子: 很經典的圖解:

iOS 編寫高質量Objective-C程式碼(五)

解釋:
1.開燈:引申為:“ 建立物件 ”
2.關燈:引申為:“ 銷燬物件 ”

iOS 編寫高質量Objective-C程式碼(五)

解釋:
1.有人來上班打卡了:開燈。——(建立物件,計數為1)
2.又有人來了:保持開燈。——(保持物件,計數為2)
3.又有人來了:保持開燈。——(保持物件,計數為3)
4.有人下班打卡了:保持開燈。——(保持物件,計數為2)
5.又有人下班了:保持開燈。——(保持物件,計數為1)
6.所有員工全下班了:關燈。——(銷燬物件,計數為0)


場景 對應OC的動作 對應OC的方法
上班開燈 生成物件 alloc/new/copy/mutableCopy等
需要照明 持有物件 retain
不需要照明 解除持有 release
下班關燈 銷燬物件 dealloc

如果覺得本書中的例子說的有點抽象難懂,沒關係,請看下面圖解示例:
提示:實箭頭為強引用,虛箭頭為弱引用。

iOS 編寫高質量Objective-C程式碼(五)

  • 屬性存取方法中的記憶體管理:

這裡有個set方法的例子:

- (void)setObject:(id)object {

   [object retain];// Added by ARC
   [_object release];// Added by ARC

   _object = object; 
}
複製程式碼

解釋:set方法將保留新值,釋放舊值,然後更新例項變數。這三個語句的順序很重要。 如果先releaseretain。那麼該物件可能已經被回收,此時retain操作無效,因為物件已釋放。這時例項變數就變成了懸掛指標。(懸掛指標:指標指nil的指標。)

  • 自動釋放池: 細心的同學會發現,在我們寫iOS程式時,main函式裡就有一個autoreleasepool(自動釋放池)。
int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}
複製程式碼

autorelease能延長物件的生命週期,在物件跨越“方法呼叫邊界”後(就是}後)依然可以存活一段時間。

  • 迴圈引用:

迴圈引用(retain cycle)又稱為“保留環”。 形成迴圈引用的原因:是物件之間互相通過強指標指向對方(或者說互相強持有對方)。 在開發中,我們不希望出現迴圈引用,因為會造成記憶體洩漏。 解決方案:有一方使用弱引用(weak reference),解開迴圈引用,讓多個物件都可以釋放。 PS:關於如何檢驗專案中有無記憶體洩漏:參考這篇部落格

二、以ARC簡化引用計數

,在ARC環境下,禁止?呼叫:retainreleaseautoreleasedealloc方法。

  • 使用ARC時必須遵循的方法命名規則: 若方法名以allocnewcopymutableCopy開頭,則規定返回的物件歸呼叫者。

  • 變數的記憶體管理語義:

對比一下MRC和ARC在程式碼上的區別

MRC環境下:

- (void)setObject:(id)object {

    [_object release];
    _object = [object retain];
}
複製程式碼

這樣會出現一種邊界情況,如果新值和舊值是同一個物件,那麼會先釋放掉,object就變成懸掛指標。

ARC環境下:

- (void)setObject:(id)object {

    _object = object;
}
複製程式碼

ARC會用一種更安全的方式解決邊界問題:先保留新值,再釋放舊值,最後更新例項變數。

同時,ARC可以通過修飾符來改變區域性變數和例項變數的語義:

修飾符 語義
__strong 預設,強持有,保留此值。
__weak 不保留此值,安全。物件釋放後,指標置nil。
__unsafe_unretained 不保留此值,不安全。物件釋放後,指標依然指向原地址(即不置nil)。
__autoreleasing 此值在方法返回時自動釋放。
  • ARC如何清理例項變數:

MRC中,開發者需要在dealloc中動插入必要的清理程式碼(cleanup code)。 而ARC會借用Objective-C++的一項特性來完成清理任務,回收OC++物件時,會呼叫C++的解構函式:底層走.cxx_destruct方法。而當釋放OC物件時,ARC在.cxx_destruct底層方法中新增所需要的清理程式碼(這個方法底層的某個時機會呼叫dealloc方法)。 不過如果有非OC的物件,還是要重寫dealloc方法。比如CoreFoundation中的物件或是malloc()分配在堆中的記憶體依然需要清理。這時要適時呼叫CFRetain/CFRelease

- (void)dealloc {

   CFRelease(_coreFoundationObject);
   free(_heapAllocatedMemoryBlob);
}
複製程式碼

三、dealloc方法中只釋放引用並解除監聽

呼叫dealloc方法時,物件已經處於回收狀態了。這時不能呼叫其他方法,尤其是非同步執行某些任務又要回撥的方法。如果非同步執行完回撥的時候物件已經摧毀,會直接crash。

dealloc方法裡要做些釋放相關的事情,比如:

  • 釋放指向其他物件的引用。
  • 取消訂閱KVO。
  • 取消NSNotificationCenter通知。

舉個例子:

  • KVO:
- (void)viewDidLoad {
    
    //....

    [webView addObserver:self forKeyPath:@"canGoBack" options:NSKeyValueObservingOptionNew context:nil];
    [webView addObserver:self forKeyPath:@"canGoForward" options:NSKeyValueObservingOptionNew context:nil];
    [webView addObserver:self forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:nil];
    [webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:nil];
}

#pragma mark - KVO

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    
    self.backItem.enabled = self.webView.canGoBack;
    self.forwardItem.enabled = self.webView.canGoForward;
    self.title = self.webView.title;
    self.progressView.progress = self.webView.estimatedProgress;
    self.progressView.hidden = self.webView.estimatedProgress>=1;
}

- (void)dealloc {
    
    [self.webView removeObserver:self forKeyPath:@"canGoBack"];//< 移除KVO
    [self.webView removeObserver:self forKeyPath:@"canGoForward"];
    [self.webView removeObserver:self forKeyPath:@"title"];
    [self.webView removeObserver:self forKeyPath:@"estimatedProgress"];
}
複製程式碼
  • NSNotificationCenter:
- (void)viewDidLoad {

    //......

    // 新增響應通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(tabBarBtnRepeatClick) name:BQTabBarButtonDidRepeatClickNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(titleBtnRepeatClick) name:BQTitleButtonDidRepeatClickNotification object:nil];
}

// 移除通知
- (void)dealloc {
    
//    [[NSNotificationCenter defaultCenter] removeObserver:self name:BQTabBarButtonDidRepeatClickNotification object:nil];
//    [[NSNotificationCenter defaultCenter] removeObserver:self name:BQTitleButtonDidRepeatClickNotification object:nil];

    // 或者使用一個語句全部移除
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}
複製程式碼

四、編寫“ 異常安全程式碼 ”時留意記憶體管理問題

異常只應在發生嚴重錯誤後丟擲。
用的不好會造成記憶體洩漏:在try塊中,如果先保留了某個物件,然後在釋放它之前又丟擲了異常,那麼除非catch塊能解決問題,否則物件所佔記憶體就會洩漏。

原因:C++的解構函式由Objective-C的異常處理例程來執行。由於丟擲異常會縮短生命期,所以發生異常時必須析構,不然就記憶體洩漏,而這時如果檔案控制程式碼(file handle)等系統資源沒有正確清理,就會發生記憶體洩漏。

  • 捕獲異常時,一定要將try塊內所創立的物件清理乾淨。
  • ARC下,編譯器預設不生成安全處理異常所需的清理程式碼。如要開啟,請手動開啟:-fobjc-arc-exceptions標誌。但很影響效能。所以建議最好還是不要用。但有種情況是可以使用的:Objective-C++模式。

PS:在執行期系統,C++Objective-C的異常互相相容。也就是說其中任一語言丟擲的異常,能用另一語言所編的**“異常處理程式”**捕獲。而在編寫Objective-C++程式碼時,C++處理異常所用的程式碼與ARC實現的附加程式碼類似,編譯器自動開啟-fobjc-arc-exceptions標誌,其效能損失不大。

最後,還是建議:

  1. 異常只用於處理嚴重的錯誤(fatal error,致命錯誤)
  2. 對於一些不那麼嚴重的錯誤(nonfatal error,非致命錯誤),有兩種解決方案:
    • 讓物件返回nil或者0(例如:初始化的引數不合法,方法返回nil或0)
    • 使用NSError

五、以弱引用避免迴圈引用(避免記憶體洩漏)

這條比較簡單,內容主旨就是標題:以弱引用避免迴圈引用(Retain Cycle)

  • 為了避免因迴圈引用而造成記憶體洩漏。這時,某些引用需要設定為弱引用(weak)。
  • 使用弱引用weak,ARC下,物件釋放時,指標會置nil

六、以 “自動釋放池塊” 降低記憶體峰值

  • 預設情況下:自動釋放池需要等待執行緒執行下一次事件迴圈時才清空,通常for迴圈會不斷建立新物件加入自動釋放池裡,迴圈結束才釋放。因此,可能會佔用大量記憶體。
  • 手動加入自動釋放池塊(@autoreleasepool):每次for迴圈都會直接釋放記憶體,從而降低了記憶體的峰值。

尤其,在遍歷處理一些大陣列或者大字典的時候,可以使用自動釋放池來降低記憶體峰值,例如:

NSArray *qiShare = /*一個很大的陣列*/
NSMutableArray *qiShareMembersArray = [NSMutableArray new];
for (NSStirng *name in qiShare) {
    @autoreleasepool {
        QiShareMember *member = [QiShareMember alloc] initWithName:name];
        [qiShareMembersArray addObject:member];
    }
}
複製程式碼

PS:自動釋放池的原理:排布在“棧”中,物件執行autorelease訊息後,系統將其放入最頂端的池裡(進棧),而清空自動釋放池就是把物件銷燬(出棧)。而呼叫出棧的時機:就是當前執行緒執行下一次事件迴圈時。

七、用 “殭屍物件” 除錯記憶體管理問題

iOS 編寫高質量Objective-C程式碼(五)

如上圖,勾選這裡可以開啟殭屍物件設定。開啟之後,系統在回收物件時,不將其真正的回收,而是把它的isa指標指向特殊的殭屍類(zombie class),變成殭屍物件。殭屍類能夠響應所有的選擇子,響應方式為:列印一條包含訊息內容以及其接收者的訊息,然後終止應用程式。

殭屍物件簡單原理:在Objective-C的執行期程式庫、Foundation框架以及CoreFoundation框架的底層加入了實現程式碼。在系統即將回收物件時,通過一個環境變數NSZombieEnabled識別是殭屍物件——不徹底回收,isa指標指向殭屍類並且響應所有選擇子。

八、不要使用retainCount

在蘋果引入ARC之後retainCount已經正式廢棄,任何時候都沒法呼叫這個retainCount方法來檢視引用計數了,因為這個值實際上已經沒有準確性了(而且在ARC環境下也呼叫不了)。但是在MRC下還是可以正常使用的。

最後,特別緻謝:《Effective Objective-C 2.0》第五章。

關注我們的途徑有:
QiShare(簡書)
QiShare(掘金)
QiShare(知乎)
QiShare(GitHub)
QiShare(CocoaChina)
QiShare(StackOverflow)
QiShare(微信公眾號)

推薦文章:
iOS與JS互動之WKWebView-WKUIDelegate協議
如果360推出辣椒水,各位女士會買嗎?
從撒狗糧帶你瞭解WoT連線場景

相關文章