0%

定时器启示录

早些时候看过一些分析定时器内存方面的文章,但在遇到这个bug前我是不屑的。不就是定时器强引用ViewController,而ViewController再用strong属性去引用定时器,必然会导致循环引用么。解决办法也很简单,只需使用weak属性去引用定时器即可。然而这一次的经历却证明我还是图样,我单知道使用weak属性不会导致循环引用,我没注意到此时的定时器在无形中延长了ViewController的生命周期。这就为这个bug埋下了隐患。

记录一下bug的解决总是有必要的。

下午的一次自测中,偶尔发现观众端听不到主播端的声音。我先是诧异,接着是很不安,因为墨菲定律告诉我们:如果你担心某种情况发生,那么它就更有可能发生。最开始以为是主播端的问题,便仔细检查了主播端的代码,又加入了另一台手机设置为观众,作为对照。运行之后发现对照组是好的,但刚才那台手机的问题仍然偶现。这就说明主播端的推流是没问题的。接下来的工作便是在问题手机上尽可能找到复现的操作,以便根据操作路径定位大致原因。在某次频繁进入离开直播房间时,APP直接卡死了,再无任何交互的响应。问题开始变得严重,时间也一分一秒的流逝在这一次次的调试中,一晃下班时间快到了,周围开始变的嘈杂,安卓兄弟开始催我下班还说要带我上王者但我是不信的。我整理了下东西,但又不想在节前留下些许问题,便又坐了回去。等到周围开始安静时,夕阳已经西下。我努力回想之前的操作,发现只在直播预约状态下,问题才会重现。于是在页面的dealloc函数中打好断点,点击返回,果然函数没有被调用。这说明页面依然被某个对象持有而没有释放。在检查了所有Block回调都使用的是weakSelf后,最后只剩下定时器了。

问题的根源算是找到了。原来在直播预约状态下会启动一个定时器,但在点击返回时忘记invalid定时器了。这让定时器延长了ViewController的生命周期。加上invalid后,问题搞定,收工。回家的路上碰巧遇到了K君,便给K君讲述了这个问题,K君听后哈哈大笑说:加上invalid只能解决这一次的bug,却不能避免下一次又忘记,而且根据页面dealloc函数里逻辑的不同,bug的外在表现形式也必然不同,到时候又得花费不少的时间找bug啊。闻道于朝,不禁感叹K君的身经百战。

回到家后,打开谷歌又搜到了早些时候看过的那些文章,感慨颇多。系统的NSTimer简单却又不那么简单:

  1. 不注意使用的话有循环引用的隐患。因为NSTimer会强引用target,如果target再强引用NSTimer那么就会发生循环引用。比如:

    1
    2
    3
    4
    5
    - (void)test_normal_timer1 {
    self.normalStrongTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(doSome) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.normalStrongTimer forMode:NSRunLoopCommonModes];
    [self.normalStrongTimer fire];
    }

    上面的代码就会发生循环引用。此时即使页面返回,但是因为循环引用,ViewController的dealloc将不会被执行,因此invalidate不能放在dealloc里,只能在其他时机比如viewDidDisappear里调用。所以一般使用weak来弱引用定时器,这样就可以在dealloc里调用invalidate。

  2. 需要在合适的地方invalid定时器,否则定时器会一直强引用target从而延长target的生命周期。

    在开发中很容易忘记invalid定时器,一旦忘记invalid定时器,定时器就会延长target的生命周期,比如页面返回了但实际还没有被销毁,从而产生一些诡异问题。这也是使用NSTimer必须注意的地方。

  3. 使用时必须保证有一个活跃的runloop,并且需要指定mode。在子线程中使用不是很方便。

  4. 精度可能不够。

  5. 网上的一个说法:创建和撤销必须在同一个线程上,在多线程环境下使用不便。(这一条存疑,经过验证在子线程创建一个定时器,在另一个子线程invalidate并没有发现什么问题)

  6. iOS10开始支持block使用,同样在使用block时一定要注意循环引用。

为了从根本上避免上述问题,一个弱引用target的、能够在自身销毁时自动invalid的定时器想必是极好的,但又该如何实现呢?好在互联网在经过这么多年的发展,第三方开源库从未像现在这般丰富,唾手可得。不多时,便在GitHub上找到了MSWeakTimer

MSWeakTimer提供了和系统NSTimer一致的接口,好的代码就该这样美美与共,和而不同:

1
2
3
4
5
6
7
8
9
@property (nonatomic, strong) MSWeakTimer *weakTimer;

self.weakTimer = [MSWeakTimer scheduledTimerWithTimeInterval:3 target:self selector:@selector(doSome) userInfo:nil repeats:YES dispatchQueue:dispatch_get_main_queue()];
//立即触发回调方法
[self.weakTimer fire];

- (void)doSome {
NSLog(@"++++++%@", self);
}

至于MSWeakTimer的实现原理自然是和NSTimer不同的:通过封装GCD定时器实现NSTimer的功能,但内部却是弱引用target,不仅如此MSWeakTimer还支持在其他线程中执行回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@interface MSWeakTimer ()
{
struct
{
uint32_t timerIsInvalidated;
} _timerFlags;
}

@property (nonatomic, assign) NSTimeInterval timeInterval;
@property (nonatomic, weak) id target; //弱引用target
@property (nonatomic, assign) SEL selector;
@property (nonatomic, strong) id userInfo;
@property (nonatomic, assign) BOOL repeats;

@property (nonatomic, ms_gcd_property_qualifier) dispatch_queue_t privateSerialQueue;

@property (nonatomic, ms_gcd_property_qualifier) dispatch_source_t timer;

@end

...

//自身销毁时invalidate掉定时器
- (void)dealloc
{
[self invalidate];

ms_release_gcd_object(_privateSerialQueue);
}

当我们使用MSWeakTimer时,就可以避免因忘记invalid定时器,导致ViewController生命周期被延长不能及时销毁而产生的bug。从这之后,我便不再遇到和NSTimer相关的bug了。

二零一八年九月二十一日
觉得文章有帮助可以打赏一下哦!