在之前『Android 解码 Base64 图片』和『Android 自定义 Dialog 布局』中提到了图形验证码的相关内容,这期来聊聊短信验证码。

现在基本上所有的 App 都要求用手机号码注册了,而短信验证码是一个相对比较方便的验证方式,从大多数 App 的交互体验得知,获取短信验证码后,会设置一个计时器让用户在短时间内不能频繁获取验证码,这是因为服务器提供商有相应的限制,以缓解其服务器的压力,降低并发量。

比如我们使用阿里云的短信服务接口,就限定了每个手机号码每分钟只能获取 1 次,每小时内只能获取 5 次,每天内只能获取 40 次,但用户是不知道这些规则的,我们也没有必要去为用户介绍这些规则,所以我们在开发过程中就要用简单易懂的方式为用户规避这些因为规则而产生的不必要的麻烦。

于是也就有了大多数 App 的计时器按钮。

短信验证码计时器

那么在 Android 开发中该如何实现这个倒计时呢,想了一下发现实现的方式还是挺多的,比如 TimerTask 或者 Handler,但自己实现的话就是重复造轮子了,我查了一下 API,发现有一个特别简单的方法,Android 为我们封装的 CountDownTimer 可以帮助我们轻松实现,而且其内部原理就是通过 Handler 机制实现的。

说实话这也是我第一次看到如此简单的官方接口,源码不用怎么费心思就看懂了,可以来看看:

public abstract class CountDownTimer {

    private final long mMillisInFuture;
    private final long mCountdownInterval;
    private long mStopTimeInFuture;
    private boolean mCancelled = false;

    /**
     * @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;
    }

    /**
     * Cancel the countdown.
     */
    public synchronized final void cancel() {
        mCancelled = true;
        mHandler.removeMessages(MSG);
    }

    /**
     * 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;
    }

    /**
     * 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();

    private static final int MSG = 1;

    /**
     * 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);
                    long lastTickDuration = SystemClock.elapsedRealtime() - lastTickStart;  // take into account user's onTick taking time to execute
                    long delay;
                    if (millisLeft < mCountdownInterval) {
                        delay = millisLeft - lastTickDuration;  // just delay until done
                        if (delay < 0) delay = 0;   // special case: user's onTick took more than interval to complete, trigger onFinish without delay
                    } else {
                        delay = mCountdownInterval - lastTickDuration;
                        while (delay < 0) delay += mCountdownInterval;  // special case: user's onTick took more than interval to complete, skip to next interval  
                    }
                    sendMessageDelayed(obtainMessage(MSG), delay);
                }
            }
        }
    };
}

先看构造方法,millisInFuture 设置的是倒计时的总时长,countDownInterval 设置的倒计时的间隔时间,单位都是毫秒。

倒计时总时长好理解,countDownInterval 这个参数解释一下,打个比方,如果总时长是 6 秒,间隔时间是 1 秒,计时器就会按照 6-5-4-3-2-1-0 的方式计时;如果总时长是 6 秒,间隔时间是 2 秒,计时器就会按照 6-4-2-0 的方式计时。

如前文所说,重点实现就是在 Handler 中,有 onTick()onFinish() 两个抽象方法,我们只需重写这两个方法来实现自己的逻辑即可,每个计时间隔 onTick() 都会触发一次,当计时结束后 onFinish() 才会触发。

结合短信验证码的需求来看看使用示例:

Button btnGetSms = findViewById(R.id.get_sms_btn);
btnGetSms.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        CountDownTimer countDownTimer = new CountDownTimer(60 * 1000, 1 * 1000) {  
            @Override
            public void onTick(long millisUntilFinished) {
                btnGetSms.setEnabled(false);
                btnGetSms.setText(millisUntilFinished / 1000 + "s");
            }
            @Override
            public void onFinish() {
                btnGetSms.setEnabled(true);
                btnGetSms.setText("点击获取");
            }
        };
        countDownTimer.start();
        requestSmsCode(tel);    // 向服务器发送获取短信验证码请求  
    }
}

在构造方法中传入两个时间参数,比如我这里就设置其计时 60 秒,计时间隔为 1 秒,然后重写 onTick()onFinish() 两个方法,使这个 Button 在倒计时期间不可用,以避免用户重复点击,同时,将倒计时显示在 Button 上,让用户知道重新可点击的时间,当倒计时结束后,将 Button 重新置为可用状态,并修改上面的文字以提示用户。

最后我们还需要调用 start() 来让计时器启动,是不是跟线程的启动差不多?这就说明这个计时是异步的,我们可以在倒计时的同时做其他操作,比如在这里就是向服务器发送获取验证码的请求,这样短信验证码就可以通过运营商下发到用户的手机了。

看回 CountDownTimer 的源码,是不是发现还有个 cancel() 方法没有使用?

正是由于 CountDownTimer 使用了 Handler,所以很容易造成内存泄漏问题,养成良好的编码习惯,要记得把 cancel() 方法补上,因为其里面有资源回收的操作,不调用的话就有可能会造成内存泄漏。

比如,在 Activity 或者 Fragment 被回收时并未调用 CountDownTimercancel() 方法结束自己,这个时候 CountDownTimerHandler 如果判断到当前的时间未走完,那么会继续调用 onTick() 方法,Activity 或者 Fragment 已经被系统回收,从而里面的变量被设置为 Null,同时 CountDownTimer 中的 Handler 还在继续运行,这一块空间始终无法被系统回收,也就造成了内存泄漏。

我们可以在 CountDownTimeronTick() 方法中对当前对象做判空处理。在 Activity 中可使用:

if (!activity.isFinishing()) {
    // 处理相应逻辑
}

Fragment 中可使用:

if (!fragment.isDetached()) {
    // 处理相应逻辑
}

ActivityFragment 生命周期结束时,要记得调用 cancel() 方法,如:

@Override
protected void onDestroy() {
    super.onDestroy();
    if (countDownTimer != null) {
        countDownTimer.cancel();
        countDownTimer = null;
    }
}

最后,再提一个小细节,在执行 onTick() 方法时,源码里面减去了程序执行到当前行时所消耗的时间,所以一个 60 秒的倒计时,点击后看到第一个显示的是 59 而不是 60,不难看出 Google 在这方面还是很严谨的。