OC学习32:计时器探索

张建 lol

简介

在iOS中,计时器是比较常用的,用于统计累加数据或者倒计时等,例如手机号获取验证码。计时器大概有那么三种,分别是:NSTimer、CADisplayLink、dispatch_source_t

创建NSTimer方式:有两种

  1. 第一种:SEL方式
1
2
3
4
5
6
7
8
9
- (void)demo{
// SEL
// 1.系统自动帮我们加入到runloop中
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(run) userInfo:nil repeats:YES];

// 2.系统不会自动帮我们加入到runloop中,需要手动添加
self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(run) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}
  1. 第二种:Block方式
1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)demo1{
// Block
// 1.系统会自动帮我们加入到runloop中
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer");
}];

// 2.系统不会自动帮我们加入到runloop中,需要我们手动添加
self.timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer1");
}];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}
  1. 由创建方式可知:
  • scheduledTimerWithTimeInterval 方式创建的,系统会自动帮我们添加到当前的runloop当中;

  • timerWithTimeInterval 方式创建的,系统默认不会帮我们添加到当前的runloop当中,需要我们自己手动添加

NSTimer与NSRunloopMode关系

  1. 当NSTimer加入到NSRunloop中的NSDefaultRunLoopMode模式时出现一个问题,就是当页面有UIScrollView滑动执行时执行的模式是UITrackingRunLoopMode,NSDefaultRunLoopMode被挂起了,会导致定时器失效,等滑动结束时才恢复定时器。

  2. 需要定时器在 UIScrollView 滑动时也不影响的话,有两种解决方法:

  • 给NSTimer分别添加到 UITrackingRunLoopModeNSDefaultRunLoopMode 这两个模式中:
1
2
3
4
[[NSRunLoop mainRunLoop] addTimer:timer 
forMode:NSDefaultRunLoopMode];
[[NSRunLoop mainRunLoop] addTimer:timer
forMode: UITrackingRunLoopMode];
  • 给NSTimer添加NSRunLoop的NSRunLoopCommonModes中,平常用这中就可以了,比较简便来。
1
2
[[NSRunLoop mainRunLoop] addTimer:timer 
forMode: NSRunLoopCommonModes];
  • NSRunLoopCommonModes相当于UITrackingRunLoopMode 和 NSDefaultRunLoopMode的伪模式

NSTimer与线程的关系

主线程默认开启runloop,子线程默认不开启runloop,需要手动开启runloop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)demo2{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// scheduledTimerWithTimeInterval
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer");
}];
[[NSRunLoop currentRunLoop] run];

//timerWithTimeInterval
self.timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer");
}];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] run];
});
}

子线程需要 [[NSRunLoop currentRunLoop] run] 才能执行NSTimer

NSTimer循环引用

  1. NSTimer使用不当就会造成内存泄漏,比如常见的使用方法:
1
2
3
4
5
6
7
8
9
10
11
12
//定义
@property (nonatomic, strong) NSTimer *timer;

//实现
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(showMsg) userInfo:nil repeats:YES];

// 销毁
-(void)dealloc
{
[self.timer invalidate];
self.timer = nil;
}

由于 NSTimer 会引用住 self,而 self 又持有 NSTimer 对象,所以形成循环引用,dealloc 会被执行,但 timer 也永远不会被释放,一直在打印,造成内存泄漏

尝试解决办法:

  • 把timer改成弱引用
1
@property (nonatomic, weak) NSTimer *timer;

虽然 selftimer弱引用,但是控制的 delloc 方法的执行依赖于 timerinvalidate,timer的invalidate又依赖于控制器的delloc方法,依旧是循环引用;

  • 使用 __weak

  
那换个思路能不能让NSTimer弱引用target:   

1
2
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(showMsg) userInfo:nil repeats:YES];

weak关键字适用于block,当block引用了块外的变量时,会根据修饰变量的关键字来决定是强引用还是弱引用,如果变量使用weak关键字修饰,那block会对变量进行弱引用,如果没有 __weak 关键字,那就是强引用。
  
但是NSTimer的 scheduledTimerWithTimeInterval:target 方法内部 不会判断修饰target的关键字,所以这里传 selfweakSelf 是没区别的,其内部会对target进行强引用,还是会产生 循环引用

  • 选择合适的时机手动释放timer

采用下面的方法解决循环引用:

1
2
3
4
5
6
7
8
- (void) viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];

if (self.timer) {
[self.timer invalidate];
self.timer = nil;
}
}

在某些情况下,这种做法是可以解决问题的,但是有时却会引起其他问题,比如控制器push到下一个控制器,viewDidDisappear执行后,timer被释放,此时再pop回来,timer已经不复存在了。

所以,这种 方案不是合理的

优化上面的方法这个时候可以采用配对使用在 viewWillAppear 开timer启,在 viewWillDisappear 关闭timer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-(void)viewWillAppear:(BOOL)animated{
[super viewWillAppear:animated];
if (!self.timer) {
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(showMsg) userInfo:nil repeats:YES];
}
}

-(void)viewWillDisappear:(BOOL)animated{
[super viewWillDisappear:animated];
if (self.timer) {
[self.timer invalidate];
self.timer = nil;
}
}

上面的方法只是维护起来比较麻烦

  1. 最终解决办法
  • 自定义 categoryblock 解决

NSTimer (TimerBlock) .h 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#import <Foundation/Foundation.h>

@interface NSTimer (TimerBlock)

/**
分类解决NSTimer在使用时造成的循环引用的问题

@param interval 间隔时间
@param block 回调
@param repeats 用于设置定时器是否重复触发

@return 返回NSTimer实体
*/
+ (NSTimer *)block_TimerWithTimeInterval:(NSTimeInterval)interval block:(void(^)())block repeats:(BOOL)repeats;

@end

NSTimer+TimerBlock.m 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#import "NSTimer+TimerBlock.h"

@implementation NSTimer (TimerBlock)
+ (NSTimer *)block_TimerWithTimeInterval:(NSTimeInterval)interval block:(void (^)())block repeats:(BOOL)reqeats{
return [self timerWithTimeInterval:interval target:self selector:@selector(blockSelector:) userInfo:[block copy] repeats:reqeats];
}

+ (void)blockSelector:(NSTimer *)timer{
void (^block)() = timer.userInfo;
if (block) {
block();
}
}
@end

上述创建方式调用者是NSTImer自己,只是NSTimer捕获了参数block。这样我们在使用timer时,由于target的改变,就不再有循环引用了。

1
2
3
4
__weak typeof(self) weakSelf = self;    // 避免 block 强引用 self
self.timer = [NSTimer block_TimerWithTimeInterval:3 block:^{
// [weakSelf dosomething];
} repeats:YES];
  1. self 添加中间件 Proxy

引入一个对象 ProxyProxy 弱引用 self,然后 Proxy 传入 NSTimer。即 self 强引用NSTimer,NSTimer 强引用 ProxyProxy 弱引用 self,这样通过弱引用来解决了相互引用,此时不会形成环。

即:self 强引用 NSTimer,NSTimer 强引用 Proxy,Proxy 弱引用 self,打破循环

定义一个继承自 NSObject 的中间代理对象 ProxyVC 不持有 timer,而是持有 Proxy 实例,让 Proxy 实例来弱引用 VC,timer强引用 Proxy 实例,直接看代码:

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
// ZJProxy.h
@interface ZJProxy : NSObject
+(instancetype)proxyWithTarget:(id)target;
@end

// ZJProxy.m
#import "ZJProxy.h"

@interface ZJProxy()
@property (nonatomic ,weak) id target;
@end

@implementation ZJProxy

+(instancetype)proxyWithTarget:(id)target
{
FFProxy *proxy = [[FFProxy alloc] init];
proxy.target = target;
return proxy;
}

// 仅仅添加了weak类型的属性还不够,为了保证中间件能够响应外部self的事件,需要通过消息转发机制,让实际的响应target还是外部self,这一步至关重要,主要涉及到runtime的消息机制。
-(id)forwardingTargetForSelector:(SEL)aSelector{
// 快速消息转发,让响应target的还是外部的self
return self.target;
}
@end
  • forwardingTargetForSelector:(SEL)aSelector是什么?

消息转发,简单来说就是如果当前对象没有实现这个方法,系统会到这个方法里来找实现对象。
本文中由于当前target是 ZJProxy,但是 ZJProxy 没有实现 showMsg 方法(当然也不需要它实现),让系统去找target实例的方法实现,也就是去找ViewController中的方法实现。

  1. 使用 NSProxy

使用 iOSNSProxy 类,NSProxy 就是专门用来 做消息转发的

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
// ZJWeakProxy.h
@interface ZJWeakProxy : NSProxy
+ (instancetype)proxyWithTarget:(id)target;
@end

// ZJWeakProxy.m
@interface ZJWeakProxy()
@property (nonatomic ,weak)id target;
@end
@implementation FFWeakProxy
+ (instancetype)proxyWithTarget:(id)target {
// NSProxy实例方法为alloc
ZJWeakProxy *proxy = [ZJWeakProxy alloc];
proxy.target = target;
return proxy;
}

/**
这个函数让重载方有机会抛出一个函数的签名,再由后面的forwardInvocation:去执行
为给定消息提供参数类型信息
*/
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [self.target methodSignatureForSelector:sel];
}

/**
* NSInvocation封装了NSMethodSignature,通过invokeWithTarget方法将消息转发给其他对象。这里转发给控制器执行。
*/
- (void)forwardInvocation:(NSInvocation *)invocation {
[invocation invokeWithTarget:self.target];
}
@end

Controller里代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)viewDidLoad {
[super viewDidLoad];
// 这里的target又发生了变化
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:[ZJWeakProxy proxyWithTarget:self] selector:@selector(showMsg) userInfo:nil repeats:YES];
}

// 销毁
-(void)dealloc
{
[self.timer invalidate];
self.timer = nil;
}

NSTimer总结:

  • 使用 NSTimer 比较方便快捷。
  • 创建 NSTimer 必须加入到 Runloop 中才能生效,创建 NSTimer 都是要加到 Runloop 中的,不管是手动添加还是系统添加。
  • Timer 加入到 runloop 的模式的 NSDefaultRunLoopMode,当UIScrollView滑动的时候会暂时失效
  • 使用 NSTimer 会存在延时,计时不是很准。因为不管是一次性的还是周期性的timer的实际触发事件的时间,都会与所加入的 RunLoopRunLoop Mode 有关。如果此 RunLoop正在执行一个连续性的运算,timer就会被延时触发。重复性的timer遇到这种情况,如果延迟超过了一个周期,则会在延时结束后立刻执行,并按照之前指定的周期继续执行。

CADisplayLink

  1. 创建方式
1
2
3
4
-(void)createCADisplayLink{
_displaylinkTimer = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
[_displaylinkTimer addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
}

当把 CADisplayLink 对象 addrunloop 中后,selector 就能被周期性调用,类似于重复的 NSTimer 被启动了;执行 invalidate 操作时,CADisplayLink 对象就会从 runloop 中移除,selector 调用也随即停止,类似于 NSTimerinvalidate 方法。

  1. 特性
  • 屏幕刷新时调用 CADisplayLink 是一个能让我们以和屏幕刷新率同步的频率将特定的内容画到屏幕上的定时器类。CADisplayLink 以特定模式注册到 runloop 后,每当屏幕显示内容刷新结束的时候,runloop 就会向 CADisplayLink 指定的 target 发送一次指定的 selector 消息, CADisplayLink 类对应的 selector 就会被调用一次。所以通常情况下,按照iOS设备屏幕的刷新率 60次/秒

  • 延迟iOS设备的屏幕刷新频率是固定的,CADisplayLink 在正常情况下会在每次刷新结束都被调用,精确度相当高。但如果调用的方法比较耗时,超过了屏幕刷新周期,就会导致跳过若干次回调调用机会。如果 CPU过于繁忙,无法保证屏幕 60次/秒 的刷新率,就会导致跳过若干次调用回调方法的机会,跳过次数取决CPU的忙碌程度。

  1. 使用场景

从原理上可以看出,CADisplayLink 适合做界面的不停重绘,比如视频播放的时候需要不停地获取下一帧用于界面渲染。

  1. 重要属性
  • frameInterval

NSInteger类型的值,用来设置 间隔多少帧 调用一次 selector 方法,默认值是 1,即每帧都调用一次。

  • duration

readOnlyCFTimeInterval 值,表示 两次屏幕刷新之间的时间间隔。需要注意的是,该属性在 targetselector 被首次调用以后才会被赋值。selector 的调用间隔时间计算方式是:调用间隔时间 = duration × frameInterval

dispatch_source_t

  1. 创建方式
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
//dispatch_source_t
-(void)createDispatch_source_t{

// 创建全局队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

// 使用全局队列创建计时器
_sourceTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);

// 定时器延迟时间
NSTimeInterval delayTime = 1.0f;

// 定时器间隔时间
NSTimeInterval timeInterval = 1.0f;

// 设置开始时间
dispatch_time_t startDelayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayTime * NSEC_PER_SEC));

// 设置计时器
dispatch_source_set_timer(_sourceTimer,startDelayTime,timeInterval*NSEC_PER_SEC,0.1*NSEC_PER_SEC);

// 执行事件
dispatch_source_set_event_handler(_sourceTimer,^{
// 销毁定时器
// dispatch_source_cancel(_sourceTimer);
});

// 启动计时器
dispatch_resume(_sourceTimer);
}
  1. 特性

默认是重复执行的,可以在事件响应回调中通过 dispatch_source_cancel 方法来设置为只执行一次,如下代码:

1
2
3
4
dispatch_source_set_event_handler(_timer, ^{
//执行事件
dispatch_source_cancel(_sourceTimer);}
);
  1. 优点:
  • 时间准确
  • 可以使用子线程,解决定时间跑在主线程上卡UI问题
  1. 需要将 dispatch_source_t timer 设置为 成员变量,不然会立即释放
  • Post title:OC学习32:计时器探索
  • Post author:张建
  • Create time:2020-08-25 13:42:40
  • Post link:https://redefine.ohevan.com/2020/08/25/OC/OC学习32:计时器探索/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.