NSTimer 避坑指南

作者: 要上班的斌哥 | 来源:发表于2018-02-28 22:39 被阅读481次

    NSTimer 的创建

    NSTimer的创建通常有两种方式,一种是以 scheduledTimerWithTimeInterval 为开头的类方法 。这些方法在创建了 NSTimer 之后会将这个 NSTimer 以 NSDefaultRunLoopMode 模式放入当前线程的 RunLoop。

    + ( NSTimer *) scheduledTimerWithTimeInterval:invocation:repeats: 
    + ( NSTimer *) scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:
    

    另一种是以 timerWithTimeInterval 为开头的类方法。这些方法创建的 NSTimer 并不能马上使用,还需要调用 RunLoop 的 addTimer:forMode: 方法将 NSTimer 放入 RunLoop,这样 NSTimer 才能正常工作。

     + ( NSTimer *) timerWithTimeInterval:invocation:repeats:
     + ( NSTimer *) timerWithTimeInterval:target:selector:userInfo:repeats:
    

    Timers work in conjunction with run loops. Run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop.

    从 NSTimer 的官方文档可以得知,RunLoop 对加入其中的 NSTimer 会添加一个强引用。这里需要注意一个细节问题,以 timerWithTimeInterval 为开头的类方法创建出来的 NSTimer 需要手动加入 RunLoop, 这样 RunLoop 才会对这个 NSTimer 有强引用。若是我们使用 weak 修饰 NSTimer 变量,在 NSTimer 创建之后加入 RunLoop 之前,将 NSTimer 对象赋值给 weak 修饰的变量,那么对导致 NSTimer 对象被释放。

    #import "TimerViewController.h"
    @interface TimerViewController ()
    // 使用 weak
    @property (nonatomic,weak) NSTimer *timer;
    
    @end
    
    @implementation TimerViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        // NSTimer 创建之后没有被自动加入 RunLoop
        self.timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(outputLog:) userInfo:nil repeats:YES];
        
        if (self.timer == nil) {
            NSLog(@"timer 被释放了");
        }
    }
    
    - (void)outputLog:(NSTimer *)timer{
        NSLog(@"it is log!");
    }
    
    @end
    
    

    代码运行之后,log 输出 “ timer 被释放了 ”,说明 self.timer 为 nil,刚刚创建的 NSTimer 对象被释放了。解决这个问题的方法也很简单, NSTimer 对象创建之后先加入 RunLoop 再赋值给变量。

    // ...... 省略代码
    - (void)viewDidLoad {
        [super viewDidLoad];
        // 创建 NSTimer
        NSTimer *doNotWorkTimer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(outputLog:) userInfo:nil repeats:YES];
        // NSTimer 加入 NSRunLoop
        [[NSRunLoop currentRunLoop] addTimer:doNotWorkTimer forMode:NSDefaultRunLoopMode];
        // 赋值给 weak 变量
        self.timer = doNotWorkTimer;
        
    }
    // ...... 省略代码
    

    NSTimer 的循环引用

    对于 NSTimer 来说,无论是重复执行的 NSTimer 还是一次性的 NSTimer 只要调用 invalidate 方法则会变得无效,NSTimer 就会释放资源。一次性的 NSTimer 执行完操作后会自动调用 invalidate 方法.

    举个例子,TimerViewController 强引用一个 NSTimer,NSTimer 的 target 设置为 TimerViewController,在 TimerViewController 的 dealloc 方法里面调用 NSTimer 的 invalidate 方法。

    
    @interface TimerViewController ()
    
    @property (nonatomic,strong) NSTimer *timer;
    
    @end
    
    @implementation TimerViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(outputLog:) userInfo:nil repeats:YES];
    }
    
    -(void)outputLog:(NSTimer *)timer{
        NSLog(@"it is log!");
    }
    
    - (void)dealloc
    {
        [self.timer invalidate];
        NSLog(@"TimerViewController dealloc!");
    }
    
    @end
    
    

    上面这段代码中的 TimerViewController 和 NSTimer 构成了循环引用,退出 TimerViewController 页面,TimerViewController 和 NSTimer 都无法释放,TimerViewController 的 dealloc 方法没有被调用,NSTimer 就没有被 invalidate ,outputLog 方法会被一直触发。

    原来 TimerViewController 强引用一个 NSTimer,NSTimer 使用TimerViewController 为 target, 这样会构成循环引用,那如果 TimerViewController 弱引用一个 NSTimer,是不是能够解决这个问题呢?

    @interface TimerViewController ()
    // 使用 weak
    @property (nonatomic,weak) NSTimer *timer;
    
    @end
    // ...... 省略代码
    

    运行结果和上面使用强引用的案例没有什么差别,究竟是什么原因呢?

    引用图示

    如上图所示 TimerViewController 弱引用 NSTimer, NSTimer 强引用 TimerViewController。

    同生共死

    TimerViewController 需要 NSTimer 同生共死。NSTimer 需要在 TimerViewController 的 dealloc 方法被 invalidate 。NSTimer 被 invalidate 的前提是 TimerViewController 被 dealloc。而 NSTimer 一直强引用着 TimerViewController 导致 TimerViewController 无法调用 dealloc 方法。
    从 NSTimer 的角度来看解决方案,如果 NSTimer 不持有 TimerViewController 的引用,那么 TimerViewController 就可以正常销毁,dealloc 方法可以正常调用 NSTimer 的 invalidate 方法,那么 NSTimer 和 TimerViewController 都可以销毁,完美!

    NSTimer 的销毁

    在 NSTimer 的使用过程,要避免循环引用问题。解决方案是 NSTimer 不持有 TimerViewController 的引用,也就是说 NSTimer 的 target 对象不要是 TimerViewController。 这里有 2 个方案可以来处理这个问题。

    第一个方案:将 target 分离出来独立成一个 WeakProxy 代理对象, NSTimer 的 target 设置为 WeakProxy 代理对象,WeakProxy 是 TimerViewController 的代理对象,所有发送到 WeakProxy
    的消息都会被转发到 TimerViewController 对象。使用代理对象可以达到 NSTimer 不直接持有 TimerViewController 的目的。

    
    #import "TimerViewController.h"
    #import "YYWeakProxy.h"
    @interface TimerViewController ()
    
    @property (nonatomic,weak) NSTimer *timer;
    
    @end
    
    @implementation TimerViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:[YYWeakProxy proxyWithTarget:self] selector:@selector(outputLog:) userInfo:nil repeats:YES];
    }
    
    - (void)outputLog:(NSTimer *)timer{
        NSLog(@"it is log!");
    }
    
    - (void)dealloc
    {
        [self.timer invalidate];
        NSLog(@"TimerViewController dealloc!");
    }
    
    
    @end
    

    YYWeakProxy 来自 YYKit 开源项目 ,是一个代理类的实现。

    第二个方案是通过 category 把 NSTimer 的 target 设置为 NSTimer 类,让 NSTimer 自身做为target, 把 selector 通过 block 传入给 NSTimer,在 NSTimer 的 category 里面触发 selector 。这样也可以达到 NSTimer 不直接持有 TimerViewController 的目的,实现更优雅 ( 如果是直接支持 iOS 10 以上的系统版本,那可以使用 iOS 10新增的系统级 block 方案 )。

    // NSTimer+BlocksSupport.h
    #import <Foundation/Foundation.h>
    
    @interface NSTimer (BlocksSupport)
    + (NSTimer *)xx_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                           repeats:(BOOL)repeats
                                             block:(void(^)())block;
    @end
    
    // NSTimer+BlocksSupport.m
    #import "NSTimer+BlocksSupport.h"
    
    @implementation NSTimer (BlocksSupport)
    + (NSTimer *)xx_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                           repeats:(BOOL)repeats
                                             block:(void(^)())block;
    {
        return [self scheduledTimerWithTimeInterval:interval
                                             target:self
                                           selector:@selector(xx_blockInvoke:)
                                           userInfo:[block copy]
                                            repeats:repeats];
    }
    + (void)xx_blockInvoke:(NSTimer *)timer {
        void (^block)() = timer.userInfo;
        if(block) {
            block();
        }
    }
    @end
    
    
    // TimerViewController.m
    #import "TimerViewController.h"
    #import "NSTimer+BlocksSupport.h"
    
    @interface TimerViewController ()
    
    @property (nonatomic,weak) NSTimer *timer;
    
    @end
    
    @implementation TimerViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.timer =[NSTimer xx_scheduledTimerWithTimeInterval:1.0 repeats:YES block:^{
                NSLog(@"it is log!");
        }];
    }
    
    - (void)dealloc
    {
        [self.timer invalidate];
        NSLog(@"TimerViewController dealloc!");
    }
    
    @end
    

    以上 2 个方案都可以达到目的,推荐使用第二个 NSTimer 的 category 方案。

    NSTimer 触发时间的准确性问题

    RunLoop 机制 -来自 sunnyxx

    从 RunLoop 的机制图中可以看到 CFRunLoopTimer 存在,CFRunLoopTimer 作为 RunLoop 的事件源之一,它的上层对应就是 NSTimer,NSTimer 的触发正是基于 RunLoop, 使用 NSTimer 之前必须注册到 RunLoop。一个NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 00:00, 00:02, 00:04,00:06 这几个时间点。RunLoop 为了节省资源,并不会在非常准确的时间点回调这个 NSTimer,NSTimer 有个属性叫做 Tolerance 表示回调 NSTimer 的时间点容许多少最大误差。

    tolerance : The amount of time after the scheduled fire date that the timer may fire.

    如果 RunLoop 执行了一个很长时间的任务,错过了某个时间点,则那个时间点的回调也会跳过去,不会延后执行。比如 00:02 这个时间点被错过了,RunLoop 不会 那么就只能等待下一个时间点 00:04 。

    RunLoop 的触发时间准确性也与 RunLoop 的 mode 相关。

    主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。这两个 Mode 都已经被标记为”Common”属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。
    有时你需要一个 Timer,在两个 Mode 中都能得到回调,一种办法就是将这个 Timer 分别加入这两个 Mode。还有一种方式,就是将 Timer 加入到顶层的 RunLoop 的 “commonModeItems” 中。”commonModeItems” 被 RunLoop 自动更新到所有具有”Common”属性的 Mode 里去。

    // ......省略代码
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.timer =[NSTimer xx_scheduledTimerWithTimeInterval:1.0 repeats:YES block:^{
                NSLog(@"it is log!");
        }];
       
        [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    }
    
    - (void)dealloc
    {
        [self.timer invalidate];
        NSLog(@"TimerViewController dealloc!");
    }
    // ......省略代码
    

    参考

    1. 《深入理解RunLoop》
    2. 《iOS刨根问底-深入理解RunLoop》
    3. NSTimer
    4. 循环引用

    相关文章

      网友评论

      • 画舫烟中浅:您好!首先我学习了一下。谢谢!但是我有点疑问,因为我照你的第二个方案写的,当我pop到上一个控制器的时候,也不会触发dealloc方法,只有先在viewWillDisappear方法里将timer释放掉,才会走dealloc方法,但这样现在做,就跟你说的写这个类别没有关系啊
        halohily:@画舫烟中浅 我的意思是你传入的block里面
        画舫烟中浅:@halohily 我和楼主写的一样的,block里面没有出现循环引用,+(NSTimer *)yh_scheduledTimerWithTimerInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(void))block{

        return [self scheduledTimerWithTimeInterval:interval target:self selector:@selector(yh_blockInvoke:) userInfo:[block copy] repeats:repeats];
        }
        +(void)yh_blockInvoke:(NSTimer *) timer{
        void (^block)(void) = timer.userInfo;
        if (block) {
        block();
        }
        }
        halohily:如果你是按文中方案写的但是依然循环引用的话,多半是因为你传入的block没有注意避免循环引用,和timer已经没关系了。
      • PGOne爱吃饺子:那个timer的循环引用问题,我看了好多的解决办法,为啥就没有这个最简单的方法呢,就是在viewwilldisappera 这个界面即将消失的方法里面触发timer的invalidate不可以么,我这个方法也实验过不会产生循环引用,请楼主解答一下
        PGOne爱吃饺子:大佬,能不能说一下有哪些业务场景需要timer和Vc同生共死的,谢谢了
        PGOne爱吃饺子:大神,可以说一下有哪些这样的业务场景么,谢谢了,谢谢了,指教一下,总是在这一块有点误区
        要上班的斌哥:@PGOne爱吃饺子 有些业务场景下,需要 timer 和 VC 同生共死,所以就不能在 viewwilldisappera 里面处理
      • bigerheng:第二个方案在52个有效方法里面出现过,感觉棒棒哒!:smile:

      本文标题:NSTimer 避坑指南

      本文链接:https://www.haomeiwen.com/subject/sogixftx.html