1. 程式人生 > >ios開發系列之記憶體洩漏分析(下)

ios開發系列之記憶體洩漏分析(下)

接上篇,本篇主要講解通知和 KVO 不移除觀察者、block 迴圈引用 、NSThread 和 RunLoop一起使用造成的記憶體洩漏。

1、通知造成的記憶體洩漏

1.1、ios9 以後,一般的通知,都不再需要手動移除觀察者,系統會自動在dealloc 的時候呼叫 [[NSNotificationCenter defaultCenter]removeObserver:self]。ios9以前的需要手動進行移除。

原因是:ios9 以前觀察者註冊時,通知中心並不會對觀察者物件做 retain 操作,而是進行了 unsafe_unretained 引用,所以在觀察者被回收的時候,如果不對通知進行手動移除,那麼指標指向被回收的記憶體區域就會成為野指標,這時再發送通知,便會造成程式崩潰。

從 ios9 開始通知中心會對觀察者進行 weak 弱引用,這時即使不對通知進行手動移除,指標也會在觀察者被回收後自動置空,這時再發送通知,向空指標傳送訊息是不會有問題的。

1.2、使用 block 方式進行監聽的通知,還是需要進行處理,因為使用這個 API 會導致觀察者被系統 retain。

請看下面這段程式碼:

[[NSNotificationCenter defaultCenter] addObserverForName:@"notiMemoryLeak" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
  NSLog(@"11111");
}];
//發個通知
[[NSNotificationCenter defaultCenter] postNotificationName:@"notiMemoryLeak" object:nil];


第一次進來列印一次,第二次進來列印兩次,第三次列印三次。大家可以在 demo 中進行嘗試,demo 地址見文章底部。

解決方法是記錄下通知的接收者,並且在 dealloc 裡面移除這個接收者就好了:

@property(nonatomic, strong) id observer;
self.observer = [[NSNotificationCenter defaultCenter] addObserverForName:@"notiMemoryLeak" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
  NSLog(@"11111");
}];
//發個通知
[[NSNotificationCenter defaultCenter] postNotificationName:@"notiMemoryLeak" object:nil];
- (void)dealloc {
  [[NSNotificationCenter defaultCenter] removeObserver:self.observer name:@"notiMemoryLeak" object:nil];
  NSLog(@"hi,我 dealloc 了啊");
}

 

2、KVO 造成的記憶體洩漏

2.1、現在一般的使用 KVO,就算不移除觀察者,也不會有問題了
請看下面這段程式碼:

- (void)kvoMemoryLeak {
    MFMemoryLeakView *view = [[MFMemoryLeakView alloc] initWithFrame:self.view.bounds];
    [ self.view addSubview:view];
  [view addObserver:self forKeyPath:@"frame" options:NSKeyValueObservingOptionNew context:nil];
  //呼叫這兩句主動激發kvo 具體的原理會有後期的kvo詳解中解釋
  [view willChangeValueForKey:@"frame"];
  [view didChangeValueForKey:@"frame"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
  if ([keyPath isEqualToString:@"frame"]) {
    NSLog(@"view = %@",object);
  }
}

這種情況不移除也不會有問題,我猜測是因為 view 在控制器銷燬的時候也銷燬了,所以 view 的 frame 不會再發生改變,不移除觀察者也沒問題,所以我做了一個猜想,要是觀察的是一個不會銷燬的物件會怎麼樣?當觀察者已經銷燬,被觀察的物件還在發生改變,會有問題嗎?

2.2、觀察一個不會銷燬的物件,不移除觀察者,會發生不確定的崩潰。

接上面的猜測,首先建立一個單例物件 MFMemoryLeakObject,有一個屬性title:

@interface MFMemoryLeakObject : NSObject
@property (nonatomic, copy) NSString *title;
+ (MFMemoryLeakObject *)sharedInstance;
@end

#import "MFMemoryLeakObject.h"
@implementation MFMemoryLeakObject
+ (MFMemoryLeakObject *)sharedInstance {
  static MFMemoryLeakObject *sharedInstance = nil;
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    sharedInstance = [[self alloc] init];
    sharedInstance.title = @"1";
  });
  return sharedInstance;
}
@end

然後在 MFMemoryLeakView 對 MFMemoryLeakObject 的 title 屬性進行監聽:

#import "MFMemoryLeakView.h"
#import "MFMemoryLeakObject.h"

@implementation MFMemoryLeakView
- (instancetype)initWithFrame:(CGRect)frame {
  if (self = [super initWithFrame:frame]) {
    self.backgroundColor = [UIColor whiteColor];
    [self viewKvoMemoryLeak];
  }
  return self;
}

#pragma mark - 6.KVO造成的記憶體洩漏
- (void)viewKvoMemoryLeak {
  [[MFMemoryLeakObject sharedInstance] addObserver:self forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:nil];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
  if ([keyPath isEqualToString:@"title"]) {
    NSLog(@"[MFMemoryLeakObject sharedInstance].title = %@",[MFMemoryLeakObject sharedInstance].title);
  }
}

最後在控制器中改變 title 的值,view 銷燬前改變一次,銷燬後改變一次:

//6.1、在MFMemoryLeakView監聽一個單例物件
MFMemoryLeakView *view = [[MFMemoryLeakView alloc] initWithFrame:self.view.bounds];
[self.view addSubview:view];
[MFMemoryLeakObject sharedInstance].title = @"2";

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
  [view removeFromSuperview];
  [MFMemoryLeakObject sharedInstance].title = @"3";
});

經過嘗試,第一次沒有問題,第二次就發生崩潰,報錯野指標,具體的大家可以用 demo 做測試,demo 地址見底部。

解決方法也很簡單,在view 的 dealloc 方法裡移除觀察者就好:

- (void)dealloc {
  [[MFMemoryLeakObject sharedInstance] removeObserver:self forKeyPath:@"title"];
  NSLog(@"hi,我MFMemoryLeakView dealloc 了啊");
}

 

總的來說,寫程式碼還是規範一點,有觀察就要有移除,不然專案裡容易產生各種欲仙欲死的 bug。KVO 還有一個重複移除導致崩潰的問題,請參考這篇文章: https://www.cnblogs.com/wengzilin/p/4346775.html。

3、block 造成的記憶體洩漏

block 造成的記憶體洩漏一般都是迴圈引用,即 block 的擁有者在 block 作用域內部又引用了自己,因此導致了 block 的擁有者永遠無法釋放記憶體。

本文只講解 block 造成記憶體洩漏的場景分析和解決方法,其他 block 的原理會在之後 block 的單章裡進行講解。

3.1、block 作為屬性,在內部呼叫了 self 或者成員變數造成迴圈引用。

請看下面這段程式碼,先定義一個 block 屬性:

typedef void (^BlockType)(void);

@interface MFMemoryLeakViewController ()
@property (nonatomic, copy) BlockType block;
@property (nonatomic, assign) NSInteger timerCount;
@end

然後進行呼叫:

#pragma mark - 7.block 造成的記憶體洩漏
- (void)blockMemoryLeak {
  // 7.1 正常block迴圈引用
  self.block = ^(){
    NSLog(@"MFMemoryLeakViewController = %@",self);
    NSLog(@"MFMemoryLeakViewController = %zd",_timerCount);
  };
  self.block();
}


這就造成了 block 和控制器的迴圈引用,解決方法也很簡單, MRC 下使用 __block、ARC 下使用 __weak 切斷閉環,成員變數使用 -> 的方式訪問就可以解決了。

需要注意的是,僅用 __weak 所修飾的物件,如果被釋放,那麼這個物件在 block 執行的過程中就會變成 nil,這就可能會帶來一些問題,比如陣列和字典的插入。

所以建議在 block 內部對__weak所修飾的物件再進行一次強引用,這樣在 Block 執行的過程中,這個物件就不會被置為nil,而在Block執行完畢後,ARC 下這個物件也會被自動釋放,不會造成迴圈引用:

__weak typeof(self) weakSelf = self;
self.block = ^(){
  //建議加一下強引用,避免weakSelf被釋放掉
  __strong typeof(weakSelf) strongSelf = weakSelf;
  NSLog(@"MFMemoryLeakViewController = %@",strongSelf);
  NSLog(@"MFMemoryLeakViewController = %zd",strongSelf->_timerCount);
};
self.block();

 

3.2、NSTimer 使用 block 建立的時候,要注意迴圈引用

請看這段程式碼:

[NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
  NSLog(@"MFMemoryLeakViewController = %@",self);
}];

從 block 的角度來看,這裡是沒有迴圈引用的,其實在這個類方法的內部,有一個 timer 對 self 的強引用,所以也要使用 __weak 切斷閉環,另外,這種方式建立的 timer,repeats 為 YES 的時候,也需要進行invalidate 處理,不然定時器還是停不下來。

@property(nonatomic,strong) NSTimer *timer;
__weak typeof(self) weakSelf = self;
_timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
  NSLog(@"MFMemoryLeakViewController = %@",weakSelf);
}];
- (void)dealloc {
  [_timer invalidate];
  NSLog(@"hi,我MFMemoryLeakViewController dealloc 了啊");
}

 

4、NSThread 造成的記憶體洩漏

NSThread 和 RunLoop 結合使用的時候,要注意迴圈引用問題,請看下面程式碼:

- (void)threadMemoryLeak {
  NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(threadRun) object:nil];
  [thread start];
}

- (void)threadRun {
  [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];
  [[NSRunLoop currentRunLoop] run];
}

導致問題的就是 “[[NSRunLoop currentRunLoop] run];” 這一行程式碼。原因是 NSRunLoop 的 run 方法是無法停止的,它專門用於開啟一個永不銷燬的執行緒,而執行緒建立的時候也對當前當前控制器(self)進行了強引用,所以造成了迴圈引用。

解決方法是建立的時候使用block方式建立:

- (void)threadMemoryLeak {
  NSThread *thread = [[NSThread alloc] initWithBlock:^{
    [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];
  }];
  [thread start];
}

這樣控制器是可以得到釋放了,但其實這個執行緒還是沒有銷燬,就算呼叫 “CFRunLoopStop(CFRunLoopGetCurrent());” 也無法停止這個執行緒,因為這個只能停止這一次的 RunLoop,下次迴圈依然可以繼續進行下去。具體的解決方法我會在 RunLoop 的單章裡進行講解。

本次的記憶體洩漏分析,就寫到這裡,因為本人水平所限,很多地方還是沒能講得足夠深入,歡迎諸位進行指正。

demo地址: https://github.com/zmfflying/ZMFBlogProject.git

&n