如何正确的使用NSTimer

最近在复习iOS中NSTimer的知识,有一些新的收获,因此记录下来。

废话不多说,先来看看timer最常用的写法。

@interface TimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation TimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerRun) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}

- (void)timerRun {
    NSLog(@"%s", __func__);
}

- (void)dealloc {
    [self.timer invalidate];
    NSLog(@"%s", __func__);
}
@end

这里的TimerViewController是从上一个控制器push过来的,当进入当前控制器的时候,timerRun正常执行。

但是当回退到上一个控制器的时候,发现并没有走当前控制器的dealloc方法,也就是说当前控制器并没有被释放,那么当前控制器强引用的timer对象也没有被释放,这就造成了内存泄漏,如下图所示。

这里的每一个箭头代表着一个强指针,当回退上一个界面时,NavigationController指向TimerViewController的强引用被销毁,但是TimerViewController和timer之间互相强引用,内存泄漏。


image.png

1. 方案一

既然说TimerViewController和timer之间互相强引用,那么如果将之间的一个强指针改为弱指针也许能解决问题,于是有了下面的代码

@interface TimerViewController ()
// 这里变为了weak
@property (nonatomic, weak) NSTimer *timer;
@end

@implementation TimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerRun) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
    // 这里先添加到当前runloop再赋值给timer
    self.timer = timer;
}

- (void)timerRun {
    NSLog(@"%s", __func__);
}

- (void)dealloc {
    [self.timer invalidate];
    NSLog(@"%s", __func__);
}

经过上面的改造,现在TimerViewController和timer之间有一个为弱指针,但是运行代码发现,内存泄漏的问题仍然没有得到解决。

因为这里虽然没有循环引用,但是RunLoop引用着timer,而timer又引用着TimerViewController,虽然pop时指向TimerViewController的强指针销毁,但是仍然有timer的强指针指向TimerViewController,因此仍然还是内存泄漏,如下图所示。


image.png

2. 方案二

既然从左边不行,那么试试从右边可不可以。

在这里加入了一个中间代理对象LJProxy,TimerViewController不直接持有timer,而是持有LJProxy实例,让LJProxy实例来弱引用TimerViewController,timer强引用LJProxy实例,直接看代码

@interface LJProxy : NSObject
+ (instancetype) proxyWithTarget:(id)target;
@property (weak, nonatomic) id target;
@end
@implementation LJProxy
+ (instancetype) proxyWithTarget:(id)target
{
    LJProxy *proxy = [[LJProxy alloc] init];
    proxy.target = target;
    return proxy;
}

- (id)forwardingTargetForSelector:(SEL)aSelector
{
    return self.target;
}
@end

Controller里只修改了下面一句代码

- (void)viewDidLoad {
    [super viewDidLoad];
    // 这里的target发生了变化
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:[LJProxy proxyWithTarget:self] selector:@selector(timerRun) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}
  • 首先当执行pop的时候,1号指针被销毁,现在就没有强指针再指向TimerViewController了,TimerViewController可以被正常销毁。
  • TimerViewController销毁,会走dealloc方法,在dealloc里调用了[self.timer invalidate],那么timer将从RunLoop中移除,3号指针会被销毁。
  • 当TimerViewController销毁了,对应它强引用的指针也会被销毁,那么2号指针也会被销毁。
  • 上面走完,timer已经没有被别的对象强引用,timer会销毁,LJProxy实例也就自动销毁了。


    image.png

这里需要注意的有两个地方:
1.- (id)forwardingTargetForSelector:(SEL)aSelector是什么?
了解iOS消息转发的朋友肯定知道这个东西,不了解的可以去这个博客看看
(https://www.jianshu.com/p/eac6ed137e06)。
简单来说就是如果当前对象没有实现这个方法,系统会到这个方法里来找实现对象。
本文中由于LJProxy没有实现timerRun方法(当然也不需要它实现),让系统去找target实例的方法实现,也就是去找TimerViewController中的方法实现。
2.timer的invalidate方法的具体作用参考苹果官方,这个方法会停止timer并将其从RunLoop中移除。
This method is the only way to remove a timer from an [NSRunLoop]object. The NSRunLoop object removes its strong reference to the timer, either just before the [invalidate] method returns or at some later point.

3. 方案三

经过上面的改造,似乎timer已经没有什么问题了,但是在浏览yykit源码的时候发现了一个以前一直没有注意过的类NSProxy,这是一个专门用于做消息转发的类,我们需要通过子类的方式来使用它。

参考YYTextWeakProxy的写法,写了如下的代码

@interface LJWeakProxy : NSProxy
+ (instancetype)proxyWithTarget:(id)target;
@property (weak, nonatomic) id target;
@end
@implementation LJWeakProxy
+ (instancetype)proxyWithTarget:(id)target {
    LJWeakProxy *proxy = [LJWeakProxy alloc];
    proxy.target = target;
    return proxy;
}

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

- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.target];
}
@end

Controller里修改了如下代码

- (void)viewDidLoad {
    [super viewDidLoad];
    // 这里的target又发生了变化
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:[LJWeakProxy proxyWithTarget:self] selector:@selector(timerRun) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}

看上去这次的LJWeakProxy和前面的LJProxy似乎没有什么区别,好像LJWeakProxy还更复杂一些,但是还是稍微整理一下

    1. LJProxy的父类为NSObject,LJWeakProxy的父类为NSProxy。
    1. LJProxy只实现了forwardingTargetForSelector:方法,但是LJWeakProxy没有实现forwardingTargetForSelector:方法,而是实现了methodSignatureForSelector:和forwardInvocation:。

下面是NSProxy的Apple文档说明,简单来说提供了几个信息

  • NSProxy是一个专门用来做消息转发的类
  • NSProxy是个抽象类,使用需自己写一个子类继承自NSProxy
  • NSProxy的子类需要实现两个方法,就是上面那两个

NSProxy implements the basic methods required of a root class, including those defined in the NSObject protocol. However, as an abstract class it doesn’t provide an initialization method, and it raises an exception upon receiving any message it doesn’t respond to. A concrete subclass must therefore provide an initialization or creation method and override the forwardInvocation: and methodSignatureForSelector: methods to handle messages that it doesn’t implement itself. A subclass’s implementation of forwardInvocation: should do whatever is needed to process the invocation, such as forwarding the invocation over the network or loading the real object and passing it the invocation. methodSignatureForSelector: is required to provide argument type information for a given message; a subclass’s implementation should be able to determine the argument types for the messages it needs to forward and should construct an NSMethodSignature object accordingly. See the NSDistantObject, NSInvocation, and NSMethodSignature class specifications for more information

说了那么多,到底NSProxy的好处在哪呢?
如果了解了OC中消息转发的机制,那么你肯定知道,当某个对象的方法找不到的时候,也就是最后抛出doesNotRecognizeSelector:的时候,它会经历几个步骤

  1. 消息发送,从方法缓存中找方法,找不到去方法列表中找,找到了将该方法加入方法缓存,还是找不到,去父类里重复前面的步骤,如果找到底都找不到那么进入2。
  2. 动态方法解析,看该类是否实现了resolveInstanceMethod:和resolveClassMethod:,如果实现了就解析动态添加的方法,并调用该方法,如果没有实现进入3。
  3. 消息转发,这里分二步
    • 调用forwardingTargetForSelector:,看返回的对象是否为nil,如果不为nil,调用objc_msgSend传入对象和SEL。
    • 如果上面为nil,那么就调用methodSignatureForSelector:返回方法签名,如果方法签名不为nil,调用forwardInvocation:来执行该方法

从上面可以看出,当继承自NSObject的对象,方法没有找到实现的时候,是需要经过第1步,第2步,第3步的操作才能抛出错误,如果在这个过程中我们做了补救措施,比如LJProxy就是在第3步的第1小步做了补救,那么就不会抛出doesNotRecognizeSelector:,程序就可以正常执行。
但是如果是继承自NSProxy的LJWeakProxy,就会跳过前面的所有步骤,直接到第3步的第2小步,直接找到对象,执行方法,提高了性能。

有人可能觉得那为什么不在第3步的第1小步来做补救呢?
但是很不巧,NSProxy只有methodSignatureForSelector:和forwardInvocation:这两个方法,官方的文档里也是让实现这两个方法。

4. 方案四

上面说了那么多,其实还有一种更加简单的方式来解决这里内存泄漏的问题,代码如下

@interface TimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation TimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    __weak typeof(self) weakSelf = self;
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
        [weakSelf timerRun];
    }];
}

- (void)timerRun {
    NSLog(@"%s", __func__);
}

- (void)dealloc {
    [self.timer invalidate];
    NSLog(@"%s", __func__);
}

上面这种方式虽然TimerViewController强引用timer,但是timer并没有强引用TimerViewController(由于这里的weakSelf),因此不会出现内存泄漏的问题。

如何在子线程使用NSTimer

有的时候需要在子线程使用NSTimer,下面是代码

@interface TimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) LJThread *thread;
@property (assign, nonatomic) BOOL stopTimer;
@end

@implementation TimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:[LJWeakProxy proxyWithTarget:self] selector:@selector(timerRun) userInfo:nil repeats:YES] ;
    
    __weak typeof(self) weakSelf = self;
    self.thread = [[LJThread alloc] initWithBlock:^{
        [[NSRunLoop currentRunLoop] addTimer:weakSelf.timer forMode:NSDefaultRunLoopMode];
        // 这里需要注意不要使用[[NSRunLoop currentRunLoop] run]
        while (weakSelf && !weakSelf.stopTimer) {
            [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }
    }];
    [self.thread start];
}

- (void)timerRun {
    NSLog(@"thread - %@ - %s", [NSThread currentThread], __func__);
}

- (void)dealloc {
    [self stop];
}

- (void)stop {
    [self performSelector:@selector(stopThread) onThread:self.thread withObject:nil waitUntilDone:YES];
}

// 用于停止子线程的RunLoop
- (void)stopThread {
    // 设置标记为YES
    self.stopTimer = YES;
    // 停止RunLoop
    CFRunLoopStop(CFRunLoopGetCurrent());
    // 清空线程
    self.thread = nil;
}

这里LJThread继承了NSThread,可以看到上面代码中在LJThread的Block中没有通过[[NSRunLoop currentRunLoop] run]来开启当前线程的RunLoop,而是使用了runMode: beforeDate:。
这是由于通过run方法开启的RunLoop是无法停止的,但在控制器pop的时候,需要将timer,子线程,子线程的RunLoop停止和销毁,因此需要通过while循环和runMode: beforeDate:来运行RunLoop。

通过上面的总结可以看到,虽然只是一个小小的Timer,也有这么多地方需要我们注意。

其他:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容