Timer
A timer that fires after a certain time interval has elapsed, sending a specified message to a target object.
簡單來說就是在指定時間過去,定時器會被啟動并發(fā)送消息給目標(biāo)對象去執(zhí)行對應(yīng)的事件
定時器(Timer
)的功能是與Runloop
相關(guān)聯(lián)的,Runloop
會強引用Timer
,所以當(dāng)定時器被添加到Runloop
之后,我們并沒有必須強引用定時器(Timer
)
理解Run Loop概念
談到定時器,首先需要了解的一個概念是 RunLoop。一般來講,一個線程一次只能執(zhí)行一個任務(wù),執(zhí)行完成后線程就會退出。如果我們需要一個機制,讓線程能隨時處理事件但并不退出,通常的代碼邏輯是這樣的:
function loop() {
initialize();
do {
var message = get_next_message();
process_message(message);
} while (message != quit);
}
這種模型通常被稱作 Event Loop。 Event Loop 在很多系統(tǒng)和框架里都有實現(xiàn),比如Windows 程序的消息循環(huán),再比如 OSX/iOS 里的 RunLoop。實現(xiàn)這種模型的關(guān)鍵點在于:如何管理事件/消息,如何讓線程在沒有處理消息時休眠以避免資源占用、在有消息到來時立刻被喚醒。
所以,RunLoop實際上就是一個對象,這個對象管理了其需要處理的事件和消息,并提供了一個入口函數(shù)來執(zhí)行上面 Event Loop 的邏輯。線程執(zhí)行了這個函數(shù)后,就會一直處于這個函數(shù)內(nèi)部 “接受消息->等待->處理” 的循環(huán)中,直到這個循環(huán)結(jié)束(比如傳入 quit 的消息),函數(shù)返回。
OSX/iOS 系統(tǒng)中,提供了兩個這樣的對象:RunLoop 和 CFRunLoopRef。
更多詳細的內(nèi)容可以看深入理解RunLoop,也可以參考官方文檔Threading Programming Guide
重復(fù)和非重復(fù)定時器
- 重復(fù)定時
常用的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")
}
參數(shù)介紹
-
timeInterval
:延時時間,單位為秒,可以是小數(shù)。如果值小于等于0.0的話,系統(tǒng)會默認賦值0.1毫秒 -
target
:目標(biāo)對象,一般是self
,但是注意timer
會強引用target
,直到調(diào)用invalidate
方法 -
selector
: 執(zhí)行方法 -
userInfo
: 傳入信息 -
repeats
:是否重復(fù)執(zhí)行
使用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
}
}
上面兩種方式可以實現(xiàn)重復(fù)定時觸發(fā)事件,但是target-action
方式會存在一個問題?那就是對象之間的引用問題導(dǎo)致內(nèi)存泄露,因為定時器強引用了self
,而本身又被runloop
強引用。所以timer
和self
都得不到釋放,所以定時器一直存在并觸發(fā)事件,這樣就會導(dǎo)致內(nèi)存泄露。
為了避免內(nèi)存泄露,所以需要在不使用定時器的時候,手動執(zhí)行timer.invalidate()
方法。而block
方式雖然并不會存在循環(huán)引用情況,但是由于本身被runloop
強引用,所以也需要執(zhí)行timer.invalidate()
方法,否則定時器還是會一直存在。
invalidate
方法有2個功能:一是將timer
從runloop
中移除,二是timer
本身也會釋放它持有的資源
因此經(jīng)常會對timer
進行引用。
self.timer = timer
失效定時器
timer.invalidate()
timer = nil
具體的循環(huán)引用例子,后面會有
- 非重復(fù)定時
非重復(fù)定時器只會執(zhí)行一次,執(zhí)行結(jié)束會自動調(diào)用invalidates
方法,這樣能夠防止定時器再次啟動。實現(xiàn)很簡單將repeats
設(shè)置為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
允許我們?yōu)?code>Timer指定Tolerance
,這樣會給你的timer添加一些時間寬容度可以降低它的電力消耗以及增加響應(yīng)。好比如:“我希望1秒鐘運行一次,但是晚個200毫秒我也不介意”。
當(dāng)你指定了時間寬容度,就意味著系統(tǒng)可以在原有時間附加該寬容度內(nèi)的任意時刻觸發(fā)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,如果一個重復(fù)性timer由于設(shè)定的時間寬容度推遲了一小會執(zhí)行,這并不意味著后續(xù)的執(zhí)行都會晚一會。iOS不允許timer
總體上的漂移,也就是說下一次觸發(fā)會快一些。
舉例的話,如果一個timer
每1秒運行一次,并有0.5秒的時間寬容度,那么實際可能是這樣:
- 1.0秒后
timer
觸發(fā) - 2.4秒后
timer
再次觸發(fā),晚了0.4秒,但是在時間寬容度內(nèi) - 3.1秒后
timer
第三次觸發(fā),和上一次僅差0.7秒,但每次觸發(fā)的時間是按原始時間算的。
等等…
使用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協(xié)同工作
當(dāng)使用下列方法創(chuàng)建timer
,需要手動添加timer
到Run Loop
并指定運行模型,上面使用的方法都是自動添加到當(dāng)前的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)
比如創(chuàng)建timer
添加到當(dāng)前的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開發(fā)中經(jīng)常遇到的場景,tableView
上有定時器,當(dāng)用戶用手指觸摸屏幕,定時器會停止執(zhí)行,滾動停止才會恢復(fù)定時。但是這并不是我們所想要的?為什么會出現(xiàn)呢?
主線程的RunLoop
里有兩個預(yù)置的 Mode
:kCFRunLoopDefaultMode
和 UITrackingRunLoopMode
。
這兩個Mode
都已經(jīng)被標(biāo)記為”Common”
屬性。DefaultMode
是 App
平時所處的狀態(tài),TrackingRunLoopMode
是追蹤 ScrollView
滑動時的狀態(tài)。當(dāng)你創(chuàng)建一個 Timer
并加到 DefaultMode
時,Timer
會得到重復(fù)回調(diào),但此時滑動一個TableView
時,RunLoop
會將 mode
切換為 TrackingRunLoopMode
,這時 Timer
就不會被回調(diào),并且也不會影響到滑動操作。
所以當(dāng)你需要一個Timer
,在兩個 Mode
中都能得到回調(diào),有如下方法
- 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簡單實現(xiàn)倒計時功能
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、定時器的循環(huán)引用
常見的場景:
有兩個控制器ViewControllerA
和ViewControllerB
,ViewControllerA
跳轉(zhuǎn)到ViewControllerB
中,ViewControllerB
開啟定時器,但是當(dāng)返回ViewControllerA
界面時,定時器依然還在走,控制器也并沒有執(zhí)行deinit
方法銷毀掉
為何會出現(xiàn)循環(huán)引用的情況呢?原因是:定時器對控制器 (self
) 進行了強引用,定時器被runloop
引用,定時器得不到釋放,所以控制器也不會被釋放
具體代碼
TimerViewController
是第二個界面,實現(xiàn)很簡單,也是初學(xué)者經(jīng)常做的事情,僅僅是啟動一個定時器,在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")
}
}
運行程序之后,可以看到進入該視圖控制頁面,定時器正常執(zhí)行,返回上級頁面,定時器仍然執(zhí)行,而且視圖控制也沒有得到釋放。為了解決這個問題,有兩種方法
方式1:
蘋果官方為了給我們解決對象引用的問題,提供了一個新的定時器方法,利用block
來解決與視圖控制器的引用循環(huán),但是只適用于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
方式解決循環(huán)引用問題,我們也可以模仿Apple
使用block
來解決,擴展Timer
添加一個新方法來創(chuàng)建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
的參數(shù)傳入,當(dāng)定時器啟動的時候,獲取block
,并執(zhí)行。
簡單使用一下
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
都得到了釋放
當(dāng)然,除了擴展Timer
,也可以創(chuàng)建一個新的類,實現(xiàn)都大同小異,通過中間類切斷強引用。
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 定時器
定時器不準(zhǔn)時的原因:
- 定時器計算下一個觸發(fā)時間是根據(jù)初始觸發(fā)時間計算的,下一次觸發(fā)時間是定時器的整數(shù)倍+容差tolerance
- 定時器是添加到
runloop中
的,如果runloop
阻塞了,調(diào)用或執(zhí)行方法所花費的時間長于指定的時間間隔(第1點計算得到的時間,就會推遲到下一個runloop
周期。 - 定時器是不會嘗試補償在調(diào)用或執(zhí)行指定方法時可能發(fā)生的任何錯過的觸發(fā)
-
runloop
的模式影響
高精度的 iOS 定時器
提高調(diào)度優(yōu)先級:
#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實現(xiàn)一個好的定時器
而眾所周知的是,NSTimer
有不少需要注意的地方。
-
循環(huán)引用問題
NSTimer會強引用target,同時RunLoop會強引用未invalidate的NSTimer實例。 容易導(dǎo)致內(nèi)存泄露。
(關(guān)于NSTimer引起的內(nèi)存泄露可閱讀iOS夯實:ARC時代的內(nèi)存管理 NSTimer一節(jié)) -
RunLoop問題
因為NSTimer依賴于RunLoop機制進行工作,因此需要注意RunLoop相關(guān)的問題。NSTimer默認運行于RunLoop的default mode中。
而ScrollView在用戶滑動時,主線程RunLoop會轉(zhuǎn)到UITrackingRunLoopMode
。而這個時候,Timer就不會運行,方法得不到fire。如果想要在ScrollView滾動的時候Timer不失效,需要注意將Timer設(shè)置運行于NSRunLoopCommonModes
。 -
線程問題
NSTimer無法在子線程中使用。如果我們想要在子線程中執(zhí)行定時任務(wù),必須激活和自己管理子線程的RunLoop。否則NSTimer是失效的。
-
不支持動態(tài)修改時間間隔
NSTimer無法動態(tài)修改時間間隔,如果我們想要增加或減少NSTimer的時間間隔。只能invalidate之前的NSTimer,再重新生成一個NSTimer設(shè)定新的時間間隔。
-
不支持閉包。
NSTimer只支持調(diào)用
selector
,不支持更現(xiàn)代的閉包語法。
利用DispatchSource
來解決上述問題,基于DispatchSource
構(gòu)建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、后臺定時器繼續(xù)運行
蘋果上面的App
一般都是不允許在后臺運行的,比如說:定時器計時,當(dāng)用戶切換到后臺,定時器就被被掛起,等回到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)
實現(xiàn)如下
@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.
內(nèi)容參考Scheduled NSTimer when app is in background,如果想了解后臺任務(wù)Background Modes Tutorial: Getting Started