NSTimer,NSRunLoop,autoreleasepool,多線程的愛恨情仇

引言

NSTimer內存泄漏真的是因為vc與timer循環引用嗎?不是!

小伙伴們都知道,循環引用會造成內存泄漏,所謂循環引用無非就是強指針連成一個圈。但是,沒連成圈的強指針引用同樣可能造成內存泄漏,如NSTimer
注意:timer內存泄漏,部分童鞋認為是vc與timer循環引用造成的,這種說法是錯誤的!

正文

  • 內存泄漏

NSTimer內存泄漏的坑很多人都遇到過,為避免內存泄漏,部分童鞋是這么做的:

- (void)dealloc {
    [_timer invalidate];
}

更有甚者是這么做的:

- (void)dealloc {
    [_timer invalidate];
    _timer = nil;
}

然而并沒有什么...用!

通常會這么寫:

@interface NSTimerExample ()
@property (nonatomic, weak) NSTimer *timer0;
@property (nonatomic, weak) NSTimer *timer1;
@end

- (void)viewDidLoad {
    [super viewDidLoad];

    {
        NSTimer *one = [NSTimer scheduledTimerWithTimeInterval:1.f target:self selector:@selector(tick:) userInfo:@"timer0" repeats:YES];
        _timer0 = one;
    }
}

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo

這句代碼timer會強引用target,即timer強引用vc,然而vc并沒有強引用timer,哪來的vc與timer循環引用?但是,如果vc沒有強引用timer,timer是如何存活的?
其實,上句代碼默認將timer加入到currentRunLoop中,currentRunLoop會強引用timer,而currentRunLoop就是mainRunLoop,mainRunLoop一直存活,所以timer可以存活

如,我們還會顯式的這么寫(這種runloop模式,在scrollview滑動時同樣可以工作):

- (void)viewDidLoad {
    [super viewDidLoad];

    {
        NSTimer *one = [NSTimer timerWithTimeInterval:1.f target:self selector:@selector(tick:) userInfo:@"timer1" repeats:YES];
        [[NSRunLoop currentRunLoop] addTimer:one forMode:NSRunLoopCommonModes];
        _timer1 = one;  
    }
}

回到問題,為啥vc的dealloc方法進不去?

關系圖.png

從以上關系圖可見,只要runLoop存活,vc必然存活,所以vc的dealloc方法自然就不會執行。因此,將timer的銷毀方法放在dealloc中必然造成內存泄漏!

基于這種關系鏈,只要銷毀兩條線中的任意一條,就不會出現內存泄漏

所以出現了這種方案:

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    
    NSTimer *one = [NSTimer scheduledTimerWithTimeInterval:1.f target:self selector:@selector(tick:) userInfo:@"timer0" repeats:YES];
    _timer0 = one;
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    
    [_timer0 invalidate];
}

這樣做簡單粗暴,直接將兩條線都銷毀,確實沒有內存泄漏,可以滿足部分場景。但是如果在這個vc基礎上push一個新vc,而原vc的定時器還要繼續工作,這種方案顯然無法滿足需求。
雖然NSRunLoop提供了addTimer接口,但是并沒有提供removeTimer接口,顯然,runLoop與timer這條線無法直接銷毀,所以只能從vc與timer持有關系入手。
(CFRunLoopRef有這種接口)

CF_EXPORT void CFRunLoopAddTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFRunLoopMode mode);
CF_EXPORT void CFRunLoopRemoveTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFRunLoopMode mode);

思路很簡單,以NSProxy為基類,創建一個weak proxy類,弱引用target即可,關系圖如下:


新關系圖.png

proxy弱引用vc,所以vc可以釋放,當vc執行dealloc,在dealloc內部銷毀timer即可

proxy可以這么寫:

NS_ASSUME_NONNULL_BEGIN

@interface JKWeakProxy : NSProxy

@property (nonatomic, weak, readonly) id target;

+ (instancetype)proxyWithTarget:(id)target;

@end

NS_ASSUME_NONNULL_END
@implementation JKWeakProxy

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

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

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [_target methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    if ([_target respondsToSelector:invocation.selector]) {
        [invocation invokeWithTarget:_target];
    }
}

- (NSUInteger)hash {
    return [_target hash];
}

- (Class)superclass {
    return [_target superclass];
}

- (Class)class {
    return [_target class];
}

- (BOOL)isKindOfClass:(Class)aClass {
    return [_target isKindOfClass:aClass];
}

- (BOOL)isMemberOfClass:(Class)aClass {
    return [_target isMemberOfClass:aClass];
}

- (BOOL)conformsToProtocol:(Protocol *)aProtocol {
    return [_target conformsToProtocol:aProtocol];
}

- (BOOL)isProxy {
    return YES;
}

- (NSString *)description {
    return [_target description];
}

- (NSString *)debugDescription {
    return [_target debugDescription];
}

@end

此時代碼只要這樣寫就可以了:

- (void)viewDidLoad {
    [super viewDidLoad];

    {
        JKWeakProxy *proxy = [JKWeakProxy proxyWithTarget:self];
        NSTimer *one = [NSTimer scheduledTimerWithTimeInterval:1.f target:proxy selector:@selector(tick:) userInfo:@"proxyTimer0" repeats:YES];
        _proxyTimer0 = one;
    }
}

- (void)dealloc {
    [_proxyTimer0 invalidate];
}

到這里,timer內存泄漏的坑已經完美解決。

再簡單說說timer與runloop的組合使用

  • NSTimer && NSRunLoop && autoreleasepool && 多線程

前面已經說過如何在scrollview滑動時,讓timer繼續工作

  NSTimer *one = [NSTimer timerWithTimeInterval:1.f target:self selector:@selector(tick:) userInfo:@"timer1" repeats:YES];
  [[NSRunLoop currentRunLoop] addTimer:one forMode:NSRunLoopCommonModes];
  _timer1 = one; 

簡單說下NSRunLoopCommonModes,NSRunLoopCommonModes是一種偽模式,它表示一組runLoopMode的集合,具體包含:NSDefaultRunLoopMode、NSTaskDeathCheckMode、UITrackingRunLoopMode。由于含有UITrackingRunLoopMode,所以可以在滑動時繼續工作。

多線程中又是如何使用timer的呢?

    {
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            JKWeakProxy *proxy = [JKWeakProxy proxyWithTarget:self];
            NSTimer *one = [NSTimer scheduledTimerWithTimeInterval:1.f target:proxy selector:@selector(tick:) userInfo:@"dispatchTimer0" repeats:YES];
            [[NSRunLoop currentRunLoop] run];
            _dispatchTimer0 = one;
        });
    }

多線程中需要顯示啟動runloop,為啥?

我們無法主動創建runloop,只能通過[NSRunLoop currentRunLoop]CFRunLoopGetCurrent()[NSRunLoop mainRunLoop]CFRunLoopGetMain()來獲取runloop。除主線程外,子線程創建后并不存在runloop,主動獲取后才有runloop。當子線程銷毀,runloop也隨之銷毀。

上句代碼為,獲取子線程的runloop,并開啟runloop,所以timer才可以正常運行。而在主線程中,mainRunLoop已經在程序運行時默認開啟,所以不需要顯示啟動runloop。

問題又來了,runloop開啟后,timer會一直在子線程中運行,所以子線程不會銷毀,因此runloop無法自動停止,這似乎又是個死循環。既然無法自動停止,可以選擇其他方式停止runloop

  1. runUntilDate
    {
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            JKWeakProxy *proxy = [JKWeakProxy proxyWithTarget:self];
            NSTimer *one = [NSTimer scheduledTimerWithTimeInterval:1.f target:proxy selector:@selector(tick:) userInfo:@"dispatchTimer0" repeats:YES];
//            [[NSRunLoop currentRunLoop] run];
            
            NSDate *date = [NSDate dateWithTimeIntervalSinceNow:5.f];
            [[NSRunLoop currentRunLoop] runUntilDate:date];
            _dispatchTimer0 = one;
        });
    }

這樣可以讓runloop在5s后銷毀,但是如果銷毀時間不確定怎么辦?

這涉及到runloop的運行機制:
runloop在指定mode模式下運行,每種mode至少要有一個mode item,如果runloop在某種mode模式下不含mode item,那么runloop直接退出。mode item有3種,分別為: Source/Timer/Observer,它們對應CFRunLoopSourceRef/CFRunLoopTimerRef/CFRunLoopObserverRef。Source為事件源,Timer為時間觸發器,Observer為觀察者。

OK,了解這些之后現在可以銷毀子線程了

  1. invalidate

如果runloop在某種特停情況下要退出,由于上述代碼runloop的mode item只有Timer,所以只要銷毀timer,runloop就會退出

[_timer invalidate];
  • NSTimer并非真實的時間機制
    什么意思?即NSTimer并非基于我們現實生活中的物理時間。似乎還不是太好懂,可以從以下幾點理解:
  1. timer需要在某種特定的runLoopMode下運行,如果當前mode為非指定mode,timer不會被觸發,直到mode變成指定mode,timer開始運行
  2. 如果在指定mode下運行,但timer觸發事件的時間點runloop剛好在處理其他事件,timer對應的事件不會被觸發,直到下一次runloop循環
  3. 如果timer設置精度過高,由于runloop可能存在大量mode item,timer精度過高極有可能timer對應處理事件時間點出現誤差(精度最好不超過0.1s)

Tip: runloop的內部是通過dispatch_source_t和mach_absolute_time()控制時間的,因此要實現精確的定時器,應使用dispatch_source_t或者mach_absolute_time()。CADisplayLink在某種程度上也可以當做定時器,但與NSTimer一樣,并不準確。由于默認刷新頻率與屏幕相同,因此可以用來檢測FPS

  • autoreleasepool
    既然說到runloop,簡單說下autoreleasepool。runloop會默認創建autoreleasepool,在runloop睡眠前或者退出前會執行pop操作

autoreleasepool的簡單應用

    NSLog(@"begin");
    for (NSUInteger i = 0; i < 10000; i++) {
        NSString *str = [NSString stringWithFormat:@"hello %zd", i];
        NSLog(@"%@", str);
    }
    NSLog(@"end");

before.gif

可以看到,內存一直在增加,并且for循環結束后,內存仍然不會釋放

可以將代碼加上autoreleasepool:

   NSLog(@"begin");
    for (NSUInteger i = 0; i < 10000; i++) {
        @autoreleasepool {
            NSString *str = [NSString stringWithFormat:@"hello %zd", i];
            NSLog(@"%@", str);
        }
    }
    NSLog(@"end");
after.gif

可以看到,內存幾乎沒有變化

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 一、什么是runloop 字面意思是“消息循環、運行循環”。它不是線程,但它和線程息息相關。一般來講,一個線程一次...
    WeiHing閱讀 8,177評論 11 111
  • 轉自http://blog.ibireme.com/2015/05/18/runloop 深入理解RunLoop ...
    飄金閱讀 1,008評論 0 4
  • Runloop實現了線程內部的事件循環。一個線程通常一次只能執行一個任務,任務執行完成線程就會退出,但是有時候,我...
    大猿媛閱讀 241評論 0 1
  • RunLoop 是 iOS 和 OS X 開發中非常基礎的一個概念,這篇文章將從 CFRunLoop 的源碼入手,...
    iOS_Alex閱讀 913評論 0 10
  • Runloop是iOS和OSX開發中非常基礎的一個概念,從概念開始學習。 RunLoop的概念 -般說,一個線程一...
    小貓仔閱讀 1,019評論 0 1