iOS 定时器

179 阅读1分钟

1 NSTimer

1.1 初始化方法

// 自动添加到Runloop中,并自动启动定时器
// 如果此时所在的线程不是主线程,则需要我们手动启动当前线程的 RunLoop
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti
                                 invocation:(NSInvocation *)invocation
                                    repeats:(BOOL)yesOrNo;

// 自动添加到Runloop中,并自动启动定时器
// 如果此时所在的线程不是主线程,则需要我们手动启动当前线程的 RunLoop
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti
                                     target:(id)aTarget
                                   selector:(SEL)aSelector
                                   userInfo:(nullable id)userInfo
                                    repeats:(BOOL)yesOrNo;

// iOS 10.0 新增
// 自动添加到Runloop中,并自动启动定时器
// 如果此时所在的线程不是主线程,则需要我们手动启动当前线程的 RunLoop
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                    repeats:(BOOL)repeats
                                      block:(void (^)(NSTimer *timer))block;

// 需要手动添加到 Runloop 中
// 需要手动启动定时器
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti
                        invocation:(NSInvocation *)invocation
                           repeats:(BOOL)yesOrNo;

// 需要手动添加到 Runloop 中
// 需要手动启动定时器
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti
                            target:(id)aTarget
                          selector:(SEL)aSelector
                          userInfo:(nullable id)userInfo
                           repeats:(BOOL)yesOrNo;

// 使用触发时间、时间间隔、目标对象、选择器等参数初始化计时器
// 需要手动添加到 Runloop 中
// 会在设定的时间自动启动定时器
- (instancetype)initWithFireDate:(NSDate *)date
                         interval:(NSTimeInterval)ti
                           target:(id)t
                         selector:(SEL)s
                         userInfo:(nullable id)ui
                          repeats:(BOOL)rep; 

1.2 操作方法

// 立即触发定时器,但不会改变预定周期性调度
- (void)fire;

// 停止计时器,并从循环池中移除定时器
- (void)invalidate;

// 将计时器添加到指定的运行循环,并设置运行模式
- (void)addToRunLoop:(NSRunLoop *)runLoop forMode:(NSRunLoopMode)mode;

// 从指定的运行循环中移除计时器
- (void)removeFromRunLoop:(NSRunLoop *)runLoop forMode:(NSRunLoopMode)mode;

// 返回计时器是否仍然有效
- (BOOL)isValid;

// 返回计时器是否重复触发
- (BOOL)repeats;

// 返回计时器的触发时间间隔
- (NSTimeInterval)timeInterval;

// 返回计时器的首次触发时间
- (NSDate *)fireDate;

// 返回允许的触发时间偏差
- (NSTimeInterval)tolerance;

// 返回存储的额外信息对象
- (nullable id)userInfo; 

1.3 属性

// 计时器的首次触发时间
@property (copy) NSDate *fireDate;

// 计时器触发的时间间隔 
@property (readonly) NSTimeInterval timeInterval;

// 允许的触发时间偏差
@property NSTimeInterval tolerance

// 计时器当前是否有效
@property (readonly, getter=isValid) BOOL valid;

// 存储额外信息的对象
@property (nullable, readonly, retain) id userInfo;

2 注意事项

2.1 Runloop 和 NSTimer

  1. NSTimer 是一种计时器,用于在未来的某个时间点触发一个事件

  2. Runloop 是一个事件循环,用于管理事件源(如触摸、定时器、网络请求等)和线程的消息循环

  3. NSTimer 是通过将计时器添加到 Runloop 中的特定模式下来触发的

  4.  NSTimer 是线程安全的,事件触发由 Runloop 管理,且事件处理在同一线程中执行,不会涉及到多线程竞争的问题

  5. 在多线程环境下,Runloop 是与线程相关的,而 NSTimer 是一种事件源,可以在任何线程中使用

2.2 多线程下使用 NSTimer

NSTimer 创建分为三种情况:

  1. scheduled- 类方法会自动添加到 Runloop 中,并启动定时器
  2. timer- 类方法需要手动添加到 Runloop 中,并手动启动定时器
  3. init- 实例方法需要手动添加到 Runloop 中,可以自行启动定时器

在 iOS 开发中,主线程拥有一个自动运行 Runloop,但是对于其他线程,需要手动创建和运行 Runloop。NSTimer 依赖于 Runloop 来触发事件,当在子线程中创建 NSTimer 时,需要在子线程中创建并运行 Runloop,并在其中添加 NSTimer。

// 获取当前线程的 RunLoop
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];

// 创建一个 NSTimer
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
                                                 target:self
                                               selector:@selector(timerAction:)
                                               userInfo:nil
                                                repeats:YES];
    
// 将 NSTimer 添加到 RunLoop 中
[runLoop addTimer:timer forMode:NSDefaultRunLoopMode];
    
// Runloop 开始运行,保持线程处于活跃状态
[runLoop run]; 

NSTimer 的触发是由 Runloop 来管理的,一旦 NSTimer 被添加到了 Runloop 中,它就会按照指定的时间间隔自动触发相应的事件,而不需要手动调用fire方法。

2.3 RunLoop 模式影响

现象:在 ScrollView 中使用 NSTimer,当滚动 ScrollView 的时候 NSTimer 无响应,停止滚动 ScrollView 的时候 NSTimer 又可以正常运行。

原因:将 NSTimer 添加到 RunLoop 时,如果模式是 NSDefaultRunLoopMode,当滚动 ScrollView 的时候 RunLoop 将会切换到 UITrackingRunLoopMode

解决方案:将 NSTimer 加入到 NSRunLoopCommonModes 模式下,保证 NSTimer 在任何情况下都能够正常工作。

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; 

2.4 循环引用问题

现象: viewDidLoad 中创建并持有 NSTimer 实例,如果要在 dealloc 中销毁定时器,此时就会出现循环引用。

原因:创建 NSTimer 时会对传入的 invocation、target 这些目标对象进行强引用。如果在目标对象内部同时持有了这个 NSTimer,就会造成循环引用,导致内存泄漏。上面的案例中,不调用 invalidate 方法,永远不会执行 dealloc 方法;而不执行 dealloc 方法,则永远不会调用 invalidate 方法。这个时候就会出现内存泄漏。

解决方案:

1. 弱引用解决循环引用

当一个对象持有对另一个对象的 weak 引用时,这个 weak 引用不会增加被引用对象的引用计数,因此不会阻止被引用对象的释放。如果被引用对象被释放,weak 引用会自动被置为 nil,这样在访问这个 weak 引用时就不会导致野指针错误。

__weak typeof(self) weakSelf = self;

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
                                         target:weakSelf
                                       selector:@selector(timerAction:)
                                       userInfo:nil
                                        repeats:YES];

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; 

2. 在适当的时候手动将 NSTimer 从 Runloop 中移除

// 用于通知视图控制器即将被添加到父视图控制器中或者从父视图控制器中移除
- (void)willMoveToParentViewController:(UIViewController *)parent {
    // parent:父视图控制器,如果视图控制器即将被移除,则该参数为 nil。
    if(!parent){
        // 停止计时器,并从循环池中移除定时器
        [timer invalidate];
        // 计时器对象本身仍未销毁,需要将计时器对象设置为 nil
        timer = nil;
    }
}

3. iOS 10+新增 scheduledTimerWithTimeInterval:repeats:block: 方法创建的定时器不会自动导致循环引用。在 block 内部引用了当前对象或其他可能会形成循环引用的对象时,在 block 内部使用弱引用来避免循环引用。

__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
    // 使用weakSelf来避免循环引用
    [weakSelf doSomething];
}];

4. 中间件 proxy

// NSProxy 是一个抽象基类,用于实现代理模式。通常被用于消息转发、远程消息传递等场景。
@interface WeakProxy : NSProxy

@property (nonatomic, weak) id target;

- (instancetype)initWithTarget:(id)target;

+ (instancetype)proxyWithTarget:(id)target;

@end

@implementation WeakProxy

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

+ (instancetype)proxyWithTarget:(id)target {
    // NSProxy 实例方法为 alloc
    WeakProxy *proxy = [WeakProxy alloc];    
    proxy.target = target;
    return proxy;
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (_target && [_target respondsToSelector:aSelector]) {        
        return [_target methodSignatureForSelector:aSelector];    
    } else {
        return [super methodSignatureForSelector:aSelector];
    }
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    if (_target && [_target respondsToSelector:anInvocation.selector]) {        
        [anInvocation invokeWithTarget:_target];    
    } else {
        [super forwardInvocation:anInvocation];    
    }
}

@end

3 其他定时器方案

NSTimer 并不是绝对准确的,它的触发时间可能会受到系统负载、其他任务的影响而延迟。对于需要高精度的定时任务,可以考虑使用更为准确的方法,如 CADisplayLink 或 GCD 定时器。

3.1 CADisplayLink

CADisplayLink 是 Core Animation 提供的一种定时器,它与屏幕的刷新频率同步,通常是每秒钟刷新60次(60Hz)。其本质也是通过 RunLoop 机制实现,所以仍可能会受到系统负载、其他任务的影响而延迟。

// 初始化 CADisplayLink,并设置回调方法
CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallback)];

// 将 CADisplayLink 添加到 RunLoop 中
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

// 移除 CADisplayLink
[displayLink invalidate];
displayLink = nil;

3.2 GCD 定时器

GCD(Grand Central Dispatch)提供了一种基于队列的定时器。GCD 定时器是基于系统时钟的,而不是依赖于运行循环,能够提供更高的精确度。常用于音频处理或实时数据处理。

// 创建一个 dispatch queue,用于执行 timer 回调
dispatch_queue_t timerQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

// 创建一个 dispatch source,指定为定时器类型
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, timerQueue);

// 设置定时器的触发时间、间隔等参数
dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 0), 1 * NSEC_PER_SEC, 0);

// 使用 weakSelf 避免循环引用
__weak typeof(self) weakSelf = self;

// 设置定时器的触发事件
dispatch_source_set_event_handler(timer, ^{
    typeof(self) strongSelf = weakSelf;
    if (strongSelf) {
        // 在 block 内部使用 strongSelf 来访问 self
        [strongSelf doSomething];
    }
});

// 启动定时器
dispatch_resume(timer); 

// 在适当的时候,停止定时器
// dispatch_source_cancel(timer);