经验总结:Android通过AlarmManager开发闹钟功能

背景

刚开始听到需求要做闹钟功能的时候,我是拒绝的,虽然闹钟程序功能很简单,但是要在Android系统中(特别是国产手机)开发自己的闹钟,这会面临许多不可抗拒的问题,吓得我冷汗直流。然而经过一段时间的研究,把在这个过程中收获的小小经验分享给大家。

面临的问题

1.闹钟需要后台常驻,手机关机重启后需要自启动,这两点在国产手机中如果不手动设置是不可能实现的;
2.需要和AlarmManager打交道,程序完全退出后,AlarmManager设置的定时任务也将被清除,App重新启动后需要重新设置;
3.无法查看AlarmManager已经设置好的定时任务,对闹钟的增删改查需要同时维护AlarmManager中的定时任务;
4.用户修改手机时间造成的影响;

这些问题就够让你在未来的一段时间里夜不能寐了。

创建表

首先我们在本地需要创建一个闹钟表T_CLOCK,类似下表,主要记录闹铃时间,具体字段看你的闹钟功能自行增加。

AlarmManager的使用

AlarmManager中提供的方法比较简单,常用的主要有:

  • public void set(int type, long triggerAtMillis, PendingIntent operation)
  • public void setRepeating(int type, long triggerAtMillis,long intervalMillis, PendingIntent operation)
  • public void cancel(PendingIntent operation)

在Android4.4(API19)后又增加了:

  • public void setExact(int type, long triggerAtMillis, PendingIntent operation)
  • public void setWindow(int type, long windowStartMillis, long windowLengthMillis, PendingIntent operation)

除了上述方法以外还有一些重载的方法,这里就不介绍了,这些set方法最终都是调用内部setImpl实现的:

    private void setImpl(int type, long triggerAtMillis, long windowMillis, long intervalMillis,
            int flags, PendingIntent operation, final OnAlarmListener listener, String listenerTag,
            Handler targetHandler, WorkSource workSource, AlarmClockInfo alarmClock) {
        if (triggerAtMillis < 0) {
            /* NOTYET
            if (mAlwaysExact) {
                // Fatal error for KLP+ apps to use negative trigger times
                throw new IllegalArgumentException("Invalid alarm trigger time "
                        + triggerAtMillis);
            }
            */
            triggerAtMillis = 0;
        }

        ListenerWrapper recipientWrapper = null;
        if (listener != null) {
            synchronized (AlarmManager.class) {
                if (sWrappers == null) {
                    sWrappers = new ArrayMap<OnAlarmListener, ListenerWrapper>();
                }

                recipientWrapper = sWrappers.get(listener);
                // no existing wrapper => build a new one
                if (recipientWrapper == null) {
                    recipientWrapper = new ListenerWrapper(listener);
                    sWrappers.put(listener, recipientWrapper);
                }
            }

            final Handler handler = (targetHandler != null) ? targetHandler : mMainThreadHandler;
            recipientWrapper.setHandler(handler);
        }

        try {
            mService.set(mPackageName, type, triggerAtMillis, windowMillis, intervalMillis, flags,
                    operation, recipientWrapper, listenerTag, workSource, alarmClock);
        } catch (RemoteException ex) {
            throw ex.rethrowFromSystemServer();
        }
    }

可以看到最终是调用了mService.set()方法,而这个mService是什么呢?
private final IAlarmManager mService;
可以知道这是个Binder类,调用AlarmManagerService来实现闹钟的设置,在AlarmManagerService中可以找到它的具体实现类。

接下来我们对set方法的参数逐个介绍:

type: 闹钟类型,主要就是分为两类,系统相对时间和绝对时间和是否唤醒CPU;
ELAPSED_REALTIME:使用相对时间,可以通过SystemClock.elapsedRealtime() 获取(从开机到现在的毫秒数,包括手机的睡眠时间),设备休眠时并不会唤醒设备。
ELAPSED_REALTIMEWAKEUP:与ELAPSEDREALTIME基本功能一样,只是会在设备休眠时唤醒设备。
RTC:使用绝对时间,可以通过 System.currentTimeMillis()获取,设备休眠时并不会唤醒设备。
RTC_WAKEUP: 与RTC基本功能一样,只是会在设备休眠时唤醒设备。

triggerAtMillis:触发闹钟的时间;
windowMillis: 这个参数只有在setWindow方法中用到,意思是给触发闹钟指定一个时间范围,可以说是误差范围的意思;
intervalMillis:重复时间间隔,在setRepeating中用到;
operation: 设置一个PendingIntent,当闹钟到来的时候会启动,可以是一个Broadcast,Service或者Activity,具体用法就不说了;

这里需要注意的是,从Android4.4(API19)开始,为了节能省电(减少系统唤醒和电池使用),使用AlarmManager.set()和AlarmManager.setRepeating()已经不保证精确性,系统会对唤醒顺序进行优化,会将唤醒时刻接近的安排在一起唤醒。我们看看AlarmManager的set方法是怎么判断的:

    public void set(int type, long triggerAtMillis, String tag, OnAlarmListener listener,
            Handler targetHandler) {
        setImpl(type, triggerAtMillis, legacyExactLength(), 0, 0, null, listener, tag,
                targetHandler, null, null);
    }

可以看到windowMillis传入了legacyExactLength()的返回值,我们看看返回了什么:

 private long legacyExactLength() {
        return (mAlwaysExact ? WINDOW_EXACT : WINDOW_HEURISTIC);
    }

mAlwaysExact = (mTargetSdkVersion < Build.VERSION_CODES.KITKAT);  

这里我们可以看到,这里是通过mAlwaysExact 字段来判断闹钟是否精确,mAlwaysExact是根据sdkVersion来判断的,小于Build.VERSION_CODES.KITKAT则为精确的,这里就验证了上面官方的说法。那现在我们知道了,是否精确是通过给windowMillis传入WINDOW_EXACT来设置的,我们可以看看AlarmManager.setExact方法,这个方法是API19后提供精确闹铃的:

    public void setExact(int type, long triggerAtMillis, PendingIntent operation) {
        setImpl(type, triggerAtMillis, WINDOW_EXACT, 0, 0, operation, null, null, null,
                null, null);
    }

可以看到它这里的windowMillis传入的是WINDOW_EXACT。

所以,我们的闹钟程序首选的方法当然就是精确的setExact()了,但是根据我的实际测试,这个方法并不能每次都精确到达,有点时候会偏离几秒或者十几秒。之后我测试setWindow()方法,设置intervalMillis为100,意思是误差在100millis,基本上能精确到达,所以我们这里最终选择使用setWindow().

使用的时候我们需要根据API版本来判断调用,比如:

 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            alarmManager.setWindow(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), 100, sender);
        } else {
            alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), INTERVAL, sender);
        }

开始

介绍完AlarmManager的使用之后,那么我们就来针对开篇提到的问题进行解答:

问题1: 闹钟需要后台常驻,手机关机重启后需要自启动,这两点在国产手机中如果不手动设置是不可能实现的;

想要实现App的后台常驻这是一个大难题,特别是在国产手机的定制系统中,任何应用都是可以被杀掉的,参考了其他闹钟的解决方案,大部分是通过强提示和引导用户打开App后台保护。

问题2:需要和AlarmManager打交道,程序完全退出后,AlarmManager设置的定时任务也将被清除,App重新启动后需要重新设置;   

问题3:无法查看AlarmManager已经设置好的定时任务,对闹钟的增删改查需要同时维护AlarmManager中的定时任务;   

问题4:用户修改手机时间造成的影响; 

问题2-4主要是通过AlarmManager对闹铃时间点进行设置,但是在国产手机中退出应用后会清除掉之前已经设置的闹钟,同时无法通过AlarmManager来获取之前所有设置好的闹钟(只能获取到下一次的),那么有没有其他好的方法呢?

终于在一个夜黑风高的夜晚,灵光一闪,我们可以每分钟扫描一下T_CLOCK闹钟表,比较当前时间和表中的闹铃时间,这样我们仅需要维护我们本地的T_CLOCK表的状态,同时也没有以上三个问题了,这样开发起来就简单多了,一分钟扫描一次也是可以接受的。

查询发现Android有个ACTION_TIME_TICK广播,分钟改变的时候会发送一次,很适合我们的需求,在文档中有注明该广播只能通过动态注册获取,我们可以在一个Service去注册和反注册,这样就可以实现后台长期监听了。

这样我们开发起来甚至都不需要使用到AlarmManager了,但是理想总是美好的,经过一系列的机型测试,发现ACTION_TIME_TICK广播在OPPO手机中,如果App切换到后台是收不到该广播的,只有App在前台的时候才能收到广播,这就很尴尬了,怎么办呢?那就只能我们自己手动设置一个每分钟扫描的定时器,依然还是得通过AlarmManager。

    public static void startTimer(Context context) {
        Intent intent = new Intent(ACTION);
        PendingIntent sender = PendingIntent.getBroadcast(context, CLOCK_ID, intent, PendingIntent.FLAG_CANCEL_CURRENT);

        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);

        Calendar calendar = Calendar.getInstance();
        int second = calendar.get(Calendar.SECOND);
        int delay = 60 - second + 1;
        calendar.add(Calendar.SECOND, delay);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            alarmManager.setWindow(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), 100, sender);
        } else {
            alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), INTERVAL, sender);
        }
    }

代码很简单,以上代码是使用AlarmManager的典型代码,其中需要额外处理闹铃的时间,要在下一分钟开始的时候。通过AlarmManager.setWindow是没有周期重复的,那要实现周期重复的话,只需要在广播接收器中再次设置就可以实现周期提醒了,代码如下:

    public static class TimeChangeReceiver extends BroadcastReceiver {

        private static final String TAG = TimeChangeReceiver.class.getName();

        @Override
        public void onReceive(Context context, Intent intent) {

            Intent i = new Intent(ACTION);
            AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
            PendingIntent sender = PendingIntent.getBroadcast(context, CLOCK_ID, i, PendingIntent.FLAG_CANCEL_CURRENT);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                am.setWindow(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + INTERVAL, 100, sender);
            }
        }
    }

总结

本文实现闹钟的思路很简单,就是通过每分钟扫描T_CLOCK表来实现闹铃。应用运行了一段时间,只要应用不被后台清除,都是可以正常闹铃。本篇是我对Android闹铃开发的一些见解,如果有不对的地方或者有很好的方案欢迎提出。

引用 http://blog.csdn.net/editor1994/article/details/50610429