• 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 秒停顿的时间很长。

    仔细看一下日志里标注的地方,如果你想直接看解决方案,可以直接滑到日志最下方,或者在顶部目录里选择最后一栏“三、终极解决”查看。

    二、分析源码

    (一)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);
    ------------------------------------------------------------------------
    完~
    写得有点啰嗦,望多多指教~~
  • 相关阅读:
    一个小程序的经验总结
    my favorite computer publishers
    关于在天涯看小说
    书店
    Server 2003&Windows 7&Server 2008 R2&Visual Studio&MSDN: my personal best practice
    Google搜索:基本语法
    【我喜欢的一篇文章】Fire And Motion
    Windbg学习笔记(1)
    如何清除Help Viewer 2.0之Filter Contents中的列表内容
    5年了,难道我真的不适合做一个程序员吗,请告诉我我该怎么做?
  • 原文地址:https://www.cnblogs.com/chenxibobo/p/9650430.html
Copyright © 2020-2023  润新知