Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

智慧并不产生于学历,而是来自对于知识的终生不懈的追求。这篇文章主要讲述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); } @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  秒停顿的时间很长。
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

仔细看一下日志里标注的地方,如果你想直接看解决方案,可以直接滑到日志最下方,或者在顶部目录里选择最后一栏“三、终极解决”查看。
二、分析源码 (一)API 25  源码分析查看  CountDownTimer 源码(API 25),
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

发现 start() 中计算的  mStopTimeInFuture(未来停止倒计时的时刻,即倒计时结束时间) 加了一个  SystemClock.elapsedRealtime()  ,系统自开机以来(包括睡眠时间)的毫秒数,后文中以“系统时间戳”简称。
即倒计时结束时间为“当前系统时间戳 + 你设置的倒计时时长  mMillisInFuture  ”,也就是计算出的相对于手机系统开机以来的一个时间。
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

继续往下看,多处用到了  SystemClock.elapsedRealtime()  。
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

在源码里添加 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);             }         }     } };

 
打印日志:
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

倒计时 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():
Android 关于 CountDownTimer 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  秒。
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

此时 lastTickStart = 349004114,而 349004114 + 1990 =349006104,也就是  第 6  次  进入 handleMessage  时调用  onFinish()  的时间。
延迟了  delay = 996  毫秒后,接下来,第 5 次进入  handleMessage 时,因为  millisLeft = 988 < mCountdownInterval = 1000 ,导致没有触发 onTick(),而是直接发送了一个延迟了 millisLeft = 988 毫秒的 message。此时的  elapsedRealtime = 349005115。
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

延迟了 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(),结束倒计时~
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

所以在  第 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  啦~
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

当然,你也可以写一个自己的  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  的逻辑:
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

这里按期望应该是要执行一次 onTick() 。
所以我们加上一句 onTick()  即可。
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

打印日志:
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片

修改后的完整代码见:CountDownTimerImproveFromAPI25.java
不过这也有个问题,因为我们是直接将倒计时时间加长了,虽然只是几十毫秒,但也会造成整个倒计时的时间(从 start()  到  onFinish())不是精确的,而且这个 20  毫秒只是我根据前面程序运行的时间规律算的,可能也有程序从  start()  运行到  第一次进入 handleMessage()  会超过 20  毫秒的情况呢?
 
(三)API 26  源码分析 先看一下运行效果:   这是又一次运行时的输出日志:
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片
  可以看到  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
Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析

文章图片
 
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); } } } };

【Android 关于 CountDownTimer onTick() 倒计时不准确问题源码分析】 
可以看到  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);

------------------------------------------------------------------------ 完~ 写得有点啰嗦,望多多指教~~

    推荐阅读