iOS倒數計時的探究與選擇

Balaeniceps_rex發表於2018-06-08

我們在開發應用的過程中,往往在很多地方需要倒數計時,比如說輪播圖,驗證碼,活動倒數計時等等。而在實現這些功能的時候,我們往往會遇到很多坑需要我們小心的規避掉。 因為文章內容的關係,要求大家都有一些runloop的基礎知識,當然如果沒有,也沒什麼特別大的問題。這裡推薦一下 ibireme的這篇文章。

話不多說,直接上正題:

倒數計時的種類

在開發過程中,我們基本上只用了這幾種方式來實現倒數計時

1.PerformSelecter 2.NSTimer 3.CADisplayLink 4.GCD

PerformSelecter

我們使用下面的程式碼可以實現指定延遲之後執行:

- (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.

這個方法在Foundation框架下的NSRunLoop.h檔案下。當我們呼叫NSObject 這個方法的時候,在runloop的內部是會建立一個Timer並新增到當前執行緒的 RunLoop 中。所以如果當前執行緒沒有 RunLoop,則這個方法會失效。而且還有幾個很大的缺陷:

  • 這個方法必須在NSDefaultRunLoopMode下才能執行
  • 因為它基於RunLoop實現,所以可能會造成精確度上的問題。 這個問題在其他兩個方法上也會出現,所以我們下面細說
  • 記憶體管理上非常容易出問題。 當我們執行 [self performSelector: afterDelay:]的時候,系統會將self的引用計數加1,執行完這個方法時,還會將self的引用計數減1,當方法還沒有執行的時候,要返回父檢視釋放當前檢視的時候,self的計數沒有減少到0,而導致無法呼叫dealloc方法,出現了記憶體洩露。

因為它有如此之多的缺陷,所以我們不應該使用它,或者說,不應該在倒數計時這方法使用它。

NSTimer

+ (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物件的方法,因為這樣做可能會導致意外的結果。

CADisplayLink

建立方法
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

終於,我們講到重點了: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倒數計時算得上最精確的。

那麼有沒有可能出現不精確的問題呢?
答案是也有可能!
這裡我們看一張圖

iOS倒數計時的探究與選擇
這張圖來自Concurrent Programming: APIs and Challenges ,大家有時間可以看一下。 在GCD的執行緒池中,總大小目前來看應該是255,有關倒數計時的優先順序是預設default的。
假如存在很多的High的任務,或者255個執行緒都卡住了(這個其實不太可能),GCD的倒數計時也是會受到一定影響的。而且它本身可能也會受到執行緒分配的影響,建立過多執行緒也是要耗費一定資源的。

結論

假如你對時間的精確的沒有特別高的要求,比如說輪播圖什麼的,可以選擇使用NSTimer;建立動畫什麼的,可以使用CADisplayLink;想要追求高精度,可以使用GCD倒數計時;至於PerformSelecter,還是算了吧。

實踐

1.輪播圖

我當初曾經將一個輪播圖作為一個tableview的headerView。測試的時候發現一個大家可能都會遇到的問題,滑動tableview的時候輪播圖不滑了。這個問題很好解決,

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
複製程式碼

更換RunLoop的mode,就可以了。

2.檢測FPS

這個我是受到了FPSLabel的啟發,在它的基礎上擴充套件了一下,只做了一個在頁面最上層滑動的View。它主要是用來在debug模式下進行測試,上面展示了頁面本身的FPS,App版本號,iOS版本號,手機型號等等資料。我們一般情況下認為,FPS在55-60之間,算的上流暢,低於55就要找問題,解決問題了。當然,這個view本身新增的本身也會影響到當前頁面的FPS。“觀察者效應”嘛。

3.多個活動的倒數計時

當初曾經接觸到一個需求,要在一個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都會受系統時間影響,但他們二者做一個減法所得的值,就和系統時間無關了。這樣就可以避免使用者修改時間了。當然使用者如果關機,過段時間再開機,會導致我們獲取到的時間慢與伺服器時間,真實場景中,慢於伺服器時間往往影響較小,我們一般擔心的是客戶端時間快於伺服器時間。

這種方法原理上和我的差不多,但是請求次數會比我的少一些,但是缺點上文也說了:有可能會導致我們獲取到的時間慢與伺服器時間

4.驗證碼

使用者在傳送完驗證碼,然後誤觸退出頁面再重新進入,很多app都是會重新重新整理傳送驗證碼的按鈕,當然,出於保護機制,往往第二個驗證碼不會很快的傳送過來。因為之前已經實現了一個倒數計時的單例,我把這個頁面的倒數計時的終點時間,設定為倒數計時的一個單例屬性,在進入下一步。在重新進入這個頁面的時候,進行上一條中做出的操作,進行判斷。

感謝:

深入理解RunLoop
從NSTimer的失效性談起(二):關於GCD Timer和libdispatch
iOS關於時間的處理

相關文章