安卓:倒计时器跳过最后一个onTick()!

52
public class SMH extends Activity {  

    public void onCreate(Bundle b) {  
        super.onCreate(b);  
        setContentView(R.layout.main);  

        TextView tv = (TextView) findViewById(R.id.tv);  

        new CountDownTimer(10000, 2000) {  
            public void onTick(long m) {  
               long sec = m/1000+1;  
               tv.append(sec+" seconds remain\n");  
            }  
            public void onFinish() {  
               tv.append("Done!");  
            }  
        }.start();  
   }

输出:
剩余10秒
剩余8秒
剩余6秒
剩余4秒
完成!

问题:

如何让它显示“剩余2秒”?已经经过了10秒,但最后一个onTick()没有发生。如果我将第二个参数从2000更改为1000,则会得到以下输出:

剩余10秒
剩余9秒
剩余8秒
剩余7秒
剩余6秒
剩余5秒
剩余4秒
剩余3秒
剩余2秒
完成!

所以你看,似乎跳过了最后一个onTick()调用。另外,XML文件基本上是默认的main.xml,TextView分配了id tv并将文本设置为空。


3
此问题已在 Oreo 中得到修复。 - Dewsworld
如果有人对向后兼容性感兴趣,虽然这个问题可能已经在Android 8中得到解决,但它仍然存在于Android 7中,并且我没有看到它在Android 5或6中按照OP的想法工作。 - Smartybartfast
12个回答

57
我检查了CountDownTimer的源代码。 "缺少的tick"来自于CountDownTimer的一个特殊功能,我尚未看到在其他地方有文档记录:
在每次tick开始之前,在调用onTick()之前,计算距离倒计时结束的剩余时间。如果这个时间小于倒计时时间间隔,则不再调用onTick。而只会安排下一个tick(在那里将调用 onFinish() 方法)。
考虑到硬件时钟并不总是非常精确,可能还有其他后台进程延迟运行 CountDownTimer 线程,以及 Android 本身在调用 CountDownTimer 的消息处理程序时可能会产生一些小延迟,因此最后一个 tick 调用很可能晚至少一毫秒,因此不会调用 onTick()。
对于我的应用程序,我通过使 tick 间隔“略微”减小(500 毫秒)来解决这个问题。
    myCountDownTimer = new CountDownTimer(countDownTime, intervalTime - 500) {
                                   ...
    }

我可以保持代码不变。对于时间间隔长度很关键的应用程序,这里发布的其他解决方案可能是最好的选择。


4
是的,作为替代,您可以说“countDownTime+900”或类似的内容。如果倒计时时间为10000毫秒,间隔为1000毫秒,则只会看到10个刻度,如果每个刻度都能准时触发。在这个例子中添加900毫秒不足以使其多出一个刻度,但会给它足够的时间来完成“最后”一个刻度,并留出一些余量以应对时钟不准确的情况。(例如当最后一个刻度在离开始10004毫秒的时候触发,您会错过它,但如果添加一些缓冲区,就不会有问题) - JamieB
将间隔从1000毫秒改为500毫秒也解决了我的问题。 - rsa
2
请将@JamieB的建议添加到您的答案中。这样,即使您的应用程序需要在两个时间间隔之间具有特定的时间,它也能正常工作。我注意到在我的设备上存在6毫秒的延迟。因此,在大多数情况下,countDownTime+200应该解决问题,并且对用户来说几乎无法察觉。 - Arijoon
由于“功能”仍然存在于Android 7中,我记录了onTick触发时剩余的时间,以1000毫秒为间隔。在5秒倒计时中,最后一个tick是在1959时刻。它总是短了约40毫秒左右,添加50毫秒就给出了(虚假的)最后一个tick。在20秒内,不足约70毫秒。在60秒内,不足180毫秒。添加固定时间是一种不适用于可变倒计时长度的补救措施。在短倒计时上,在1000毫秒的tick上添加900毫秒实际上相当于多了一个完整的tick,这意味着在短倒计时上,最后一个tick就像是2个ticks,这是问题的重要部分。 - Smartybartfast

23

我不知道为什么最后一个滴答声不起作用,但您可以使用Runable创建自己的计时器。

class MyCountDownTimer {
    private long millisInFuture;
    private long countDownInterval;
    public MyCountDownTimer(long pMillisInFuture, long pCountDownInterval) {
            this.millisInFuture = pMillisInFuture;
            this.countDownInterval = pCountDownInterval;
        }
    public void Start() 
    {
        final Handler handler = new Handler();
        Log.v("status", "starting");
        final Runnable counter = new Runnable(){

            public void run(){
                if(millisInFuture <= 0) {
                    Log.v("status", "done");
                } else {
                    long sec = millisInFuture/1000;
                    Log.v("status", Long.toString(sec) + " seconds remain");
                    millisInFuture -= countDownInterval;
                    handler.postDelayed(this, countDownInterval);
                }
            }
        };

        handler.postDelayed(counter, countDownInterval);
    }
}

并且要启动它,

new MyCountDownTimer(10000, 2000).Start();

针对GOOFY问题的编辑:

你应该有一个变量来保存计数器状态(布尔值)。然后你就可以像Start()方法一样编写一个Stop()方法。

针对GOOFY问题的第二次编辑:

实际上,停止计数器没有bug,但在停止后再次启动(恢复)时存在bug。

我正在编写一个新的更新完整代码,我刚刚尝试了一下,它能够正常工作。这是一个基本的计数器,可以通过开始和停止按钮在屏幕上显示时间。

计数器类:

public class MyCountDownTimer {
    private long millisInFuture;
    private long countDownInterval;
    private boolean status;
    public MyCountDownTimer(long pMillisInFuture, long pCountDownInterval) {
            this.millisInFuture = pMillisInFuture;
            this.countDownInterval = pCountDownInterval;
            status = false;
            Initialize();
    }

    public void Stop() {
        status = false;
    }

    public long getCurrentTime() {
        return millisInFuture;
    }

    public void Start() {
        status = true;
    }
    public void Initialize() 
    {
        final Handler handler = new Handler();
        Log.v("status", "starting");
        final Runnable counter = new Runnable(){

            public void run(){
                long sec = millisInFuture/1000;
                if(status) {
                    if(millisInFuture <= 0) {
                        Log.v("status", "done");
                    } else {
                        Log.v("status", Long.toString(sec) + " seconds remain");
                        millisInFuture -= countDownInterval;
                        handler.postDelayed(this, countDownInterval);
                    }
                } else {
                    Log.v("status", Long.toString(sec) + " seconds remain and timer has stopped!");
                    handler.postDelayed(this, countDownInterval);
                }
            }
        };

        handler.postDelayed(counter, countDownInterval);
    }
}

活动类

public class CounterActivity extends Activity {
    /** Called when the activity is first created. */
    TextView timeText;
    Button startBut;
    Button stopBut;
    MyCountDownTimer mycounter;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        timeText = (TextView) findViewById(R.id.time);
        startBut = (Button) findViewById(R.id.start);
        stopBut = (Button) findViewById(R.id.stop);
        mycounter = new MyCountDownTimer(20000, 1000);
        RefreshTimer();
    }

    public void StartTimer(View v) {
        Log.v("startbutton", "saymaya basladi");
        mycounter.Start();
    }

    public void StopTimer(View v) {
        Log.v("stopbutton", "durdu");
        mycounter.Stop();
    }

    public void RefreshTimer() 
    {
        final Handler handler = new Handler();
        final Runnable counter = new Runnable(){

            public void run(){
                timeText.setText(Long.toString(mycounter.getCurrentTime()));
                handler.postDelayed(this, 100);
            }
        };

        handler.postDelayed(counter, 100);
    }
}

main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:weightSum="1">
    <TextView android:textAppearance="?android:attr/textAppearanceLarge" 
              android:text="TextView" android:layout_height="wrap_content" 
              android:layout_width="wrap_content" 
              android:id="@+id/time">
    </TextView>
    <Button android:text="Start" 
            android:id="@+id/start" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:onClick="StartTimer">
    </Button>
    <Button android:text="Stop" 
            android:id="@+id/stop" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:onClick="StopTimer">
    </Button>
</LinearLayout>

1
我想我应该只是使用Handler来处理它,但是除了我在问题中提到的缺陷之外,CountDownTimer非常简单和完美!我的意思是,仅从您的代码示例和我的代码示例,您就可以看出相比于Handler,CountDownTimer是多么简单和紧凑!不过,如果它能正常工作就好了,哈哈。 - ProgrammingMakesMeQQ
3
完全正确,我只是想表明你可以通过替代方法来做到这一点。 - Okan Kocyigit
2
@Goofy,实际上当你停止它时它并没有在运行,但是恢复它存在问题。我在我的答案中添加了一个全新的更新代码,也许可以帮助你。 - Okan Kocyigit
1
@ocanal,我该如何联系你? - Goofy
我该如何找到倒计时已经完成?然后再次开始倒计时? - sud
显示剩余8条评论

4

我花了几个小时来尝试解决这个问题,现在我很高兴能向您展示一个很好的解决方案。不必等待 onFinish() 的调用,只需将您的单位增加 1(或任何您的间隔),然后在 onTick() 中添加 if 语句。只需在最后一个 onTick() 中完成您的 onFinish() 任务即可。以下是我的代码:

    new CountDownTimer( (countDownTimerValue + 1) * 1000, 1000) { //Added 1 to the countdownvalue before turning it into miliseconds by multiplying it by 1000.
        public void onTick(long millisUntilFinished) {

          //We know that the last onTick() happens at 2000ms remaining (skipping the last 1000ms tick for some reason, so just throw in this if statement.
            if (millisUntilFinished < 2005){ 
                //Stuff to do when finished.
            }else{
                mTextField.setText("Time remaining: " + (((millisUntilFinished) / 1000) - 1));  //My textfield is obviously showing the remaining time. Note how I've had to subtrack 1 in order to display the actual time remaining.
            }
        }

        public void onFinish() {
        //This is when the timer actually finishes (which would be about 1000ms later right? Either way, now you can just ignore this entirely.


        }
    }.start();

我喜欢你的解决方案,但我发现至少对于Android >=6而言,问题在于最终的onTick()没有被调用,但是onFinish()仍然在正确的时间被调用。由于我只在我的onTick中更新倒计时显示,所以我只使用你的解决方案来显示最后的更新,但将onFinish()动画保留在原地。 - rockhammer

4
我提出的最简单的解决方案如下。请注意,它只适用于需要显示秒倒计时的简单屏幕。
mTimer = new CountDownTimer(5000, 100){
            public void onTick(long millisUntilFinished) {
                mTimerView.setText(Long.toString(millisUntilFinished/1000));                
             }

             public void onFinish() {
                 mTimerView.setText("Expired");
             }
        };

        mTimer.start();

在上述代码中,onTick() 每 100 毫秒调用一次,但只会显示秒数。

3

虽然上面的解决方案是有效的,但它可以进一步改进。它不必要地在另一个类内部拥有一个可运行的实例(这个实例本身就可以独立处理)。因此,只需创建一个扩展线程(或可运行)的类。

    class MyTimer extends Thread {
      private long millisInFuture;
      private long countDownInterval;
      final Handler mHandler = new Handler();

      public MyTimer(long pMillisInFuture, long pCountDownInterval) {
        this.millisInFuture = pMillisInFuture;
        this.countDownInterval = pCountDownInterval;
      }

      public void run() {
        if(millisInFuture <= 0) {
          Log.v("status", "done");
        } else {
          millisInFuture -= countDownInterval;
          mHandler.postDelayed(this, countDownInterval);
        }
      }
    }

2

我觉得我可能有点过了头,因为我的计时器在它自己的线程中运行,而不是使用postDelay处理程序,尽管它总是回到它创建的线程。我也知道我只关心秒数,所以简化了它的想法。它还允许你取消和重新启动它。我没有内置暂停,因为这不符合我的需求。

/**
* Created by MinceMan on 8/2/2014.
*/
public abstract class SecondCountDownTimer {

private final int seconds;
private TimerThread timer;
private final Handler handler;

/**
 * @param secondsToCountDown Total time in seconds you wish this timer to count down.
 */
public SecondCountDownTimer(int secondsToCountDown) {
    seconds = secondsToCountDown;
    handler = new Handler();
    timer = new TimerThread(secondsToCountDown);
}

/** This will cancel your current timer and start a new one.
 *  This call will override your timer duration only one time. **/
public SecondCountDownTimer start(int secondsToCountDown) {
    if (timer.getState() != State.NEW) {
        timer.interrupt();
        timer = new TimerThread(secondsToCountDown);
    }
    timer.start();
    return this;
}

/** This will cancel your current timer and start a new one. **/
public SecondCountDownTimer start() {
    return start(seconds);
}

public void cancel() {
    if (timer.isAlive()) timer.interrupt();
    timer = new TimerThread(seconds);
}

public abstract void onTick(int secondsUntilFinished);
private Runnable getOnTickRunnable(final int second) {
    return new Runnable() {
        @Override
        public void run() {
            onTick(second);
        }
    };
}

public abstract void onFinish();
private Runnable getFinishedRunnable() {
    return new Runnable() {
        @Override
        public void run() {
            onFinish();
        }
    };
}

private class TimerThread extends Thread {

    private int count;

    private TimerThread(int count) {
        this.count = count;
    }

    @Override
    public void run() {
        try {
            while (count != 0) {
                handler.post(getOnTickRunnable(count--));
                sleep(1000);
            }
        } catch (InterruptedException e) { }
        if (!isInterrupted()) {
            handler.post(getFinishedRunnable());
        }
    }
}

}


2
我找到了一个简单的解决方案。我需要CountDown来更新ProgressBar,因此我做了以下操作:
new CountDownTimer(1000, 100) {

    private int counter = 0;

    @Override
    public void onTick(long millisUntilFinished) {
        Log.d(LOG_TAG, "Tick: " + millisUntilFinished);
        if (++counter == 10) {
            timeBar.setProgress(--lenght); // timeBar and lenght defined in calling code
            counter = 0;
        }
    }


    @Override
    public void onFinish() {
        Log.d(LOG_TAG, "Finish.");

        timeBar.setProgress(0);
    }

};

点滴小技巧:)


1
我也遇到了与CountDownTimer相同的问题,尝试了不同的方法。其中一个最简单的解决方案是由@Nantoca提供的 - 他建议将频率从1000ms加倍到500ms。但我不喜欢这个解决方案,因为它会增加更多的工作量,消耗一些额外的电池资源。
所以我决定使用@ocanal的解决方案,并编写自己简单的CustomCountDownTimer。
但我发现他的代码有几个缺陷:
  1. 有点低效(创建第二个处理程序来发布结果)
  2. 它开始延迟发布第一个结果。(您需要在第一次初始化期间使用post()方法而不是postDelayed()
  3. 看起来有点奇怪。方法用大写字母表示,状态而不是经典的isCanceled布尔值等等。
所以我稍微整理了一下,这是他方法更普遍的版本:
private class CustomCountDownTimer {

    private Handler mHandler;
    private long millisUntilFinished;
    private long countDownInterval;
    private boolean isCanceled = false;

    public CustomCountDownTimer(long millisUntilFinished, long countDownInterval) {
        this.millisUntilFinished = millisUntilFinished;
        this.countDownInterval = countDownInterval;
        mHandler = new Handler();
    }

    public synchronized void cancel() {
        isCanceled = true;
        mHandler.removeCallbacksAndMessages(null);
    }

    public long getRemainingTime() {
        return millisUntilFinished;
    }

    public void start() {

        final Runnable counter = new Runnable() {

            public void run() {

                if (isCanceled) {
                    publishUpdate(0);
                } else {

                    //time is out
                    if(millisUntilFinished <= 0){
                        publishUpdate(0);
                        return;
                    }

                    //update UI:
                    publishUpdate(millisUntilFinished);

                    millisUntilFinished -= countDownInterval;
                    mHandler.postDelayed(this, countDownInterval);
                }
            }
        };

        mHandler.post(counter);
    }
}

1

为了进一步解释Nantoka的答案。这是我的代码,以确保视图正确更新:

countDownTimer = new CountDownTimer(countDownMsec, 500) 
{
    public void onTick(long millisUntilFinished)
    {
        if(millisUntilFinished!=countDownMsec)
        {
            completedTick+=1;
            if(completedTick%2==0)      // 1 second has passed
            {
                // UPDATE VIEW HERE based on "seconds = completedTick/2"
            }
            countDownMsec = millisUntilFinished;  // store in case of pause
        }
    }

    public void onFinish()
    {
        countDownMsec = 0;
        completedTick+=2;       // the final 2 ticks arrive together
        countDownTimer = null;

        // FINAL UPDATE TO VIEW HERE based on seconds = completedTick/2 == countDownMsec/1000
    }
}

1
如果您的时间间隔大于4秒,那么每次onTick()调用都不会很准确。因此,如果您想要精确的结果,请将时间间隔保持在5秒以下。原因是在每个计时器开始之前,在调用onTick()之前,会计算剩余时间直到倒计时结束。如果这段时间小于倒计时时间间隔,则onTick()将不再被调用。相反,只有下一个tick(其中将调用onFinish()方法)被预定。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接