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會強(qiáng)引用Timer,所以當(dāng)定時器被添加到Runloop之后,我們并沒有必須強(qiáng)引用定時器(Timer)
理解Run Loop概念
談到定時器,首先需要了解的一個概念是 RunLoop。一般來講,一個線程一次只能執(zhí)行一個任務(wù),執(zhí)行完成后線程就會退出。如果我們需要一個機(jī)制,讓線程能隨時處理事件但并不退出,通常的代碼邏輯是這樣的:
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。更多詳細(xì)的內(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)會默認(rèn)賦值0.1毫秒 -
target:目標(biāo)對象,一般是self,但是注意timer會強(qiáng)引用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)存泄露,因為定時器強(qiáng)引用了self,而本身又被runloop強(qiáng)引用。所以timer和self都得不到釋放,所以定時器一直存在并觸發(fā)事件,這樣就會導(dǎo)致內(nèi)存泄露。
為了避免內(nèi)存泄露,所以需要在不使用定時器的時候,手動執(zhí)行timer.invalidate()方法。而block方式雖然并不會存在循環(huán)引用情況,但是由于本身被runloop強(qiáng)引用,所以也需要執(zhí)行timer.invalidate()方法,否則定時器還是會一直存在。
invalidate方法有2個功能:一是將timer從runloop中移除,二是timer本身也會釋放它持有的資源
因此經(jīng)常會對timer進(jìn)行引用。
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。例如,如果你要timer1秒后運行,并有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
默認(rèn)的時間寬容度是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并在默認(rèn)模型(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、在子線程中進(jìn)行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) 進(jìn)行了強(qiáng)引用,定時器被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")
}
}
運行程序之后,可以看到進(jìn)入該視圖控制頁面,定時器正常執(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來解決,擴(kuò)展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)然,除了擴(kuò)展Timer,也可以創(chuàng)建一個新的類,實現(xiàn)都大同小異,通過中間類切斷強(qiáng)引用。
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周期。 - 定時器是不會嘗試補(bǔ)償在調(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會強(qiáng)引用target,同時RunLoop會強(qiáng)引用未invalidate的NSTimer實例。 容易導(dǎo)致內(nèi)存泄露。
(關(guān)于NSTimer引起的內(nèi)存泄露可閱讀iOS夯實:ARC時代的內(nèi)存管理 NSTimer一節(jié)) -
RunLoop問題
因為NSTimer依賴于RunLoop機(jī)制進(jìn)行工作,因此需要注意RunLoop相關(guān)的問題。NSTimer默認(rèn)運行于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