Android Wear计时器开发
记得在2013年12月的时候,有系列文章是介绍怎么开发一个智能手表的App,让用户可以在足球比赛中记录停表时间。随着Android Wear的问世,在可穿戴设备中开发一款这样的App确实是个很不错的想法,但是按照目前对于Android Wear的架构了解来说,似乎有些困难。所以本系列文章我们就重写这个应用,带领大家进入Android Wear的世界。
本文不会长篇大论地讲解我们要开发的这款App的用途,因为我们在之前的系列文章已经深入了解过了。这么说吧,这是一个计时类应用,在比赛开始的时候开始执行,在比赛的过程中可以暂停(停表),然后45分钟过去后会有震动提醒,然后比赛进行45分钟后也会有提醒。
在开始之前,很有必要先看看我们为什么要重写这个App而不是直接上代码。智能手表使用的是一个修改版的Android1.6的系统,所以它的架构很像一个运行Android1.6的手机,所以我们的App基于一个Activity,我们所有的工作都运行在这个Activity上。在开始学习智能手表开发之前,我们必须很清楚地知道,我们之前的设计在Android Wear上并不适用,尽管它也是支持Activity,但是在Android Wear上工作方式是不同的。在手机或者平板上,如果一个Activity从sleep状态回到唤醒状态,Activity会被重新唤醒,但是在Wear上却不是这样。一段时间过去后Wear设备会进入sleep,但是在设备唤醒后,处于sleep状态的Activity却不会再被唤醒了。
首先这个问题使我非常惊讶,我一直很想知道Activity有了这个限制后,还能开发实用的App吗?后来才发现这个问题完全是多虑的,我渐渐地发现,要开发一个实用的App也很简单——我们只需要转变我们的软件设计模式,使它更符合Android Wear的体系结构,而不是当做一个手机来看。
这里我们需要考虑的最基本的问题是,这个计时应用程序需要基于一个一直运行的服务来记录时间。但是基于长运行的服务不是一个好的方案,因为它会耗电。这里我们提到的记录时间这个关键词,也就是说,我们并不需要真的实现一个长运行的服务,只要在用户需要看的时候我们可以更新消息显示就行。在大部分的时间里,其实用户只需要了解大概过去了多长时间,只有在比赛暂停或者中场快结束的时候才需要显示更详细的信息。所以在大部分的时间里,我们只需要显示精确到分钟即可,然后在用户需要的时候才精确到秒。
我们要实现这个方法的基本方法就是使用AlarmManager每分钟触发一次更新通知事件,去更新分钟显示。这个通知事件还包括显示精确到秒的Activity,但是只有在用户滑动屏幕的时候才会显示整个通知。通过这种方式我们可以在必须显示的时候才去更新消息,所以对大部分设备来说,每分钟更新一次消息显示比一直运行一个服务更加省电。
下图显示充分证明了这点,首先我们需要打开通知,这样就可以得到精确到秒的显示了。
然而,在有信息显示或者设备休眠的时候,我们只需要显示精确到分钟就可以了。
有一件事情需要说明一下,就是这个App的名字已经改变了。之前在在I'm Watch的版本上叫做“Footy Timer”,现在改为“Match Timer”。因为在使用语音启动App的时候,Google的声音识别对“Footy”这个词很不敏感,我们用“ok Google,start Footy Timer”这个命令不能启动应用,而使用“ok Google,start Match Timer”就可以使用。
最后,很抱歉这篇文章没有代码,但是本系列文章会稍微有些变动。以前本人会在每篇文章末尾附上文章相关的代码段,这个请放心,之后的文章还是会这样的,因为这个是一个功能完善的App,而不是系列技术文章,所以在接下来的文章会包含一些代码示例和注释,在本系列文章完结的时候会附上整个项目的源码。
Match Timer 可以在Google Play上找到:https://play.google.com/store/apps/details?id=com.stylingandroid.matchtimer
上面我们解释了为什么要在Android Wear重写这个计时器app(因为之前已经在“I'm Watch”里面开发过了),下面我们就来看看代码。
我们以这个app的一个核心类开始,这个类负责控制计时器的状态。这个类包含了4个long类型的变量:第一个代表计时器开始的时间;第二个代表计时器停止的时间(在运行中的话,它就是0);第三个代表计时器停表的时间(如果当前没有停表,那它也是0),第四个代表总共停表的时长。通过这四个变量我们就可以维持计时器的状态了,还可以通过计算得到我们需要展示的其他信息。这个类的基本功能就是都是为了操作这些变量,即维持计时器的这些状态。
public final class MatchTimer { . . . public static final int MINUTE_MILLIS = 60000; private long start; private long currentStoppage; private long totalStoppages; private long end; . . . public long getElapsed() { if (isRunning()) { return System.currentTimeMillis() - start; } if (end > 0) { return end - start; } return 0; } public boolean isRunning() { return start > 0 && end == 0; } public boolean isPaused() { return currentStoppage > 0; } public int getElapsedMinutes() { return (int) ((System.currentTimeMillis() - start) / MINUTE_MILLIS); } public long getTotalStoppages() { long now = System.currentTimeMillis(); if (isPaused()) { return totalStoppages + (now - currentStoppage); } return totalStoppages; } public long getPlayed() { return getElapsed() - getTotalStoppages(); } public long getStartTime() { return start; } . . . }
这些都是基本的java代码,就不费时间讲了。下面的函数更高级一些,可以操作计时器的状态。
public final class MatchTimer { . . . public void start() { if (end > 0) { start = System.currentTimeMillis() - (end - start); end = 0; } else { start = System.currentTimeMillis(); } save(); } public void stop() { if (isPaused()) { resume(); } end = System.currentTimeMillis(); save(); } public void pause() { currentStoppage = System.currentTimeMillis(); save(); } public void resume() { totalStoppages += System.currentTimeMillis() - currentStoppage; currentStoppage = 0L; save(); } public void reset() { resetWithoutSave(); save(); } private void resetWithoutSave() { start = 0L; currentStoppage = 0L; totalStoppages = 0L; end = 0L; } }
这些还是基本的Java代码,也可以不用讲了。只有save()方法我们还没有见到,这是在类的最后写的,这个函数才值得的我们讲讲。
前一篇文章我们讨论了关于唤醒机制的问题,我们不需要去维持一个长连接或者后台服务,只需要维持这几个计时器的状态就可以了。我们使用SharedPreference来实现:
public final class MatchTimer implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String KEY_START = "com.stylingandroid.matchtimer.KEY_START"; private static final String KEY_CURRENT_STOPPAGE = "com.stylingandroid.matchtimer.KEY_CURRENT_STOPPAGE"; private static final String KEY_TOTAL_STOPPAGES = "com.stylingandroid.matchtimer.KEY_TOTAL_STOPPAGES"; private static final String KEY_END = "com.stylingandroid.matchtimer.KEY_END"; private static final String PREFERENCES = "MatchTimer"; private final SharedPreferences preferences; public static MatchTimer newInstance(Context context) { SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); long start = preferences.getLong(KEY_START, 0); long currentStoppage = preferences.getLong(KEY_CURRENT_STOPPAGE, 0); long totalStoppages = preferences.getLong(KEY_TOTAL_STOPPAGES, 0); long end = preferences.getLong(KEY_END, 0); return new MatchTimer(preferences, start, currentStoppage, totalStoppages, end); } private MatchTimer(SharedPreferences preferences, long start, long currentStoppage, long totalStoppages, long end) { this.preferences = preferences; this.start = start; this.currentStoppage = currentStoppage; this.totalStoppages = totalStoppages; this.end = end; } public void save() { preferences.edit() .putLong(KEY_START, start) .putLong(KEY_CURRENT_STOPPAGE, currentStoppage) .putLong(KEY_TOTAL_STOPPAGES, totalStoppages) .putLong(KEY_END, end) .apply(); } public void registerForUpdates() { preferences.registerOnSharedPreferenceChangeListener(this); } public void unregisterForUpdates() { preferences.unregisterOnSharedPreferenceChangeListener(this); } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { long value = sharedPreferences.getLong(key, 0L); if (key.equals(KEY_START)) { start = value; } else if (key.equals(KEY_END)) { end = value; } else if (key.equals(KEY_CURRENT_STOPPAGE)) { currentStoppage = value; } else if (key.equals(KEY_TOTAL_STOPPAGES)) { totalStoppages = value; } } . . . }
我们需要的就是newInstance()方法从SharedPreference中构造一个MatchTimer实例,我们还需要save()方法,可以帮我们把当前的计时器状态保存到SharedPreference中。
最后我们要说明的是,如果某一部分持有MatchTimer对象的引用,但是其他对象已经改变了计时器的状态,就可能会发生异常(见下一篇文章)。所以我们还需要提供一些方法去注册和注销MatchTImer的实例,在Sharedpreference的值改变时去接收计时器状态的变化。
现在我们已经定义了一个基本的计时器了,下一篇文章我们会介绍怎么保持计时器的状态以及在需要的时候去唤醒这些状态。
Match Timer 可以在Google Play上下载:Match Timer.
在本系列前几篇文章中,我们介绍了Android Wear计时器app,对设计思路和app的结构进行了分析。本文将讲解如何定时唤醒程序提醒用户。
对于为什么不用后台服务的方式一直运行,我们已经进行了解释——这种方式非常耗电。因此,我们必须要有一个定时唤醒机制。我们可以使用AlarmManager来实现这个机制,定时执行一个Intent,然后通知BroadcastReceiver。之所以选择BroadcastReceiver而不用IntentService,是因为我们要运行的任务是轻量级的而且生命周期非常短暂。使用BroadcastReceiver可以避免每次执行任务的时候都经历Service的整个生命周期。因此,对于我们这种轻量级的任务来说非常合适——我们执行的任务都在毫秒级。
BroadcastReceiver的核心在于onReceiver方法,我们需要在这里安排各种事件响应。
public class MatchTimerReceiver extends BroadcastReceiver { public static final int MINUTE_MILLIS = 60000; private static final long DURATION = 45 * MINUTE_MILLIS; private static final Intent UPDATE_INTENT = new Intent(ACTION_UPDATE); private static final Intent ELAPSED_ALARM = new Intent(ACTION_ELAPSED_ALARM); private static final Intent FULL_TIME_ALARM = new Intent(ACTION_FULL_TIME_ALARM); private static final int REQUEST_UPDATE = 1; private static final int REQUEST_ELAPSED = 2; private static final int REQUEST_FULL_TIME = 3; public static void setUpdate(Context context) { context.sendBroadcast(UPDATE_INTENT); } . . . private void reset(MatchTimer timer) { timer.reset(); } private void resume(Context context, MatchTimer timer) { timer.resume(); long playedEnd = timer.getStartTime() + timer.getTotalStoppages() + DURATION; if (playedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM, playedEnd); } } private void pause(Context context, MatchTimer timer) { timer.pause(); cancelAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM); long elapsedEnd = timer.getStartTime() + DURATION; if (!isAlarmSet(context, REQUEST_ELAPSED, ELAPSED_ALARM) && elapsedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_ELAPSED, ELAPSED_ALARM, elapsedEnd); } } private void stop(Context context, MatchTimer timer) { timer.stop(); cancelAlarm(context, REQUEST_UPDATE, UPDATE_INTENT); cancelAlarm(context, REQUEST_ELAPSED, ELAPSED_ALARM); cancelAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM); } private void start(Context context, MatchTimer timer) { timer.start(); long elapsedEnd = timer.getStartTime() + DURATION; setRepeatingAlarm(context, REQUEST_UPDATE, UPDATE_INTENT); if (timer.getTotalStoppages() > 0 && !timer.isPaused()) { long playedEnd = timer.getStartTime() + timer.getTotalStoppages() + DURATION; if (playedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM, playedEnd); } if (elapsedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_ELAPSED, ELAPSED_ALARM, elapsedEnd); } } else { if (elapsedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM, elapsedEnd); } } } . . . }
代码还是非常直观易于理解的。首先实例化一个MatchTimer对象(从SharedPreference中读取数据),然后分别传给对应的事件处理Handler。之后等待动作发生,最后更新Notification。
这里会处理8个事件动作,其中5个负责控制计时器的状态(START、STOP、PAUSE、RESUME、RESET);一个负责更新Notification,剩下两个负责到45分钟唤醒后震动提示。
我们先从这几个控制状态开始:
public class MatchTimerReceiver extends BroadcastReceiver { public static final int MINUTE_MILLIS = 60000; private static final long DURATION = 45 * MINUTE_MILLIS; private static final Intent UPDATE_INTENT = new Intent(ACTION_UPDATE); private static final Intent ELAPSED_ALARM = new Intent(ACTION_ELAPSED_ALARM); private static final Intent FULL_TIME_ALARM = new Intent(ACTION_FULL_TIME_ALARM); private static final int REQUEST_UPDATE = 1; private static final int REQUEST_ELAPSED = 2; private static final int REQUEST_FULL_TIME = 3; public static void setUpdate(Context context) { context.sendBroadcast(UPDATE_INTENT); } . . . private void reset(MatchTimer timer) { timer.reset(); } private void resume(Context context, MatchTimer timer) { timer.resume(); long playedEnd = timer.getStartTime() + timer.getTotalStoppages() + DURATION; if (playedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM, playedEnd); } } private void pause(Context context, MatchTimer timer) { timer.pause(); cancelAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM); long elapsedEnd = timer.getStartTime() + DURATION; if (!isAlarmSet(context, REQUEST_ELAPSED, ELAPSED_ALARM) && elapsedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_ELAPSED, ELAPSED_ALARM, elapsedEnd); } } private void stop(Context context, MatchTimer timer) { timer.stop(); cancelAlarm(context, REQUEST_UPDATE, UPDATE_INTENT); cancelAlarm(context, REQUEST_ELAPSED, ELAPSED_ALARM); cancelAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM); } private void start(Context context, MatchTimer timer) { timer.start(); long elapsedEnd = timer.getStartTime() + DURATION; setRepeatingAlarm(context, REQUEST_UPDATE, UPDATE_INTENT); if (timer.getTotalStoppages() > 0 && !timer.isPaused()) { long playedEnd = timer.getStartTime() + timer.getTotalStoppages() + DURATION; if (playedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM, playedEnd); } if (elapsedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_ELAPSED, ELAPSED_ALARM, elapsedEnd); } } else { if (elapsedEnd > System.currentTimeMillis()) { setAlarm(context, REQUEST_FULL_TIME, FULL_TIME_ALARM, elapsedEnd); } } } . . . }
这些方法主要有两个功能:首先设置MatchTimer的状态,然后设置时间提醒的闹铃,改变参数就可以播放闹铃。这个功能还可以封装成一个工具方法,叫setUpdate()。这样外部也可以触发计时器的更新。
我们使用标准AlarmManager的方法来设置闹铃:
public class MatchTimerReceiver extends BroadcastReceiver { . . . public static final int MINUTE_MILLIS = 60000; . . . private void setRepeatingAlarm(Context context, int requestCode, Intent intent) { AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT); alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis(), MINUTE_MILLIS, pendingIntent); } private boolean isAlarmSet(Context context, int requestCode, Intent intent) { return PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_NO_CREATE) != null; } private void setAlarm(Context context, int requestCode, Intent intent, long time) { AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT); alarmManager.setExact(AlarmManager.RTC_WAKEUP, time, pendingIntent); } private void cancelAlarm(Context context, int requestCode, Intent intent) { PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_NO_CREATE); if (pendingIntent != null) { AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); alarmManager.cancel(pendingIntent); pendingIntent.cancel(); } } . . . }
这里值得讨论的是setRepeatingAlarm()这个方法。因为在Wear在实现方式上有点不一样。我们会在Start事件中每秒钟触发一次闹铃更新Notification动作,所以这里需要记录具体已经过去了多少分钟。正常来说我们会每隔60秒触发一次这个动作,但是在Wear上不能这么做。原因是——当设备在唤醒着的时候可以这样做,但是如果设备进入睡眠状态就需要重新计算下一分钟的边界值。这就需要异步更新部件,然后设备只需要每分钟唤醒一次。一分钟结束后在计时器需要更新状态的时候触发操作。
对于我们的计时器应用来说,显示的分钟数会比实际时间少1分钟。但是显示分钟并不要求非常实时(但显示秒数时需要非常精确),所以我们可以这样操作:
完整的alarm Handler是这样使用振动服务的:
public class MatchTimerReceiver extends BroadcastReceiver { . . . private static final long[] ELAPSED_PATTERN = {0, 500, 250, 500, 250, 500}; private static final long[] FULL_TIME_PATTERN = {0, 1000, 500, 1000, 500, 1000}; private void elapsedAlarm(Context context) { Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); vibrator.vibrate(ELAPSED_PATTERN, -1); } private void fullTimeAlarm(Context context) { Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); vibrator.vibrate(FULL_TIME_PATTERN, -1); } . . . }
最后,我们通过这个方法来构造Notification然后呈现给用户:
public class MatchTimerReceiver extends BroadcastReceiver { public static final int NOTIFICATION_ID = 1; . . . private void updateNotification(Context context, MatchTimer timer) { NotificationBuilder builder = new NotificationBuilder(context, timer); Notification notification = builder.buildNotification(); NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); notificationManager.notify(NOTIFICATION_ID, notification); } }
Notification是Wear计时器的一个重要的部分,这里还需要一个自定义类来构造这些Notification通知。下一篇文章我们会讲如何在计时器app中使用Notification。
Match Timer可以在Google Play上下载:Match Timer。