利用Android设计一个倒计时组件
1 背景
我们在项目中经常有倒计时的场景,比如活动倒计时、抢红包倒计时等等。通常情况下,我们实现倒计时的方案有Android
中的CountDownTimer
、Java
中自带的Timer
和ScheduleExcutorService
、RxJava
中的interval
操作符。 在实际项目中存在2个典型的问题,一是倒计时的实现形式不统一,不统一的原因分为认知不一致、每种倒计时方案各有优势;二是存在大量倒计时同时执行。
2 对比分析
关于几种方案的用法不是本文要讨论的重点,在此我们通过表格的方式列出来各自的特性,表格底部的CountDownTimerManager
就是本文要为大家介绍的新鲜出炉的中心化倒计时组件。
2.1 是否是倒计时
Rx中的interval
操作符是每隔一段时间会发送一个事件,可以说是一个计数器,而不是倒计时,在实际项目中会发现很多同学都把它当做倒计时在使用。下图是RxJava
官方对interval
的图解:
interval.png *The Interval operator returns an Observable that emits an infinite sequence of ascending integers, with a constant interval of time of your choosing between emissions.(简单理解就是固定间隔时间进行回调)
通过源码,我们也可以看出在ObservableInterval
中实际也是进行了周期性调度。
public final class ObservableInterval extends Observable<Long> { @Override public void subscribeActual(Observer<? super Long> observer) { IntervalObserver is = new IntervalObserver(observer); observer.onSubscribe(is); Scheduler sch = scheduler; if (sch instanceof TrampolineScheduler) { Worker worker = sch.createWorker(); is.setResource(worker); // 以给定的初始时间延迟、周期时间进行周期性执行 worker.schedulePeriodically(is, initialDelay, period, unit); } else { // 以给定的初始时间延迟、周期时间进行周期性执行 Disposable d = sch.schedulePeriodicallyDirect(is, initialDelay, period, unit); is.setResource(d); } }
那么作为倒计时使用会有什么问题呢?
问题一是回调可能不准确,假设倒计时9.5秒,每1秒刷新一次view,该怎么设置回调间隔时间呢?
问题二是在手机长时间息屏后,某些厂商会将CPU休眠,RxJava
的interval
操作符此时将被按下暂停键,当APP再次回到前台,interval会继续执行,假设暂停时倒计时剩余100秒,回到前台后实际只有10秒了,但是interval
还是从100继续执行。
2.2 支持多任务
Timer
是单线程串行执行多任务,假设taskA设定1秒后执行,taskB设定2秒后执行,实际上taskB是在taskA执行结束后才执行taskB,所以taskB的执行时间是在第3秒,所以Timer
只算是伪支持多任务。ScheduledExecutorService
是利用线程池支持了多任务调度的。
2.3 支持时间校准
CountDownTimer
中每次onTick()方法回调,都会重新计算下一次onTick
的时间。其中主要优化有2点,一是减去onTick执行耗时;二是针对特殊情况(如1.2.1中提到的手机息屏后CPU休眠场景),对比delay
是否小于0,如果小于0则需要累加mCountdownInterval。
long lastTickStart = SystemClock.elapsedRealtime(); onTick(millisLeft); long lastTickDuration = SystemClock.elapsedRealtime() - lastTickStart; long delay; if (millisLeft < mCountdownInterval) { // 减去上面onTick方法执行耗时 delay = millisLeft - lastTickDuration; if (delay < 0) { delay = 0; } else { delay = mCountdownInterval - lastTickDuration; // 针对特殊情况(如1.2.1中提到的手机息屏后CPU休眠场景) // 对比delay是否小于0,如果小于0则需要累加mCountdownInterval while (delay < 0) { delay += mCountdownInterval; } } sendMessageDelayed(obtainMessage(MSG), delay); }
2.4 支持同帧刷新
我们项目中有很多场景是这样的:
倒计时A先执行,倒计时B后执行,A和B的倒计时结束时间是一致的,那么我们假设倒计时时间为10秒,每1秒刷新一次,A在剩余10秒时执行,B在剩余9.5秒执行,当二者在同一页面显示时,就会刷新不一致,这个问题在我们新的倒计时组件中将得到解决,文章后面将会详细说明。
2.5 支持延迟执行
延迟1分钟再执行10秒的倒计时?Android
中提供的CountDownTimer
是做不到的,只能额外写一个1分钟的定时器,到时间后再启动倒计时。
2.6 支持CPU休眠
我们这里提到的支持CPU休眠,并不是指CPU休眠期间倒计时仍能得到执行,而是在CPU休眠后能够恢复正常执行。和1.2.3中提到的时间校准类似,解决了时间校准的问题也就支持了CPU休眠的特性。
3 需求目标
- 设计一个中心化的倒计时组件,同时支持上述提到的一系列特性。
- 接口易于调用,使用者只需关注计时回调的逻辑。
4 设计类结构
CountDownTimer
采用静态内部类形式实现单例,暴露countdown()
、timer()
方法供业务方ClientA/ClientB/ClientC
等调用,Task是抽象任务,每次调用countdown()
、timer()
后都生成一个task,交给优先级队列管理,内部通过handler不断从队列中取task执行。
5 具体实现
5.1 收口
收口可以理解为进行统一管理,这里我们通过一个优先级队列管理所有倒计时、定时器,优先级队列可以直接采用Java中已有的数据结构PriorityQueue
,设置队列大小默认为5,根据task中的mExecuteTimeInNext
进行正序排序。这里有一个特别需要注意的点,PriorityQueue
需要传入实现Comparator
接口的对象,在实现Comparator
时,因为mExecuteTimeInNext
的数据类型是long类型,而compare()
方法返回的是int类型,如果直接使用二者相减再强制转换为int
,会有溢出的风险,所以可以使用Long.compare()
来实现大小比较。
/** * 优先级队列,保存task,以 {@link Task#mExecuteTimeInNext} 作为基准 */ private final Queue<Task> mTaskQueue = new PriorityQueue<>(DEFAULT_INITIAL_CAPACITY, new Comparator<Task>() { @Override public int compare(Task task1, Task task2) { // return (int) (task1.mExecuteTimeInNext - task2.mExecuteTimeInNext); 错误示范 return Long.compare(task1.mExecuteTimeInNext, task2.mExecuteTimeInNext); } });
5.2 支持与RxJava协同
提供倒计时countdown
、定时器timer
操作符,直接返回Observable
,方便与RxJava
框架协同。
/** * 倒计时 * * @param millisInFuture Millis since epoch when alarm should stop. * @param countDownInterval The interval in millis that the user receives callbacks. * @param delayMillis The delay time in millis. * @return Observable */ public synchronized Observable<Long> countdown(long millisInFuture, long countDownInterval, long delayMillis) { AtomicReference<Task> taskAtomicReference = new AtomicReference<>(); return Observable.create((ObservableOnSubscribe<Long>) emitter -> { Task newTask = new Task(millisInFuture, countDownInterval, delayMillis, emitter); taskAtomicReference.set(newTask); synchronized (CountDownTimerManager.this) { Task topTask = mTaskQueue.peek(); if (topTask == null || newTask.mExecuteTimeInNext < topTask.mExecuteTimeInNext) { cancel(); } mTaskQueue.offer(newTask); if (mCancelled) { start(); } } }).doOnDispose(() -> { if (taskAtomicReference.get() != null) { taskAtomicReference.get().dispose(); } }); }
/** * 定时器 * * @param millisInFuture Millis since epoch when alarm should stop. * @return Observable */ public synchronized Observable<Long> timer(long millisInFuture) { return countdown(0, 0, millisInFuture); } private synchronized void remove(Task task) { mTaskQueue.remove(task); if (mTaskQueue.size() == 0) { cancel(); } }
5.3 支持时间校准
不推荐使用RxJava
中的interval
,因为RxJava中的实现无法保障倒计时的准确执行,如在手机CPU进入休眠之后再恢复到前台。那么如何实现呢?这里借鉴了Android
中CountDownTimer
的设计思路,在每次onTick后重新计算了下一次onTick的时间,比如前文提到的“CPU进入休眠”的情况,我们通过一个while循环,计算出下一次onTick的时间(其条件是大于当前时间)。
mTaskQueue.poll(); if (!task.isDisposed()) { if (stopMillisLeft <= 0 || task.mCountdownInterval == 0) { task.mDisposed = true; task.mEmitter.onNext(0L); task.mEmitter.onComplete(); } else { task.mEmitter.onNext(stopMillisLeft % task.mCountdownInterval == 0 ? stopMillisLeft : (stopMillisLeft / task.mCountdownInterval + 1) * task.mCountdownInterval); // 时间校准 // special case: // user's onTick took more than interval to complete // cpu slept do { task.mExecuteTimeInNext += task.mCountdownInterval; } while (task.mExecuteTimeInNext < SystemClock.elapsedRealtime()); mTaskQueue.offer(task); } }
5.4 支持同步刷新
针对多个倒计时在同一时刻结束的情况,优化了刷新不同步的问题。 mExecuteTimeInNext
是下一次任务执行时间,假设倒计时剩余时间为9.5秒,每1秒刷新,那么下一次的执行时间则是在0.5秒之后。
private Task(long millisInFuture, long countDownInterval, long delayMillis, @NonNull ObservableEmitter<Long> emitter) { mCountdownInterval = countDownInterval; // 计算出下次执行的时间 mExecuteTimeInNext = SystemClock.elapsedRealtime() + (mCountdownInterval == 0 ? 0 : millisInFuture % mCountdownInterval) + delayMillis; mStopTimeInFuture = SystemClock.elapsedRealtime() + millisInFuture + delayMillis; mEmitter = emitter; }
5.5 支持延迟执行
在计算下次执行的时间时,加上了delayMillis
,这样就支持了延迟执行。
private Task(long millisInFuture, long countDownInterval, long delayMillis, @NonNull ObservableEmitter<Long> emitter) { mCountdownInterval = countDownInterval; // 计算出下次执行的时间 mExecuteTimeInNext = SystemClock.elapsedRealtime() + (mCountdownInterval == 0 ? 0 : millisInFuture % mCountdownInterval) + delayMillis; mStopTimeInFuture = SystemClock.elapsedRealtime() + millisInFuture + delayMillis; mEmitter = emitter; }