Watch Faceを作る サンプルのコードを読み解く

2015/11/13 3722hit

Android WearのWatch Faceを作る 目次
前回は環境構築とチュートリアルに従って雛形を作成しました。
今回は前回作成したモジュールを元に、どのようにWatch Faceが動いているか読み解いていきます。

wearモジュールを開くとjavaファイルとしてMyWatchFaceが1つ作成されています。
この1つのファイルにWearアプリを作るために必要な殆どの内容が入っていて、初期にテンプレートとして作成されるファイルにしてはかなり大きなものになっています。
コメントも全文英語なのでちょっと分かりづらいですね。
それでは1つずつ分解してみていきましょう。

クラス構成

ファイルは1つですが、MyWatchFaceに加えて1つのstaticなinner classであるEngineHandlerとinner classであるEngineがあるため実際は3つのクラスから構成されています。

MyWatchFaceはWatchFaceの本体となるクラスです。
EngineはWatchFaceを作る上で最も重要なクラスでライフサイクルや描画などさまざまな処理を取り扱います。
EngineHandlerは時計で大切な一定時間後にEngineの処理を呼びます。

MyWatchFaceの処理

MyWatchFaceはその名の通り、WatchFaceの本体ですが、実際のところ殆どの処理はEngineに記載されており、Innerクラスを排除するとこれだけコンパクトになります。

/**
* Analog watch face with a ticking second hand. In ambient mode, the second hand isn't shown. On
* devices with low-bit ambient mode, the hands are drawn without anti-aliasing in ambient mode.
*/
public class MyWatchFace extends CanvasWatchFaceService {
/**
* Update rate in milliseconds for interactive mode. We update once a second to advance the
* second hand.
*/
private static final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.SECONDS.toMillis(1);

/**
* Handler message id for updating the time periodically in interactive mode.
*/
private static final int MSG_UPDATE_TIME = 0;

@Override
public Engine onCreateEngine() {
return new Engine();
}
}

書き換え間隔の指定

書き換え間隔を秒で指定しています。ここでは1秒が指定されています。
実際に格納される値はミリ秒なので1000が設定されます。

private static final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.SECONDS.toMillis(1);


ハンドラー送信元を特定するためのIDを指定

ハンドラーの送信元を特定するためのIDを指定しています。
ここでは0が設定されていますが送信側と受信側が同じ値であればどんな値でも良いです。

private static final int MSG_UPDATE_TIME = 0;


Engineの作成

Engineのインスタンスを作成して返すことで、WatchFaceのライフサイクルでEngineを呼び出させることが出来ます。

@Override
public Engine onCreateEngine() {
return new Engine();
}


Engineの処理

EngineはWatchFaceの多くの処理を行います。
インスタンス変数として次のような値を持ちます。

final Handler mUpdateTimeHandler = new EngineHandler(this);
boolean mRegisteredTimeZoneReceiver = false;
Paint mBackgroundPaint;
Paint mHandPaint;
boolean mAmbient;
Time mTime;
final BroadcastReceiver mTimeZoneReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
mTime.clear(intent.getStringExtra("time-zone"));
mTime.setToNow();
}
};
int mTapCount;

mUpdateTimeHandlerには一定時間ごとのイベントを取り扱うためのEngineHandlerを持ちます。
mRegisteredTimeZoneReceiverはタイムゾーンの変更時のReceiverが登録されているかどうかを持ちます。
mBackgroundPaintは背景を描画するため、mHandPaintは針を描画するためのPaintです。
mAmbientは現在がAmbientモードになっているかどうかを持ちます。
mTimeは時間関係を取り扱うためのクラスです。Timeは扱える範囲が狭く2032年以降が取り扱えないので、本当ならこの実装はあまり良くない気がします。
余裕がある人はCalendarに書き換えてください。
mTimeZoneReceiverはタイムゾーンが変更された時のReceiverです。mTimeのタイムゾーンを設定して時間を置き換えています。このため、海外旅行などでタイムゾーンが変更された場合でも、自動的に新しいタイムゾーンに移行させることが出来ます。
mTapCountはタップされた回数を記録しています。これは背景をタップするたびに色を切り替えるために使っているだけで、WatchFaceの作り次第で普通は不要になると思います。
mLowBitAmbientはAmbient時に白黒2値をサポートするかどうかを格納します。

onCreate()の処理

Engineが作成された時に行う処理を記載します。
Watch Faceは実行時間がかなり長くなるのと、普段使用時の消費電力を極端に抑える必要が有ることから、1回で済む処理は全てここで記載してしまい、あとから行う処理を最小限にします。

WatchFaceのスタイルを設定します。
CardPeekModeは通知が来た時にどのようにWatchFace上に通知を見せるかを指定します。
BackgroundVisibilityは通知が来た時に背景を表示するかどうかを指定します。
ShowSystemUiTimeはシステムが提供する標準的なデジタルの時刻表示を行うかどうかを指定します。
WatchFaceだけでは時刻がわかりにくい場合などにもシステムの時刻が表示されます。
AcceptsTapEventsではユーザーがタップした時に処理を行うかどうかを指定します。

setWatchFaceStyle(new WatchFaceStyle.Builder(MyWatchFace.this)
.setCardPeekMode(WatchFaceStyle.PEEK_MODE_SHORT)
.setBackgroundVisibility(WatchFaceStyle.BACKGROUND_VISIBILITY_INTERRUPTIVE)
.setShowSystemUiTime(false)
.setAcceptsTapEvents(true)
.build());

描画を行うのに必要な準備を行います。
実際の描画は後で行いますが描画処理は何度も呼び出されるのでインスタンスの生成のような重たい処理は出来るだけonCreateのタイミングで行ってしまい、描画処理の負荷を下げます。

Resources resources = MyWatchFace.this.getResources();

mBackgroundPaint = new Paint();
mBackgroundPaint.setColor(resources.getColor(R.color.background));

mHandPaint = new Paint();
mHandPaint.setColor(resources.getColor(R.color.analog_hands));
mHandPaint.setStrokeWidth(resources.getDimension(R.dimen.analog_hand_stroke));
mHandPaint.setAntiAlias(true);
mHandPaint.setStrokeCap(Paint.Cap.ROUND);

mTime = new Time();


onDestroy()の処理

onDestroy()では一定時間ごとに処理を行うmUpdateTimeHandlerのメッセージを削除して次回以降の処理が行われないようにします。

@Override
public void onDestroy() {
mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
super.onDestroy();
}



onPropertiesChanged()の処理

onPropertiesChanged()はプロパティーが変更された時に呼び出されます。
ここではAmbient時に白黒2値をサポートしているかを判別し保存しています。

@Override
public void onPropertiesChanged(Bundle properties) {
super.onPropertiesChanged(properties);
mLowBitAmbient = properties.getBoolean(PROPERTY_LOW_BIT_AMBIENT, false);
}


onTimeTick()の処理

onTimeTick()は一定時間ごとに呼び出されます。invalidate()を呼んで画面の再描画を促します。
ただし、ここで呼び出されるのは最短でも1分に一度だけです。そのため、秒針などを表現したい場合は、mEngineHandlerを使って別に呼び出します。

@Override
public void onTimeTick() {
super.onTimeTick();
invalidate();
}


onAmbientModeChanged()の処理

一定時間操作を行わないと、AmbientModeというバックライトが消えて色数が少ない省電力モードに移行します。
AmbientModeになった後は画面の色数を減らして、画面描画を最短で1分ごとに抑えます。
AmbientModeに入るときはinAmbientModeにtrueが、AmbientModeから出るときはinAmbientModeにfalseが渡されます。

AmbientModeが切り替わった時は最新のAmbient状態をmAmbientに保存します。
つぎに、白黒2値のAmbientModeに対応している場合は灰色が表現できなくなるためPaintのAntiAliasをAmbient時にfalse、Ambientから出て通常状態になる時にtrueに書き換えます。
白黒2値のAmbientModeに対応していない場合はAntiAliasはtrueのままです。
これにより白黒2値に対応していてAmbientModeに入る時だけAntiAliasがfalseとなり、それ以外ではtrueとなります。

AntiAliasとは半透明の色を使うことで、線をなめらかに見せる機能ですが、その分余計に必要な処理が増えます。
白黒2値の場合半透明が使用できずせっかくAntiAliasを計算しても白か黒に丸められてしまうので、無駄な処理を行わないようにしているというわけです。

invalidate()を呼んで画面の再描画をリクエストして、次の1分まで待たずにAmbient状態に応じた描画を行います。
updateTimer()はAmbient状態と通常状態に応じて画面の描画間隔を切り替えるためにタイマーを設定します。(後述)


if (mAmbient != inAmbientMode) {
mAmbient = inAmbientMode;
if (mLowBitAmbient) {
mHandPaint.setAntiAlias(!inAmbientMode);
}
invalidate();
}

// Whether the timer should be running depends on whether we're visible (as well as
// whether we're in ambient mode), so we may need to start or stop the timer.
updateTimer();
}

onTapCommand()の処理

画面をタップした時はonTapCommand()が呼び出されます。
引数tapTypeはどのようにタップしたかについて、画面をタップし始めた時はTAP_TYPE_TOUCH
ユーザーがタップ操作を取りやめたり別のジェスチャーに切り替えた時はTAP_TYPE_TOUCH_CANCEL
ユーザーがタップ操作を完了した時はTAP_TYPE_TAPが渡されます。
int xとint yはタップした座標が渡されます。eventTimeはタップされた時間が渡されます。

このサンプルでは、タップが行われた場合、タップの回数をカウントし、タップの回数が偶数の時はR.color.backgroundを、奇数の時はR.color.bacground2を背景色に設定しています。
最後にタップ操作を即座に反映するためにinvalidate()を呼んで画面の再描画をリクエストします。

@Override
public void onTapCommand(int tapType, int x, int y, long eventTime) {
Resources resources = MyWatchFace.this.getResources();
switch (tapType) {
case TAP_TYPE_TOUCH:
// The user has started touching the screen.
break;
case TAP_TYPE_TOUCH_CANCEL:
// The user has started a different gesture or otherwise cancelled the tap.
break;
case TAP_TYPE_TAP:
// The user has completed the tap gesture.
mTapCount++;
mBackgroundPaint.setColor(resources.getColor(mTapCount % 2 == 0 ?
R.color.background : R.color.background2));
break;
}
invalidate();
}


onDarw()の処理

onDraw()内で実際の画面描画を行います。通常のAndroidアプリでもそうですが、Wearアプリでは特にこのonDraw()内の処理を短く終わらせる必要があります。

描画処理を行うための元となる時間を現在時刻にセットします。

mTime.setToNow();


前回までの描画をリセットするために背景色を塗りつぶします。
Ambientモードでは黒一緒、Ambientモード以外ではタップ数に応じてpaintに設定された色で塗りつぶします。
Ambientモードでは省電力のため画面を黒多めにすることが推奨されています。これは特に有機ELの液晶を持つ機種において消費電力の節約に役立ちます。

// Draw the background.
if (isInAmbientMode()) {
canvas.drawColor(Color.BLACK);
} else {
canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), mBackgroundPaint);
}


引数で渡されるboundsの半分を取得することで画面中央の座標を取得します。
実際には毎回の描画ごと座標を求めるのは非効率なのでインスタンス変数に格納して画面サイズが変わらないうちは再利用した方がいい気がします。

float centerX = bounds.width() / 2f;
float centerY = bounds.height() / 2f;


次に針の先部分を求めます。
これにはいくつかの方法があるのですが、ここでは三角関数を使って針の位置を求めています。
まず、針の角度を求めます。

通常、角度は0〜360度で求めますが、ここでは三角関数と相性がいい「ラジアン(rad)」と呼ばれる単位を使っています。
ラジアンは一周(360度)が2 x π(3.14) radに相当します。

円の外周は半径 x 2 x πなので、半径を1とすると弧の角度(rad)と弧長の長さが同じになります。

半径1mの正円では、角度1radの弧長は1m、角度2radの弧長は2mとなる。
円周の長さは2π m(いわゆる直径x3.14)になり、一周の角度は2π radとなる。

つまり角度がχ radの時、円弧の長さはχ×半径となります。

秒針の角度を求めます。
360°は2π radなので、秒(0〜59)を30で割ることで1分を0〜2(正確には59秒までしか無いので1.9666...)を求めてこの値にπをかけることで秒数に応じた0〜2π radを求めています。
これが秒針の角度(rad)になります。
秒針は1秒毎にカチカチと動くタイプなので秒だけを元に角度を求めています。

πの値はMath.PIで求めることが出来ます。 Math.PIは64bitの精度を持つdouble型で定義されていますが、そこまでの精度は必要ないのと処理量を抑えるために32bitの精度を持つfloat型に丸めて計算しています。

float secRot = mTime.second / 30f * (float) Math.PI;


同様に分針の角度も求めます。
こちらも、1分ごとに針が動くタイプなので分針だけを元に角度を求めています。

int minutes = mTime.minute;
float minRot = minutes / 30f * (float) Math.PI;


次に時針の角度も求めます。
時針は12時間で一周します。さらに1時間毎にカチカチと動くのではなく、分も考慮してなめらかに動くようにする必要もあります。
60(分)で1(時間)となるように分を60で割ります。この値を時間に足します。
これにより例えば4:30なら4.5に、2:15なら2.25になります。

この値を6で割ることで12時間で0〜2まで1分ごとに等分に変化する値を求めています。

float hrRot = ((mTime.hour + (minutes / 60f)) / 6f ) * (float) Math.PI;

次に針の長さを求めます。
画面幅の半分から、秒針は20ひいた値、分針は40ひいた値、時針は80ひいた値としています。

float secLength = centerX - 20;
float minLength = centerX - 40;
float hrLength = centerX - 80;


秒針を描画します。ambientモードの時は1分に1回しか書き換えが行わないため、秒針を描画すると動かない秒針となってしまいます
そこで、Ambientモード以外(通常モードの時)だけ秒針を描画します。

針の先は三角関数を使って求めています。
時計における3時の方向を基準に sin(ラジアン角度)を渡すことで、針先のY座標を求めることが出来ます
同様にcos(ラジアン角度)を渡すことで、針先のX座標を求めることが出来ます。

ラジアン角度をχ、半径の長さを1とした場合、斜線と円の接する座標は次のように求めることが出来ます。


この時に求まる値は3時の方向を基準とした時計回りです。
※一般的に数学の説明では3時の方向を基準とした反時計回りで説明されます。
しかしながら、Androidをはじめとする多くのコンピューターの世界では下に行くほど数値が大きくなる上下が入れ替わった座標系を使っているため上下が反転されて時計回りとなります。

xをcos、yをsinとすると3時を基準とした時計回りとなる。


時計の針で欲しいのは12時を基準とした時計回りなので、ちょっと工夫して座標を入れ替えます。
まず、xとyを入れ替えます。
sinの結果をX、cosの結果をYとすることで、x=-yの斜線を対象とした座標となり6時を基準とした反時計回りになります。

xとyを入れ替えると6時を基準とした反時計回りとなる。



6時を基準とした反時計回りはちょうど12時を基準とした時計回りを上下反転した形になるので、Y座標に-1をかけて上下反転させます。

上下を反転させることで12時を基準とした時計回りとなる。



これで求まるのは、半径が1の場合の座標なので、線の長さ(secLength)を掛けて線の長さに応じた位置を求めます。
sin、cosで取得される座標は、円の中心が左上(X:0,Y:0)とした場合の場所なので、中心からの位置を足して、中心からの位置に移動してやります。

画面中心から、求まった線先の座標までの線をdrawLineで描くことで、秒針が描画できます。


if (!mAmbient) {
float secX = (float) Math.sin(secRot) * secLength;
float secY = (float) -Math.cos(secRot) * secLength;
canvas.drawLine(centerX, centerY, centerX + secX, centerY + secY, mHandPaint);
}


同様に分針と時針も描画します。
分針と時針はambientモードにかかわらず描画するため、if文の外で描画します。

float minX = (float) Math.sin(minRot) * minLength;
float minY = (float) -Math.cos(minRot) * minLength;
canvas.drawLine(centerX, centerY, centerX + minX, centerY + minY, mHandPaint);

float hrX = (float) Math.sin(hrRot) * hrLength;
float hrY = (float) -Math.cos(hrRot) * hrLength;
canvas.drawLine(centerX, centerY, centerX + hrX, centerY + hrY, mHandPaint);


onVisibilityChanged()の処理


Watch Faceが表示されていない時に不要な描画を避けるため、
また、Watch Faceが表示された瞬間に最新の状態で画面を描画して更新を再開するために、onVisibilityChanged()で表示状態が変更された時の処理を行います。

画面が描画された時はタイムゾーンが変更された時の通知を受けるためのReceiverを登録します。
また、画面我猫されていない間にタイムゾーンが変更されている場合に備えて現在のタイムゾーンをmTimeに設定し現在時刻を取得します。

registerReceiver();

// Update time zone in case it changed while we weren't visible.
mTime.clear(TimeZone.getDefault().getID());
mTime.setToNow();


画面が非表示になる時にはタイムゾーンの切り替わりによるReceiverを解除します。

unregisterReceiver();


状態に応じて画面の更新を行い続けるかどうかを指定するためupdateTimer()を呼びます。

updateTimer();


registerReceiver()の処理

registerReceiver()ではタイムゾーンが変更された時に呼び出されるReceiverを登録しています。

private void registerReceiver() {
if (mRegisteredTimeZoneReceiver) {
return;
}
mRegisteredTimeZoneReceiver = true;
IntentFilter filter = new IntentFilter(Intent.ACTION_TIMEZONE_CHANGED);
MyWatchFace.this.registerReceiver(mTimeZoneReceiver, filter);
}


unregisterReceiver()の処理

registerReceiverで登録したReceiverを解除します。

private void unregisterReceiver() {
if (!mRegisteredTimeZoneReceiver) {
return;
}
mRegisteredTimeZoneReceiver = false;
MyWatchFace.this.unregisterReceiver(mTimeZoneReceiver);
}


updateTimer()の処理

タイマーを解除した後、1秒毎に更新が必要な場合、タイマーを設定し直します。

private void updateTimer() {
mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
if (shouldTimerBeRunning()) {
mUpdateTimeHandler.sendEmptyMessage(MSG_UPDATE_TIME);
}
}


shouldTimerBeRunning()の処理

1秒ごとに更新が必要かどうかを返します。
1秒毎に更新が必要なのは画面が描画されておりambientModeではない時です。

private boolean shouldTimerBeRunning() {
return isVisible() && !isInAmbientMode();
}


handleUpdateTimeMessage()の処理

次の1秒で呼び出されるようにTimerを設定します。
この処理自体が毎回1秒毎に呼び出されます。最初にinvalidate()で画面描画処理のリクエストを行ったら、次の1秒後にまたこの処理が呼ばれるようにTimerを設定します。

現在時刻に対して描画間隔を足し、現在時刻から描画間隔のあまりをひいた時間に処理が呼び出されるようにしています。
これはなにをやっているかというと、タイマーは1秒毎に設定しても、きっかり1秒毎に呼び出されるとは限りません。
また、処理中も時間がかかるので、例えば00分00秒に処理が呼ばれた場合、
単純に現在時刻+1秒とすると、登録する時には既に00分00秒100ミリ秒になっているかもしれません。
そうすると、時間の処理は00分01秒100ミリ秒に呼び出されてしまいます。
このまま続けると、次第に時間がずれてきますし、処理に時間がかかった時だけ更新間隔が広がってしまい、ぎこちない動きになってしまいます。

そうなることを防ぐために、登録するとき秒の余分(100ミリ秒)削ることで、00分01秒000ミリ秒に次の処理が呼ばれるようにしています。


invalidate();
if (shouldTimerBeRunning()) {
long timeMs = System.currentTimeMillis();
long delayMs = INTERACTIVE_UPDATE_RATE_MS
- (timeMs % INTERACTIVE_UPDATE_RATE_MS);
mUpdateTimeHandler.sendEmptyMessageDelayed(MSG_UPDATE_TIME, delayMs);
}



EngineHandlerの処理

EngineHandlerはEngine#handleUpdateTimeMessageで登録され、毎秒ごとにアラートで呼び出されます。
呼びだされたらEngine.handleUpdateTimeMessageを呼ぶことでEngine.handleUpdateTimeMessageとEngineHandlerがお互いを呼び合う形となり、毎秒ごとの処理を行います。


ただし、一秒とはいえ、未来時間で呼び出されるため、これが呼び出された時には既にWatchFaceは使われていない可能性があります。
そのようなときに、EngineHandlerがEngineの参照を保持していると、GC出来なくなってしまうので、EngineHandlerではEngineを弱参照で保持します。
メッセージを受け取ったら、弱参照のEngineが破棄されていないか確認し、破棄されていなければ、EngineのhandleUpdateTimeMessage()を呼び、描画と次の1秒への登録を行わせます。



private static class EngineHandler extends Handler {
private final WeakReference<MyWatchFace.Engine> mWeakReference;

public EngineHandler(MyWatchFace.Engine reference) {
mWeakReference = new WeakReference<>(reference);
}

@Override
public void handleMessage(Message msg) {
MyWatchFace.Engine engine = mWeakReference.get();
if (engine != null) {
switch (msg.what) {
case MSG_UPDATE_TIME:
engine.handleUpdateTimeMessage();
break;
}
}
}
}


標準サンプルではラインによる描画ですが、これでは個性的な時計を作りづらいです。
次回はこのサンプルを元に画像を使ったアナログ時計を作ってみましょう。

関連キーワード

[Android][Java][モバイル][IT][ウェアラブル]

コメントを投稿する

名前URI
コメント