安卓倒计时方案实现经验案例
-
背景
目前做的设备中,管理员密码登录和安全维护项目中安全问提与安全邮箱验证错误5次后都后将会对相关功能进行锁定并要求显示剩余时间的倒计时。考虑到倒计时在开发中也属于一种比较常用的交互功能。通常来说,我们可以使用Handler延时发送Message,Timer+TimerTask的方式自己封装一个类来实现倒计时,但事实上,谷歌已经在Android.os包下为我们封装好了一个倒计时的工具CountDownTimer。
-
目的
本经验案例的主要目的有以下三点:
- CountDownTimer的使用
- CountDowenTimer解析
- CountDownTimer在实际使用中常见问题分析
-
CountDownTimer的使用
CountDownTimer的使用十分的简单,我们看到谷歌给出的官方案例:
object : CountDownTimer(30000, 1000) {
override fun onTick(millisUntilFinished: Long) {
mTextField.setText("seconds remaining: " + millisUntilFinished / 1000)
}
override fun onFinish() {
mTextField.setText("done!")
}
}.start()
该案例实现的是一个30s的的倒计时,每隔1s将会在文本上更新一次显示剩余时间,在倒计时结束时文本将会显示”done!”。
简单来说,CountDownTimer的使用总共为三步:
- 构造函数:CountDownTimer(millisInFuture: Long, countDownInterval: Long)
- 重写 onTick(long time) 和 onFinish() 回调方法
- 调用start()方法开启倒计时
-
CountdownTimer解析
4.1 CountDownTimer结构
CountDownTimer的源码并不是十分复杂,其成员变量和成员函数也相对较少。
成员变量 | |
mMillisInFuture | 需要进行倒计时总时长,单位ms |
mCountdownInterval | 倒计时时间间隔,单位ms |
mStopTimeInFuture | 倒计时的截止时间,为当前时间戳加倒计时总时长计算所得 |
mCancelled | 判断倒计时是否已经取消 |
表4.1.1 CountDownTimer成员变量表
成员函数 | |
cancel() | 取消计时 |
onFinish() | 开始计时 |
onTick(millisUntilFinished: Long) | 每个时间间隔触发一次回调 |
start() | 在整个计时器结束之后触发回调 |
表4.1.2 CountDownTimer成员函数表
4.2 CountDownTimer源码
首先我们从CountDownTimer的构造方法开始看他的源码:
/**
* @param millisInFuture The number of millis in the future from the call
* to {@link #start()} until the countdown is done and {@link #onFinish()}
* is called.
* @param countDownInterval The interval along the way to receive
* {@link #onTick(long)} callbacks.
*/
public CountDownTimer(long millisInFuture, long countDownInterval) {
mMillisInFuture = millisInFuture;
mCountdownInterval = countDownInterval;
}
- millisInFuture:从调用start方法开始直到倒计时结束的毫秒时间
- countDownInterval:接收onTick回调的时间间隔
然后我们继续往下看到CountDownTimer的start()方法:
/**
* Start the countdown.
*/
public synchronized final CountDownTimer start() {
mCancelled = false;
if (mMillisInFuture <= 0) {
onFinish();
return this;
}
mStopTimeInFuture = SystemClock.elapsedRealtime() + mMillisInFuture;
mHandler.sendMessage(mHandler.obtainMessage(MSG));
return this;
}
该方法首先置mCancelled值为false,表示倒计时还未取消;接下来判断mMillisInFuture的值,判断倒计时总时间是否合法,如果不合法直接执行onFinish()回调函数;
接下来计算当前时间戳与倒计时总时间的和以获取截止时间,即在代码中对倒计时需要结束的时间戳做了个标记mStopTimeInFuture。
最后sendMessage(),向handler发送一个信息,正式开始倒计时,从这里可以看出CountDownTimer工具类也是通过Handler实现的。
接下来我们先就看看接收消息地方的源码,如下所示:
// handles counting down
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
synchronized (CountDownTimer.this) {
if (mCancelled) {
return;
}
final long millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime();
if (millisLeft <= 0) {
onFinish();
} else {
long lastTickStart = SystemClock.elapsedRealtime();
onTick(millisLeft);
// take into account user's onTick taking time to execute
long lastTickDuration = SystemClock.elapsedRealtime() - lastTickStart;
long delay;
if (millisLeft < mCountdownInterval) {
// just delay until done
delay = millisLeft - lastTickDuration;
// special case: user's onTick took more than interval to
// complete, trigger onFinish without delay
if (delay < 0) delay = 0;
} else {
delay = mCountdownInterval - lastTickDuration;
// special case: user's onTick took more than interval to
// complete, skip to next interval
while (delay < 0) delay += mCountdownInterval;
}
sendMessageDelayed(obtainMessage(MSG), delay);
}
}
}
};
首先,为了保证代码的健壮性和一致性,谷歌为该handler进行了同步锁,可以保证消息的处理顺序;
接下来通过mCancelled值判断倒计时时候已经取消,是则结束倒计时。
然后计算截止时间-当前时间来获取剩余时间,通过判断剩余时间确定倒计时是否已经结束,是则回调onFinish()方法;否则首先获取调用onTick()回调方法的时间戳,然后再回调onTick()方法,接下来计算onTick()方法的执行时间lastTickDuration的值;
下一步则判断剩余时间是否小于时间间隔,是则置下一次sendMessage的延迟时间delay为剩余时间-onTick()方法的执行时间(若delay<0则为0),否则值delay为时间间隔-onTick()方法的执行时间,最后sendMessageDelayed(*,delay)。
从上面源码我们可以知道,当未到倒计时结束时间时会调用onTick()方法,否则会调用onFinish()方法,而这两个方法都是抽象方法,因此需要子类实现该方法,两个方法源码如下所示:
/**
* Callback fired on regular interval.
* @param millisUntilFinished The amount of time until finished.
*/
public abstract void onTick(long millisUntilFinished);
/**
* Callback fired when the time is up.
*/
public abstract void onFinish();
最后我们看到onCancle()方法:
/**
* Cancel the countdown.
*/
public synchronized final void cancel() {
mCancelled = true;
mHandler.removeMessages(MSG);
}
该方法设置mCancelled 为true,使还没有执行onTick的消息停止运行并清除消息。
4.3 CountDownTimer流程图
梳理完CountDownTimer的源码,或许还不是很好懂,可以看到更加清晰的流程图如下:
图4.3 CountDownTimer整体流程图
-
CountDowenTimer和项目开发里的常见问题
5.1 计时不准的问题
我们执行下面一段简单的倒计时demo,进行5s内的倒计时:
object : CountDownTimer(5000, 1000) {
override fun onTick(millisUntilFinished: Long) {
Log.i("CountDownTimer", "onTick($millisUntilFinished)")
tvCount.text = "seconds remaining: " + millisUntilFinished / 1000
}
override fun onFinish() {
tvCount.text = "done!"
}
}.start()
我们在设备上查看这段函数的执行效果会发现TextView的显示并不是5、4、3、2、1、0,而是4、3、2、1,与我们所期望的倒计时实现效果不同。
现在我们查看控制台的打印:
…… I/CountDownTimer: onTick(4980)
…… I/CountDownTimer: onTick(3980)
…… I/CountDownTimer: onTick(2979)
…… I/CountDownTimer: onTick(1974)
这里的打印最开始是从4980开始的,并到1974后就没有再回调onTick()函数。
通过对源码进行的分析,我们知道CountDownTimer()内部是通过Handler来实现的,并且谷歌在实现过程中有考虑到从start()方法到HanlerMessage()方法间这段消耗时间十分短暂的代码并计算在内,故而导致了onTick()的回调并不是从5000开始的这个问题,因此如果我们需要从设置的倒计时总时间开始倒数,则需要通过延长时间来补上这个误差,通常来说小于1000的任意值都可以,但是不能太小以至于达不到延长的效果。
例如这里我们将构造函数中的millisInFuture的值改为5000+400
CountDownTimer timer = new CountDownTimer(5000 + 400, 1000){
……
};
timer.start();
这时候我们看到倒计时为:5、4、3、2、1
控制台的打印为:
…… I/CountDownTimer: onTick(5378)
…… I/CountDownTimer: onTick(4377)
…… I/CountDownTimer: onTick(3377)
…… I/CountDownTimer: onTick(2376)
…… I/CountDownTimer: onTick(1376)
现在我们实现了倒计时从5开始打印,但是倒计时仍然无法到0,假设sendMessage的时间也是无限小,那我们在第六次执行handler时候剩余时间应该在376>0还可以执行一次onTick()才对。这是因为在旧的API中,执行handler的时候会先判断剩余时间与时间间隔的大小,若剩余时间<时间间隔,则不执行onTick并再进行一次Handler,该问题已经在最新API中解决,可以考虑升级API解决改问题。
若仍需使用旧的API,我们还是可以通过延长总时间来解决该问题,因为在剩余时间<时间间隔的时候就不会再执行onTick(),所以我们在总时间上再+1000ms,然后在显示的时候减去这1s就可以实现了,代码如下:
object : CountDownTimer(5000 + 400 + 1000, 1000) {
override fun onTick(millisUntilFinished: Long) {
Log.i("CountDownTimer", "onTick($millisUntilFinished)")
tvCount.text = "seconds remaining: " + ((millisUntilFinished / 1000) - 1)
}
override fun onFinish() {
tvCount.text = "done!"
}
}.start()
此时我们可以看到倒计时为:5、4、3、2、1、0
控制台的打印为:
…… I/CountDownTimer: onTick(6374)
…… I/CountDownTimer: onTick(5373)
…… I/CountDownTimer: onTick(4372)
…… I/CountDownTimer: onTick(3372)
…… I/CountDownTimer: onTick(2370)
…… I/CountDownTimer: onTick(1369)
5.2 内存泄漏/空指针的问题
从源码中我们可以看出,CountDownTimer的内部实现是采用Handler机制,因此很容易造成内存泄漏的问题。因为如果在Activity或者Fragment关闭而倒计时还未结束的时候,Handler如果判断到倒计时还没有结束,则会继续在后台执行,而很多时候我们用倒计时会有更新UI的操作,而控件都持有activity的引用,长期得不到释放的话就会造成内存泄漏。
另外,在handler调用过程中,会触发onTick()回调方法的执行,此时如果onTick()方法总有UI相关的操作,Activity或者Fragment已经被系统回收,从而里面的变量被设置为Null,此时如果再次调用tvCount.text = "seconds remaining: " + ((millisUntilFinished / 1000) - 1)则会为空,也就会报空指针。
因此为了避免空指针和内存泄露,我们要注意在宿主Activity或fragment生命周期结束的时候,记得调用cancle()方法。
5.3 设备修改时间后导致倒计时提前结束
设备处于锁定状态的时候,项目中构造CountDownTimer函数传入的millisInFuture倒计时总时间是需要进行计算的,刚开始锁定的时候,倒计时总时间就是默认值30min,而过段时间后再次打开锁定相关的页面,则需要重新计算倒计时总时间,该时间计算为30-(当前时间-锁定开始时间),这个时候关于当前时间和锁定开始时间我们则需要获取系统的时间戳。
最开始我使用的是System.currentTimeMillis() ,是一个 标准的“墙”时钟(时间和日期),表示从纪元到现在的毫秒数。但是在测试过程中,发现该时间戳能够通过修改系统时间进行改变,导致倒计时剩余时间不准确的问题。
后来在经过查找资料,由于管理员认证锁定的需求要求在重新开机后不再对设备进行锁定,决定使用SystemClock.elapsedRealtime(),该时间戳返回系统启动到现在的时间,包含设备深度休眠的时间。该时钟被确保单调,即使CPU在省电模式下,该时间也会继续计时。
5.4 设备在使用dialog显示倒计时时出现了空指针问题
在最开始进行锁定功能开发的时候,是直接在xml中进行图像的绘制达到弹出对话框的效果,后来经反应交互效果并不是很好后,改为使用DialogFragment的方式显示锁定弹窗。在配合DialogFragment使用时,如果关闭了弹窗后,倒计时仍然在继续的话,在调用onTick()回调方法时需要对DialogFragment进行判空以防出现空指针的问题。若在onFinish()方法调用了 dismiss()方法让弹框消失也需进行判空操作。