跃迁引擎

空気を読んだ雨降らないでよ

iOS Research & Development


为 ReactiveCocoa 提供可独立区分延迟执行和间隔执行时间的定时器扩展

为 RAC 定时器提供一个可独立区分延迟执行和间隔执行时间的定时器扩展

背景

这几天有一个需求需要 Native 侧来承接,一句话描述就是 APP 应用前台运行时间以心跳的方式记录并上报。需求很简单,也是非常小的功能,基本上一个定时器加上前后台状态监控即可完成。

最初,我用了很短的时间基于 GCD 定时器搭建完成了这套心跳上报流程:

  • 应用启动后立刻启动定时任务并开始心跳连接上报
  • 根据指定间隔执行时间循环执行心跳连接
  • 应用退入后台(包括切换应用、锁屏等操作)时,停止当前定时任务,断开心跳连接
  • 应用返回前台时,重启定时任务,重新进行心跳连接

核心代码如下:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
static NSTimeInterval const kDelayTime = 0.f;       // 执行延迟时间
static NSTimeInterval const kTimeInterval = 60.f; // 执行间隔时间

@interface YCHeartbeatManager()

@property (nonatomic) dispatch_source_t timer;

@end

@implementation YCHeartbeatManager

+ (instancetype)sharedInstance {
static YCHeartbeatManager *shareInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
shareInstance = [[YCHeartbeatManager alloc] init];
});

return shareInstance;
}

- (instancetype)init {
self = [super init];
if (self) {
// 注册RAC前后台状态监听
[self registerRACObserver];
}
return self;
}

// 启动心跳任务
- (void)startHearbeatTask {
self.timer = [YCThreadTool gcd_timer];
@weakify(self)
[YCThreadTool gcd_dispatchTimer:self.timer delayTime:kDelayTime timeInterval:kTimeInterval handler:^{
@strongify(self)
// 进行心跳连接
[self heartbeatLink];
}];
}

#pragma mark - Private

// 停止心跳任务
- (void)stopHearbeatTask {
[YCThreadTool gcd_dispatchCancelTask:self.timer];
self.timer = nil;
}

// 重启心跳任务
- (void)restartHearbeatTask {
[self startHearbeatTask];
}

// 监听前后台状态
- (void)registerRACObserver {
@weakify (self);
// 进入前台
[[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UIApplicationWillEnterForegroundNotification
object:nil] takeUntil:self.rac_willDeallocSignal] subscribeNext:^(id x) {
@strongify (self);
// 重启心跳任务
[self restartHearbeatTask];
}];
// 锁屏、来电话、进入后台
[[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil] takeUntil:self.rac_willDeallocSignal] subscribeNext:^(id x) {
@strongify (self);
// 停止心跳任务
[self stopHearbeatTask];
}];
}

// 心跳连接
- (void)heartbeatLink {
[[self heartbeatLinkSignal] subscribeNext:^(id x) {
NSLog(@"心跳连接完成");
}];
}

- (RACSignal *)heartbeatLinkSignal {
return [[[YCHeartbeatReportCMD alloc] init].rac_start
flattenMap:^RACStream *(YCMathRequest *request) {
// - Note: 心跳不关心连接成功与否
return [RACSignal return:request.responseJSONObject];
}];
}

@end

总共不到100行代码,高效的解决了这个需求,看上去一切都很完美。

RAC 定时器的问题

晚些时候,同事在 review 的时候看到了这个类,反馈希望可以统一使用 RAC 的定时器来完成这个需求,工程内希望可以统一技术栈。

既然如此,那我们就开始重构,把 GCD 的定时器替换成 RAC 的定时器:

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

@property (nonatomic, strong) RACDisposable *disposable;

...

// 启动心跳任务
- (void)startHearbeatTask {
@weakify(self)
self.disposable = [[RACSignal interval:kTimeInterval onScheduler:[RACScheduler mainThreadScheduler]] subscribeNext:^(NSDate * _Nullable x) {
@strongify(self)
// 进行心跳连接
[self heartbeatLink];
}];
}

// 停止心跳任务
- (void)stopHearbeatTask {
[self.disposable dispose];
self.disposable = nil;
}

// 重启心跳任务
- (void)restartHearbeatTask {
[self startHearbeatTask];
}

...

看上去没什么问题,不过是定时器的替换而已,于是在重构完定时器部分后,我开始运行工程检测定时器的效果。

这个时候问题出现了:

RAC 定时器的首次执行时间比首次启动时间晚了整整1分钟,而这1分钟刚好是我设定的 interval 的值,这就是个大问题了:定时器的延迟执行时间和执行间隔时间挂钩了

至少从运行表现上看,这两个时间是挂钩的。但我想 Reactive 这种级别的框架应该不会犯这种低级错误,或者不考虑两者可能独立共存的场景,于是我仔细查看了 RAC 定时器的源码:

RACSignal+Operations

RACQueueScheduler

看完后只想说一句话:WTF???

RAC 的定时器底层还是一套 GCD 的定时器,相当于给 GCD 定时器做了套兼容 RACSignal 的封装。这本来没什么,但是要命的是,RAC 的作者将延迟执行时间与执行间隔时间这两个参数进行了强关联,这两个还真的是挂钩的,且外部不可对延迟执行时间做任何操作。这也就解释了为什么在我设置了1分钟的执行间隔时间后,我的定时器会在启动后延迟1分钟的时间才开始执行。

为 RAC 定时器提供扩展

面对着这样一个要命的问题该怎么办呢?本着还是不相信 RAC 的贡献者会这么”菜”的原则,我和同事翻阅了所有 RAC 定时器可提供的 API,以及所有可能另 RACDisposable 强制立即执行的函数,最后一无所获,它真的就是互相挂钩且不支持单独设置延迟启动时间的😢。

在一阵绝望之后,我似乎想到了什么,于是乎我去搜索了工程中所有的 RAC 定时器使用场景,发现无一例外都是倒计时的定时应用。这似乎可以解释 RAC 为什么如此设计了,它可能最初在设计的时候就是为倒计时定时准备的。

它本就不适合出现在我们当前需求的这个场景里,因为它不是为此设计的。

这样一想一切就都说的通了,虽然可以说通了,但是问题依旧没有解决。目前可以确定的是,RAC 没有为后台定时场景提供任何一个 API,如果依旧想要用 RAC 来进行后台定时的话,就需要自己动手为 RAC 添加扩展,自己来实现一套支持独立区分延迟执行和间隔执行时间的定时器了。

在参考了现有的 RAC 定时器源码后,我在主工程中加入了两个 Category,分别是底层控制 GCD 定时器的 RACQueueScheduler+YCTimer 和 提供定时器上层抽象的 RACSignal+YCOperations

RACQueueScheduler+YCTimer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#import "RACQueueScheduler.h"

NS_ASSUME_NONNULL_BEGIN

@interface RACQueueScheduler (YCTimer)

/** RAC 定时器延迟执行 & 间隔时间可独立控制
*
* @param delayTime 延迟执行时间
* @param interval 间隔执行时间
* @param leeway next 可以推迟执行的最大额外时间
*
*/
- (RACDisposable *)afterDelayTime:(NSTimeInterval)delayTime repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block;

@end

NS_ASSUME_NONNULL_END
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#import "RACQueueScheduler+YCTimer.h"

@implementation RACQueueScheduler (YCTimer)

- (RACDisposable *)afterDelayTime:(NSTimeInterval)delayTime repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block {
NSCParameterAssert(interval > 0.0 && interval < INT64_MAX / NSEC_PER_SEC);
NSCParameterAssert(leeway >= 0.0 && leeway < INT64_MAX / NSEC_PER_SEC);
NSCParameterAssert(block != NULL);

uint64_t intervalInNanoSecs = (uint64_t)(interval * NSEC_PER_SEC);
uint64_t leewayInNanoSecs = (uint64_t)(leeway * NSEC_PER_SEC);

dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self.queue);
dispatch_source_set_timer(timer, delayTime, intervalInNanoSecs, leewayInNanoSecs);
dispatch_source_set_event_handler(timer, block);
dispatch_resume(timer);

return [RACDisposable disposableWithBlock:^{
dispatch_source_cancel(timer);
}];
}

@end

RACSignal+YCOperations

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
30
31
#import "RACSignal.h"

NS_ASSUME_NONNULL_BEGIN

@interface RACSignal (YCOperations)

/** RAC 定时器 [倒计时任务请使用 + (RACSignal *)interval:onScheduler:]
* 支持独立设置延迟执行时间 & 间隔执行时间
*
* @param delayTime 延迟执行时间
* @param interval 间隔执行时间
* @param scheduler 调度器
*
*/
+ (RACSignal *)delayTime:(NSTimeInterval)delayTime interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler;

/** RAC 定时器 [倒计时任务请使用 + (RACSignal *)interval:onScheduler:]
* 支持独立设置延迟执行时间 & 间隔执行时间
*
* @param delayTime 延迟执行时间
* @param interval 间隔执行时间
* @param scheduler 调度器
* @param leeway next 可以推迟执行的最大额外时间
*
*/

+ (RACSignal *)delayTime:(NSTimeInterval)delayTime interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler withLeeway:(NSTimeInterval)leeway;

@end

NS_ASSUME_NONNULL_END
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#import "RACSignal+YCOperations.h"
#import "RACQueueScheduler+YCTimer.h"

@implementation RACSignal (YCOperations)

+ (RACSignal *)delayTime:(NSTimeInterval)delayTime interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler {
return [[RACSignal delayTime:delayTime interval:interval onScheduler:scheduler withLeeway:0.0] setNameWithFormat:@"+interval: %f onScheduler: %@", (double)interval, scheduler];
}

+ (RACSignal *)delayTime:(NSTimeInterval)delayTime interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler withLeeway:(NSTimeInterval)leeway {
NSCParameterAssert(scheduler != nil);
NSCParameterAssert(scheduler != RACScheduler.immediateScheduler);

return [[RACSignal createSignal:^(id<RACSubscriber> subscriber) {
return [(RACQueueScheduler *)scheduler afterDelayTime:delayTime repeatingEvery:interval withLeeway:leeway schedule:^{
[subscriber sendNext:[NSDate date]];
}];
}] setNameWithFormat:@"+interval: %f onScheduler: %@ withLeeway: %f", (double)interval, scheduler, (double)leeway];
}

@end

RAC 定时器扩展的使用

在使用方面与 RAC 本身的定时器 API 几乎完全相同,可以做到毫无替换感知:

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
static NSTimeInterval const kDelayTime = 0.f;       // 执行延迟时间
static NSTimeInterval const kTimeInterval = 60.f; // 执行间隔时间

...

// 替换前
- (void)startHearbeatTask {
@weakify(self)
self.disposable = [[RACSignal interval:kTimeInterval onScheduler:[RACScheduler mainThreadScheduler]] subscribeNext:^(NSDate * _Nullable x) {
@strongify(self)
// 进行心跳连接
[self heartbeatLink];
}];
}

// 替换后
- (void)startHearbeatTask {
@weakify(self)
self.disposable = [[RACSignal delayTime:kDelayTime interval:kTimeInterval onScheduler:[RACScheduler mainThreadScheduler]] subscribeNext:^(NSDate * _Nullable x) {
@strongify(self)
// 进行心跳连接
[self heartbeatLink];
}];
}

...

运行后一切正常,完美的替换,现在终于可以独立控制执行延迟时间和执行间隔时间了,因为本质上仍然是 GCD 定时器,所以不用担心性能和定时器生命周期的维护等任何问题。

总结

估计是提供给 Objective-C 使用的 RAC 版本年老失修无人维护了,导致这种使用场景未能完成覆盖,实在是可惜,不过好在我们自己动手添加了一套扩展完成这部分内容的补充。

最后想说点什么呢,赶紧换 Swift 吧,RxSwift 里什么都有 [手动狗头]。

最近的文章

翻转二叉树

谷歌:我们90%的工程师使用您编写的软件(Homebrew),但是您却无法在面试时在白板上写出翻转二叉树这道题,这太糟糕了。 …

, , 开始阅读
更早的文章

二叉搜索树的第k大节点

剑指 Offer 54. 二叉搜索树的第k大节点 …

, , , , 开始阅读
comments powered by Disqus