Google Glassでポモドーロテクニック (2/3)
吉村(@alterakey)
こんにちは、ATLでウェアラブルデバイスの研究をしている吉村です。前回に引き続いて、今度はタイマーをAndroidでプロトタイピングします。
アーキテクチャ
ポモドーロタイマーということで、ユーザがActivityから離れている時にもずっと時間を追跡しつづけ、音を出しつづけることになります。
ここではセオリー通りにメイン画面はActivity+カスタムView、タイミング追跡・発音はServiceで、と分解することにします。
メイン画面の作成
Activityにビュー寄りのロジックが集中することを避けるために、カスタムビューを別途作成しましょう。
タイマービューの作成
TimerViewとして作成して行きます。
まずレイアウトですが中心寄りに、TextViewを使ってシンプルに組みます。fontFamilyプロパティを使用して、Robotoを明示的に指定していることに注意してください。
res/layout/view_timer.xml:
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 |
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:id="@+id/timer" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center|center_vertical"> <TextView android:id="@+id/min" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:fontFamily="sans-serif-thin" android:textSize="96sp" android:text="25" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:paddingLeft="12dp" android:paddingRight="12dp" android:fontFamily="sans-serif-thin" android:textSize="96sp" android:text=":" /> <TextView android:id="@+id/sec" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:fontFamily="sans-serif-thin" android:textSize="96sp" android:text="00" /> </LinearLayout> </FrameLayout> |
最低限のロジックを書いておきます。
src/main/java/com/gmail/altakey/myapplication/TimerView.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 |
package com.gmail.altakey.myapplication; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.widget.FrameLayout; import android.widget.TextView; public class TimerView extends FrameLayout { private TextView mMinutes; private TextView mSeconds; public TimerView(Context context) { super(context); init(context); } public TimerView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public TimerView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } private void init(final Context c) { final View v = View.inflate(c, R.layout.view_timer, this); mMinutes = (TextView)v.findViewById(R.id.min); mSeconds = (TextView)v.findViewById(R.id.sec); } public void setRemaining(final long remainingMillis) { final TimerReader reader = new TimerReader(remainingMillis); mMinutes.setText(String.format("%02d", reader.minutes)); mSeconds.setText(String.format("%02d", reader.seconds)); } } |
src/main/java/com/gmail/altakey/myapplication/TimerReader.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
package com.gmail.altakey.myapplication; public class TimerReader { public long seconds; public long minutes; public long remaining; public TimerReader(long remaining) { remaining = ((long)Math.ceil(remaining / 1000.0)) * 1000; seconds = remaining / 1000 % 60; minutes = remaining / 60000; this.remaining = remaining; } public long getElapsed(final long due) { return due - remaining; } } |
これをプレビューすると、以下のようになるはずです。
Activityの作成
ここまでできたら、今度はこれをActivityに組み込みます。レイアウトはただはめ込むだけです。
res/layout/activity_my.xml:
1 2 3 4 5 6 7 |
<?xml version="1.0" encoding="utf-8"?> <com.gmail.altakey.myapplication.TimerView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"/> </p> |
すると、最終的にこんな形でレンダリングされます。簡単ですね。
次にロジックを書いて行きます。ここでやることは 1) 起動と同時にサービスを起動し、2) タップされた時にメニューを表示、3) Resetでタイマーをリセットし、4) Exitで終了する、の4点です。
#1: サービス制御
Activityが起動されると通常通りonCreateが呼ばれるので、ここでサービスを起動します。サービスからはLocalBroadcastReceiverを使用してタイマー表示更新リクエストを受けとります。
src/main/java/com/gmail/altakey/myapplication/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 |
private BroadcastReceiver mReceiver; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_my); ... mReceiver = new TickReceiver(v); ... final Intent intent = new Intent(this, TimerService.class); intent.setAction(TimerService.ACTION_START); startService(intent); } ... private static class TickReceiver extends BroadcastReceiver { private TimerView mView; public TickReceiver(final TimerView v) { mView = v; } @Override public void onReceive(Context context, Intent intent) { mView.setRemaining(intent.getLongExtra(TimerService.KEY_REMAINING, 0)); } } |
#2: メニュー表示
タップされた場合にメニューを表示します。今のところこれはいわゆる普通のOptions Menuで良いです。GlassのMenuと比べると見かけ的には随分と違いますが、ここで作り込んでもしかたがない(理由は次回分かります!)のでひとまず目を瞑りましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Override protected void onCreate(Bundle savedInstanceState) { ... final TimerView v = (TimerView)findViewById(R.id.timer); v.setOnClickListener(new TapAction()); ... } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.my, menu); return true; } ... private class TapAction implements View.OnClickListener { @Override public void onClick(View view) { openOptionsMenu(); } } |
#3: タイマーリセット
Options MenuでResetが叩かれた場合の処理です。サービスをリセットします。
1 2 3 4 5 6 7 8 |
@Override public boolean onOptionsItemSelected(MenuItem item) { final Intent intent = new Intent(this, TimerService.class); switch (item.getItemId()) { case R.id.action_reset: intent.setAction(TimerService.ACTION_RESET); startService(intent); break; |
#4: 終了
Options MenuでExitが叩かれた場合の処理です。サービスを止めアプリを終了します。
1 2 3 4 5 6 7 8 9 |
@Override public boolean onOptionsItemSelected(MenuItem item) { final Intent intent = new Intent(this, TimerService.class); switch (item.getItemId()) { ... case R.id.action_exit: stopService(intent); finish(); break; |
ここまで来ればあとはタイマーの処理を担っているサービスだけです。もう一息ですね。
タイマーサービスの作成
変哲もなく、普通に書きます。以下のような形に。
src/main/java/com/gmail/altakey/myapplication/TimerService.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 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
package com.gmail.altakey.myapplication; import android.app.AlarmManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.media.AudioManager; import android.media.SoundPool; import android.os.IBinder; import android.os.PowerManager; import android.os.SystemClock; import android.support.v4.content.LocalBroadcastManager; import android.util.Log; import java.util.Timer; import java.util.TimerTask; public class TimerService extends Service { public static final String ACTION_START = "start"; public static final String ACTION_RESET = "reset"; public static final String ACTION_TIMEOUT = "timeout"; public static final String ACTION_TICK = "tick"; public static final String KEY_REMAINING = "remaining"; public static final int STATE_RESET = 0; public static final int STATE_RUNNING = 1; public static final int STATE_BREAKING = 2; private static int sState = STATE_RESET; private static long sDueMillis = 0; private PendingIntent mDueIntent = null; private Timer mTimer = null; private Timer mIdleTimer = null; private Ticker mTicker = new Ticker(this); public static int getState() { return sState; } public static long getDueMillis() { return sDueMillis; } public static long getRemaining(long due) { if (due > 0) { return getDueMillis() - SystemClock.elapsedRealtime(); } else { return 25 * 60 * 1000; } } private static String TAG = "com.gmail.altakey.mint.timer.main"; @Override public void onCreate() { super.onCreate(); mTicker.prepare(); Log.d("TS", "created"); } @Override public void onDestroy() { mTicker.cleanup(); reset(); super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return null; } @Override public int onStartCommand(Intent intent, int flags, int startId) { final String action = intent.getAction(); Log.d("TS.oHI", String.format("state: %d, due: %d", sState, sDueMillis)); updateView(); wakeUp(); if (ACTION_START.equals(action)) { start(); } else if (ACTION_RESET.equals(action)) { reset(); start(); } else if (ACTION_TIMEOUT.equals(action)) { proceed(); } if (sState == STATE_RESET) { if (mIdleTimer == null) { mIdleTimer = new Timer(); mIdleTimer.schedule(new TimerTask() { @Override public void run() { stopSelf(); } }, 90000); } } else { if (mIdleTimer != null) { mIdleTimer.cancel(); mIdleTimer.purge(); mIdleTimer = null; } } return START_NOT_STICKY; } private void wakeUp() { final PowerManager pm = (PowerManager)getSystemService(POWER_SERVICE); final PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.FULL_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE | PowerManager.ACQUIRE_CAUSES_WAKEUP, "screen_poke"); try { wl.acquire(); } finally { wl.release(); } } private void startTimer(long intervalMillis, boolean for_break) { final AlarmManager am = (AlarmManager)getSystemService(ALARM_SERVICE); resetTimer(); sDueMillis = SystemClock.elapsedRealtime() + intervalMillis; final Intent intent = new Intent(this, TimerService.class); intent.setAction(ACTION_TIMEOUT); mDueIntent = PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_ONE_SHOT); am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, sDueMillis, mDueIntent); mTimer = new Timer(); mTimer.schedule(new TimerTask() { @Override public void run() { updateView(); mTicker.tick(); } }, 1000, 1000); } private void resetTimer() { final AlarmManager am = (AlarmManager)getSystemService(ALARM_SERVICE); if (mDueIntent != null) { am.cancel(mDueIntent); mDueIntent = null; } if (mTimer != null) { mTimer.cancel(); mTimer.purge(); mTimer = null; } sDueMillis = 0; updateView(); } private void updateView() { final Intent data = new Intent(ACTION_TICK); data.putExtra(KEY_REMAINING, getRemaining(getDueMillis())); LocalBroadcastManager.getInstance(this).sendBroadcast(data); } private void start() { if (sState == STATE_RESET) { startTimer(25 * 60 * 1000, false); sState = STATE_RUNNING; } } private void reset() { if (sState != STATE_RESET) { resetTimer(); sState = STATE_RESET; } } private void proceed() { mTicker.bell(); if (sState == STATE_RUNNING) { startTimer(5 * 60 * 1000, true); sState = STATE_BREAKING; } else { resetTimer(); sState = STATE_RESET; } } private static class Ticker { private final Context mContext; private SoundPool mPool = null; private int mSoundTick = 0; private int mSoundBell = 0; public Ticker(final Context c) { mContext = c; } public void prepare() { if (mPool == null) { mPool = new SoundPool(2, AudioManager.STREAM_NOTIFICATION, 0); mSoundTick = mPool.load(mContext, R.raw.tick, 1); mSoundBell = mPool.load(mContext, R.raw.ring, 1); } } public void cleanup() { if (mPool != null) { mSoundTick = 0; mSoundBell = 0; mPool.release(); mPool = null; } } public void tick() { if (mPool != null) { mPool.play(mSoundTick, 1.0f, 1.0f, 0, 0, 1.0f); } } public void bell() { if (mPool != null) { mPool.play(mSoundBell, 1.0f, 1.0f, 0, 1, 1.0f); } } } } |
あとは、AndroidManifest.xmlでの宣言も忘れずに行なっておきましょう。
1 |
<service android:name=".TimerService" /> |
リソースの追加
res/rawに2つのOgg Vorbisファイルを追加します。
ring.ogg : クリック音
tick.ogg : ベル音
プロトタイプ完成!
ビルドして実行してみましょう。起動と同時にカウントダウンが始まりましたか?また、タップするとメニューが出現してResetで25分に戻り、Exitでカウントダウンも止まりましたか?
また、Eclipseで開発したことがある方はもう既に気づいているかもしれませんが、Android Studioではエラーなどを事前に下線などで知ることができながら、残っていてもとりあえずビルド・実行をトライさせることができます。
では、次回はこれをいよいよGlass実機へポートしてみましょう。