iOS-NSTimer真的沒有想象中的簡單:NSInvocation,NSProxy,NSRunloop居然都會用到

個人第三方庫:
UDUserDefaultsModel:以Model代替NSUserDefaults
YIIFMDB:直接操作Model進行增刪改查,數學運算等,且sql語句易于管理

在iOS開發當中,無可避免的會涉及到定時任務,比如在發送驗證碼時的倒計時:


驗證碼倒計時demo.gif

小編相信每個人都遇到過這樣的需求,都很熟練的寫出代碼來了,如下:

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerFire:) userInfo:nil repeats:YES];

簡單是簡單,但是,這個樣子寫出來的代碼卻有兩個很大的缺陷:

1.會導致內存泄漏(在@selector(timerFire:)這個方法里打印一個log,會發現這個log在pop之后還會打印)

2.如果滑動ScrollView的時候,定時器卻不會走,只有松開ScrollView之后,定時器才重新走,如此會導致體驗不佳

內存泄漏不是個小事,這個樣子會導致很多程序上的bug。而至于滑動ScrollView時定時器不走的缺陷可以暫時稍后。

為了解決這個bug,我們先來分析NSTimer內存泄漏的原因:首先在Demo中,NSTimer在初始化的時候是放在對象(其實是一個ViewController的對象)方法中的,而當前對象self又是作為NSTimer對象的一個參數存在的,為此就導致了一個死循環,即:


NSTimer循環.png

解決這個bug,必須打破self->timer->self(其中->代表強引用)這種循環引用,其中self->timer這一步沒法避免,只能從timer->self這里著手,讓其變成timer -- self(其中--代表弱引用)。

為此,我們需要查看NSTimer的官方文檔,同時也發現了如下兩個方法:

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;

NSInvocation是什么?用過Target-Action的人一定不陌生。現在我們去翻看官方文檔,第一句就已經解釋清楚了:

NSInvocation objects are used to store and forward messages between objects and between applications, primarily by NSTimer objects and the distributed objects system

簡而言之就是:NSInvocation對象會保存并轉發一些信息,而且完全可以適用于NSTimer對象。而從其暴露的方法來看,只有"invocationWithMethodSignature:"這一個方法,不解釋了,想了解的去看官方文檔,這里直接上代碼:

    NSMethodSignature *methodSignature = [[self class] instanceMethodSignatureForSelector:@selector(timerFire:)];
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
    invocation.target = self;
    invocation.selector = @selector(timerFire:);
    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 invocation:invocation repeats:YES];

然而經過測試,內存泄漏的bug依舊沒有解決,@selector(timerFire:)這里面的log在pop的時候依舊在打印。仔細分析一下會發現NSTimer導致的閉環依舊沒有解決,只不過是從self->timer->self演變成了self->timer->invocation->self罷了。

雖然NSInvocation并未解決,但是卻提供了一個思路:假設有一個對象objectA,其對self進行一個弱引用,那么就會變成self->timer->objectA--self(其中->代表強持有,而--代表弱持有)就可以了。

在翻看大量資料之后,小編得知iOS提供了這樣一個類:NSProxy。對于NSProxy的解釋,官方文檔是這樣解釋的:

An abstract superclass defining an API for objects that act as stand-ins for other objects or for objects that don’t exist yet.

(自己翻譯吧!小編倒是認為"stand-in"是關鍵詞。)

那么,我們根據NSInvocation的思想(主要有兩點:1.store messages 2.forward messages)去查看NSProxy的官方文檔,發現有兩個方法十分類似:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel;      // 類似store messages
- (void)forwardInvocation:(NSInvocation *)invocation;           // 類似forward messages,而且這里也涉及到了NSInvocation

除此之外,還要解決一個對self弱引用的問題,為此只需要給NSProxy進行一個拓展,增加一個對對象的弱引用,繼承是最好的辦法。

繼承自NSProxy聲明一個叫NSProxyInprovement的類,并在.h當中聲明一個weak修飾的屬性,如下面代碼:

@interface NSProxyInprovement : NSProxy

@property (nonatomic, weak) id aTarget;      // 此對象要從外部傳過來

@end

同時在NSProxyInprovement的.m中,實現類似NSInvocation中"store and forward messages"的兩個方法,如下面代碼:

@implementation NSProxyInprovement

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.aTarget methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.aTarget];
}

@end

使用起來很簡單但是卻比較繁瑣,如下面代碼:

self.proxy = [NSProxyInprovement alloc];
self.proxy.aTarget = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self.proxy selector:@selector(timerFire:) userInfo:userInfo repeats:YES];

經過測試,當pop的時候,調用了dealloc的方法,為此內存泄漏的bug算是解決了。

接下來解決上面遺留的那個滑動ScrollView的時候,定時器不走的缺陷。為此,我們看看官方文檔對于@selector(scheduledTimerWithTimeInterval:target:userInfo:repeats:)的解釋:

Creates a timer and schedules it on the current run loop in the default mode.

從上面的解釋當中可以看到NSTimer還結合了NSRunloop的知識,并且mode類型是NSDefaultRunLoopMode,這就是問題所在:當滑動ScrollView的時候,NSRunloop的mode并不是NSDefaultRunLoopMode,而是UITrackingRunLoopMode,為此,我們需要設置一個包含既包含NSDefaultRunLoopMode又包含UITrackingRunLoopMode的mode,那就是NSRunLoopCommonModes。
完整代碼如下:

self.proxy = [NSProxyInprovement alloc];
self.proxy.aTarget = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self.proxy selector:@selector(timerFire:) userInfo:userInfo repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

但是這個樣子的話還有一個缺陷,那就是NSRunloop使用了兩次,為了改善這個,我們使用NSTimer的另一個方法,完整代碼如下:

self.proxy = [NSProxyInprovement alloc];
self.proxy.aTarget = self;
self.timer = [NSTimer timerWithTimeInterval:1.0 target:self.proxy selector:@selector(timerFire:) userInfo:userInfo repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

詳情只需要查看NSTimer的官方文檔就可以了,官方文檔寫的很清楚。
經過測試,當滑動ScrollView的時候,定時器不走的那個缺陷也修復了,完美。

不過,在小編看來,bug與缺陷雖然都修復了,但是代碼寫起來十分的繁瑣,畢竟還要引入NSProxyInprovement這個類,還要創建,傳值,十分的繁瑣,一點都不符合組件化開發的需求。

為此小編寫了一個十分簡單的組件放到了Github上,并且可支持Cocoapods(不要吝嗇你手里的Star)。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容