倒計時設計

原文地址html

計算機是不存在倒計時這個概念的,全部的倒計時設計來源於對定時器的運用:給予一個deadline,以秒爲時間間隔,在每次回調時刷新屏幕上的數字。倒計時的實現幾乎沒有門檻,不管NSTimer也好,GCD也罷,甚至使用CADisplayLink都能用來製做一個倒計時方案。但一樣的,低門檻也意味着方案的上下限差也很大,本文打算談談如何設計一個倒計時方案node

爲何要寫這篇文章

事實上,倒計時和我目前開發的方向八竿子打不着,我也確實沒有必要和想過寫這麼一套方案。只是這幾天有朋友分享了別人設計的倒計時功能:git

採用一個全局計時管理對象針對每個倒計時按鈕分配計時器,計時器會生成一個NSOperation對象來執行回調,完成倒計時功能github

在拋開代碼不談的狀況下,這套設計思路我也是存疑的。若是倒計時要使用operation,那就須要使用queue來完成任務。根據queue的串行並行屬性,要考慮這兩點:算法

  • 若是queue是並行的,一個界面上存在多個倒計時按鈕時,可能會新建線程來處理同一個queue的任務,這部分的開銷並非必需的安全

  • operation須要投放到queue裏面啓動執行。假如每秒的回調包裝成operation處理,那麼須要一個定時機制來投放這些operation。若是是這麼,爲何不直接使用定時器,而要用operation數據結構

但在看完設計者的文章和代碼以後,我發現對方根本沒有考慮過上面的問題。他operation的任務思路很奇怪:多線程

在每個operation裏面,採用while + sleep的方式,每次回調後讓線程睡眠一秒,直至倒計時結束app

- (void)main {
    ......
    do {
        callback(self.leftTime);
        [NSThread sleepForTimeInterval: 1];
    } while (--self.leftTime > 0);
    ......
}
複製代碼

這種實現有三個坑爹的地方:異步

  1. while循環結束以前,內部的臨時變量不會被釋放,存在內存佔用過大的風險

  2. 若是queue是串行屬性,多個operation將沒法保證回調時間的正確

  3. 不該該採用sleep方式計時,這很浪費線程的執行效率

另外,應用進入後臺時,全部的子線程會被中止執行任務,這個會致使了應用切換先後臺後,倒計時剩餘時間不許。對於這種狀況通常也有三種方式來作時間校訂:

  1. 保存一個倒計時deadline,在進入active後從新計算剩餘倒計時

  2. 註冊通知,在切換先後臺時計算時長,減去這個時間更新剩餘時間

  3. 建立應用後臺任務,繼續進行倒計時

而上面的設計採用了3的解決方案,鑑於應用在後臺時,用戶對應用毫無感知的特色,這幾乎是最差的一種方案。因而基於這一個個槽點,我決定按照本身的想法,作一個相對高效的倒計時方案

存儲結構

一套功能方案設計的目的是爲了簡化邏輯流程,隱藏實現細節,儘量少的暴露接口。普通的倒計時是調用方直接使用定時器實現規律性回調,定時器須要持有調用方的信息。而倒計時設計隱藏了定時器的實現細節,只需調用方提供回調,其他的耦合關聯由管理類來完成,相似的設計有NotificationObserver

即使是系統使用的兩種相似的監聽設計,在內部實現時,所用到的存儲結構也是不一樣的。Notification不持有對象自己,採用保存對象地址的實現,但這樣存在野指針風險。Observer會持有對象,但會形成循環引用的風險。能夠說:

不一樣的考量標準和業務場景決定了不一樣的結構設計

回調設計

iOS中經常使用的回調方式包括delegateblock兩種,這兩種方式都能很好的處理倒計時的需求。但出於如下理由我選擇了block

  • delegate的耦合關係強於block

    delegate在委託方和代理方中間添加了一層中間層,解除了雙方的直接耦合關係,但一樣的委託方和代理方須要依賴於protocol,這是這種模式必然存在的耦合關係。相比之下,block並不存在這種煩惱

  • 更少的代碼

    一樣的回調處理下,delegate的代碼量要多於block。並非說更多的代碼必定很差,只是一樣符合實現需求,邏輯清晰一樣清晰的狀況下,後者優於前者:

    #pragma mark - delegate type
      - (void)doSomething {
          /// do something
          if ([_delegate respondsToSelector: @selector(countDownWithLeftTime:)]) {
              [_delegate countDownWithLeftTime: leftTime];
          }
      }
      
      #pragma mark - block type
      - (void)doSomething: (void(^)(NSInteger leftTime))countDown {
          /// do something
          if (countDown) { countDown(leftTime); }
      }
    複製代碼

任務停止

倒計時設計應當知足最基本的中止條件:倒計時歸零,但除了正常停止以外,老是有提早結束任務的可能性。對外界提供removeXXX格式的接口是一種好的思路,這讓代碼成對匹配,賞心悅目。但在實際開發中,咱們應當重視一個設計原則:

接口隔離原則:一個類與另外一個類之間的依賴性,應該依賴於儘量小的接口

對外暴露接口須要再三思考,由於一旦提供了額外的接口,這意味着實現方要多處理一個邏輯,調用方須要多調用一次代碼。決定是否提供remove接口應該取決於對象依賴,NotificationObserver都會由於保存對象的關鍵信息致使額外的風險,所以必須提供移除策略

因爲咱們已經採用block設計,無需直接依賴對象的某些信息,能夠忽略remove接口的選項。而實際上,想要提供停止任務的功能,系統的enum爲咱們提供了很好的思路:在回調中提供標記位,一旦標記位被修改,回調完成後停止任務。結合回調設計採用的方案,獲得block的結構:

typedef void(^LXDReceiverCallback)(long leftTime, bool *isStop);
複製代碼

重複任務

通知中心是去重實現中很典型的bad case:同一次通知的回調次數取決於註冊該通知的次數。若是將這種設計放到倒計時中,具體表現爲剩餘時長以正常倒計時數倍的速率減小。正常場景下很難出現這種問題,但若是在限時搶購的列表界面,多個cell被複用的狀況下,很容易出現這種狀況

typedef struct hash_entry_t {
    void *entry;
} hash_entry_t;

class LXDBaseHashmap {
public:
    LXDBaseHashmap();
    virtual ~LXDBaseHashmap();
    unsigned int entries_count;
    hash_entry_t *hash_entries;
    
protected:
    unsigned int obj_hash_code(void *obj);
};
複製代碼

hash表是一種經常使用的去重算法:一個輸入只對應一個輸出。爲了不對象重複建立倒計時任務,咱們須要找到對象所擁有的一個惟一值做爲輸入——對象地址,而且以此做爲key檢測是否存在衝突:

#define hash_bucket_count 7

unsigned int LXDBaseHashmap::obj_hash_code(void *obj) {
    uint64_t *val1 = (uint64_t *)obj;
    uint64_t *val2 = val1 + 1;
    return (unsigned int)(*val1 + *val2) % hash_bucket_count;
}
複製代碼

hash表須要考量的另外一個因素是表的長度。一樣狀況下,表長度越大,越難發生計算衝突,但一樣也佔用了更多的內存。出於如下緣由,最終桶的長度設定爲7

  1. 倒計時場景下出現大量倒計時的可能性低
  2. 因爲地址都是雙數,桶的個數必須爲單數,不然沒法計算出單數結果

另外一方面,因爲hash表的長度僅爲7,出現計算衝突的並不小,咱們還要考慮出現衝突時的處理方案,能夠借鑑@synchronized的實現方式來解決這個問題。@synchronized一樣基於對象地址進行hash計算,每一個桶存儲一張可用鎖鏈表,鏈表的節點保存對象,用於執行衝突後的再匹配工做:

typedef struct SyncData {
    id object;
    recursive_mutex_t mutex;
    struct SyncData* nextData;
    int threadCount;
} SyncData;

typedef struct SyncList {
    SyncData *data;
    spinlock_t lock;
} SyncList;

#define COUNT 16
#define HASH(obj) ((((uintptr_t)(obj)) >> 5) & (COUNT - 1))
#define LOCK_FOR_OBJ(obj) sDataLists[HASH(obj)].lock
#define LIST_FOR_OBJ(obj) sDataLists[HASH(obj)].data
static SyncList sDataLists[COUNT];
複製代碼

所以,仿照這種設計,咱們存儲的鏈表節點除了保存回調block和倒計時長以外,還須要保存調用對象的地址信息,獲得結構體:

typedef struct LXDReceiver {
    long lefttime;
    uintptr_t objaddr;
    LXDReceiverCallback callback;
} LXDReceiver;
複製代碼

鏈表性能

因爲鏈表的查找須要遍歷鏈表,效率較低。在倒計時完畢或者任務停止時,移除任務會有沒必要要的損耗。採用雙向鏈表的設計,能夠獲取到刪除任務的先後節點,快速的完成刪除。另外預留一個鏈表長度變量,每一個鏈表提供一個表頭來提供長度屬性,方便後續擴充:

typedef struct LXDReceiverNode {
    unsigned int count;
    LXDReceiver *receiver;
    LXDReceiverNode *next;
    LXDReceiverNode *previous;
    
    LXDReceiverNode(LXDReceiver *receiver = NULL, LXDReceiverNode *previous = NULL) {
        this->count = 0;
        this->next = NULL;
        this->previous = previous;
        this->receiver = receiver;
    }
} LXDReceiverNode;
複製代碼

至此爲止,方案的數據結構已經肯定,剩下的就是對倒計時邏輯的封裝設計

邏輯封裝

定時器方案

經常使用於實現倒計時的定時器有NSTimerGCD兩種,處於兩點考慮,我選擇了後者:

  1. NSTimer須要啓動子線程的runloop,另外iOS10+系統必須手動啓用一次runloop才能完成定時器的移除

  2. GCD具備更高的效率和精確度

除此以外,定時器的設計通常被分爲多定時器設計單定時器設計,兩種各有優劣

  • 多定時器設計

    多定時器的設計下,每一個倒計時任務擁有本身的計時器。優勢在於能夠單獨控制每一個任務的回調間隔。缺點是因爲多個定時器的屏幕刷新不必定會同步,致使UI更新不一樣步等

  • 單定時器設計

    單定時器的設計下,全部倒計時任務使用同一個計時器。優勢在於減小了額外的性能損耗,設計結構更清晰。缺點在定時器已經啓動的狀況下,新任務的首次倒計時可能會有明顯的提早以及多個倒計時任務強制使用同一種計時間隔。

考慮到倒計時的的UI同步效果以及更好的性能,我選擇單定時器設計方案。另外若是確實存在多個不一樣計時間隔的需求,單定時器設計也能夠很好的擴充接口提供支持

註冊任務

對象在註冊倒計時任務時,取對象的地址進行hash計算,根據結果找到存儲的對象鏈表。鏈表節點存儲的objcaddr用來匹配對象,若是匹配失敗,那麼新建一個任務回調節點。完成插入後,啓動定時器:

- (void)registerCountDown: (LXDTimerCallback)countDown
               forSeconds: (NSUInteger)seconds
             withReceiver: (id)receiver {
    if (countDown == nil || seconds <= 0 || receiver == nil) { return; }
    
    lxd_wait(self.lock);
    self.receives->insertReceiver((__bridge void *)receiver, countDown, seconds);
    [self _startupTimer];
    lxd_signal(self.lock);
}

bool LXDReceiverHashmap::insertReceiver(void *obj, LXDReceiverCallback callback, unsigned long lefttime) {
    unsigned int offset = obj_hash_code(obj);
    hash_entry_t *entry = hash_entries + offset;
    LXDReceiverNode *header = (LXDReceiverNode *)entry->entry;
    LXDReceiverNode *node = header->next;
    
    if (node == NULL) {
        LXDReceiver *receiver = create_receiver(obj, callback, lefttime);
        node = new LXDReceiverNode(receiver, header);
        header->next = node;
        header->count++;
        return true;
    }
    
    do {
        if (compare(node, obj) == true) {
            node->receiver->callback = callback;
            node->receiver->lefttime = lefttime;
            return false;
        }
    } while (node->next != NULL && (node = node->next));
    
    if (compare(node, obj) == true) {
        node->receiver->callback = callback;
        node->receiver->lefttime = lefttime;
        return false;
    }
    
    LXDReceiver *receiver = create_receiver(obj, callback, lefttime);
    node->next = new LXDReceiverNode(receiver, node);
    header->count++;
    return true;
}
複製代碼

倒計時回調

定時器啓動後,會遍歷全部的回調鏈表,而且調起回調處理。若是在本次遍歷中發生已經不存在任何倒計時任務,那麼定時器將被釋放:

- (void)_countDown {
    NSMutableArray *removeNodes = @[].mutableCopy;
    
    for (unsigned int offset = 0; offset < _receives->entries_count; offset++) {
        hash_entry_t *entry = _receives->hash_entries + offset;
        LXDReceiverNode *header = (LXDReceiverNode *)entry->entry;
        LXDReceiverNode *node = header->next;
        
        while (node != NULL) {
            [removeNodes addObject: [NSValue valueWithPointer: (void *)node]];
            node = node->next;
        }
    }
    
    if (removeNodes == 0 && self.timer != nil) {
        lxd_wait(self.lock);
        dispatch_cancel(self.timer);
        self.timer = nil;
        lxd_signal(self.lock);
    }
    
    dispatch_async(dispatch_get_main_queue(), ^{
        [removeNodes enumerateObjectsWithOptions: NSEnumerationReverse usingBlock: ^(NSValue *obj, NSUInteger idx, BOOL * _Nonnull stop) {
            LXDReceiverNode *node = (LXDReceiverNode *)[obj pointerValue];
            node->receiver->lefttime--;
            BOOL isStop = NO;
            
            node->receiver->callback(node->receiver->lefttime, &isStop);
            if (node->receiver->lefttime > 0 && !isStop) {
                [removeNodes removeObjectAtIndex: idx];
            }
        }];
        
        dispatch_async(_timerQueue, ^{
            lxd_wait(self.lock);
            for (id obj in removeNodes) {
                LXDReceiverNode *node = (LXDReceiverNode *)[obj pointerValue];
                _receives->destoryNode(node);
            }
            lxd_signal(self.lock);
        });
    });
}
複製代碼

倒計時任務會由於倒計時歸零或者標記位被修改這兩個緣由結束,假如本次回調中正好全部的倒計時任務都處理完畢了,全部的註冊者都被清除。此時並不會馬上中止定時器,而是等待到下次回調再中止。主要出於兩個條件考慮:

  1. 回調屬於異步執行,若是要本次處理完成後檢測註冊隊列狀態,須要額外的同步機制開銷
  2. 假如在下次回調前又註冊了新的倒計時任務,能夠避免銷燬重建定時器的開銷

先後臺切換

應用在先後臺切換的過程當中,會在非後臺線程執行完當前任務後掛起線程。通常來講,咱們的倒計時會由於先後臺切換而停止,除非咱們將倒計時放在主線程建立後臺線程繼續執行。此外,應用從新回到ative狀態時,只要在後臺停留的時長超出了定時器的回調間隔,那麼倒計時會馬上被回調,破壞了原有的回調時間和倒計時長

文章開頭提到有三種方案解決這種先後臺切換對定時器的方案。後臺線程倒計時能夠最大程度的保證倒計時的回調時間依舊正確,可是基於應用後臺無感知的特性,這種消耗資源的方案不在咱們的考慮範圍。因爲在設計上,我已經採用了保留lefttime的方式,所以保存deadline從新計算剩餘時長也不是最佳選擇。採用方案2計算後臺停留時間而且更新剩餘時間是最合適的作法:

- (void)applicationDidBecameActive: (NSNotification *)notif {
    if (self.enterBackgroundTime && self.timer) {
        long delay = [[NSDate date] timeIntervalSinceDate: self.enterBackgroundTime];
        
        dispatch_suspend(self.timer);
        for (unsigned int offset = 0; offset < _receives->entries_count; offset++) {
            hash_entry_t *entry = _receives->hash_entries + offset;
            LXDReceiverNode *header = (LXDReceiverNode *)entry->entry;
            LXDReceiverNode *node = header->next;
            
            while (node != NULL) {
                if (node->receiver->lefttime < delay) {
                    node->receiver->lefttime = 0;
                } else {
                    node->receiver->lefttime -= delay;
                }
                
                bool isStop = false;
                node->receiver->callback(node->receiver->lefttime, &isStop);
                if (node->receiver->lefttime <= 0 || isStop) {
                    lxd_wait(self.lock);
                    _receives->destoryNode(node);
                    lxd_signal(self.lock);
                }
            }
        }
        dispatch_resume(self.timer);
    }
}

- (void)applicationDidEnterBackground: (NSNotification *)notif {
    self.enterBackgroundTime = [NSDate date];
}
複製代碼

因爲通知的回調線程和定時器的處理線程可能存在多線程的競爭,爲了排除這一干擾,我採用了sema加鎖,以及在遍歷期間掛起定時器,減小沒必要要的麻煩

對外接口

前文說過,在不影響功能性的狀況下,應當儘可能減小對外接口的數量。所以,倒計時管理類只須要提供一個接口便可:

/*!
 *  @class  LXDTimerManager
 *  定時器管理
 */
@interface LXDTimerManager : NSObject

/*!
 *  @method timerManager
 *  獲取定時器管理對象
 */
+ (instancetype)timerManager;

/*!
 *  @method registerCountDown:forSeconds:withReceiver:
 *  註冊倒計時回調
 *
 *  @params countDown   回調block
 *  @params seconds     倒計時長
 *  @params receiver    註冊的對象
 */
- (void)registerCountDown: (LXDTimerCallback)countDown
               forSeconds: (NSUInteger)seconds
             withReceiver: (id)receiver;

@end
複製代碼

操做安全

爲了倒計時任務的可靠性,咱們應該在子線程啓動定時器,一方面提升了精準度,另外一方面避免形成主線程的卡頓。但因爲涉及到UI更新先後臺切換兩個狀況,必需要考慮到多線程可能對數據的破壞力。從設計上來講,底層設計只提供實現接口,不考慮任何業務場景。所以應該在上層調用處作安全處理

管理類使用DISPATCH_QUEUE_SERIAL屬性建立的任務隊列,確保定時器的回調之間是互不干擾的。對外提供的register接口沒法保證調用方所處的線程環境,所以應當對操做進行加鎖。此外涉及到hashmap的改動的代碼都應當加鎖保護:

- (instancetype)init {
    if (self = [super init]) {
        self.receives = new LXDReceiverHashmap();
        self.lock = dispatch_semaphore_create(1);
        self.timerQueue = dispatch_queue_create("com.sindrilin.timer.queue", DISPATCH_QUEUE_SERIAL);
        
        [[NSNotificationCenter defaultCenter] addObserver: self selector: @selector(applicationDidBecameActive:) name: UIApplicationDidBecomeActiveNotification object: nil];
        [[NSNotificationCenter defaultCenter] addObserver: self selector: @selector(applicationDidEnterBackground:) name: UIApplicationDidEnterBackgroundNotification object: nil];
    }
    return self;
}

- (void)registerCountDown: (LXDTimerCallback)countDown
           forSeconds: (NSUInteger)seconds
         withReceiver: (id)receiver {
    if (countDown == nil || seconds <= 0 || receiver == nil) { return; }
    
    lxd_wait(self.lock);
    self.receives->insertReceiver((__bridge void *)receiver, countDown, seconds);
    [self _startupTimer];
    lxd_signal(self.lock);
}
複製代碼

除了加鎖,避免線程競爭的產生環境也是可行的。一個明顯的競爭時機在於應用切換先後臺倒計時回調可能會同時被執行,所以在通知回調的遍歷操做過程先後,將定時器suspend,避免剛好發生衝突的可能。

循環引用

不一樣於大多數的倒計時方案,本方案經過擴充NSObject的方法來保證全部的類對象均可以註冊倒計時任務。在iOS中,block是最容易引發循環引用的機制之一。爲了儘可能減小可能存在的引用問題,在接口的設計上,我讓block接受一個id類型的調用對象,在接口層內部進行了一次__weak聲明,而且在對象被釋放後停止定時器任務:

@implementation NSObject (PerformTimer)

__weak typeof(self) weakself = self;
[[LXDTimerManager timerManager] registerCountDown: ^(long leftTime, bool *isStop) {
    if (weakself) {
        countDown(weakself, leftTime, (BOOL *)isStop);
    } else {
        *isStop = true;
    }
} forSeconds: seconds withReceiver: self];

@end
複製代碼

固然,我也作好了調用者徹底不用receiver的準備了~

其餘

最後再次聲明這個觀點:

倒計時方案几乎沒有門檻,但也不只限於倒計時方案

設計一個功能須要通過仔細考慮多個因素,包括邏輯性能質量多個方面。洋洋灑灑寫完以後,發現設計倒計時也不是那麼的容易,並且hash + linked list的設計上我採用了struct + C++的數據結構實現。雖然這套設計直接採用NSDictionary + NSArray來實現也是徹底沒有問題的,可是看了那麼多源碼,那麼多算法,不去實踐下實在太惋惜了。基於吐槽 + 實踐兩層緣由,最終完成了這麼一個東西

本篇文章基於這段時間學習的收穫總結而成,若是您以爲有不足之處,還萬請指出。項目已同步至cocoapods,可經過pod 'LXDTimerManager'導入

demo

關注個人公衆號獲取更新信息
相關文章
相關標籤/搜索