NSRunLoop与iOS中的倒计时(GCD,CADisplaylink,NSTimer)

相信在iOS开发中大家都用过倒计时的功能,而NSTimer也是大家用得最多用来实现该功能的类,但是可能有人不太清楚NSTimer存在计时不准并且可能会导致引用循环资源无法释放的情况,接下来我会介绍一下使用GCD以及CADisplaylink来实现倒计时以及他们三者的利弊。

RunLoop

在开始介绍下面三种方法之前,我想我们有必要先来介绍一下RunLoop,因为CADisplaylink和NSTimer都是需要通过运行在RunLoop里面才保证了每次到特定的时间点就会执行对应的事件

RunLoop是什么

一般来说线程只能执行一次任务,执行完任务之后就会退出,可是如果需要处理多个任务呢,那就需要RunLoop来保证线程能随时处理事件并且不会退出。

RunLoop实际上像是一个对象,该对象提供了一个入口函数,该入口函数会实现像不断的循环获取任务执行任务的功能,当线程执行了这个入口函数之后,就会一直处于函数内部:接受任务->等待->处理这样的循环中,知道接受到quit消息,就会推出该入口函数,然后线程销毁。

RunLoop和线程之间的关系

RunLoop和线程是一一对应的,它们通过key-value的形式保存在一个全局的字典里面(key是p_thread,value是CFRunLoopRef),iOS中不允许直接创建RunLoop,可以通过两个方法获取RunLoop ,CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。这两个方法内部会调用CFRunLoopRef _CFRunLoopGet(pthread_t thread)方法,调用该方法时,会优先判断该字典是不是为空,如果是的话就创建一个以pthread_main_thread_np()为key的runloop并且放到字典里面。然后在字典中寻找thread为key的runloop,如果不存在则创建一个新的RunLoop并且注册一个回调,当线程销毁时,也销毁RunLoop。

RunLoopMode

苹果提供了两个公开的RunLoopMode,NSDefaultRunLoop以及UITrackingRunLoopMode,第一个mode程序默认的mode,当程序中有ScrollView滚动的时候,RunLoop就会将当前的mode切换为UITrackingRunLoopMode。

相信很多人都有过NSTimer在默认情况下可用,当APP有ScrollView在滚动的时候就不可用的回调,那是因为NSTimer加到runloop里面的时候默认是NSDefaultRunLoop,当页面滑动的时候RunLoop切换到了UITrackingRunLoopMode,所以timer就不起作用了,这个时候需要使用[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];将timer标记为NSRunLoopCommonModes,NSRunLoopCommonModes默认是包含了NSDefaultRunLoop和UITrackingRunLoopMode两个model。

RunLoop的结构

我们首先来看一下RunLoop的都包含什么东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct __CFRunLoopMode {
CFStringRef _name; // Mode Name, 例如 @"kCFRunLoopDefaultMode"
CFMutableSetRef _sources0; // Set
CFMutableSetRef _sources1; // Set
CFMutableArrayRef _observers; // Array
CFMutableArrayRef _timers; // Array
...
};

struct __CFRunLoop {
CFMutableSetRef _commonModes; // Set
CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
CFRunLoopModeRef _currentMode; // Current Runloop Mode
CFMutableSetRef _modes; // Set
...
};

_CFRunLoop:

  • _commonModes: 一个标记为common的集合,通过CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);可以将一个mode加到commonModes里面,当RunLoop的内容变化的时候,RunLoop都会将Timer/Observer/Sources同步到标记为“common”的Mode里面

  • _commonModeItems: 被加到CommonModes里面的所有的Item的集合,一个Item包含_source0,_source1,_observers,_timers。

  • _currentMode: 当前RunLoop的mode,可以通过 CFRunLoopRunInMode(CFStringRef modeName, ...);来切换mode

  • _modes:RunLoop包含的mode

_CFRunLoopMode:

  • source0(CFRunLoopSourceRef): mode的事件源,source0只包含一个回调(函数指针),它并不会自动触发,需要先调用 CFRunLoopSourceSinal(source)将source标为待处理,然后调用CFRunLoopSourceWakeUp(source)来唤醒RunLoop才会调用这个方法。

  • _sources1(CFRunLoopSourceRef): mode的另外一个事件源,source1包含了回调以及一个mach-port,被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程

  • _timers: 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)

  • _observers: 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。

更多的关于RunLoop的内容可以看深入理解RunLoop

NSTimer

我们通常会使用以下的代码来创建一个Timer并且将Timer加到RunLoop里面。

1
2
sellf.timer = [NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(doSomeThing) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

以上的代码会有两个问题:

  1. 内存泄漏的问题,首先RunLoop会强引用timer,而timer会强引用self,所以timer不释放的时候,self也无法释放。通常我们会用以下的代码来释放Timer,但是需要找一个合适的时机去释放它,加入我们像以下代码那样在viewWillDisapper那样释放它,当回到主屏幕的时候那timer又要被销毁了,然后重新进入重新创建一个timer,这样就会非常麻烦,需要多个地方维护timer的状态。
1
2
3
4
5
- (void)viewWillDisappear:(BOOL)animated{
[super viewWillDisappear:animated];
[self.timer invalidate];
self.timer = nil;
}

很明显我们这里要解决的就是timer的释放时机的问题,我们当然是希望持有timer的视图控制器执行dealloc释放的时候释放它,但是这时候千万别企图在dealloc方法里面做这个事情,原因自己想。

我们的思路就是通过创建一个MagicClass来弱应用这个target,然后timer的target强引用这个MagicClass,执行MagicClass的一个替身Action,在这个Action里面我们可以判断target是不是被销毁了(因为这个时候没有Timer强引用它,所以不会有有内存泄漏的问题),然后没有被销毁则执行真正的Action,如果Target已经被销毁了则调用invalidate销毁timer。

here is the code

NSTimer+LMExtension.h

1
2
3
4
5
#import <Foundation/Foundation.h>

@interface NSTimer (LMExtension)
+ (instancetype)lmScheduledTimerWithTimeInterval:(NSTimeInterval)interval target:(id)target selector:(SEL)selector userInfo:(id)userInfo;
@end

NSTimer+LMExtension.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#import "NSTimer+LMExtension.h"

@interface LMMagicTarget : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer *timer;
@end

@implementation LMMagicTarget
- (void)LMTimerStarAction:(NSTimer *)timer
{
if (self.target) {
[self.target performSelector:self.selector withObject:timer afterDelay:0.0];
} else {
[self.timer invalidate];
self.timer = nil;
}
}

@end


@implementation NSTimer (LMExtension)
+ (instancetype)lmScheduledTimerWithTimeInterval:(NSTimeInterval)interval target:(id)target selector:(SEL)selector userInfo:(id)userInfo{
LMMagicTarget *magicTarget = [[LMMagicTarget alloc]init];
magicTarget.target = target;
magicTarget.selector = selector;
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:interval target:magicTarget selector:@selector(LMTimerStarAction:) userInfo:nil repeats:YES];
magicTarget.timer = timer;
return timer;
}
@end
  1. 第二个问题,精度问题。NSTimer其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的,一个timer注册好后,RunLoop会在其重复的时间点注册事件,但是如果这个时候RunLoop正在处理一个其他任务的时候,错过了该事件点,则该次不会执行timer的事件源,会跳过当前时间点,直到下一个时间点才执行该timer的事件源。所以timer会有一个Tolerance的属性,这属性就是宽容度,该属性标记当时间点到了后,容许有多少的误差。

CADisplayLink是一个以屏幕刷新频率同步的计时器。以下是创建方法:

1
2
3
4
5
6
创建方法
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
[self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
停止方法
[self.displayLink invalidate];
self.displayLink = nil;

CADisplayLink的计算时间并不依靠RunLoop,当一个屏幕刷新完成时候则会通知RunLoop给对应的target执行action。但是CADisplayLink依然会有精度的问题,当两次界面刷新之间执行了一次长任务的时候,那就会有一帧被跳过去,也就是所谓的掉帧,那相应的此次也不会调用target的action。

GCD

GCD提供了一个计时的方法,GCD定时器的底层是由XNU内核中的select方法实现的。具体的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
dispatch_source_set_timer(self.timer, DISPATCH_TIME_NOW , 1 * NSEC_PER_SEC, 0 * NSEC_PER_SEC);
dispatch_source_set_event_handler(self.timer, ^{
NSLog(@"GCD");
});
//开启定时器
dispatch_resume(self.timer);

//销毁定时器
dispatch_source_cancel(self.timer);
self.timer = nil;

总结

如果是对时间的要求不精确的计算,可以使用NSTimer,如果是对时间比较精确的,可以使用GCD提供的倒计时方法。如果是实现动画,需要高频率的绘制,可以使用CADisplayLink。

-------评论系统采用disqus,如果看不到需要翻墙-------------