Android应用基础 简易后台计时器的安装方法

分享一篇个人练习定时提醒和安卓系统广播时写的计时器应用的开发思路~


功能概要

  • 1.计时器为秒单位倒计时
  • 2.按下主画面开始按钮触发计时器,按下重置按钮结束并重置计时器
  • 3.如始终保持应用active,计时器结束后弹出toast并自动重置
  • 4.在计时途中离开应用则在后台继续计时:
       a) 计时器结束前未返回应用:计时结束时推送顶部通知, 再次返回应用时计数器重置。
       b) 计时器结束前返回应用:继续计时,与始终打开应用的情况相同,只显示toast不推送通知。

确定布局

  • activity_main.xml

    • TextView displayTime 显示时间 形式是mm:ss
    • Button startButton 开始计时
    • Button resetButton 结束并重置计时器

    布局文件


安装主画面(MainActivity)

(一)仅在前台运行

   首先考虑最简单的情况,即用户从计时开始到结束始终停留在计时器应用。 这时只需要做三件事:

  1. 对开始按钮和重置按钮设置View.OnClickListener监听器,分别传入开启计时器重置计时器两个行为.
  2. 开启计时器需要实现一个CountDownTimer接口, 该接口使用onTickonFinish方法分别监听每一次倒计时减少时和计时器结束(倒计时耗尽)后的行为.
  3. 重置计时器时使用计时器接口中的cancel()方法来停止计时, 并将画面上的一切归位。

   当然,我们还希望根据计时器的状态改变按钮的活性,比如已经开始的计时器不应该被再次开启。我用updateButton()方法来统一重置按钮状态。

  • 主画面MainActivity.java:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
        //字段定义
        private final long initialTimeMilli = 60_000; //计时器时长总量(毫秒)
        private long remainTimeMilli = initialTimeMilli; //剩余时间总量
        private enum STATE { //枚举计时器可能出现的状态,便于管理
          STARTED, STOPPED
        }
        private STATE state;
        //...

        @Override
        protected void onCreate(Bundle savedInstanceState) {
          //...
          state = STATE.STOPPED;
          //初始化显示时间
          updateText(initialTimeMilli);
          updateButton();
          //...
          //设置监听器
          startButton.setOnClickListener(v -> {
            state = STATE.STARTED;
            startTimer();
            updateButton();
          });
          resetButton.setOnClickListener(v -> {
            resetTimer();
            updateButton();
          });

        }

        //开启计时器
        private void startTimer() {
        timer = new CountDownTimer(remainTimeMilli, 1000) { //参数是计时开始前的剩余时间总量(毫秒数)、倒计时间隔(1000毫秒=1秒)
            @Override
            public void onTick(long millisUntilFinish) { //传入每次倒计时后当前剩余时间
                remainTimeMilli = millisUntilFinish; //将当前剩余时间赋值给剩余时间总量,以便暂停后可以从停止的时间点重新开始
                updateText(remainTimeMilli); //每次倒计时都修改显示时间,达到倒计时动态变化的效果
            }

            @Override
            public void onFinish() {
                resetTimer(); //计时完毕时和手动按下reset按钮时同样需要重置计时器
                Toast.makeText(MainActivity.this, "Timer expired!!!", Toast.LENGTH_LONG).show();
            }
        }.start();//不要忘了执行开始方法
    }

        //重置计时器
        private void resetTimer() {
        if (state == STATE.STARTED) {
            timer.cancel();
        }
        state = STATE.STOPPED;
        remainTimeMilli = initialTimeMilli;//剩余时长变回初始值
        updateText(remainTimeMilli);
        updateButton();
    }

        //重置按钮
        private void updateButton() {
        switch (state) {
            case STARTED://已开始的计时器只能被重置,不能被开启
                startButton.setEnabled(false);
                resetButton.setEnabled(true);
                break;
            case STOPPED://已停止的计时器只能被开启,不能被重置
                startButton.setEnabled(true);
                resetButton.setEnabled(false);
                break;
        }
    }

(二)完善后台运行

   至此已经实现了功能概要里1~3项的内容。但是由于目前的计时器依托于UI显示,当用户离开应用,计时将不再继续。即使计时在后台继续,万一用户没能在计时结束前回来,也应该想办法在计时结束时通知用户。接下来讨论这种情况该如何实现。
   正如功能概要中提到的,这里还须再分出两种情况:

a)用户在计时器开启后离开应用,且计时器结束前并未返回

这种情况下要做的事:

  1. 想象用户可能把手机丢在了一旁,我们需要在计时结束时唤醒手机并推送一条醒目的通知提醒用户回来。这里只需使用AlarmManager在主画面的onPause设置一个精确计时的提醒,使其在指定时刻被一个自定义的BroadcastReceiver接收。然后在BroadcastReceiveronReceive里创建一个Notification并发送。
  2. 如果用户点击通知,就回到显示计时器的画面,让他认识到自己错过了啥。这一步主要是在给通知传递intent时做文章。首先需要一个PendingIntent来指定点击通知时需要唤醒的画面。我们还可以用另一个PendingIntent来设置用户删除通知时的行为。
  • 主画面MainActivity.java:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        //...
        //在onCreate中加入动态注册广播接收器的代码
        //用于接收用户删除通知后的行为
        //IntentFilter过滤Intent的Action
      registerReceiver(mReceiver, new IntentFilter(ACTION_DELETE_NOTIFICATION))
        //...
      }

      @Override
      protected void onPause() {
          super.onPause();
          //离开应用时
          if (state == state.STARTED) {
              timer.cancel();
              //设置提醒
              setAlarm();
          }
      }

      @Override
      protected void onDestroy() {
          unregisterReceiver(mReceiver);//取消注册广播接收器(勿忘)
          super.onDestroy();
      }

      private void setAlarm() {
          //当前时间+计时器的剩余时间就是计时结束、发送提醒的时间
          long alarmTime = System.currentTimeMillis() + remainTimeMilli;
          alarmManager = (AlarmManager) this.getSystemService(Context.ALARM_SERVICE);
          //用intent指定接收提醒的BroacastReceiver
          Intent intent = new Intent(this, TimerExpiredReceiver.class);
          //由于intent需要在指定的时间被延迟处理,
          //需要把intent封装在PendingIntent里
          PendingIntent pending = PendingIntent.getBroadcast(this, 123, intent, 0);
          //setExact表示精确计时,也有模糊计时的选项
          alarmManager.setExact(AlarmManager.RTC_WAKEUP, alarmTime, pending);
      }  

      //用于接收删除通知行为的广播接收器
      public class NotificationReceiver extends BroadcastReceiver {
          @Override
          public void onReceive(Context context, Intent intent) {

              if (intent.getAction() == ACTION_DELETE_NOTIFICATION) {
                  resetTimer();
                  updateButton();
              }
          }
      }

  • 接收计时器耗尽的广播接收器:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
      public class TimerExpiredReceiver extends BroadcastReceiver {

          @Override
          public void onReceive(Context context, Intent intent) {
              NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
              //API 26及以上必须设置NotificationChannel
              if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                  NotificationChannel channel = new NotificationChannel(TIMER_CHANNEL_ID, TIMER_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT);
                  //... 其他设定已省略
                  channel.setDescription("Notification from Timer");
                  manager.createNotificationChannel(channel);
              }

              //指定点击通知时希望启动的Activity
              Intent contentIntent = new Intent(context, MainActivity.class);
              PendingIntent contentPendingIntent = PendingIntent.getActivity(context, TIMER_ID, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT);

              //在删除通知时再次发送广播,在Activity中接收
              Intent deleteIntent = new Intent(ACTION_DELETE_NOTIFICATION);
              PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, TIMER_ID, deleteIntent, PendingIntent.FLAG_CANCEL_CURRENT);

              NotificationCompat.Builder builder = new NotificationCompat.Builder(context, TIMER_CHANNEL_ID);
              builder.setContentTitle("Timer Expired!!")
                  .setSmallIcon(R.drawable.ic_launcher_foreground)
                  .setContentIntent(contentPendingIntent)//点击通知时的PendingIntent
                  .setDeleteIntent(deletePendingIntent)//删除通知时的PendingIntent
                  .setAutoCancel(true);

              manager.notify(TIMER_ID, builder.build());//发送通知
          }
      }

b)用户在计时器开启后离开应用,且计时器结束前返回

   这种情况和前一种不同的是,提醒和通知都无须发送,且回到页面后应继续开始计时。这里要做的是:

  1. onPause时把当前时刻计时器剩余时间储存至SharedPreference,回到应用时在onResume中提取离开时储存的时刻,计算出当前经过的时间,和离开时计时器的剩余时间相比较,得出从何处继续开始计时。
  2. 回到应用时取消通知和提醒。
  • 主画面MainActivity.java:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
        @Override
        protected void onResume() {
            super.onResume();
            // 获取上次离开时的剩余时间,设置提醒的时间以及当前时间
            SharedPreferences preference = PreferenceManager.getDefaultSharedPreferences(this);
            long lastRemainTimeMilli = preference.getLong(TIMER_REMAIN_TIME, 0);
            long lastSetTimeMilli = preference.getLong(TIMER_SET_TIME, 0);
            long pastTimeMilli = System.currentTimeMillis() - lastSetTimeMilli;

            if (lastSetTimeMilli <= 0) {// 上一次离开时计时器未启动
                // Do nothing
            } else if (pastTimeMilli < lastRemainTimeMilli) { // 计时器已启动且计时未完成,则继续计时
                remainTimeMilli = lastRemainTimeMilli - pastTimeMilli;
                state = STATE.STARTED;
                startTimer();
            } else { // 计时器已启动且计时已完成,则重置计时器
                resetTimer();
            }

            if (alarmManager != null) {
                removeAlarm();//取消提醒和通知
            }
            preference.edit().clear().apply();
        }

      @Override
      protected void onPause() {
          super.onPause();
          // 添加以下操作:
          // 计时途中离开时储存剩余时间到和当前时间到sp
          if (state == state.STARTED) {
              timer.cancel();
              SharedPreferences preference = PreferenceManager.getDefaultSharedPreferences(this);
              preference.edit().putLong(TIMER_REMAIN_TIME, remainTimeMilli).apply();
              preference.edit().putLong(TIMER_SET_TIME, System.currentTimeMillis()).apply();
          //...
        }
    }

      //取消提醒
      private void removeAlarm() {
          Intent intent = new Intent(this, TimerExpiredReceiver.class);
          PendingIntent pending = PendingIntent.getBroadcast(this, 123, intent, 0);
          alarmManager.cancel(pending);
      }  

小结

   作为安卓应用开发的入门级小白,比较容易对Activity、Fragment类的布局或者UI的部分感兴趣,接触系统广播、services、contentProvider等等和os、系统架构交互的部分比较少,需要勤加练习。另外遇到逻辑分支多比较绕的情况,可以先写最基本的case再慢慢考虑其他的,比较不容易遗漏。
完整代码:https://github.com/rikucherry1993/BackgroundCountDownTimer
参考教程:https://www.youtube.com/watch?v=xtElLuzjA0U