App 开发中经常用到倒计时定时器(Countdown Timer),一般我们会使用 NSTimer 或者 GCD 的 dispatch_source_t 来创建定时器。然而众所周知的是,这两个方法使用起来都有一定的缺点:对 NSTimer 来说,当 CPU 忙的时候 NSTimer 计时不精确,如果添加到错误的 runloop mode 的话可能会达不到自己想要的效果,并且 NSTimer 会强引用 target 对象(这有可能导致内存泄露),一般我们需要使用 Proxy 的方式来创建一个 weak reference 的 NSTimer 封装,例如:YYKit/YYWeakProxy;对 dispatch_source_t 来说主要就是使用上比较麻烦,可能你需要自己创建一个对 dispatch_source_t 的封装才能比较方便的使用,例如:TimerWithGCD

NSTimer 的解决方案不仅实现起来有点麻烦(需要多一层封装),而且使用的时候代码也散落在程序的不同地方,相比起来 GCD 的方案会更好一些,但是当你接触 RxSwift 之后会发现使用 RxSwift 来写一个 Countdown Timer 可以更简单。我们写个 Demo 看看。

首先创建一个项目,并引入 RxSwift/RxCocoa/RxGesture 这三个库,这里使用 Cocoapods 的方式引入:

pod 'RxSwift'
pod 'RxCocoa'
pod 'RxGesture'

执行完 pod install 之后先编译项目生成 Framework 库,然后打开 Main.storyboard,在 ViewController 上添加一个 UILabel 并绑定到 ViewController.swift 里面的一个 IBOutlet,下面显示定时器时间的时候会用到它。

打开 ViewController.swift 导入这三个库:

import RxSwift
import RxCocoa
import RxGesture

这里我想要实现以下几个需求:

  • 在 view 上添加一个点击手势,并且在 3 秒钟后才允许接收点击事件;
  • 当用户点击后启动一个定时器并将倒计时时间显示在 label 上;
  • 计时期间 view 不接收点击事件

我们来看看代码:

@IBOutlet weak var label: UILabel!
private let db = DisposeBag()
private var skip = true

override func viewDidLoad() {
    super.viewDidLoad()
        
    let tap = view.rx.tapGesture()
        .share(replay: 1, scope: .whileConnected)
        .skipWhile { [unowned self](_) -> Bool in
            return self.skip
        }
        
    tap
        .when(.recognized)
        .flatMap { _ in
            return Observable<Int>
                .timer(0, period: 1, scheduler: MainScheduler.instance)
                .take(4)
                .map { 3 - $0 }
        }
        .do(onNext: { [unowned self] v in
            self.view.isUserInteractionEnabled = v > 0 ? false : true
        })
        .map { String($0) }
        .asDriver(onErrorJustReturn: "Error")
        .drive(label.rx.text)
        .disposed(by: db)
        
    Observable<Int>
        .timer(3, scheduler: MainScheduler.instance)
        .subscribe(onNext: { [unowned self] _ in self.skip = false })
        .disposed(by: db)
}

短短的 30 行不到的代码就完全实现了上面我列出来的需求,非常简洁而且直观易懂。我们来分析分析这段代码:首先,通过 RxGesture 库提供的方法我们给 view 添加了一个 UITapGestureRecognizer。注意到这里使用 .share(replay: 1, scope: .whileConnected) 来确保这个手势可以在被订阅的时候共享给其他的 subscription,可以添加下面的代码检验一下:

tap
    .when(.ended)
    .subscribe(onNext: {_ in print("end")})
    .disposed(by: db)
Observable<Int>
    .timer(0, period: 0.5, scheduler: MainScheduler.instance)
    .subscribe(onNext: { [unowned self]_ in
        print(self.view.gestureRecognizers!)
    })
    
    .disposed(by: db)

编译运行,可以看到类似如下的输出:

...

[<UITapGestureRecognizer: 0x1c41f9900; state = Possible; view = <UIView 0x10240cee0>; target= <(action=eventHandler:, target=<_TtGC7RxCocoa13GestureTargetCSo19UIGestureRecognizer_ 0x1c4448760>)>>]
end

...

可以看到即使我们订阅了两次 view 也只有一个 UITapGestureRecognizer,如果去掉 share 这一行呢?

...

[<UITapGestureRecognizer: 0x1c01f8600; state = Possible; view = <UIView 0x10380ba50>; targets= <(
    "(action=eventHandler:, target=<_TtGC7RxCocoa13GestureTargetCSo19UIGestureRecognizer_ 0x1c045aa00>)",
    "(action=eventHandler:, target=<_TtGC7RxCocoa13GestureTargetCSo19UIGestureRecognizer_ 0x1c045ad60>)"
)>>]

...

可以看到,由于我们 subscribe 了两次这个 tapGesture,它生成了两个 UITapGestureRecognizer 并添加到 view 上。

下面,使用 skipWhile 根据 closure 的结果来决定是否跳过一些事件,这里我使用了 skip 属性来模拟我们想要的结果:使用 Observable.timer 启动一个定时器,3 秒后将 skip 置为false。需要注意的是:这段代码我们只限制 app 在运行的前 3 秒不允许点击,如果你想要通过开关切换 view 是否响应点击,那么你应该使用 filter 操作代替 skipWhile

.filter { [unowned self] _ in !self.skip }

skipWhilefilter 区别在于:

  • skipWhile 操作会持续跳过 closure 返回为 true 的事件,但是一旦 closure 返回为 false,即使后续事件依然可以令 closure 返回 true 也不跳过:

  • filter 操作则对每次接收到的事件进行判断是否满足 closure 条件:

因此,在使用的时候我们应该根据需求选择合适的操作符,避免误用导致的 bug。

接下来,当点击事件被识别到的时候(.when(.recognized)),我们在 flatMap 操作里返回一个定时器。定时器的代码很简单:在 0 秒的时候启动一个周期为 1 秒的定时器,取前 4 个元素并用 map 操作映射成倒序的元素序列,也就是说,[0, 1, 2, 3] 被映射成了 [3, 2, 1, 0],这恰好就是我们想要的倒计时序列。flatMap 方法非常类似 Swift 标准库中的 flatMap 方法,不同的地方在于:Swift.flatMap 可以将序列中的 nil 值清除或者解包 Optional 值,并且可以将数组中的数组进行“压扁”成为一个数组;而 RxSwift 中的 flatMap 则可以将返回的 Observable 对象进行“解包”,并“流入”到下一个操作符中去。这使得它非常适合用在一些需要异步操作的地方,例如网络操作,在 flatMap 的 closure 中返回一个获取数据的 Observable 对象,当真正获取到数据或者发生错误的时候,数据流才会进行到下一个操作符。

接着,使用 do(onNext) 操作进行了一些有 “副作用” 的操作,这不是一个好的使用习惯,但这里恰好可以让我们来控制 view 是否可以点击。

再看 .asDriver(onErrorJustReturn: "Error")asDriver 方法将前面的 Sequence 转化为一个 Driver 对象,它有这样一些好处:自动切换到主线程,并且可以用来 drive 其他 ObserverType 或 Variable 对象,这样的话就可以避免写 bind(to)subscribe(onNext) 之类的方法了,非常适合用来驱动 UI 更新。

到这里,上面的代码就全部分析完了,非常的简洁易懂,虽然 RxSwift 有一定的学习成本,但一旦掌握之后我们的工作就可以事半功倍,推荐大家学习。