記得在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。