1. 程式人生 > >Android 關於 CountDownTimer onTick() 倒計時不準確問題源碼分析

Android 關於 CountDownTimer onTick() 倒計時不準確問題源碼分析

一次 span 得出 mapi str num too 思路 -h

一、問題

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);
    }
 
    @Override
    
public 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() 倒計時不準確問題源碼分析