iOS 內存泄漏場景與解決方案

歡迎訪問個人博客原文javascript

內存泄漏指的是程序中已動態分配的堆內存(程序員本身管理的空間)因爲某些緣由未能釋放或沒法釋放,形成系統內存的浪費,致使程序運行速度變慢甚至系統崩潰。html

在 iOS 開發中會遇到的內存泄漏場景能夠分爲幾類:java

循環引用

當對象 A 強引用對象 B,而對象 B 又強引用對象 A,或者多個對象互相強引用造成一個閉環,這就是循環引用ios

Block

Block 會對其內部的對象強引用,所以使用的時候須要確保不會造成循環引用。git

舉個例子,看下面這段代碼:程序員

self.block = ^{
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", self.name);
    });
};
self.block();
複製代碼

blockself 的屬性,所以 self 強引用了 block,而 block 內部又調用了 self,所以 block 也強引用了 self。要解決這個循環引用的問題,有兩種思路。github

使用 Weak-Strong Dance

先用 __weakself 置爲弱引用,打破「循環」關係,可是 weakSelfblock 中可能被提早釋放,所以還須要在 block 內部,用 __strongweakSelf 進行強引用,這樣能夠確保 strongSelfblock 結束後纔會被釋放。macos

__weak typeof(self) weakSelf = self;
self.block = ^{
    __strong typeof(self) strongSelf = weakSelf;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", strongSelf.name);
    });
};
self.block();
複製代碼

斷開持有關係

使用 __block 關鍵字設置一個指針 vc 指向 self,從新造成一個 self → block → vc → self 的循環持有鏈。在調用結束後,將 vc 置爲 nil,就能斷開循環持有鏈,從而令 self 正常釋放。json

__block UIViewController *vc = self;
self.block = ^{
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", vc.name);
        vc = nil;
    });
};
self.block();
複製代碼

這裏還要補充一個問題,爲何要用 __block 修飾 vc設計模式

首先,block 自己不容許修改外部變量的值。但被 __block 修飾的變量會被存在了一個棧的結構體當中,成爲結構體指針。當這個對象被 block 持有,就將「外部變量」在棧中的內存地址放到堆中,進而能夠在 block 內部修改外部變量的值。

還有一種方式能夠斷開持有關係。就是將 self 以傳參的形式傳入 block 內部,這樣 self 就不會被 block 持用,也就不會造成循環持有鏈。

self.block = ^(UIViewController *vc){
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", vc.name);
    });
};
self.block(self);
複製代碼

NSTimer

咱們知道 NSTimer 對象是採用 target-action 方式建立的,一般 target 就是類自己,而咱們爲了方便又常把 NSTimer 聲明爲屬性,像這樣:

// 第一種建立方式,timer 默認添加進 runloop
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f target:self selector:@selector(timeFire) userInfo:nil repeats:YES];
// 第二種建立方式,須要手動將 timer 添加進 runloop
self.timer = [NSTimer timerWithTimeInterval:1.0f target:self selector:@selector(timeFire) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
複製代碼

這就造成了 self → timer → self(target) 的循環持有鏈。只要 self 不釋放,dealloc 就不會執行,timer 就沒法在 dealloc 中銷燬,self 始終被強引用,永遠得不到釋放,循環矛盾,最終形成內存泄漏。

那麼若是隻把 timer 做爲局部變量,而不是屬性呢?

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0f target:self selector:@selector(timeFire) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
複製代碼

self 一樣釋放不了。

由於在加入 runloop 的操做中,timer 被強引用,這就造成了一條 runloop → timer → self(target) 的持有鏈。而 timer 做爲局部變量,沒法執行 invalidate,因此在 timer 被銷燬以前,self 也不會被釋放。

因此只要申請了 timer,加入了 runloop,而且 targetself,就算不是循環引用,也會形成內存泄漏,由於 self 沒有釋放的時機。

解決這個問題有好幾種方式,開發者能夠自行選擇。

在合適的時機銷燬 NSTimer

NSTimer 初始化以後,加入 runloop 會致使被當前的頁面強引用,所以不會執行 dealloc。因此須要在合適的時機銷燬 _timer,斷開 _timer、runloop 和當前頁面之間的強引用關係。

[_timer invalidate];
_timer = nil;
複製代碼

ViewController 中的時機能夠選擇 didMoveToParentViewControllerviewDidDisappearView 中能夠選擇 removeFromSuperview 等,但這種方案並必定是正確可行的。

好比在註冊頁面中加了一個倒計時,若是在 viewDidDisappear 中銷燬了 _timer,當用戶點擊跳轉到用戶協議頁面時,倒計時就會被提早銷燬,這是不合邏輯的。所以須要結合具體業務的需求場景來考慮。

使用 GCD 的定時器

GCD 不基於 runloop,能夠用 GCD 的計時器代替 NSTimer 實現計時任務。但須要注意的是,GCD 內部 block 中的循環引用問題仍是須要解決的。

__weak typeof(self) weakSelf = self;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
dispatch_source_set_timer(_timer, DISPATCH_TIME_NOW, 1.0 * NSEC_PER_SEC, 0);
dispatch_source_set_event_handler(_timer, ^{
    [weakSelf timeFire];
});
// 開啓計時器
dispatch_resume(_timer);
// 銷燬計時器
// dispatch_source_cancel(_timer);
複製代碼

藉助中介者銷燬

中介者指的是用別的對象代替 target 裏的 self,中介者綁定 selector 以後,再在 dealloc 中釋放 timer

這裏介紹兩種中介者,一種是 NSObject 對象,一種是 NSProxy 的子類。它們的存在是爲了斷開對 self 的強引用,使之能夠被釋放。

以一個 NSObject 對象做爲中介者

新建一個 NSObject 對象 _target,爲它動態添加一個方法,方法的地址指向 self 方法列表中的 timeFire 的 IMP。這樣 _targetself 之間沒有直接的引用關係,又能引用 self 裏的方法,就不會出現循環引用。

_target = [NSObject new];
class_addMethod([_target class], @selector(timeFire), class_getMethodImplementation([self class], @selector(timeFire)), "v@:");
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f target:_target selector:@selector(timeFire) userInfo:nil repeats:YES];
複製代碼
以 NSProxy 的子類做爲中介者

建立一個繼承自 NSProxy 的子類 WeakProxy,將 timertarget 設置爲 WeakProxy 實例,利用完整的消息轉發機制實現執行 self 中的計時方法,解決循環引用。

// WeakProxy.h
@property (nonatomic, weak, readonly) id weakTarget;

+ (instancetype)proxyWithTarget:(id)target;
- (instancetype)initWithTarget:(id)target;

// WeakProxy.m
@implementation WeakProxy

+ (instancetype)proxyWithTarget:(id)target {
    return [[self alloc] initWithTarget:target];
}

- (instancetype)initWithTarget:(id)target {
    _weakTarget = target;
    return self;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    SEL sel = [invocation selector];
    if ([self.weakTarget respondsToSelector:sel]) {
        [invocation invokeWithTarget:self.weakTarget];
    }
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.weakTarget methodSignatureForSelector:sel];
}

- (BOOL)respondsToSelector:(SEL)aSelector {
    return [self.weakTarget respondsToSelector:aSelector];
}
@end
複製代碼

而後這樣建立 timer

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f target:[WeakProxy proxyWithTarget:self] selector:@selector(timeFire) userInfo:nil repeats:YES];
複製代碼

這時候的循環持有鏈是這樣的:

因爲 WeakProxyself 之間是弱引用關係,self 最終是能夠被銷燬的。

帶 block 的 timer

iOS 10 以後,Apple 提供了一種 block 的方式來解決循環引用的問題。

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
複製代碼

爲了兼容 iOS 10 以前的方法,能夠寫成 NSTimer 分類的形式,將 block 做爲 SEL 傳入初始化方法中,統一以 block 的形式處理回調。

// NSTimer+WeakTimer.m
#import "NSTimer+WeakTimer.h"
 
@implementation NSTimer (WeakTimer)
 
+ (NSTimer *)ht_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                       repeats:(BOOL)repeats
                                         block:(void(^)(void))block {
    return [self scheduledTimerWithTimeInterval:interval
                                         target:self
                                       selector:@selector(ht_blockInvoke:)
                                       userInfo:[block copy]
                                        repeats:repeats];
}
 
+ (void)ht_blockInvoke:(NSTimer *)timer {
    void (^block)(void) = timer.userInfo;
    if(block) {
        block();
    }
}

@end
複製代碼

而後在須要的類中建立 timer

__weak typeof(self) weakSelf = self;
self.timer = [NSTimer ht_scheduledTimerWithTimeInterval:1.0f repeats:YES block:^{
    [weakSelf timeFire];
}];
複製代碼

委託模式

委託模式,是對象之間通訊的一種設計模式。該模式的主旨是:定義一套接口,某對象若想接受另外一個對象的委託,則需聽從此接口,以便成爲其「委託對象」。

UITableView 的 delegate

咱們經常使用的 tableViewViewController 就是委託方代理方的關係。

須要在控制器中加入列表時,一般咱們會將 tableView 設爲 ViewControllerview 的子視圖,UIViewController 的源碼是這樣定義 view 的:

@property(null_resettable, nonatomic, strong) UIView *view;
複製代碼

所以 ViewController 強引用了 tableView。而 tableView 又要委託 ViewController 幫它實現幾個代理方法和數據源方法。若是此時 dataSourcedelegate 屬性用 strong 來修飾,就會出現 UITableViewViewController 互相強引用,造成循環引用

那麼看一下 UITableView 的實現源碼,咱們會發現其中定義 dataSourcedelegate 屬性時是用 weak 修飾的。

@property (nonatomic, weak, nullable) id <UITableViewDataSource> dataSource;
@property (nonatomic, weak, nullable) id <UITableViewDelegate> delegate;
複製代碼

因此 tableViewdataSourcedelegate 只是 weak 指針,指向了 ViewController,它們之間的關係是這樣的:

這也就避免了循環引用的發生。

NSURLSession 的 delegate

那麼 delegate 必定被 weak 修飾嗎?

也不必定,須要看具體的場景。好比 NSURLSession 類中的 delegate 就是用 retain 修飾的。

@property (nullable, readonly, retain) id <NSURLSessionDelegate> delegate;
複製代碼

它這麼作,是由於了確保網絡請求回調以前,delegate 不被釋放。

這也間接引發了 AFNetworking循環引用的出現。咱們看 AFURLSessionManager 類中聲明的 sessionstrong 類型的。

/** The managed session. */
@property (readonly, nonatomic, strong) NSURLSession *session;
複製代碼

在構造 session 對象時,也將 delegate 設爲了 self,也就是 AFURLSessionManager 類。

- (NSURLSession *)session {
    @synchronized (self) {
        if (!_session) {
            _session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
        }
    }
    return _session;
}
複製代碼

如此三者就造成了這樣循環持有關係。

要解決這個問題,有兩種解決思路:

方式一:將 AFHTTPSessionManager 對象設爲單例

對於客戶端來講,大多數狀況下都是對應同一個後臺服務,因此能夠將 AFHTTPSessionManager 對象設爲單例來處理。

- (AFHTTPSessionManager *)sharedManager {
    static dispatch_once_t onceToken;
    static AFHTTPSessionManager *_manager = nil;
    dispatch_once(&onceToken, ^{
        _manager = [AFHTTPSessionManager manager];
        _manager.requestSerializer = [AFHTTPRequestSerializer serializer];
        _manager.responseSerializer.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/html",@"text/json", @"text/plain", @"text/javascript",@"text/xml", nil];
        _manager.responseSerializer = [AFHTTPResponseSerializer serializer];
    });
    return _manager;
}
複製代碼

若是要設定固定請求頭, 以這種 key-value 形式加入到 dispatch_once 中。

[_manager.requestSerializer setValue:@"application/json;charset=utf-8" forHTTPHeaderField:@"Content-Type"];
複製代碼

缺點:由於請求的 header 是由 AFHTTPSessionManagerrequestSerializer.mutableHTTPRequestHeaders 字典持有的,因此這種單例模式會致使全局共享一個 header,若是要處理不一樣自定義 header 的請求就會變得很麻煩。

方式二:在請求結束時,手動銷燬 session 對象

因爲 session 對象對 delegate 強持有,要打破循環引用,須要在請求結束後手動調用 AFHTTPSessionManager 對象銷燬的方法。

- (AFHTTPSessionManager *)getSessionManager{
    AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    manager.requestSerializer = [AFHTTPRequestSerializer serializer];
    manager.responseSerializer.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/html",@"text/json", @"text/plain", @"text/javascript",@"text/xml", nil];
    manager.responseSerializer = [AFHTTPResponseSerializer serializer];
    return manager;
}

- (void)sendRequest{
    AFHTTPSessionManager *manager = [self getSessionManager];
    __weak typeof(manager)weakManager = manager;
    [manager GET:@"https://blog.fiteen.top" parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
         __strong typeof (weakManager)strongManager = weakManager;
        NSLog(@"success 回調");
        [strongManager invalidateSessionCancelingTasks:YES];
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        __strong typeof (weakManager)strongManager = weakManager;
        NSLog(@"error 回調");
        [strongManager invalidateSessionCancelingTasks:YES];
    }];
}
複製代碼

非 OC 對象內存處理

雖然如今已經普及了 ARC 模式,但它僅對 OC 對象進行自動內存管理。對於非 OC 對象,好比 CoreFoundation 框架下的 CICGCF 等開頭的類的對象,在使用完畢後仍需咱們手動釋放。

好比這段獲取 UUID 的代碼:

CFUUIDRef puuid = CFUUIDCreate( kCFAllocatorDefault );
CFStringRef uuidString = CFUUIDCreateString( kCFAllocatorDefault, puuid );
NSString *uuid = [(NSString *)CFBridgingRelease(CFStringCreateCopy(NULL, uuidString)) uppercaseString];
// 使用完後釋放 puuid 和 uuidString 對象
CFRelease(puuid);
CFRelease(uuidString);
複製代碼

還有 C 語言中,若是用 malloc 動態分配內存後,須要用 free 去釋放,不然會出現內存泄漏。好比:

person *p = (person *)malloc(sizeof(person));
strcpy(p->name,"fiteen");
p->age = 18;
// 使用完釋放內存
free(p);
// 防止野指針
p = NULL;
複製代碼

循環加載引發內存峯值

先看下面這段代碼,看似沒有內存泄漏的問題,可是在實際運行時,for 循環內部產生了大量的臨時對象,會出現 CPU 暴增。

for (int i = 0; i < 1000000; i++) {
    NSString *str = @"Abc";
    str = [str lowercaseString];
    str = [str stringByAppendingString:@"xyz"];
    NSLog(@"%@", str);
}
複製代碼

這是由於循環內產生大量的臨時對象,直至循環結束才釋放,可能致使內存泄漏。

解決方案

在循環中建立本身的 autoreleasepool,及時釋放佔用內存大的臨時變量,減小內存佔用峯值。

for (int i = 0; i < 100000; i++) {
    @autoreleasepool {
        NSString *str = @"Abc";
        str = [str lowercaseString];
        str = [str stringByAppendingString:@"xyz"];
        NSLog(@"%@", str);
    }
}
複製代碼

在沒有手加自動釋放池的狀況下,autorelease 對象是在當前的 runloop 迭代結束時釋放的,而它可以釋放的緣由是系統在每一個 runloop 迭代中都會先銷燬並從新建立自動釋放池。

下面舉個特殊的例子,使用容器 block 版本的枚舉器時,內部會自動添加一個自動釋放池,好比:

[array enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
    // 這裏被一個局部 @autoreleasepool 包圍着
}];
複製代碼

野指針與殭屍對象

指針指向的對象已經被釋放/回收,這個指針就叫作野指針。這個被釋放的對象就是殭屍對象

若是用野指針去訪問殭屍對象,或者說向野指針發送消息,會發生 EXC_BAD_ACCESS 崩潰,出現內存泄漏

// MRC 下
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Student *stu = [[Student alloc] init];
        [stu setAge:18];
        [stu release];      // stu 在 release 以後,內存空間被釋放並回收,stu 變成野指針
        // [stu setAge:20]; // set 再調用 setAge 就會崩潰
    }
    return 0;
}
複製代碼

解決方案:當對象釋放後,應該將其置爲 nil

內存泄漏檢查工具

Instruments

Instruments 是 Xcode 自帶的工具集合,爲開發者提供強大的程序性能分析和測試能力。

它打開方式爲:Xcode → Open Developer Tool → Instruments。其中的 Allocations 和 Leaks 功能能夠協助咱們進行內存泄漏檢查。

  • Leaks:動態檢查泄漏的內存,若是檢查過程時出現了紅色叉叉,就說明存在內存泄漏,能夠定位到泄漏的位置,去解決問題。此外,Xcode 中還提供靜態監測方法 Analyze,能夠直接經過 Product → Analyze 打開,若是出現泄漏,會出現「藍色分支圖標」提示。

  • Allocations:用來檢查內存使用/分配狀況。好比出現「循環加載引發內存峯值」的狀況,就能夠經過這個工具檢查出來。

  • Zombies:檢查是否訪問了殭屍對象。

Instruments 的使用相對來講比較複雜,你也能夠經過在工程中引入一些第三方框架進行檢測。

MLeaksFinder

MLeaksFinder 是 WeRead 團隊開源的 iOS 內存泄漏檢測工具。

它的使用很是簡單,只要在工程引入框架,就能夠在 App 運行過程當中監測到內存泄漏的對象並當即提醒。MLeaksFinder 也不具有侵入性,使用時無需在 release 版本移除,由於它只會在 debug 版本生效。

不過 MLeaksFinder 的只能定位到內存泄漏的對象,若是你想要檢查該對象是否存在循環引用。就結合 FBRetainCycleDetector 一塊兒使用。

FBRetainCycleDetector

FBRetainCycleDetector 是 Facebook 開源的一個循環引用檢測工具。它會遞歸遍歷傳入內存的 OC 對象的全部強引用的對象,檢測以該對象爲根結點的強引用樹有沒有出現循環引用。

相關文章
相關標籤/搜索