咱們在開發應用的過程當中,每每在不少地方須要倒計時,好比說輪播圖,驗證碼,活動倒計時等等。而在實現這些功能的時候,咱們每每會遇到不少坑須要咱們當心的規避掉。 由於文章內容的關係,要求你們都有一些runloop的基礎知識,固然若是沒有,也沒什麼特別大的問題。這裏推薦一下 ibireme的這篇文章。html
話很少說,直接上正題:ios
在開發過程當中,咱們基本上只用了這幾種方式來實現倒計時git
1.PerformSelecter 2.NSTimer 3.CADisplayLink 4.GCDgithub
咱們使用下面的代碼能夠實現指定延遲以後執行:macos
- (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay;
複製代碼
它的方法描述以下編程
Invokes a method of the receiver on the current thread using the default mode after a delay. This method sets up a timer to perform the aSelector message on the current thread’s run loop. The timer is configured to run in the default mode (NSDefaultRunLoopMode). When the timer fires, the thread attempts to dequeue the message from the run loop and perform the selector. It succeeds if the run loop is running and in the default mode; otherwise, the timer waits until the run loop is in the default mode. If you want the message to be dequeued when the run loop is in a mode other than the default mode, use the performSelector:withObject:afterDelay:inModes: method instead. If you are not sure whether the current thread is the main thread, you can use the performSelectorOnMainThread:withObject:waitUntilDone: or performSelectorOnMainThread:withObject:waitUntilDone:modes:method to guarantee that your selector executes on the main thread. To cancel a queued message, use the cancelPreviousPerformRequestsWithTarget: or cancelPreviousPerformRequestsWithTarget:selector:object:method.api
這個方法在Foundation框架下的NSRunLoop.h文件下。當咱們調用NSObject 這個方法的時候,在runloop的內部是會建立一個Timer並添加到當前線程的 RunLoop 中。因此若是當前線程沒有 RunLoop,則這個方法會失效。並且還有幾個很大的缺陷:數組
- 這個方法必須在NSDefaultRunLoopMode下才能運行
- 由於它基於RunLoop實現,因此可能會形成精確度上的問題。 這個問題在其餘兩個方法上也會出現,因此咱們下面細說
- 內存管理上很是容易出問題。 當咱們執行 [self performSelector: afterDelay:]的時候,系統會將self的引用計數加1,執行完這個方法時,還會將self的引用計數減1,當方法尚未執行的時候,要返回父視圖釋放當前視圖的時候,self的計數沒有減小到0,而致使沒法調用dealloc方法,出現了內存泄露。
由於它有如此之多的缺陷,因此咱們不該該使用它,或者說,不該該在倒計時這方法使用它。xcode
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
複製代碼
方法描述以下安全
A timer that fires after a certain time interval has elapsed, sending a specified message to a target object. Timers work in conjunction with run loops. Run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop. To use a timer effectively, you should be aware of how run loops operate. See Threading Programming Guide for more information. A timer is not a real-time mechanism. If a timer’s firing time occurs during a long run loop callout or while the run loop is in a mode that isn't monitoring the timer, the timer doesn't fire until the next time the run loop checks the timer. Therefore, the actual time at which a timer fires can be significantly later. See also Timer Tolerance. NSTimer is toll-free bridged with its Core Foundation counterpart, CFRunLoopTimerRef. See Toll-Free Bridging for more information.
這個方法在Foundation框架下的NSTimer.h文件下。一個NSTimer的對象只能註冊在一個RunLoop當中,可是能夠添加到多個RunLoop Mode當中。 NSTimer 其實就是 CFRunLoopTimerRef,他們之間是 Toll-Free Bridging 的。它的底層是由XNU 內核的 mk_timer來驅動的。一個 NSTimer 註冊到 RunLoop 後,RunLoop 會爲其重複的時間點註冊好事件。例如 10:00, 10:10, 10:20 這幾個時間點。RunLoop爲了節省資源,並不會在很是準確的時間點回調這個Timer。Timer 有個屬性叫作Tolerance (寬容度),標示了當時間點到後,允許有多少最大偏差。 在文件中,系統提供了一共8個方法,其中三個方法是直接將timer添加到了當前runloop 的DefaultMode,而不須要咱們本身操做,固然這樣的代價是runloop只能是當前runloop,模式是DefaultMode:
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
複製代碼
其餘五個方法,是不會自動添加到RunLoop的,還須要調用addTimer:forMode:
:
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo repeats:(BOOL)yesOrNo;
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(id)ui repeats:(BOOL)rep;
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
複製代碼
假如咱們開啓了NSTimer,可是卻沒有運行,咱們能夠檢查RunLoop是否運行,以及運行的Mode是否正確
NSTimer和PerformSelecter有不少相似的地方,好比說二者的建立和撤銷都必需要在同一個線程上,內存管理上都有泄露的風險,精度上都有問題。下面讓咱們講一下後兩個問題。
當咱們使用了NSTimer的時候,RunLoop會強持有一個NSTimer,而NSTimer內部持有一個self的target,而控制器又持有NSTimer對象,這樣就形成了一個循環引用。雖然系統提供了一個invalidate方法來把NSTimer從RunLoop中釋放掉並取消強引用,可是每每找不到應有的位置來放置。 咱們解決這個問題的思路很簡單,初始化NSTimer時把觸發事件的target替換成一個單獨的對象,而後這個對象中NSTimer的SEL方法觸發時讓這個方法在當前的視圖self中實現。 利用RunTime在target對象中動態的建立SEL方法,而後target對象關聯當前的視圖self,當target對象執行SEL方法時,取出關聯對象self,而後讓self執行該方法。 實現代碼以下:
.h
#import <Foundation/Foundation.h>
@interface NSTimer (Brex)
/**
* 建立一個不會形成循環引用的循環執行的Timer
*/
+ (instancetype)brexScheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo;
@end
.m
#import "NSTimer+Brex.h"
@interface BrexTimerTarget : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer *timer;
@end
@implementation PltTimerTarget
- (void)brexTimerTargetAction:(NSTimer *)timer
{
if (self.target) {
[self.target performSelector:self.selector withObject:timer afterDelay:0.0];
} else {
[self.timer invalidate];
self.timer = nil;
}
}
@end
@implementation NSTimer (Brex)
+ (instancetype)brexScheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo
{
BrexTimerTarget *timerTarget = [[BrexTimerTarget alloc] init];
timerTarget.target = aTarget;
timerTarget.selector = aSelector;
NSTimer *timer = [NSTimer timerWithTimeInterval:ti target:timerTarget selector:@selector(brexTimerTargetAction:) userInfo:userInfo repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
timerTarget.timer = timer;
return timerTarget.timer;
}
@end
複製代碼
固然,真正在使用的時候,仍是須要經過測試再來驗證。
上面咱們也提到了,其實NSTimer並非很是準確的。 NSTimer其實算不上一個真正的時間機制。它只有在被加入到RunLoop的時候才能觸發。 假如在一個RunLoop下沒能檢測到定時器,那麼它會在下一個RunLoop中檢查,並不會延後執行。換個說法,咱們能夠理解爲:「這趟火車沒遇上,等下一班吧」。 另外,有時候RunLoop正在處理一個很費事的操做,好比說遍歷一個很是很是大的數組,那麼也可能會「忘記」查看定時器了。這麼咱們能夠理解爲「火車晚點了」。 固然,這兩種狀況表現起來其實都是NSTimer不許確。 因此,真正的定時器觸發時間不是本身設定的那個時間,而是可能加入了一個RunLoop的觸發時間。而且,NSRunLoop算不上真正的線程安全,假如NSTimer沒有在一個線程中操做,那麼可能會觸發不可意料的後果。
Warning The NSRunLoop class is generally not considered to be thread-safe and its methods should only be called within the context of the current thread. You should never try to call the methods of an NSRunLoop object running in a different thread, as doing so might cause unexpected results. NSRunLoop類一般不被認爲是線程安全的,它的方法應該只在當前線程中調用。您不該嘗試調用在不一樣線程中運行的NSRunLoop對象的方法,由於這樣作可能會致使意外的結果。
建立方法
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
[self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
中止方法
[self.displayLink invalidate];
self.displayLink = nil;
複製代碼
CADisplayLink是一個能讓咱們以和屏幕刷新率同步的頻率將特定的內容畫到屏幕上的定時器類。它和NSTimer在實現上有些相似。不過區別在於每當屏幕顯示內容刷新結束的時候,runloop就會向CADisplayLink指定的target發送一次指定的selector消息, 而NSTimer以指定的模式註冊到runloop後,每當設定的週期時間到達後,runloop會向指定的target發送一次指定的selector消息。 固然,和NSTimer相似,CADisplayLink也會由於一樣的緣由出現精問題,不過單就精度而言,CADisplayLink會更高一點。這裏的表現就就是畫面掉幀了。 咱們一般狀況下,會把它使用在界面的不停重繪,好比視頻播放的時候須要不停地獲取下一幀用於界面渲染,還有動畫的繪製等地方。
終於,咱們講到重點了:GCD倒計時
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
dispatch_source_set_timer(timer, dispatch_walltime(NULL, 0), 10*NSEC_PER_SEC, 1*NSEC_PER_SEC); //每10秒觸發timer,偏差1秒
dispatch_source_set_event_handler(timer, ^{
// 定時器觸發時執行的 block
});
dispatch_resume(timer);
複製代碼
瞭解GCD倒計時的原理,須要咱們最好閱讀一下libdispatch源碼。固然,若是你不想閱讀,直接往下看也能夠。 dispatch_source_create
這個API爲一個dispatch_source_t
類型的結構體ds作了分配內存和初始化操做,而後將其返回。
下面從底層源碼的角度來研究這幾行代碼的做用。首先是 dispatch_source_create
函數,它和以前見到的 create 函數都差很少,對 dispatchsourcet 對象作了一些初始化工做:
dispatch_source_t ds = NULL;
ds = _dispatch_alloc(DISPATCH_VTABLE(source), sizeof(struct dispatch_source_s));
_dispatch_queue_init((dispatch_queue_t)ds);
ds->do_suspend_cnt = DISPATCH_OBJECT_SUSPEND_INTERVAL;
ds->do_targetq = &_dispatch_mgr_q;
dispatch_set_target_queue(ds, q);
return ds;
複製代碼
這裏涉及到兩個隊列,其中 q 是用戶指定的隊列,表示事件觸發的回調在哪一個隊列執行。而 _dispatch_mgr_q 則表示由哪一個隊列來管理這個 source,mgr 是 manager 的縮寫.
其次是 dispatch_source_set_timer,
void
dispatch_source_set_timer(dispatch_source_t ds,
dispatch_time_t start,
uint64_t interval,
uint64_t leeway)
{
......
struct dispatch_set_timer_params *params;
......
dispatch_barrier_async_f((dispatch_queue_t)ds, params,
_dispatch_source_set_timer2);
}
複製代碼
這段代碼中,首先會對參數進行一個過濾和從新設置,而後建立一個dispatch_set_timer_params
的指針:
//這個 params 負責綁定定時器對象與他的參數
struct dispatch_set_timer_params {
dispatch_source_t ds;
uintptr_t ident;
struct dispatch_timer_source_s values;
};
複製代碼
最後調用
dispatch_barrier_async_f((dispatch_queue_t)ds, params, _dispatch_source_set_timer2);
複製代碼
隨後調用_dispatch_source_set_timer2
方法:
static void _dispatch_source_set_timer2(void *context) {
// Called on the source queue
struct dispatch_set_timer_params *params = context;
dispatch_suspend(params->ds);
dispatch_barrier_async_f(&_dispatch_mgr_q, params,
_dispatch_source_set_timer3);
}
複製代碼
而後接着調用_dispatch_source_set_timer3
方法:
static void _dispatch_source_set_timer3(void *context)
{
// Called on the _dispatch_mgr_q
struct dispatch_set_timer_params *params = context;
......
_dispatch_timer_list_update(ds);
......
}
複製代碼
_dispatch_timer_list_update
函數的做用是根據下一次觸發時間將 timer 排序。
接下來,當初分發到 manager 隊列的 block 將要被執行,走到 _dispatch_mgr_invoke
函數,其中有以下代碼:
r = select(FD_SETSIZE, &tmp_rfds, &tmp_wfds, NULL, sel_timeoutp);
複製代碼
可見,GCD定時器的底層是由XNU內核中的select方法實現的。熟悉socket編程的朋友可能對這個方法很熟悉。這個方法能夠用來處理阻塞,粘包等問題。
由於方法來自於最底層,GCD倒計時算得上最精確的。
那麼有沒有可能出現不精確的問題呢?
答案是也有可能!
這裏咱們看一張圖
假如你對時間的精確的沒有特別高的要求,好比說輪播圖什麼的,能夠選擇使用NSTimer;建立動畫什麼的,可使用CADisplayLink;想要追求高精度,可使用GCD倒計時;至於PerformSelecter,仍是算了吧。
我當初曾經將一個輪播圖做爲一個tableview的headerView。測試的時候發現一個你們可能都會遇到的問題,滑動tableview的時候輪播圖不滑了。這個問題很好解決,
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
複製代碼
更換RunLoop的mode,就能夠了。
這個我是受到了FPSLabel的啓發,在它的基礎上擴展了一下,只作了一個在頁面最上層滑動的View。它主要是用來在debug模式下進行測試,上面展現了頁面自己的FPS,App版本號,iOS版本號,手機型號等等數據。咱們通常狀況下認爲,FPS在55-60之間,算的上流暢,低於55就要找問題,解決問題了。固然,這個view自己添加的自己也會影響到當前頁面的FPS。「觀察者效應」嘛。
當初曾經接觸到一個需求,要在一個tableview上實現多個帶倒計時cell。最開始的時候我是使用NSTimer一個一個來實現的,可是後來發現,當cell多起來的時候,頁面會變得很是卡頓。爲了解決這個,我本身想出了一個辦法:我實現了一個倒計時的單例,每過1秒就會發出一個對應頁面的block(當時有好幾個頁面須要),以及一個總的通知,裏面只包含一個當前的時間戳,而且公開開啓倒計時以及關閉倒計時的方法。這樣,一個頁面就能夠只使用一個倒計時來實現了。每一個cell只須要持有一個倒計時的終點時間就能夠了。
我就是在當時開始研究倒計時的問題,甚至本身用select函數實現了一個倒計時單例。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[NSThread currentThread] setName:@"custom Timer"];
......
fd_set read_fd_set;
FD_ZERO(&read_fd_set);
FD_SET(self.fdCustomTimerModifyWaitTimeReadPipe, &read_fd_set);
struct timeval tv;
tv.tv_sec = self.customTimerWaitTimeInterval;
tv.tv_usec = 0;
......
long ret = select(self.fdCustomTimerModifyWaitTimeReadPipe + 1, &read_fd_set, NULL, NULL, &tv);//核心
self.customTimerSelectTime = [[NSDate date] timeIntervalSince1970];
......
if(ret == 0){
NSLog(@"select 超時!\n");
NSLog(@"self.customTimerWaitTimeInterval:%lld", self.customTimerWaitTimeInterval);
if(self.customTimerNeedNotification)
{
dispatch_sync(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:customTimerIntervalNotification object:nil];
});
}
if(self.auctionHouseDetailViewControllerTimerCallBack)
{
dispatch_sync(dispatch_get_main_queue(), ^{
self.auctionHouseDetailViewControllerTimerCallBack();
});
}
}
複製代碼
後來思考了一下,爲了項目的穩定,仍是返回去用GCD來從新實現了。
後來測試的時候又發現了一個可能出現的問題,用戶手機的時間可能不是準確的,或者通過的修改,跟服務器時間有很大的差距。這樣就出現了一個很好笑的情況:8點開始的活動,由於手機自己時間的不許確,原本應該還有一個小時的時間,可是顯示出來就只有40分鐘了。這就很尷尬了。
爲了解決這個問題,咱們將方法修改了一下:
在進入頁面的時候,咱們要返回一個服務器時間,同時獲取一個本地時間,計算出二者的差值,在計算倒計時的時候,把這個差值計算進去,以便保持時間的相對準確。同時,假如用戶在本頁面進入了後臺模式又返回到前臺模式,咱們經過一個接口接收當前的服務器時間,在進行以前的計算,假如兩次的獲得的時間差大體相等,咱們就不作處理;假如發現時間差發生了很大的變化(主要是爲了防止用戶修改系統時間),就強制刷新頁面。
我閱讀MrPeak的這篇文章,學習了另一個辦法:
首先仍是會依賴於接口和服務器時間作同步,每次同步記錄一個serverTime(Unix time),同時記錄當前客戶端的時間值lastSyncLocalTime,到以後算本地時間的時候先取curLocalTime,算出偏移量,再加上serverTime就得出時間了:
uint64_t realLocalTime = 0;
if (serverTime != 0 && lastSyncLocalTime != 0) {
realLocalTime = serverTime + (curLocalTime - lastSyncLocalTime);
}
else {
realLocalTime = [[NSDate date] timeIntervalSince1970]*1000;
}
複製代碼
若是歷來沒和服務器時間同步過,就只能取本地的系統時間了,這種狀況幾乎也沒什麼影響,說明客戶端還沒開始用過。
關鍵在於若是獲取本地的時間,能夠用一個小技巧來獲取系統當前運行了多長時間,用系統的運行時間來記錄當前客戶端的時間:
//get system uptime since last boot
- (NSTimeInterval)uptime
{
struct timeval boottime;
int mib[2] = {CTL_KERN, KERN_BOOTTIME};
size_t size = sizeof(boottime);
struct timeval now;
struct timezone tz;
gettimeofday(&now, &tz);
double uptime = -1;
if (sysctl(mib, 2, &boottime, &size, NULL, 0) != -1 && boottime.tv_sec != 0)
{
uptime = now.tv_sec - boottime.tv_sec;
uptime += (double)(now.tv_usec - boottime.tv_usec) / 1000000.0;
}
return uptime;
}
複製代碼
gettimeofday和sysctl都會受系統時間影響,但他們兩者作一個減法所得的值,就和系統時間無關了。這樣就能夠避免用戶修改時間了。固然用戶若是關機,過段時間再開機,會致使咱們獲取到的時間慢與服務器時間,真實場景中,慢於服務器時間每每影響較小,咱們通常擔憂的是客戶端時間快於服務器時間。
這種方法原理上和個人差很少,可是請求次數會比個人少一些,可是缺點上文也說了:有可能會致使咱們獲取到的時間慢與服務器時間。
用戶在發送完驗證碼,而後誤觸退出頁面再從新進入,不少app都是會從新刷新發送驗證碼的按鈕,固然,出於保護機制,每每第二個驗證碼不會很快的發送過來。由於以前已經實現了一個倒計時的單例,我把這個頁面的倒計時的終點時間,設置爲倒計時的一個單例屬性,在進入下一步。在從新進入這個頁面的時候,進行上一條中作出的操做,進行判斷。
深刻理解RunLoop
從NSTimer的失效性談起(二):關於GCD Timer和libdispatch
iOS關於時間的處理