Android 關於 CountDownTimer onTick() 倒計時不準確問題源碼分析
一、問題
CountDownTimer 使用比較簡單,設置 5 秒的倒計時,間隔為 1 秒。
final String TAG = "CountDownTimer"; new CountDownTimer(5 * 1000, 1000) { @Override public void onTick(long millisUntilFinished) { Log.i(TAG, "onTick → millisUntilFinished = " + millisUntilFinished + ", seconds = " + millisUntilFinished / 1000); } @Overridepublic void onFinish() { Log.i(TAG, "onFinish"); } }.start();
以 API 25 為例。即 app 的 build.gradle 中設置的編譯版本是 25(後續會提到版本問題)。
compileSdkVersion 25
我們期待的效果是:“5-4-3-2-1-finish”或者“5-4-3-2-1-0”。這裏,我認為 顯示 0 和 finish 的時間應該是一致的,所以把 0 放在 onFinish() 裏顯示也可以。
打印日誌可以看到有幾個問題:
問題1. 每次 onTick() 都會有幾毫秒的誤差,並不是期待的準確的 "5000, 4000, 3000, 2000, 1000, 0"。
問題2. 多運行幾次,就會發現這幾毫秒的誤差,導致了計算得出的剩余秒數並不準確,如果你的倒計時需要顯示剩余秒數,就會發生 秒數跳躍/缺失 的情況(比如一開始從“4”開始顯示——缺少“5”,或者直接從“5”跳到了“3”——缺少“4”)。
問題3. 最後一次 onTick() 到 onFinish() 的間隔通常超過了 1 秒,差不多是 2 秒左右。如果你的倒計時在顯示秒數,就能很明顯的感覺到最後 1 秒停頓的時間很長。
仔細看一下日誌裏標註的地方,如果你想直接看解決方案,可以直接滑到日誌最下方,或者在頂部目錄裏選擇最後一欄“三、終極解決”查看。
二、分析源碼
(一)API 25 源碼分析
查看 CountDownTimer 源碼(API 25),
發現 start() 中計算的 mStopTimeInFuture(未來停止倒計時的時刻,即倒計時結束時間) 加了一個 SystemClock.elapsedRealtime() ,系統自開機以來(包括睡眠時間)的毫秒數,後文中以“系統時間戳”簡稱。
即倒計時結束時間為“當前系統時間戳 + 你設置的倒計時時長 mMillisInFuture ”,也就是計算出的相對於手機系統開機以來的一個時間。
繼續往下看,多處用到了 SystemClock.elapsedRealtime() 。
在源碼裏添加 Log 打印看看。(直接在源碼裏修改是不會打印出來的,因為運行時不是編譯的你剛剛修改的源碼,而是手機裏對應的源碼。我復制了一份源碼添加的 Log,見 demo 裏的CountDownTimerCopyFromAPI25.java)
String TAG = "CountDownTimer-25";
/** * Start the countdown. */ public synchronized final CountDownTimerCopyFromAPI25 start() { mCancelled = false; if (mMillisInFuture <= 0) { onFinish(); return this; } //Add Log.i(TAG, "start → mMillisInFuture = " + mMillisInFuture + ", seconds = " + mMillisInFuture / 1000 ); mStopTimeInFuture = SystemClock.elapsedRealtime() + mMillisInFuture; //Add Log.i(TAG, "start → elapsedRealtime = " + SystemClock.elapsedRealtime()); Log.i(TAG, "start → mStopTimeInFuture = " + mStopTimeInFuture); mHandler.sendMessage(mHandler.obtainMessage(MSG)); return this; }
// handles counting down @SuppressLint("HandlerLeak") private Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { synchronized (CountDownTimerCopyFromAPI25.this) { if (mCancelled) { return; } final long millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime(); //Add Log.i(TAG, "handleMessage → elapsedRealtime = " + SystemClock.elapsedRealtime()); Log.i(TAG, "handleMessage → millisLeft = " + millisLeft + ", seconds = " + millisLeft / 1000 ); if (millisLeft <= 0) { //Add Log.i(TAG, "onFinish → millisLeft = " + millisLeft); onFinish(); } else if (millisLeft < mCountdownInterval) { //Add Log.i(TAG, "handleMessage → millisLeft < mCountdownInterval !"); // no tick, just delay until done sendMessageDelayed(obtainMessage(MSG), millisLeft); } else { long lastTickStart = SystemClock.elapsedRealtime(); //Add Log.i(TAG, "before onTick → lastTickStart = " + lastTickStart); Log.i(TAG, "before onTick → millisLeft = " + millisLeft + ", seconds = " + millisLeft / 1000 ); onTick(millisLeft); //Add Log.i(TAG, "after onTick → elapsedRealtime = " + SystemClock.elapsedRealtime()); // take into account user‘s onTick taking time to execute long delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime(); //Add Log.i(TAG, "after onTick → delay1 = " + delay); // special case: user‘s onTick took more than interval to // complete, skip to next interval while (delay < 0) delay += mCountdownInterval; //Add Log.i(TAG, "after onTick → delay2 = " + delay); sendMessageDelayed(obtainMessage(MSG), delay); } } } };
打印日誌:
倒計時 5 秒,而 onTick() 一共只執行了 4 次。
start() 啟動計時時,mMillisInFuture = 5000。
且根據當前系統時間戳(記為 elapsedRealtime0 = 349001103,開始 start() 倒計時時的系統時間戳)計算了倒計時結束時相對於系統開機時的時間點 mStopTimeInFuture。
mStopTimeInFuture = SystemClock.elapsedRealtime() + mMillisInFuture;//---------(1)
此後到第一次進入 handleMessage() 時,中間經歷了很短的時間 349001109 - 349001103 = 6 毫秒。
handleMessage() 這裏精確計算了程序執行時間,雖然是第一次進入 handleMessage,也沒有直接使用 mStopTimeInFuture,而是根據程序執行到此處時的 elapsedRealtime() (記為 elapsedRealtime1)來計算此時剩余的倒計時時長。
final long millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime();//---------(2)
根據 (1) 式和 (2) 式,調換一下運算順序,其實就是
millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime() = elapsedRealtime0 + mMillisInFuture - elapsedRealtime1 = mMillisInFuture - (elapsedRealtime1 - elapsedRealtime0)//減去程序從 start() 執行到此處花掉的時間 = 5000 - (349001109 - 349001103) = 4994
millisLeft = 4994,進入 else,執行 onTick():
所以第一次 onTick() 時,millisLeft = 4994,導致計算的剩余秒數是“4994 / 1000 = 4”,所以倒計時顯示秒數是從“4”開始,而不是“5”開始。這便是前面提到的 問題1 和 問題2。
onTick() 後還計算了下一次發送 message 的一個延遲時間 delay:
long lastTickStart = SystemClock.elapsedRealtime(); onTick(millisLeft); // take into account user‘s onTick taking time to execute // 考慮到用戶執行 onTick 需要時間 long delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime();
lastTickStart = SystemClock.elapsedRealtime() 即此次觸發 onTick() 前時的系統時間戳,
mCountdownInterval 即我們設置的 onTick() 的調用間隔。
兩者相加,再減去執行完 onTick() 後時的系統時間戳,得到 delay 的值。
同樣的,我們調換一下加減運算順序,可以看到
delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime() = mCountdownInterval - (SystemClock.elapsedRealtime() - lastTickStart) = mCountdownInterval - 此次 onTick() 的執行時間 //看到這裏其實就明白了,計算 delay 是為了保證 onTick() 每次調用時的間隔是 mCountdownInterval. = 1000 - (349001129 - 349001110) = 981
可是日誌裏輸出的 delay = 980,看看我們添加的打印 log 語句,
onTick(millisLeft); //Add Log.i(TAG, "after onTick → elapsedRealtime = " + SystemClock.elapsedRealtime());//----(3) // take into account user‘s onTick taking time to execute // 考慮到用戶執行 onTick 需要時間 long delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime();//-----(4)
可見在 (3) 式打印日誌時到 (4) 式計算 delay 時中間剛好消耗了 1 毫秒。也就是計算 delay 時系統時間戳實際是 elapsedRealtime = 349001129 + 1 = 349001130。
所以我們的 mCountdownInterval 依然是每次 調用 onTick() 時的時間間隔。
繼續往下看代碼,發現在發送下一次 message 前,還對 delay 的值做了判斷:
// 特殊情況:用戶的 onTick 執行時間超過了給定的時間間隔 mCountdownInterval,則直接跳到下一次間隔 while (delay < 0) delay += mCountdownInterval; sendMessageDelayed(obtainMessage(MSG), delay);
如果這次 onTick() 執行時間太長,超過了 mCountdownInterval ,那麽執行完 onTick() 後計算得到的 delay 是一個負數,此時直接跳到下一次 mCountdownInterval 間隔,讓 delay + mCountdownInterval。
似乎有點繞,那我們帶入具體的數值來計算一下吧。
我們設定每 1000 毫秒執行一次 onTick()。假設第一次 onTick() 開始前時的相對於手機系統開機時間的剩余倒計時時長是 5000 毫秒, 執行完這次 onTick() 操作消耗了 1005 毫秒,超出了我們設定的 1000 毫秒的間隔,那麽第一次計算的 delay = 1000 - 1005 = -5 < 0,那麽負數意味著什麽呢?
本來我們設定的 onTick() 調用間隔是 1000 毫秒,可是它執行完一次卻用了 1005 毫秒,現在剩余倒計時還剩下 5000 - 1005 = 3995 毫秒,本來第二次 onTick() 按期望應該是在 4000 毫秒時開始執行的,可是此時第一次的 onTick() 卻還未執行完。所以第二次 onTick() 就會被延遲 delay = -5 + 1000 = 995 毫秒,也就是到剩余 3000 毫秒時再執行了。
回到我們的 log 裏~第一次 onTick() 執行完後,log 打印出 elapsedRealtime = 349001129,前面分析了此時實際的系統時間戳其實是 349001129 + 1 = 349001130。然後延遲了 delay = 980 毫秒後,第二次進入 handleMessage(),我們計算此時系統時間戳為 349001130 + 980 = 349002110,和 log打印一致。再來計算此時的 millisLeft:
millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime() = elapsedRealtime0 + mMillisInFuture - elapsedRealtime2 = mMillisInFuture - (elapsedRealtime2 - elapsedRealtime0)//減去程序從 elapsedRealtime0 執行到此處花掉的時間 = 5000 - (349002110 - 349001103) = 3993
剩余秒數為 seconds = 3993 / 1000 = 3 秒。執行完第二次 onTick() 時的系統時間戳是 elapsedRealtime = 349002117,
delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime()
= mCountdownInterval - (SystemClock.elapsedRealtime() - lastTickStart)
= 1000 - (349002117 - 349002111)
= 994
後續第 3、4 次的計算就不寫了,和上面的計算類似。
從日誌可以看到,最後一次調用 onTick() 是在 第 4 次處理 handleMessage 時調用的,此時倒計時顯示剩余 millisLeft = 1990 毫秒 = (int)(1990 /1000) 秒 = 1 秒。
此時 lastTickStart = 349004114,而 349004114 + 1990 =349006104,也就是 第 6 次 進入 handleMessage 時調用 onFinish() 的時間。
延遲了 delay = 996 毫秒後,接下來,第 5 次進入 handleMessage 時,因為 millisLeft = 988 < mCountdownInterval = 1000 ,導致沒有觸發 onTick(),而是直接發送了一個延遲了 millisLeft = 988 毫秒的 message。此時的 elapsedRealtime = 349005115。
延遲了 988 毫秒後,elapsedRealtime = 349005115 + 988 = 349006103,log 打印為 349006104,差不多。記 elapsedRealtime3= 349006104。
現在第 6 次進入 handleMessage,
millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime() = elapsedRealtime0 + mMillisInFuture - elapsedRealtime3 = mMillisInFuture - (elapsedRealtime3 - elapsedRealtime0)//減去程序從 start() 執行到此處花掉的時間 = 5000 - (349006104 - 349001103) = -1
millisLeft = -1 < 0,調用 finish(),結束倒計時~
所以在 第 4 次 handleMessage() 後就沒有再觸發 onTick() 了,而且從前面分析處標紅文字可以看到,最後一次 onTick() 調用後,一共延遲了 2 次,共 996 + 988 = 1984 ≈ 1990 毫秒,才執行到 onFinish()。這便是文章初提到的問題3:倒計時最後 1 秒停頓時間過長。
至此,關於 API 25 裏的 CountDownTimer 源碼分析完畢,所以其實源碼也並不是絕對正確的,我們發現了有幾處問題。接下來針對這幾處問題來分析一下如何改進~
(二)API 25 源碼改進
針對 問題1 和 問題 2:
問題1. 每次 onTick() 都會有幾毫秒的誤差,並不是期待的準確的 "5000, 4000, 3000, 2000, 1000, 0"。
問題2. 多運行幾次,就會發現這幾毫秒的誤差,導致了計算得出的剩余秒數並不準確,如果你的倒計時需要顯示剩余秒數,就會發生 秒數跳躍/缺失 的情況(比如一開始從“4”開始顯示——缺少“5”,或者直接從“5”跳到了“3”——缺少“4”)。
這 2 個問題可以放在一起處理,網上也有很多人對這裏做了改進,那就是給我們的 倒計時時長擴大一點點,通常是 手動將 mMillisInFuture 擴大幾十毫秒,比如文章開頭的例子,可以在 new CountDownTimer() 時修改傳參:
final String TAG = "CountDownTimer"; new CountDownTimer(5 * 1000 + 20, 1000) { // 方案1:修改構造方法的傳參 @Override public void onTick(long millisUntilFinished) { Log.i(TAG, "onTick → millisUntilFinished = " + millisUntilFinished + ", seconds = " + millisUntilFinished / 1000); } @Override public void onFinish() { Log.i(TAG, "onFinish"); } }.start();
這裏多加了 20 毫秒,運行一下(具體代碼可見 demo,這裏只是舉個栗子)
倒計時:“5,4,3,2,1,finish”,
基本可以解決 問題1 和 問題2 啦~
當然,你也可以寫一個自己的 CountdownTimer,在構造方法裏修改,這樣就不用每次調用時手動改時長了:
public MyCountDownTimer(long millisInFuture, long countDownInterval) { mMillisInFuture = millisInFuture + 20; // 方案2:直接在構造方法裏修改 mMillisInFuture mCountdownInterval = countDownInterval; }
針對 問題3:
問題3. 最後一次 onTick() 到 onFinish() 的間隔通常超過了 1 秒,差不多是 2 秒左右。如果你的倒計時在顯示秒數,就能很明顯的感覺到最後 1 秒停頓的時間很長。
其實我們增加了 20 毫秒後,查看日誌就發現這個延遲也變小了,幾乎和 最後一次 onTick() 一致了,所以如果你需要最後顯示 0 ,而又不需要在 onFinish() 裏做什麽的話,修改至此就 ok 啦~
我們看看之前有問題的日誌呢,可以發現 第 5 次進入 handleMessage() 時,因為 millisLeft = 988 < 1000,所以會進入 else if 的邏輯:
這裏按期望應該是要執行一次 onTick() 。
所以我們加上一句 onTick() 即可。
打印日誌:
修改後的完整代碼見:CountDownTimerImproveFromAPI25.java
不過這也有個問題,因為我們是直接將倒計時時間加長了,雖然只是幾十毫秒,但也會造成整個倒計時的時間(從 start() 到 onFinish())不是精確的,而且這個 20 毫秒只是我根據前面程序運行的時間規律算的,可能也有程序從 start() 運行到 第一次進入 handleMessage() 會超過 20 毫秒的情況呢?
(三)API 26 源碼分析
先看一下運行效果: 這是又一次運行時的輸出日誌: 可以看到 API 26 的倒計時有所改進,咋一看是正確的,能夠倒計時至 0 。但仔細看一看最後 2 行的時間戳,發現倒計時 0 秒後,又經過了大概 1 秒鐘,才觸發的 onFinish()。而且同樣的沒有顯示最初的 5 秒。 多運行幾次就會發現(比如日誌裏的情形),和 API 25 一樣存在 秒數跳躍的問題。 所以總結一下 API 26 的問題:問題1. 每次 onTick() 都會有幾毫秒的誤差,並不是期待的準確的 "5000, 4000, 3000, 2000, 1000, 0"。
問題2. 這幾毫秒的誤差,導致了計算得出的剩余秒數並不準確,如果你的倒計時需要顯示剩余秒數,就會發生 秒數跳躍/缺失 的情況(比如一開始從“4”開始顯示——缺少“5”,或者直接從“5”跳到了“3”——缺少“4”),並且都沒有顯示 “0”秒。
問題3. 最後一次 onTick() 顯示為 0 ,到 onFinish() 的間隔約有 1 秒。
其中問題1 和 問題2 和 API 25 的一致,不再詳述。
看一下 API 26 的代碼吧,demo 中見 CountDownTimerCopyFromAPI26.java
private Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { synchronized (CountDownTimerCopyFromAPI26.this) { if (mCancelled) { return; } final long millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime(); //Add Log.i(TAG, "handleMessage → elapsedRealtime = " + SystemClock.elapsedRealtime()); Log.i(TAG, "handleMessage → millisLeft = " + millisLeft + ", seconds = " + millisLeft / 1000); if (millisLeft <= 0) { //Add Log.i(TAG, "onFinish → millisLeft = " + millisLeft); onFinish(); } else { long lastTickStart = SystemClock.elapsedRealtime(); //Add Log.i(TAG, "before onTick → lastTickStart = " + lastTickStart); Log.i(TAG, "before onTick → millisLeft = " + millisLeft + ", seconds = " + millisLeft / 1000); onTick(millisLeft); // take into account user‘s onTick taking time to execute // 考慮到用戶執行 onTick 需要時間 long lastTickDuration = SystemClock.elapsedRealtime() - lastTickStart; long delay; //Add Log.i(TAG, "after onTick → lastTickDuration = " + lastTickDuration); if (millisLeft < mCountdownInterval) { // just delay until done //直接延遲到計時結束 delay = millisLeft - lastTickDuration; //Add Log.i(TAG, "after onTick → delay1 = " + delay); // special case: user‘s onTick took more than interval to // complete, trigger onFinish without delay // 特殊情況:用戶的 onTick 執行時間超過了給定的時間間隔 mCountdownInterval,則立即觸發 onFinish if (delay < 0) delay = 0; //Add Log.i(TAG, "after onTick → delay2 = " + delay); } else { delay = mCountdownInterval - lastTickDuration; //Add Log.i(TAG, "after onTick → delay1 = " + delay); // special case: user‘s onTick took more than interval to // complete, skip to next interval // 特殊情況:用戶的 onTick 執行時間超過了給定的時間間隔 mCountdownInterval,則直接跳到下一次間隔 while (delay < 0) delay += mCountdownInterval; //Add Log.i(TAG, "after onTick → delay2 = " + delay); } sendMessageDelayed(obtainMessage(MSG), delay); } } } };
可以看到 API 26 中將 handleMessage 裏的邏輯有所修改,可見官方也發現了這裏的問題。 API 26 中 將原先 API 25 裏的 else if 和 else 放在了一起處理,這樣 當 0 < millisLeft < mCountdownInterval 時,也會觸發 onTick(),和咱們之前在 API 25 的 else if 中加上一句 onTick() 思路一致。不過官方還做了更多的修改,也就是紅框裏面的: 新增了一個 lastTickDuration 來記錄剛剛的 onTick() 的執行時間,並且更改了當 0 < millisLeft < mCountdownInterval 時的 delay 值。 millisLeft 是 進入 handleMessage 時的還剩下的倒計時時間。 假設我們設置的 mCountdownInterval 間隔為 1000 毫秒,也就是 1 秒。 當 millisLeft > mCountdownInterval 時,和之前 API 25 的 else 裏的邏輯是一致的。 當 0 < millisLeft < mCountdownInterval 時,也就是剩余時間已經不足 1 秒了,只足夠觸發最後 1 次 onTick() 了,即剛剛執行完的 onTick() 就是最後一次。 (1)如果 millisLeft < lastTickDuration,則 delay < 0 ,即執行這最後一次 onTick() 時間太長超出了剩余的時間,那麽則令 delay = 0,立即發送消息,觸發 onFinish(),倒計時結束。 (2)如果 millisLeft > lastTickDuration,即這最後一次 onTick() 執行完後離我們設定的倒計時時間還有一會,那麽就延遲一個時間 delay = millisLeft - lastTickDuration 到最後時刻再發送消息觸發 onFinish()。 官方比咱們想的稍微周到一點,對 delay 做了更細致的計算,使得 onFinish() 的觸發能保證在我們設定的倒計時結束時或者結束後才執行。 關於問題 3 ,如果我們依舊將 mMillisInFuture 手動擴大 20 毫秒,問題也是能解決的,和前面 API 25 一致。
三、終極解決
但是如果我們想要精確一點的倒計時,不想擴大呢?而且這個擴大的時間也不好掌握,太大了會精度下降,太小了可能還是會出現 問題1 和 問題2。 其實看看每次日誌裏的 millisLeft 能發現,和我們預期的整數(5000-4000-3000等)都只差幾毫秒左右,所以我覺得最好的解決辦法是:我們在 onTick() 裏做一下四舍五入 就可以了。final String TAG = "CountDownTimer"; new CountDownTimer(5 * 1000, 1000) { @Override public void onTick(long millisUntilFinished) { //四舍五入取整 Log.i(TAG, "onTick → millisUntilFinished = " + millisUntilFinished + ", seconds = " + Math.round((double) millisUntilFinished / 1000)); } @Override public void onFinish() { Log.i(TAG, "onFinish"); } }.start();最後總結一下: 1. 復制一份 API 26 的CountdownTimer 代碼(CountDownTimerCopyFromAPI26.java)放在項目裏,替代 SDK 裏的版本。 2. 在你自己的 onTick() 裏 修改一下秒數的計算,改為四舍五入取整
seconds = Math.round((double) millisecond / 1000);
------------------------------------------------------------------------
完~
寫得有點啰嗦,望多多指教~~Android 關於 CountDownTimer onTick() 倒計時不準確問題源碼分析