NSTimer的使用問題
用NSTimer
做計時器循環事件的時候,很有可能會遇到以下兩個問題:
- 正常啓動的
timer
在滾動視圖滾動的時候不能夠接收事件消息了- 當前引用
timer
的類不能夠得到釋放,進而造成內存泄露的問題
所以針對於以上問題,進行記錄與說明。
產生原因以及解決方法
正常啓動的
timer
在滾動視圖滾動的時候不能夠接收事件消息了
因爲系統的timer
記時器是通過iOS中的Runloop
實現的,每一個定時器timer
的實例都需要加入到Runloop
中才能夠有效,由於Runloop
有五種模式,分別是NSDefaultRunLoopMode、NSEventTrackingRunLoopMode、NSModalPaneRunLoopMode、NSTrackingRunLoopMode、NSRunLoopCommonModes
。
這五種模式會在Runloop
的不同的場景下進行來回切換,而定時器timer
如果沒有加入到切換對應的場景mode
中,則就會導致當前的mode
中不存在加入的timer
,也就會引發timer
接收不到定時器消息的問題。本質是runloop
因爲切換mode
,且對應mode
中沒有當前的timer
對象,在當前的mode
中,導致timer
收不到事件消息的問題。
解決方法其實很簡單,在創建定時器的時候,將定時器加入到runloop
的不同的mode
中,這樣就能確保runloop
在切換mode
的時候能夠找到對應mode
中的定時器,也就能夠發送定時器消息以保證定時器回調事件的正常了。
//注意,以下的方法會導致循環引用的發生,直接導致timer釋放不掉,解決方案在第二個問題記錄中
- (void)normalTimer{
self.timer = [NSTimer timerWithTimeInterval:1.f target:self selector:@selector(timerEvent) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
- (void)cycleTimer{
self.timer =
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerEvent) userInfo:nil repeats:YES];
}
- (void)timerEvent{
NSLog(@"timer事件--%s",__func__);
}
- (void)dealloc{
NSLog(@"%s",__func__);
}
當前引用
timer
的類不能夠得到釋放,進而造成內存泄露的問題
以上的定時器timer
雖然能夠在Runloop
的各種mode
中完美運行,但是會導致當前的對象與timer
相互引用導致循環引用問題的產生。總結來說就是:
由於定時器timer
被當前的對象引用,而啓動定時器的時候,又將當前對象作爲參數傳入到定時器中,二者相互引用導致循環引用的產生。如下圖:
這裏說一種錯誤的解決方法:將self
改成weak
類型後依舊會有循環引用,原因是修改weak
屬性只對block
有效,對於timer
對象的內部Target
的strong
引用是沒有效果的。
本質是循環引用導致的內存泄露,所以在相互引用上解除引用纔是解決的根本。這裏有兩種方案去解決這樣的問題:
- 如果是iOS10以上,我們可以直接使用
timer
的scheduledTimerWithTimeInterval:repeats:block:
方法進行設置
- (void)timerBlock{
if (__builtin_available(iOS 10, *)) {
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.f repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"%s",__func__);
}];
} else {}
}
- 可以引入新的對象C,將引用鏈由
A->B,B->A
改成A->C, C->B, B->A
引入新對象C
之後,三者引用關係就如上圖,這樣就不存在兩個對象之間相互引用了,在銷燬對象的時候,只需要消除其中一條引用,則可以全部消除引用關係。比如ObjectA
在銷燬前,可以向Timer
發送invalidate
消息,消除對於ObjectC
的引用,這樣就消除了一個引用關係,過程如下:
- 調用
timer
的invidate
方法結束定時器對對象C
的引用,讓引入的新對象C
先dealloc
- 引入的新
對象C
的釋放,結束了對於對象A
的引用,當前對象A
也緊接着dealloc
了- 當前
對象A
的釋放,結束了對於定時器B
的引用,定時器對象B
也緊接着dealloc了
基於上述的問題,我們可以封裝一個解除timer
引用的臨時對象,對象的內容實現如下:
LCSafeObj.h
//
// LCSafeObj.h
// Timer
//
// Created by Leo on 2020/12/1.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface LCSafeObj : NSObject
+ (NSTimer *)addTimerInterval:(NSTimeInterval)interval target:(id)target selecter:(SEL)selecter isrepeat:(BOOL)repeat obj:(id)object;
@end
NS_ASSUME_NONNULL_END
LCSafeObj.m
//
// LCSafeObj.m
// Timer
//
// Created by Leo on 2020/12/1.
//
#import "LCSafeObj.h"
@interface LCSafeObj ()
@property (nonatomic, strong) id target;
@property (nonatomic, assign) SEL selecter;
@end
@implementation LCSafeObj
- (instancetype)initWithTarget:(id)target selecter:(SEL)selecter{
if (self = [super init]) {
self.target = target;
self.selecter = selecter;
}
return self;
}
+ (NSTimer *)addTimerInterval:(NSTimeInterval)interval target:(id)target selecter:(SEL)selecter isrepeat:(BOOL)repeat obj:(id)object{
//此時LCSafeObj單獨引用外部對象
LCSafeObj *safeObj = [[LCSafeObj alloc] initWithTarget:target selecter:selecter];
//注意這裏的Target傳入的是LCSafeObj類型的,並不是外部對象,目的是讓定時器timer引用新引入的對象C,
NSTimer *timer =
[NSTimer scheduledTimerWithTimeInterval:interval target:safeObj selector:selecter userInfo:object repeats:repeat];
//返回給傳入的對象,讓其單引用定時器timer,且控制定時器的invalid的時間,至此完成單鏈的引用
return timer;
}
/// 使用消息轉發來將SafeObj中沒有的方法調用轉移到傳入的對象中
/// @param aSelector 方法轉發
- (id)forwardingTargetForSelector:(SEL)aSelector{
if (aSelector == self.selecter) {
return self.target;
}
return [super forwardingTargetForSelector:aSelector];
}
- (void)dealloc{
NSLog(@"%s",__func__);
}
@end
這裏實現的過程中注意用到了運行時的消息轉發機制
,以確保傳入對象的正確方法調用,以及代碼的簡潔。
優化內容
上述的方法存在一些瑕疵,就是使用的時候可能還是需要在當前使用的類中去手動invalidDate timer計時器才能夠將三者釋放掉,這樣在開發的過程中也是比較繁瑣的,可以考慮將釋放工作放到引入的三方對象C中,具體做法參考如下:
//
// LCSafeObj.m
// Timer
//
// Created by Leo on 2020/12/1.
//
#import "LCSafeObj.h"
@interface LCSafeObj ()
@property (nonatomic, weak) NSTimer *timer;
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selecter;
@property (nonatomic, copy) void (^timerEventBlock)(void);
@end
@implementation LCSafeObj
- (instancetype)initWithTarget:(id)target selecter:(SEL)selecter timerEventBlock:(void (^)(void))timerEventBlock{
if (self = [super init]) {
self.target = target;
self.selecter = selecter;
self.timerEventBlock = timerEventBlock;
}
return self;
}
- (void)dealloc{
NSLog(@"%s",__func__);
}
- (void)setTimer:(NSTimer *)timer{
_timer = timer;
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
+ (NSTimer *)addTimerInterval:(NSTimeInterval)interval target:(id)target selecter:(SEL)selecter isrepeat:(BOOL)repeat{
//此時LCSafeObj單獨引用外部對象
LCSafeObj *safeObj = [[LCSafeObj alloc] initWithTarget:target selecter:selecter timerEventBlock:nil];
//注意這裏的Target傳入的是LCSafeObj類型的,並不是外部對象,目的是讓定時器timer引用新引入的對象C,
safeObj.timer =
[NSTimer scheduledTimerWithTimeInterval:interval target:safeObj selector:@selector(targetAction) userInfo:nil repeats:repeat];
//返回給傳入的對象,讓其單引用定時器timer,且控制定時器的invalid的時間,至此完成單鏈的引用
return safeObj.timer;
}
- (void)targetAction{
if (!self.target) {
[self.timer invalidate];
}
if (self.target && [self.target respondsToSelector:self.selecter]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self.target performSelector:self.selecter];
#pragma clang diagnostic pop
}
if (self.timerEventBlock) {self.timerEventBlock();}
}
+ (NSTimer *)addTimerInterval:(NSTimeInterval)interval isRepeat:(BOOL)repeat eventBlock:(void (^)(void))eventBlock{
//此時LCSafeObj單獨引用外部對象
LCSafeObj *safeObj = [[LCSafeObj alloc] initWithTarget:nil selecter:nil timerEventBlock:eventBlock];
//注意這裏的Target傳入的是LCSafeObj類型的,並不是外部對象,目的是讓定時器timer引用新引入的對象C,
safeObj.timer =
[NSTimer scheduledTimerWithTimeInterval:interval target:safeObj selector:@selector(targetAction) userInfo:nil repeats:repeat];
//返回給傳入的對象,讓其單引用定時器timer,且控制定時器的invalid的時間,至此完成單鏈的引用
return safeObj.timer;
}
@end
優化方案兩個要點:
- 對外部target的引用採取weak弱引用,以保證外部對象的正常釋放
@property (nonatomic, weak) id target;
- 定時器事件方法中判斷target引用是否依舊存在,不存在則使用invalidDate 去除定時器timer對於引入的對象C的引用
- (void)targetAction{
if (!self.target) {
[self.timer invalidate];
}
if (self.target && [self.target respondsToSelector:self.selecter]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self.target performSelector:self.selecter];
#pragma clang diagnostic pop
}
if (self.timerEventBlock) {self.timerEventBlock();}
}