【OC】NSTimer
NSTimer
文章目录
NSTimer - 为什么定时器停不下来?
- NSTimer 基础
- RunLoop
- NSTimer 的循环引用
- 实战:无限轮播图的完整 Timer 方案
为什么定时器停不下来?
我曾经做过一个定时器按钮,但是里面有这样一个问题:连续点击几次「开始」按钮之后,点「结束」按钮怎么都停不下来
我们先来看一下代码
-(void)startTimer{self.timer=[NSTimer scheduledTimerWithTimeInterval:1.0target:selfselector:@selector(tick)userInfo:nil repeats:YES];}-(void)stopTimer{[self.timer invalidate];self.timer=nil;}根本原因是每次点击「开始」都会创建一个新的 timer 并加入 RunLoop,但self.timer只保存最后一个引用
前面创建的 timer 失去了引用,但是它还活着,还在 RunLoop 里继续跑,invalidate只能停掉最后一个,之前的全部失控
直观一点的看就是连续点击开始按钮就会变成:
un loop 里有 timerA、timerB、timerC
self.timerView 只指向 timerC
这时点击“停止”只能停掉 timerC,前面的 timerA、timerB 还在运行
解决方案就是:开始之前先判断是否已经存在
-(void)startTimer{if(self.timer&&self.timer.isValid)return;// 已经有在跑的 timer 就直接返回,不重复创建self.timer=[NSTimer scheduledTimerWithTimeInterval:1.0target:selfselector:@selector(tick)userInfo:nil repeats:YES];}这个 Bug 背后隐藏了 NSTimer 最重要的两个知识点:
- timer 依赖 RunLoop 运行
- timer 的生命周期管理
NSTimer 基础
NSTimer 是一个注册在 RunLoop 上的时间源,本质上就是告诉系统:每隔多少秒,帮我调一次这个方法
- 两种创建方式
// 方式一:自动加入当前 RunLoop 的 DefaultMode(最常用)[NSTimer scheduledTimerWithTimeInterval:1.0target:selfselector:@selector(tick)userInfo:nil repeats:YES];// 方式二:手动创建,手动加入 RunLoop(可以指定 Mode)NSTimer*timer=[NSTimer timerWithTimeInterval:1.0target:selfselector:@selector(tick)userInfo:nil repeats:YES];[[NSRunLoop mainRunLoop]addTimer:timer forMode:NSRunLoopCommonModes];- repeats 参数
repeats:YES// 重复触发,需要手动 invalidate 停止repeats:NO// 只触发一次,触发后自动从 RunLoop 移除并释放- timer 的生命周期
创建 → 加入 RunLoop → 触发回调 → invalidate → 从 RunLoop 移除 → 释放
invalidate之后 timer 永久失效,不能重新激活,要重新用必须重新创建
- invalidate和self.timerView = nil
self.timerView = nil只是把“我们手里的引用”清掉 ,代表 timer 停了 ,如果还有别的强引用,timer 仍然活着
但是[timer invalidate]让 timer 失效 是从 run loop 中移除 ,之后不再触发回调 ,如果没有别的强引用,timer 才会释放
所以即使你丢了对旧 timer 的引用,旧 timer 也可能还活着
因为run loop 持有 timer ,timer 持有 target:self 可能形成这种关系:
RunLoop → NSTimer → ViewController
所以问题不只是“停不下来”,还可能是 旧 timer 无法释放 ,ViewController 也可能因为被 timer 持有而不能释放
- 时间不精确
NSTimer 不是严格精确的,RunLoop 每跑一圈才检查一次 timer 有没有到时间,如果这一圈处理了耗时任务,timer 会延迟触发,所以 NSTimer 适合做轮播这种对精度要求不高的场景,不适合做精确计时
RunLoop
RunLoop 是一个事件循环,本质是一个 while 循环:
while(有事件){处理事件// 会检查timer有没有到时间?有没有触摸事件?有没有网络回调?// 如果某件事处理很耗时间,这一个循环就会很耗时间// timer 到点了但还没到检查的时机,就会延迟触发}// 没事件就休眠,有事件就唤醒没有RunLoop的话,程序执行完main函数就直接退出了,RunLoop 让程序一直活着,等待触摸事件、定时器、网络回调这些事件进来
- RunLoop 的五种模式
RunLoop 同一时间只能跑在一种模式下,这种模式下只处理注册在这个模式里的事件源:
| 模式 | 说明 |
|---|---|
| NSDefaultRunLoopMode | 默认模式,空闲时跑这个 |
| UITrackingRunLoopMode | 手指滑动时切换到这个 |
| NSRunLoopCommonModes | 伪模式,同时标记 Default 和 Tracking |
| UIInitializationRunLoopMode | 启动时用,启动完切回 Default |
| GSEventReceiveRunLoopMode | 系统内部使用 |
- 滑动时定时器为什么暂停
scheduledTimerWithTimeInterval默认把 timer 加在NSDefaultRunLoopMode里:
// 正常状态RunLoop 跑在 DefaultMode → timer 正常触发// 手指开始滑动RunLoop 切换到 TrackingMode → timer 只注册在 DefaultMode 里 → 不触发(RunLoop 只处理 TrackingMode 里的事件) → 定时器暂停// 手指滑动时系统需要以极高的频率处理触摸事件,保证滑动流畅不卡顿,如果这时候还要同时处理 DefaultMode 里的所有事件(网络回调、timer、各种通知),会抢占资源导致滑动卡顿// 所以系统把滑动单独放在 TrackingMode 里,切换过去之后只专心处理触摸事件,其他事情都先放下,保证滑动帧率// 手指松开后,系统检测到触摸结束,RunLoop 从 TrackingMode 切回 DefaultMode,timer 重新在当前模式下被检测到,继续正常触发,轮播就恢复了这就是无限轮播图在手动滑动时自动轮播会暂停,一停下来又继续的原因
- CommonModes 解决方案
NSRunLoopCommonModes不是真正的模式,是一个标记,把 timer 同时注册到 Default 和 Tracking 两个模式里,不管 RunLoop 跑在哪个模式都会触发:
NSTimer*timer=[NSTimer timerWithTimeInterval:2.0target:selfselector:@selector(nextToPage)userInfo:nil repeats:YES];[[NSRunLoop mainRunLoop]addTimer:timer forMode:NSRunLoopCommonModes];self.timer=timer;NSTimer 的循环引用
- 为什么会循环引用
@property(nonatomic,strong)NSTimer*timer;// self 强持有 timerself.timer=[NSTimer scheduledTimerWithTimeInterval:1.0target:self// timer 强持有 selfselector:@selector(tick)userInfo:nil repeats:YES];引用链:
两个互相强持有,引用计数永远不为 0,dealloc永远走不到,timer 永远不释放,内存泄漏。
- 为什么 weakSelf 无效
很多人第一反应是用 weakSelf:
__weaktypeof(self)weakSelf=self;self.timer=[NSTimer scheduledTimerWithTimeInterval:1.0target:weakSelf// 以为这样就行了...];但这是无效的,weakSelf只是一个弱引用变量,但把它传给target:的那一刻,NSTimer 内部会对这个对象做一次retain,变成强引用,弱引用在传递的过程中就失效了,target:weakSelf和target:self对 NSTimer 来说没有区别,都是强持有
- 解决方案一:TimerProxy 中间对象
核心思路是让 timer 持有一个中间对象 proxy,proxy 弱引用 self,把循环打断:
@interfaceTimerProxy:NSObject@property(nonatomic,weak)id target;+(instancetype)proxyWithTarget:(id)target;@end@implementationTimerProxy+(instancetype)proxyWithTarget:(id)target{TimerProxy*proxy=[[TimerProxy alloc]init];proxy.target=target;returnproxy;}-(void)nextToPage{// target 是 weak,self 释放后 target 变 nil// [nil nextToPage] 在 OC 里不会崩溃,直接忽略[self.target performSelector:@selector(nextToPage)];}@end// ViewController.m-(void)startTimer{TimerProxy*proxy=[TimerProxy proxyWithTarget:self];self.timer=[NSTimer scheduledTimerWithTimeInterval:1.0target:proxy selector:@selector(nextToPage)userInfo:nil repeats:YES];}用户离开页面时:self引用计数归零(没有对象强持有self),调用dealloc去销毁定时器,timer释放,然后proxy释放,由于proxy.target本来就是weak,self释放时自动变nil
⚠️但是这只是一个示范展示一下思路,因为它只适用于nextToPage一个函数,有很大的局限性
- 解决方案二:NSProxy(推荐)
苹果提供了专门用于消息转发的基类NSProxy,比手写 TimerProxy 更通用,不需要手动写每个转发方法:
@interfaceWeakProxy:NSProxy@property(nonatomic,weak)id target;+(instancetype)proxyWithTarget:(id)target;@end@implementationWeakProxy+(instancetype)proxyWithTarget:(id)target{WeakProxy*proxy=[WeakProxy alloc];// NSProxy 没有 init,直接 allocproxy.target=target;returnproxy;}// 所有发给 proxy 的消息自动转发给 target,不需要手动写每个方法// 告诉系统,这个方法的"签名"是什么样的// 假如系统收到消息:proxy nextToPage->系统问:proxy,你认得 nextToPage 方法吗?->proxy 说:我不认识,但我的 target 可能认识 -> 系统调用 methodSignatureForSelector:->proxy 向 target 问方法签名->target 提供签名->系统拿到签名,准备转发-(NSMethodSignature*)methodSignatureForSelector:(SEL)sel{return[self.target methodSignatureForSelector:sel];}// 真正执行消息转发-(void)forwardInvocation:(NSInvocation*)invocation{if(self.target){[invocation invokeWithTarget:self.target];}}@end// ViewController.m-(void)viewDidLoad{[superviewDidLoad];// 创建 proxyWeakProxy*proxy=[WeakProxy proxyWithTarget:self];self.timer1=[NSTimer scheduledTimerWithTimeInterval:1.0target:proxy selector:@selector(nextToPage)userInfo:nil repeats:YES];}NSProxy 的优势是通用性,任何方法都不需要额外处理,自动转发给 target
- 解决方案三:block
我们直接使用另外一个函数创建NSTimer
// iOS 10+ 的 Block 方式 timer 不会循环引用-(void)startTimer{__weaktypeof(self)weakSelf=self;// 之前那个方法的timer 的 target 会强引用self// 但是在 block 内部使用 weakSelf,block捕获的就会是弱引用的selfself.timer=[NSTimer scheduledTimerWithTimeInterval:1.0repeats:YES block:^(NSTimer*_Nonnull timer){// __strong typeof(weakSelf) strongSelf = weakSelf;// 也可以加入 strongSelf 保证执行安全[weakSelf updateTime];}];}实战:无限轮播图的完整 Timer 方案
结合前面所有知识点,把三个问题一次性解决:
-(void)startAutoScroll{// 防止重复创建if(self.timer&&self.timer.isValid)return;// 用 NSProxy 解决循环引用WeakProxy*proxy=[WeakProxy proxyWithTarget:self];// 手动加入 RunLoop,指定 CommonModes 防止滑动时暂停self.timer=[NSTimer timerWithTimeInterval:2.0target:proxy selector:@selector(nextToPage)userInfo:nil repeats:YES];[[NSRunLoop mainRunLoop]addTimer:self.timer forMode:NSRunLoopCommonModes];}-(void)stopAutoScroll{[self.timer invalidate];self.timer=nil;}-(void)dealloc{[selfstopAutoScroll];}但其实对于用户滑动的这个情况:
- 如果不使用
NSRunLoopCommonModes,用户滑动的时候自动轮播暂停是ok的,但是停止滑动后由于过了几秒定时器重新启动会把之前错过的事件补上,所以就会出现很快滑动几张的情况 - 如果使用
NSRunLoopCommonModes,用户滑动和自动轮播同时存在,虽然时间上不太会重叠,但是用户可能在慢慢滑的时候定时器起作用突然跳到下一页也会影响用户体验 - 最好的处理办法是在用户滑动的时候停止定时器,检测到用户一段时间没有动作再重启定时器
-(void)restartAutoScrollAfterDelay{// NSObject 的方法,用来取消之前用 performSelector:afterDelay: 延迟调用的方法[NSObject cancelPreviousPerformRequestsWithTarget:selfselector:@selector(startAutoScroll)object:nil];[selfperformSelector:@selector(startAutoScroll)withObject:nil afterDelay:2.0];}-(void)viewWillAppear:(BOOL)animated{[superviewWillAppear:animated];[selfstartAutoScroll];}-(void)viewWillDisappear:(BOOL)animated{[superviewWillDisappear:animated];[selfstopAutoScroll];[NSObject cancelPreviousPerformRequestsWithTarget:selfselector:@selector(startAutoScroll)object:nil];}-(void)scrollViewWillBeginDragging:(UIScrollView*)scrollView{[selfstopAutoScroll];[NSObject cancelPreviousPerformRequestsWithTarget:selfselector:@selector(startAutoScroll)object:nil];}-(void)scrollViewDidEndDragging:(UIScrollView*)scrollView willDecelerate:(BOOL)decelerate{// decelerate 表示手指松开后 scrollView 是否还会继续减速滑动if(!decelerate){// decelerate = NO,松手后直接停了,这里重启自动轮播[selfrestartAutoScrollAfterDelay];}}