把倒計時做到極致,又準(zhǔn)、又穩(wěn)!
https://juejin.cn/post/6984725689257689101 | 原文
能上架的 App,都逃不過一個倒計時的功能,手機驗證碼登錄總歸是要實現(xiàn)的。那這個功能中,實現(xiàn)的倒計時就包括每秒去修改 UI 倒計時計數(shù),讓時間的流逝做到用戶可感知。
那么對于倒計時的功能,有那些要求呢?我認(rèn)為有 2 點:準(zhǔn) & 穩(wěn)。
準(zhǔn)是說,一個 2 分鐘的倒計時,就應(yīng)該嚴(yán)格執(zhí)行 2 分鐘,不會多 1 秒也不會少 1 秒;穩(wěn)的意思就是說,每次執(zhí)行同步 UI 的更新,間隔都是固定的,例如需要每秒 -1 的倒計時,那每次更新 UI 的時間,應(yīng)該都差不多是間隔 1秒。
而在客戶端的環(huán)境下,有太多影響因素。比如 App 退出后臺并且鎖屏,導(dǎo)致 CPU 休眠了;又比如定時器每次喚醒的時間差了幾毫秒甚至幾十毫秒,倒計時結(jié)束時,總間隔時長大于預(yù)設(shè)的時長,這都是需要解決的問題。
今天給大家推薦一篇來自快手電商無線團(tuán)隊-小政的文章,看快手是如何處理倒計時問題的,讓時間倒數(shù)的又準(zhǔn)又穩(wěn)。
一、背景
我們在項目中經(jīng)常有倒計時的場景,比如活動倒計時、搶紅包倒計時等等。通常情況下,我們實現(xiàn)倒計時的方案有 Android 中的 CountDownTimer、Java 中自帶的 Timer 和 ScheduleExcutorService、RxJava 中的 interval 操作符。
在實際項目中存在 2 個典型的問題。
倒計時的實現(xiàn)形式不統(tǒng)一,不統(tǒng)一的原因分為認(rèn)知不一致、每種倒計時方案各有優(yōu)勢; 存在大量倒計時同時執(zhí)行。
二、對比分析
關(guān)于幾種方案的用法不是本文要討論的重點,在此我們通過表格的方式列出來各自的特性,表格底部的 CountDownTimerManager 就是本文要為大家介紹的新鮮出爐的中心化倒計時組件。

2.1 是否是倒計時
Rx 中的 interval 操作符,是每隔一段時間會發(fā)送一個事件,可以說是一個計數(shù)器,而不是倒計時,在實際項目中會發(fā)現(xiàn)很多同學(xué),都把它當(dāng)做倒計時在使用。下圖是 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.(簡單理解就是固定間隔時間進(jìn)行回調(diào))
通過源碼,我們也可以看出在 ObservableInterval 中實際也是進(jìn)行了周期性調(diào)度。
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);
}
}
}
那么作為倒計時使用會有什么問題呢?
問題一:回調(diào)可能不準(zhǔn)確,假設(shè)倒計時 9.5 秒,每 1 秒刷新一次 View,該怎么設(shè)置回調(diào)間隔時間呢?
問題二:在手機長時間息屏后,某些廠商會將 CPU 休眠,RxJava 的
interval操作符此時將被按下暫停鍵,當(dāng) APP 再次回到前臺,interval會繼續(xù)執(zhí)行,假設(shè)暫停時倒計時剩余 100 秒,回到前臺后實際只有 10 秒了,但是interval還是從 100 繼續(xù)執(zhí)行。
2.2 支持多任務(wù)
Timer 是單線程串行執(zhí)行多任務(wù),假設(shè) taskA 設(shè)定 1 秒后執(zhí)行,taskB 設(shè)定 2 秒后執(zhí)行,實際上 taskB 是在 taskA 執(zhí)行結(jié)束后才執(zhí)行 taskB,所以 taskB 的執(zhí)行時間是在第 3 秒,所以 Timer 只算是偽支持多任務(wù)。ScheduledExecutorService 是利用線程池支持了多任務(wù)調(diào)度的。
2.3 支持時間校準(zhǔn)
CountDownTimer 中每次 onTick() 方法回調(diào),都會重新計算下一次 onTick 的時間。其中主要優(yōu)化有 2 點,一是減去 onTick() 執(zhí)行耗時;二是針對特殊情況(如 2.1 中提到的手機息屏后 CPU 休眠場景),對比 delay 是否小于 0,如果小于 0 則需要累加 mCountdownInterval。
long lastTickStart = SystemClock.elapsedRealtime();
onTick(millisLeft);
long lastTickDuration = SystemClock.elapsedRealtime() - lastTickStart;
long delay;
if (millisLeft < mCountdownInterval) {
delay = millisLeft - lastTickDuration;
if (delay < 0) {
delay = 0;
} else {
delay = mCountdownInterval - lastTickDuration;
while (delay < 0) {
delay += mCountdownInterval;
}
}
sendMessageDelayed(obtainMessage(MSG), delay);
}
2.4 支持同幀刷新
我們項目中有很多場景是這樣的:
倒計時 A 先執(zhí)行,倒計時 B 后執(zhí)行,A 和 B 的倒計時結(jié)束時間是一致的,那么我們假設(shè)倒計時時間為 10 秒,每 1 秒刷新一次,A 在剩余 10 秒時執(zhí)行,B 在剩余 9.5 秒執(zhí)行,當(dāng)二者在同一頁面顯示時,就會刷新不一致,這個問題在我們新的倒計時組件中將得到解決,文章后面將會詳細(xì)說明。
2.5 支持延遲執(zhí)行
延遲 1 分鐘再執(zhí)行 10 秒的倒計時?Android 中提供的 CountDownTimer 是做不到的,只能額外寫一個 1 分鐘的定時器,到時間后再啟動倒計時。
2.6 支持 CPU 休眠
我們這里提到的支持 CPU 休眠,并不是指 CPU 休眠期間倒計時仍能得到執(zhí)行,而是在 CPU 休眠后能夠恢復(fù)正常執(zhí)行。和 1.2.3 中提到的時間校準(zhǔn)類似,解決了時間校準(zhǔn)的問題也就支持了 CPU 休眠的特性。
三、需求目標(biāo)
設(shè)計一個中心化的倒計時組件,同時支持上述提到的一系列特性; 接口易于調(diào)用,使用者只需關(guān)注計時回調(diào)的邏輯;
四、設(shè)計類結(jié)構(gòu)
CountDownTimer 采用靜態(tài)內(nèi)部類形式實現(xiàn)單例,暴露 countdown()、timer() 方法供業(yè)務(wù)方 ClientA/ClientB/ClientC 等調(diào)用,Task 是抽象任務(wù),每次調(diào)用 countdown()、timer() 后都生成一個 task,交給優(yōu)先級隊列管理,內(nèi)部通過 handler 不斷從隊列中取 task 執(zhí)行。

五、具體實現(xiàn)
5.1 收口
收口可以理解為進(jìn)行統(tǒng)一管理,這里我們通過一個優(yōu)先級隊列管理所有倒計時、定時器,優(yōu)先級隊列可以直接采用 Java 中已有的數(shù)據(jù)結(jié)構(gòu) PriorityQueue,設(shè)置隊列大小默認(rèn)為 5,根據(jù) task 中的 mExecuteTimeInNext 進(jìn)行正序排序。
這里有一個特別需要注意的點,PriorityQueue 需要傳入實現(xiàn) Comparator 接口的對象,在實現(xiàn) Comparator 時,因為 mExecuteTimeInNext 的數(shù)據(jù)類型是 long 類型,而 compare() 方法返回的是 int 類型,如果直接使用二者相減再強制轉(zhuǎn)換為 int,會有溢出的風(fēng)險,所以可以使用 Long.compare() 來實現(xiàn)大小比較。
private final Queue<Task> mTaskQueue = new PriorityQueue<>(DEFAULT_INITIAL_CAPACITY,
new Comparator<Task>() {
@Override
public int compare(Task task1, Task task2) {
return Long.compare(task1.mExecuteTimeInNext, task2.mExecuteTimeInNext);
}
});
5.2 支持與 RxJava 協(xié)同
提供倒計時countdown、定時器 timer 操作符,直接返回 Observable,方便與 RxJava 框架協(xié)同。
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();
}
});
}
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 支持時間校準(zhǔn)
不推薦使用 RxJava 中的 interval,因為 RxJava 中的實現(xiàn)無法保障倒計時的準(zhǔn)確執(zhí)行,如在手機 CPU 進(jìn)入休眠之后再恢復(fù)到前臺。那么如何實現(xiàn)呢?這里借鑒了 Android 中 CountDownTimer 的設(shè)計思路,在每次 onTick 后重新計算了下一次 onTick 的時間,比如前文提到的 “CPU 進(jìn)入休眠” 的情況,我們通過一個 while 循環(huán),計算出下一次 onTick 的時間(其條件是大于當(dāng)前時間)。
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);
do {
task.mExecuteTimeInNext += task.mCountdownInterval;
} while (task.mExecuteTimeInNext < SystemClock.elapsedRealtime());
mTaskQueue.offer(task);
}
}
5.4 支持同步刷新
針對多個倒計時在同一時刻結(jié)束的情況,優(yōu)化了刷新不同步的問題。mExecuteTimeInNext 是下一次任務(wù)執(zhí)行時間,假設(shè)倒計時剩余時間為 9.5 秒,每 1 秒刷新,那么下一次的執(zhí)行時間則是在 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 支持延遲執(zhí)行
在計算下次執(zhí)行的時間時,加上了 delayMillis,這樣就支持了延遲執(zhí)行。
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;
}
六、小結(jié)
文內(nèi)邏輯清晰,核心代碼都在文中,有需要自己復(fù)制出來改改就能用。
另外我看到有人比較關(guān)心如何測試 CPU 休眠這個場景,CPU 是否進(jìn)入休眠,完全取決于 OS 的策略。
不過有個步驟可以試試,將 App 置為后臺,然后鎖屏,在現(xiàn)在續(xù)航優(yōu)化的大環(huán)境下,多數(shù)手機廠商對電池的優(yōu)化手段,都會在這個場景,進(jìn)入 CPU 休眠狀態(tài),如果還不行,可以將手機設(shè)為「省電模式」。
