Timer
A timer that fires after a certain time interval has elapsed, sending a specified message to a target object.
简单来说就是在指定时间过去,定时器会被启动并发送消息给目标对象去执行对应的事件
定时器(Timer
)的功能是与Runloop
相关联的,Runloop
会强引用Timer
,所以当定时器被添加到Runloop
之后,我们并没有必须强引用定时器(Timer
)
理解Run Loop概念
谈到定时器,首先需要了解的一个概念是 RunLoop。一般来讲,一个线程一次只能执行一个任务,执行完成后线程就会退出。如果我们需要一个机制,让线程能随时处理事件但并不退出,通常的代码逻辑是这样的:
function loop() {
initialize();
do {
var message = get_next_message();
process_message(message);
} while (message != quit);
}
这种模型通常被称作 Event Loop。 Event Loop 在很多系统和框架里都有实现,比如Windows 程序的消息循环,再比如 OSX/iOS 里的 RunLoop。实现这种模型的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。
所以,RunLoop实际上就是一个对象,这个对象管理了其需要处理的事件和消息,并提供了一个入口函数来执行上面 Event Loop 的逻辑。线程执行了这个函数后,就会一直处于这个函数内部 “接受消息->等待->处理” 的循环中,直到这个循环结束(比如传入 quit 的消息),函数返回。
OSX/iOS 系统中,提供了两个这样的对象:RunLoop 和 CFRunLoopRef。
更多详细的内容可以看深入理解RunLoop,也可以参考官方文档Threading Programming Guide
重复和非重复定时器
- 重复定时
常用的target-action
方式
func addRepeatedTimer() {
let timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(fireTimer),
userInfo: nil,
repeats: true)
}
@objc func fireTimer() {
print("fire timer")
}
参数介绍
-
timeInterval
:延时时间,单位为秒,可以是小数。如果值小于等于0.0的话,系统会默认赋值0.1毫秒 -
target
:目标对象,一般是self
,但是注意timer
会强引用target
,直到调用invalidate
方法 -
selector
: 执行方法 -
userInfo
: 传入信息 -
repeats
:是否重复执行
使用block方式
func addRepeatedTimerWithClosure() {
if #available(iOS 10.0, *) { // iOS10之后的API
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
print("fire timer")
}
} else {
// Fallback on earlier versions
}
}
上面两种方式可以实现重复定时触发事件,但是target-action
方式会存在一个问题?那就是对象之间的引用问题导致内存泄露,因为定时器强引用了self
,而本身又被runloop
强引用。所以timer
和self
都得不到释放,所以定时器一直存在并触发事件,这样就会导致内存泄露。
为了避免内存泄露,所以需要在不使用定时器的时候,手动执行timer.invalidate()
方法。而block
方式虽然并不会存在循环引用情况,但是由于本身被runloop
强引用,所以也需要执行timer.invalidate()
方法,否则定时器还是会一直存在。
invalidate
方法有2个功能:一是将timer
从runloop
中移除,二是timer
本身也会释放它持有的资源
因此经常会对timer
进行引用。
self.timer = timer
失效定时器
timer.invalidate()
timer = nil
具体的循环引用例子,后面会有
- 非重复定时
非重复定时器只会执行一次,执行结束会自动调用invalidates
方法,这样能够防止定时器再次启动。实现很简单将repeats
设置为false
即可
// target-action方式
func addNoRepeatedTimer() {
let timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(fireTimer),
userInfo: nil,
repeats: false)
}
// block方式
func addUnRepeatedTimerWithClosure() {
if #available(iOS 10.0, *) { // iOS10之后的API
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { (timer) in
print("fire timer")
}
} else {
// Fallback on earlier versions
}
}
@objc func fireTimer() {
print("fire timer")
}
定时容忍范围(Timer Tolerance)
在iOS7
之后,iOS
允许我们为Timer
指定Tolerance
,这样会给你的timer添加一些时间宽容度可以降低它的电力消耗以及增加响应。好比如:“我希望1秒钟运行一次,但是晚个200毫秒我也不介意”。
当你指定了时间宽容度,就意味着系统可以在原有时间附加该宽容度内的任意时刻触发timer
。例如,如果你要timer
1秒后运行,并有0.5秒的时间宽容度,实际就可能是1秒,1.5秒或1.3秒等。
下面是每秒运行一次的timer
,并有0.2秒的时间宽容度
let timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(fireTimer),
userInfo: nil,
repeats: true)
timer.tolerance = 0.2
默认的时间宽容度是0,如果一个重复性timer由于设定的时间宽容度推迟了一小会执行,这并不意味着后续的执行都会晚一会。iOS不允许timer
总体上的漂移,也就是说下一次触发会快一些。
举例的话,如果一个timer
每1秒运行一次,并有0.5秒的时间宽容度,那么实际可能是这样:
- 1.0秒后
timer
触发 - 2.4秒后
timer
再次触发,晚了0.4秒,但是在时间宽容度内 - 3.1秒后
timer
第三次触发,和上一次仅差0.7秒,但每次触发的时间是按原始时间算的。
等等…
使用userInfo获取额外信息
func getTimerUserInfo() {
let timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(fireTimer),
userInfo: ["score": 90],
repeats: false)
}
@objc func fire(_ timer: Timer) {
guard let userInfo = timer.userInfo as? [String: Int],
let score = userInfo["score"] else {
return
}
print("score: \(score)")
}
与Run Loop协同工作
当使用下列方法创建timer
,需要手动添加timer
到Run Loop
并指定运行模型,上面使用的方法都是自动添加到当前的Run Loop
并在默认模型(default mode
)允许
public init(timeInterval ti: TimeInterval,
invocation: NSInvocation,
repeats yesOrNo: Bool)
public init(timeInterval ti: TimeInterval,
target aTarget: Any,
selector aSelector: Selector,
userInfo: Any?,
repeats yesOrNo: Bool)
public init(fireAt date: Date,
interval ti: TimeInterval,
target t: Any,
selector s: Selector,
userInfo ui: Any?,
repeats rep: Bool)
比如创建timer
添加到当前的Run Loop
// 手动添加到runloop,指定模型
func addTimerToRunloop() {
let timer = Timer(timeInterval: 1.0,
target: self,
selector: #selector(fireTimer),
userInfo: nil,
repeats: true)
RunLoop.current.add(timer, forMode: .common)
}
iOS开发中经常遇到的场景,tableView
上有定时器,当用户用手指触摸屏幕,定时器会停止执行,滚动停止才会恢复定时。但是这并不是我们所想要的?为什么会出现呢?
主线程的RunLoop
里有两个预置的 Mode
:kCFRunLoopDefaultMode
和 UITrackingRunLoopMode
。
这两个Mode
都已经被标记为”Common”
属性。DefaultMode
是 App
平时所处的状态,TrackingRunLoopMode
是追踪 ScrollView
滑动时的状态。当你创建一个 Timer
并加到 DefaultMode
时,Timer
会得到重复回调,但此时滑动一个TableView
时,RunLoop
会将 mode
切换为 TrackingRunLoopMode
,这时 Timer
就不会被回调,并且也不会影响到滑动操作。
所以当你需要一个Timer
,在两个 Mode
中都能得到回调,有如下方法
- 1、将这个
Timer
分别加入这两个Mode
RunLoop.current.add(timer, forMode: .default)
RunLoop.current.add(timer, forMode: .tracking)
- 2、将
Timer
加入到顶层的RunLoop
的common
模式中
RunLoop.current.add(timer, forMode: .common)
- 3、在子线程中进行Timer的操作,再在主线程中修改UI界面
实际场景
1、利用Timer简单实现倒计时功能
class TimerViewController: BaseViewController {
var timer: Timer?
var timeLeft = 60
lazy var timeLabel: UILabel = {
let label = UILabel(frame: CGRect(x: 0, y: 0, width: 60, height: 60))
label.backgroundColor = UIColor.orange
label.textColor = UIColor.white
label.text = "60 s"
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
addTimeLabel()
countDownTimer()
}
func addTimeLabel() {
view.addSubview(timeLabel)
timeLabel.center = view.center
}
func countDownTimer() {
timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(countTime),
userInfo: nil,
repeats: true)
}
@objc func countTime() {
timeLeft -= 1
timeLabel.text = "\(timeLeft) s"
if timeLeft <= 0 {
timer?.invalidate()
timer = nil
}
}
}
2、定时器的循环引用
常见的场景:
有两个控制器ViewControllerA
和ViewControllerB
,ViewControllerA
跳转到ViewControllerB
中,ViewControllerB
开启定时器,但是当返回ViewControllerA
界面时,定时器依然还在走,控制器也并没有执行deinit
方法销毁掉
为何会出现循环引用的情况呢?原因是:定时器对控制器 (self
) 进行了强引用,定时器被runloop
引用,定时器得不到释放,所以控制器也不会被释放
具体代码
TimerViewController
是第二个界面,实现很简单,也是初学者经常做的事情,仅仅是启动一个定时器,在TimerViewController
被释放的时候,释放定时器
class TimerViewController: BaseViewController {
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
addRepeatedTimer()
}
func addRepeatedTimer() {
let timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(fireTimer),
userInfo: nil,
repeats: true)
self.timer = timer
}
@objc func fireTimer() {
print("fire timer")
}
func cancelTimer() {
timer?.invalidate()
timer = nil
}
deinit {
cancelTimer()
print("deinit timerviewcontroller")
}
}
运行程序之后,可以看到进入该视图控制页面,定时器正常执行,返回上级页面,定时器仍然执行,而且视图控制也没有得到释放。为了解决这个问题,有两种方法
方式1:
苹果官方为了给我们解决对象引用的问题,提供了一个新的定时器方法,利用block
来解决与视图控制器的引用循环,但是只适用于iOS10
和更高版本:
func addRepeatedTimerWithClosure() {
if #available(iOS 10.0, *) {
weak var weakSelf = self
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
weakSelf?.doSomething()
}
self.timer = timer
} else {
// Fallback on earlier versions
}
}
func doSomething() {
print("fire timer")
}
方式2:
既然Apple
为我们提供了block
方式解决循环引用问题,我们也可以模仿Apple
使用block
来解决,扩展Timer
添加一个新方法来创建Timer
extension Timer {
class func sh_scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void) -> Timer {
if #available(iOS 10.0, *) {
return Timer.scheduledTimer(withTimeInterval: interval,
repeats: repeats,
block: block)
} else {
return Timer.scheduledTimer(timeInterval: interval,
target: self,
selector: #selector(timerAction(_:)),
userInfo: block,
repeats: repeats)
}
}
@objc class func timerAction(_ timer: Timer) {
guard let block = timer.userInfo as? ((Timer) -> Void) else {
return
}
block(timer)
}
}
由上可知很简单iOS10
还是使用官方API,iOS10
以前也是使用的官方API
,只不过将target
变成了Timer
自己,然后将block
作为userInfo
的参数传入,当定时器启动的时候,获取block
,并执行。
简单使用一下
func addNewMethodOfTimer() {
weak var weakSelf = self
let timer = Timer.sh_scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
weakSelf?.doSomething()
}
self.timer = timer
}
运行程序可以看到,controller
和timer
都得到了释放
当然,除了扩展Timer
,也可以创建一个新的类,实现都大同小异,通过中间类切断强引用。
final class WeakTimer {
fileprivate weak var timer: Timer?
fileprivate weak var target: AnyObject?
fileprivate let action: (Timer) -> Void
fileprivate init(timeInterval: TimeInterval,
target: AnyObject,
repeats: Bool,
action: @escaping (Timer) -> Void) {
self.target = target
self.action = action
self.timer = Timer.scheduledTimer(timeInterval: timeInterval,
target: self,
selector: #selector(fire),
userInfo: nil,
repeats: repeats)
}
class func scheduledTimer(timeInterval: TimeInterval,
target: AnyObject,
repeats: Bool,
action: @escaping (Timer) -> Void) -> Timer {
return WeakTimer(timeInterval: timeInterval,
target: target,
repeats: repeats,
action: action).timer!
}
@objc fileprivate func fire(timer: Timer) {
if target != nil {
action(timer)
} else {
timer.invalidate()
}
}
}
更多详情可以看 Weak Reference to NSTimer Target To Prevent Retain Cycle
3、定时器的精确
一般情况下使用Timer
是没什么问题,但是对于精确到要求较高可以使用CADisplayLink
(做动画)和GCD
,对于CADisplayLink
不了解,可以看CADisplayLink的介绍,对于定时器之间的比较,可以看更可靠和高精度的 iOS 定时器
定时器不准时的原因:
- 定时器计算下一个触发时间是根据初始触发时间计算的,下一次触发时间是定时器的整数倍+容差tolerance
- 定时器是添加到
runloop中
的,如果runloop
阻塞了,调用或执行方法所花费的时间长于指定的时间间隔(第1点计算得到的时间,就会推迟到下一个runloop
周期。 - 定时器是不会尝试补偿在调用或执行指定方法时可能发生的任何错过的触发
-
runloop
的模式影响
高精度的 iOS 定时器
提高调度优先级:
#include <mach/mach.h>
#include <mach/mach_time.h>
#include <pthread.h>
void move_pthread_to_realtime_scheduling_class(pthread_t pthread) {
mach_timebase_info_data_t timebase_info;
mach_timebase_info(&timebase_info);
const uint64_t NANOS_PER_MSEC = 1000000ULL;
double clock2abs = ((double)timebase_info.denom / (double)timebase_info.numer) * NANOS_PER_MSEC;
thread_time_constraint_policy_data_t policy;
policy.period = 0;
policy.computation = (uint32_t)(5 * clock2abs); // 5 ms of work
policy.constraint = (uint32_t)(10 * clock2abs);
policy.preemptible = FALSE;
int kr = thread_policy_set(pthread_mach_thread_np(pthread_self()),
THREAD_TIME_CONSTRAINT_POLICY,
(thread_policy_t)&policy,
THREAD_TIME_CONSTRAINT_POLICY_COUNT);
if (kr != KERN_SUCCESS) {
mach_error("thread_policy_set:", kr);
exit(1);
}
}
精确延时:
#include <mach/mach.h>
#include <mach/mach_time.h>
static const uint64_t NANOS_PER_USEC = 1000ULL;
static const uint64_t NANOS_PER_MILLISEC = 1000ULL * NANOS_PER_USEC;
static const uint64_t NANOS_PER_SEC = 1000ULL * NANOS_PER_MILLISEC;
static mach_timebase_info_data_t timebase_info;
static uint64_t abs_to_nanos(uint64_t abs) {
return abs * timebase_info.numer / timebase_info.denom;
}
static uint64_t nanos_to_abs(uint64_t nanos) {
return nanos * timebase_info.denom / timebase_info.numer;
}
void example_mach_wait_until(int argc, const char * argv[]) {
mach_timebase_info(&timebase_info);
uint64_t time_to_wait = nanos_to_abs(10ULL * NANOS_PER_SEC);
uint64_t now = mach_absolute_time();
mach_wait_until(now + time_to_wait);
}
High Precision Timers in iOS / OS X
利用GCD实现一个好的定时器
而众所周知的是,NSTimer
有不少需要注意的地方。
-
循环引用问题
NSTimer会强引用target,同时RunLoop会强引用未invalidate的NSTimer实例。 容易导致内存泄露。
(关于NSTimer引起的内存泄露可阅读iOS夯实:ARC时代的内存管理 NSTimer一节) -
RunLoop问题
因为NSTimer依赖于RunLoop机制进行工作,因此需要注意RunLoop相关的问题。NSTimer默认运行于RunLoop的default mode中。
而ScrollView在用户滑动时,主线程RunLoop会转到UITrackingRunLoopMode
。而这个时候,Timer就不会运行,方法得不到fire。如果想要在ScrollView滚动的时候Timer不失效,需要注意将Timer设置运行于NSRunLoopCommonModes
。 -
线程问题
NSTimer无法在子线程中使用。如果我们想要在子线程中执行定时任务,必须激活和自己管理子线程的RunLoop。否则NSTimer是失效的。
-
不支持动态修改时间间隔
NSTimer无法动态修改时间间隔,如果我们想要增加或减少NSTimer的时间间隔。只能invalidate之前的NSTimer,再重新生成一个NSTimer设定新的时间间隔。
-
不支持闭包。
NSTimer只支持调用
selector
,不支持更现代的闭包语法。
利用DispatchSource
来解决上述问题,基于DispatchSource
构建Timer
class SwiftTimer {
private let internalTimer: DispatchSourceTimer
init(interval: DispatchTimeInterval, repeats: Bool = false, queue: DispatchQueue = .main , handler: () -> Void) {
internalTimer = DispatchSource.makeTimerSource(queue: queue)
internalTimer.setEventHandler(handler: handler)
if repeats {
internalTimer.scheduleRepeating(deadline: .now() + interval, interval: interval)
} else {
internalTimer.scheduleOneshot(deadline: .now() + interval)
}
}
deinit() {
//事实上,不需要手动cancel. DispatchSourceTimer在销毁时也会自动cancel。
internalTimer.cancel()
}
func rescheduleRepeating(interval: DispatchTimeInterval) {
internalTimer.scheduleRepeating(deadline: .now() + interval, interval: interval)
}
}
4、后台定时器继续运行
苹果上面的App
一般都是不允许在后台运行的,比如说:定时器计时,当用户切换到后台,定时器就被被挂起,等回到App
之后,才会Resume
。
但是任何的app
都能够使用 UIApplication background tasks
在后台运行一小段时间,除此之外没有其他的办法。
在后台运行定时器需要注意:
- You need to opt into background execution with beginBackgroundTaskWithExpirationHandler.
- Either create the Timer on the main thread, OR you will need to add it to the mainRunLoop manually with
RunLoop.current.add(timer, forMode: .default)
实现如下
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var backgroundUpdateTask: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier(rawValue: 0)
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationDidEnterBackground(_ application: UIApplication) {
let application = UIApplication.shared
self.backgroundUpdateTask = application.beginBackgroundTask {
self.endBackgroundUpdateTask()
}
DispatchQueue.global().async {
let timer = Timer.scheduledTimer(timeInterval: 2, target: self, selector: #selector(self.methodRunAfterBackground), userInfo: nil, repeats: true)
RunLoop.current.add(timer, forMode: .default)
RunLoop.current.run()
}
}
@objc func methodRunAfterBackground() {
print("methodRunAfterBackground")
}
func endBackgroundUpdateTask() {
UIApplication.shared.endBackgroundTask(self.backgroundUpdateTask)
self.backgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
}
func applicationWillEnterForeground(_ application: UIApplication) {
self.endBackgroundUpdateTask()
}
}
注意:
- Apps only get ~ 10 mins (~3 mins as of iOS 7) of background execution - after this the timer will stop firing.
- As of iOS 7 when the device is locked it will suspend the foreground app almost instantly. The timer will not fire after an iOS 7 app is locked.
内容参考Scheduled NSTimer when app is in background,如果想了解后台任务Background Modes Tutorial: Getting Started