1 背景
我们在项目中经常有倒计时的场景,比如活动倒计时、抢红包倒计时等等。通常情况下,我们实现倒计时的方案有Android中的CountDownTimer
、Java中自带的Timer
和ScheduleExcutorService
、RxJava中的interval
操作符。
在实际项目中存在2个典型的问题,一是倒计时的实现形式不统一,不统一的原因分为认知不一致、每种倒计时方案各有优势;二是存在大量倒计时同时执行。
2 对比分析
关于几种方案的用法不是本文要讨论的重点,在此我们通过表格的方式列出来各自的特性,表格底部的CountDownTimerManager
就是本文要为大家介绍的新鲜出炉的中心化倒计时组件。
2.1 是否是倒计时
Rx中的interval操作符是每隔一段时间会发送一个事件,可以说是一个计数器,而不是倒计时,在实际项目中会发现很多同学都把它当做倒计时在使用。下图是RxJava官方对interval的图解:
*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;
}
hi, 我是快手电商的小政
快手电商无线技术团队正在招贤纳士🎉🎉🎉! 我们是公司的核心业务线, 这里云集了各路高手, 也充满了机会与挑战. 伴随着业务的高速发展, 团队也在快速扩张. 欢迎各位高手加入我们, 一起创造世界级的电商产品~
热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品经理(电商背景), 测试开发... 大量 HC 等你来呦~
内部推荐请发简历至 >>>我们的邮箱: hr.ec@kuaishou.com <<<, 备注我的花名成功率更高哦~ 😘