一个Bug案例的解决过程:连续输入错误的PIN码,不能实现第二次倒计时30s才能重试

此问题是草稿箱存了两年的一篇文章,还是重新发表了吧……^.^
当时新工作的第一个Bug,挺有纪念意义的,所以写下总结。

问题的现象:
1.打开 Settings → Security →Screen lock,设置PIN。
2.重新打开该选项,输入错误的PIN五次,手机会开始提示30s后才能继续尝试。
3.等待30s后,再次输入错误的PIN五次,观察现象。

预期结果:
步骤3之后的效果和步骤2之后的效果一样,需要30s之后才能重新尝试输入。

实际结果:
步骤3之后,点击continue按钮无反应,输入框里仍可以继续输入。

复现概率:
5/5

问题分析过程:

.
猜想是代码逻辑有问题,所以首先找到该页面的实现代码。
根据页面关键字串找到该页面的实现逻辑在:packages/apps/Settings/src/com/android/settings/ConfirmLockPassword.java类中:

二.
页面点击事件的处理如下:

        public void onClick(View v) {
            if (getActivity() == null) return;

            switch (v.getId()) {
                case R.id.next_button:  //点击CONTINUE按钮
                    handleNext();
                    break;

                case R.id.cancel_button:    //点击CANCEL按钮
                    getActivity().setResult(RESULT_CANCELED);
                    getActivity().finish();
                    break;
            }
        }

其中handleNext()定义如下:

        private void handleNext() {
            final String pin = mPasswordEntry.getText().toString();
            if (mLockPatternUtils.checkPassword(pin)) { //输入正确的PIN之后的处理

                Intent intent = new Intent();
                if (getActivity() instanceof ConfirmLockPassword.InternalActivity) {
                    intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_TYPE,
                                    mIsAlpha ? StorageManager.CRYPT_TYPE_PASSWORD
                                             : StorageManager.CRYPT_TYPE_PIN);
                    intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_PASSWORD, pin);
                }

                getActivity().setResult(RESULT_OK, intent);
                getActivity().finish();
            } else {        //输入的PIN错误
                if (++mNumWrongConfirmAttempts >= LockPatternUtils.FAILED_ATTEMPTS_BEFORE_TIMEOUT) {    //连续错误次数大于等于5次,则启动倒计时
                    long deadline = mLockPatternUtils.setLockoutAttemptDeadline();
                    handleAttemptLockout(deadline);
                } else {    //连续输入错误次数小于5次,则还可以继续尝试
                    showError(R.string.lockpattern_need_to_unlock_wrong);
                }
            }
        }

而 handleAttemptLockout()定义如下:

private void handleAttemptLockout(long elapsedRealtimeDeadline) {
    if (mCountdownTimer != null) {
        return;
    }
    long elapsedRealtime = SystemClock.elapsedRealtime();   //获取系统当前时间
    showError(R.string.lockpattern_too_many_failed_confirmation_attempts_header, 0);
    mPasswordEntry.setEnabled(false);
    mCountdownTimer = new CountDownTimer(   
            elapsedRealtimeDeadline - elapsedRealtime,
            LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS) {

        @Override
        public void onTick(long millisUntilFinished) {  //此处是30s倒计时的处理
            final int secondsCountdown = (int) (millisUntilFinished / 1000);
            mHeaderText.setText(getString(
                    R.string.lockpattern_too_many_failed_confirmation_attempts_footer,
                    secondsCountdown));
        }

        @Override
        public void onFinish() {    //30s倒计时完成
            mPasswordEntry.setEnabled(true);
            mHeaderText.setText(getDefaultHeader());
            mNumWrongConfirmAttempts = 0;
        }
    }.start();
}

每当我们连续输入5次错误的PIN之后,程序就会进入这个方法进行处理。
那么第一次输入5次错误PIN的时候,进入此方法,首先会判断 mCountdownTimer是否为null。在此类中可以找到mCountdownTimer的声明:

扫描二维码关注公众号,回复: 2463354 查看本文章
        private CountDownTimer mCountdownTimer;

在这里mCountdownTimer默认初始化为null,其他地方并未初始化。所以mCountdownTimer != null判断失败,此方法继续执行。
接下来,创建了一个新的用于倒计时30s的对象,并开始倒计时。

倒计时结束后,输入框恢复为可输入状态。这时再输入错误的PIN五次后,点击CONTINUE按钮,流程又会进入 handleAttemptLockout()方法中。
此时由于mCountdownTimer对象已经被创建了,并不为null,所以直接执行了return()。
继续点击CONTINUE,又进入handleAttemptLockout()中,还是return。所以再点击CONTINUE都没反应了。

三.
知道问题发生的原因之后,我一开始改动想法是这样:
既然第二次输入错误5次之后,mCountdownTimer对象已经创建了,这时候要启动倒计时,只要直接让mCountdownTimer重新执行start()方法就好了。
于是改成如下进行测试:

            if (mCountdownTimer != null) {
                showError(R.string.lockpattern_too_many_failed_confirmation_attempts_header, 0);
                mPasswordEntry.setEnabled(false);
         mCountdownTimer.start();
                return;
            }

测试结果:第二次连续输入5次错误PIN之后,果然倒计时可以正常进行了。
但是经多次测试,发现有时候倒计时并不是30s,有时候从十几秒,有时候从二十几秒开始倒计时。这是什么原因呢?
经过打log追踪规律,终于发现:如果在倒计时未完成的过程中退出此页面,下次重新输入5次PIN错误之后,倒计时就会不正常。
原因如下:
倒计时过程中如果退出该页面,则会执行onPause()方法:

        @Override
        public void onPause() {
            super.onPause();
            mKeyboardView.requestFocus();
            if (mCountdownTimer != null) {
                mCountdownTimer.cancel();
                mCountdownTimer = null;
            }
        }

可以知道,如果退出此页面的话, mCountdownTimer就会被置为null。但是要注意倒计时还在继续,cancel()方法并不会终止倒计时的进行。

而再次进入该页面的时候,会执行onResume()方法:

        @Override
        public void onResume() {
            // TODO Auto-generated method stub
            super.onResume();
            mKeyboardView.requestFocus();
            long deadline = mLockPatternUtils.getLockoutAttemptDeadline();
            if (deadline != 0) {    //倒计时未完成
                handleAttemptLockout(deadline);
            } else {
                mPasswordEntry.setEnabled(true);
                mHeaderText.setText(getDefaultHeader());
                mNumWrongConfirmAttempts = 0;
            }
        }

可以看出,如果重新进入此页面的话,如果倒计时仍未完成,则又执行 handleAttemptLockout()方法,传入的参数 deadline即为倒计时截止时的时间。
此时由于mCountdownTimer已经在onPause()方法中被置为null,所以执行handleAttemptLockout()方法后会创建一个新的mCountdownTimer,而根据传入的构造参数看,这个mCountdownTimer开始倒计时的时间就是剩余的倒计时时间,至此流程都是正常的。

但是如果这次倒计时完成后,又接着输入五次错误的PIN码之后,由于上次创建的mCountdownTimer此时不为null,所以本次会直接执行此对象的start()方法开始倒计时。但是由于上次创建对象时传入的倒计时时间参数不是30s,所以这次倒计时也不会从30s开始倒计时,而是从和上次进入倒计时页面时的剩余时间一样的时间开始,问题就是这样出现了。

因此之前的改法存在问题,得另找方法。

四.
既然mCountdownTimer有时候会被置为null,有时候又会被创建新对象。不如每次倒计时完成都置为null,每次需要倒计时再创建新对象。
为实现此目的,把handleAttemptLockout()方法做一行改动:

            if (mCountdownTimer != null) {
                mCountdownTimer = null;
//                return;
            }

就是把原来的return改为了mCountdownTimer = null。
改动之后的方法如下:

        private void handleAttemptLockout(long elapsedRealtimeDeadline) {
            if (mCountdownTimer != null) {
                mCountdownTimer = null;
            }
            long elapsedRealtime = SystemClock.elapsedRealtime();
            showError(R.string.lockpattern_too_many_failed_confirmation_attempts_header, 0);
            mPasswordEntry.setEnabled(false);
            mCountdownTimer = new CountDownTimer(
                    elapsedRealtimeDeadline - elapsedRealtime,
                    LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS) {

                @Override
                public void onTick(long millisUntilFinished) {
                    final int secondsCountdown = (int) (millisUntilFinished / 1000);
                    mHeaderText.setText(getString(
                            R.string.lockpattern_too_many_failed_confirmation_attempts_footer,
                            secondsCountdown));
                }

                @Override
                public void onFinish() {
                    mPasswordEntry.setEnabled(true);
                    mHeaderText.setText(getDefaultHeader());
                    mNumWrongConfirmAttempts = 0;
                }
            }.start();
        }

改动之后,
对于handleAttemptLockout()方法,流程走到这个方法时,有两种可能,
1.上次倒计时创建的mCountdownTimer此时已经倒计时完成,继续输入5次错误的PIN后执行此方法,此时mCountdownTimer不为null。
2.上次倒计时创建的mCountdownTimer倒计时过程中,页面退出,重新打开此页面时onResume()方法中又执行了此方法,但mCountdownTimer在页面退出前onPause()方法中已被置为null。
对于第一种可能,mCountdownTimer会被置为null,然后创建一个新的mCountdownTimer开始倒计时。
对于第二种可能,流程和改动之前无差别,会直接创建新的mCountdownTimer开始倒计时。

五.
上面的改动,经测试是可以解决问题的。但是对流程影响较大,有潜在的风险。因此思考之后,进一步优化,改为如下方案:

        private void handleAttemptLockout(long elapsedRealtimeDeadline) {
            if (mCountdownTimer != null) {
                return();//这里不动
            }
            long elapsedRealtime = SystemClock.elapsedRealtime();
            showError(R.string.lockpattern_too_many_failed_confirmation_attempts_header, 0);
            mPasswordEntry.setEnabled(false);
            mCountdownTimer = new CountDownTimer(
                    elapsedRealtimeDeadline - elapsedRealtime,
                    LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS) {

                @Override
                public void onTick(long millisUntilFinished) {
                    final int secondsCountdown = (int) (millisUntilFinished / 1000);
                    mHeaderText.setText(getString(
                            R.string.lockpattern_too_many_failed_confirmation_attempts_footer,
                            secondsCountdown));
                }

                @Override
                public void onFinish() {
                    mCountdownTimer = null;//改到这里
                    mPasswordEntry.setEnabled(true);
                    mHeaderText.setText(getDefaultHeader());
                    mNumWrongConfirmAttempts = 0;
                }
            }.start();
        }

即将mCountdownTimer = null放到了onFinish()方法中,使得每次倒计时完成时,mCountdownTimer会被置为null。

与上次的改动相比:上次的是在每次倒计时开始之前,将上次的mCountdownTimer置为null。而本次改动是将mCountdownTimer置为null的操作放在了mCountdownTimer倒计时完成之后。这样改动,流程更加合理清楚,风险也更得以避免。

总结:
1.改Bug的时候,一定先要理清流程,对于各种流程的可能性都要考虑到,必要的时候可以通过画流程图,分条列出等方式进行分析。
2.改动之后,充分验证,一般容易忽略的验证比如横竖屏切换,点击Back或者Home返回又重新进入,以及重复多次验证等都要经过测试。
3.在所有可行的方案中,选择对原有流程影响最小,最安全的。一次改动最好只解决一个问题。

猜你喜欢

转载自blog.csdn.net/fenggering/article/details/80969057