Google GlassはNexus 5で十分にプロトタイプできるか?
吉村(@alterakey)
こんにちは、ATLでウェアラブルデバイスの研究をしている吉村です。ふとしたことからGoogle Glassに触れることになってここ3〜4回ほど記事を書かせていただいていたわけなのですが、このデバイスはなかなか面白いものです。ボイスコマンドで制御され、通知が入れば首を振るだけで視野の右上にぼんやりと画面が投影されて確認でき、終われば勝手に画面が消えている…といった形のUXは、通知→ポケットから取り出して起動→情報を確認→終了する、といったような一般的な携帯電話が提供するものとは全く違い、当然アプリにもこれまでとは全く違うUXが求められます。…この辺りはもう何回も書いているわけなのですが 😉
しかし、これの上で動作するアプリを作ろうにもエミュレータは公式にはなく、実機を用意しようにも入手経路や費用面でいろいろと苦労させられるのが現状です。いろいろ考えた末、Nexus 5を使用してGlasswareを(ギリギリ)プロトタイプできるような感触を得たので、今回はそれについて書かせてもらえたらと思います。ふわふわした話になってしまう可能性がありますが、その辺はなにとぞよろしくお願いいたします m(__)m
最初に断わっておきますが、これはあくまでも私が研究に携わった中で得た一つの知見としてとらえていただければ幸いです!
ここで挙げる方法が常に最善なはずはありませんし、そうでない場合を自分もいくつか経験しています。
Nexus 5?
素のAndroid 4.4(XE16以降がベースにしているバージョン)が動く、比較的手軽なデバイスだからです。今回はAndroidベースとはいえ違うシステムで動作させるものをプロトタイプしようとしているので「素の」というところがかなり重要になります。一方でハードウェアスペック的にはGoogle GlassはどちらかというとNexus Sに似ているので、ひょっとするとCyanogenMod 11をNexus Sで動かした方が適当な場合もあるかもしれません。
ワークフロー
基本的には適当なエミュレーションレイヤーを挟み込み、共通項の多いActivityを中心にモデリングを進め、そして一通り片付いたらGlassへポートする、という段階的なアプローチがある程度有効です。
モデリング
だいたい以下のような感じにGlass固有のAPIをモックするようなレイヤーを挿入します。
LiveCard→Ongoing Notification
GlasswareではServiceにLiveCardを組み合わせるケースが一般的ですが、これはService+Notification、あるいはWidget+RemoteViewsでエミュレートするのが良いのではないかと思います。
だいたいこのように書かれるところ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
private LiveCard mLiveCard; @Override public void onCreate() { super.onCreate(); mLiveCard = new TimerCard(this, TAG); mLiveCard.attach(this); mLiveCard.setAction(PendingIntent.getActivity(this, 1, new Intent(this, TimerMenuActivity.class), 0)); mLiveCard.publish(LiveCard.PublishMode.REVEAL); } @Override public void onDestroy() { mLiveCard.unpublish(); mLiveCard = null; super.onDestroy(); } |
これをAndroidのNotificationで動作させるためのエミュレーションレイヤーはこのような感じに:
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 |
package com.google.android.glass; ... // Emulating LiveCard with On-going Notification public class LiveCard { final Context mContext; final String mTag; final Notification.Builder mBuilder; public LiveCard(final Content context, final String tag) { mContext = context; mTag = tag; mBuilder = new Notification.Builder(context); } public void attach(final Service service) { } public void setAction(final PendingIntent intent) { mNotificationBuilder.setContentIntent(PendingIntent.getActivity(this, 1, new Intent(this, TimerMenuActivity.class), 0)) } public void publish() { final NotificationManager nm = (NotificationManager)mContext.getSystemService(Context.NOTIFICATION_SERVICE); mNotificationBuilder .setSmallIcon(R.icon.notify) .setContentTitle("Running") .setContentText("Service is running"); nm.notify(mNotificationBuilder.build(), 1); } public void unpublish() { nm.cancel(1); } } |
Mirror API→GCM+Notification?
同様に、Mirror APIもGoogle Nowで…としたいのですが、残念ながら今のところGoogle Nowに独自カードを提示させることはできません。そのため、現在のところはGoogle Cloud Messaging (GCM)などを使用してリモートでNotificationを飛ばせるようなサービスをどこかに立て、それを使うことでエミュレートするほかにはなさそうです。
このようなサービスの書き方ですが、とりあえずGoとGoogle App Engineを使用してこのような形に書くことができます(注: 最新のGoogle Cloud SDKでは多少変わっているかもしれません。)
まずapp.yamlから。
1 2 3 4 5 6 7 8 |
application: your-application-id-goes-here version: 1 runtime: go api_version: go1 handlers: - url: /(|device)(/.*)? script: _go_app |
ありがたいことにGoにもGCM関係をハンドリングしてくれる gcm というライブラリがあり、ここではそれを使用させてもらうことにします。普通に開発環境で動かすにはgoapp経由でインストールするだけで良いのですが、このままだとGoogle App Engineへ展開した際に漏れてしまうので適当に作業ディレクトリへコピーしておきます。
1 2 |
$ goapp get github.com/alexjlockwood/gcm $ tar -C $(goapp env | grep GOPATH | sed -e 's/GOPATH=//;s/"//g')/src/ -c github.com/alexjlockwood/gcm | tar -x |
ロジックの実装ですが、reflector/reflector.goとして、以下のように書いておきます。GCM関連の処理はライブラリで行なうので、ここではデバイスの登録を受けつけてDatastoreに書いていたり拡散したいパラメータをライブラリに渡しているくらいなもので実にシンプルなものです。
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 |
package reflector import ( "fmt" "net/http" "appengine" "appengine/urlfetch" "appengine/datastore" "github.com/alexjlockwood/gcm" ) type Device struct { ID string } func init() { http.HandleFunc("/", handleNewSession) http.HandleFunc("/device", handleNewDevice) } func handleNewDevice(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { device := new(Device) device.ID = r.FormValue("regid") c := appengine.NewContext(r) if _, e := datastore.Put(c, datastore.NewKey(c, "Device", device.ID, 0, nil), device); e == nil { fmt.Fprintln(w, "{success:true}") } else { panic(e) } } else { http.NotFound(w, r) } } func handleNewSession(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { data := map[string]interface{}{"message":r.FormValue("message"))} c := appengine.NewContext(r) client := urlfetch.Client(c) sender := &gcm.Sender{ApiKey: "YOUR_APIKEY_GOES_HERE", Http: client} var devices []Device if _, e := datastore.NewQuery("Device").GetAll(c, &devices); e == nil { msg := gcm.NewMessage(data) msg.RegistrationIDs = Device_extractIDs(devices) _, e := sender.Send(msg, 3) if e == nil { fmt.Fprintln(w, "success") } else { panic(e) } } else { panic(e) } } else { http.NotFound(w, r) } } func Device_extractIDs(devices []Device) []string { var regids []string for _, t := range devices { regids = append(regids, t.ID) } return regids } |
これを、以下のようにまとめて適当にGoogle App Engineへ展開しておきます。
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 |
$ find . ... ./app.yaml ./github.com ./github.com/alexjlockwood ./github.com/alexjlockwood/gcm ./github.com/alexjlockwood/gcm/LICENSE.md ./github.com/alexjlockwood/gcm/message.go ./github.com/alexjlockwood/gcm/README.md ./github.com/alexjlockwood/gcm/response.go ./github.com/alexjlockwood/gcm/sender.go ./github.com/alexjlockwood/gcm/sender_test.go ./reflector ./reflector/reflector.go $ appcfg.py update . ... 12:01 PM Application: your-application-id-goes-here; version: 1 12:01 PM Host: appengine.google.com 12:01 PM Starting update of app: your-application-id-goes-here, version: 1 12:01 PM Getting current resource limits. 12:01 PM Scanning files on local disk. 12:01 PM Cloning 8 application files. 12:01 PM Compilation starting. 12:01 PM Compilation: 5 files left. 12:01 PM Compilation completed. 12:01 PM Starting deployment. 12:01 PM Checking if deployment succeeded. 12:01 PM Deployment successful. 12:01 PM Checking if updated app version is serving. 12:01 PM Completed update of app: your-application-id-goes-here, version: 1 |
これでサーバ環境の準備は完了です。これでAndroid端末を http://your-application-id-goes-here.appspot.com/device へregid=…としてPOSTすることで登録ができ、その後適当に http://your-application-id-goes-here.appspot.com/ へmessage=….という形のデータをPOSTすることで外から通知を行なうことができるようになりました。
あとはこれを端末で適当に受けてNotificationなど出すことで、外部からnon-obtrusiveな通知を送るためにMirror APIを使用しているような場合についてはかなり手荒ながらエミュレートができたかな、と思います。
この例では通知対象を限定すらしていないので通知すると全端末に飛んでしまいます。とはいえテスト用途でとりわけ小規模な使い方をしている限りあまり問題にならないかと思いますが…
GestureDetector→エミュレート
次です。さて、GestureDetectorはAndroid SDKにもあるのですが、Glasswareの場合GDK特有のGestureDetectorを使用する必要があります(さもないと検出できない)。次のようなコードがあったとします。
Glass:
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 |
public class MainActivity extends Activity { private GestureDetector mDetector; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mDetector = createGestureDetector(); } @Override public boolean onGenericMotionEvent(MotionEvent event) { if (mDetector != null) { return mDetector.onMotionEvent(event); } return false; } private GestureDetector createGestureDetector() { final GestureDetector ret = new GestureDetector(this); ret.setBaseListener(new GestureDetector.BaseListener() { @Override public boolean onGesture(Gesture gesture) { final Context c = MainActivity.this; if (c != null) { if (gesture == Gesture.TAP) { ... return true; } } return false; } }); return ret; } } |
上のコードをAndroidで動作させるために必要なエミュレーションレイヤーはだいたい次のような形になります。実質的な検出ロジックにおいては実装を割愛していますが、こちらはAndroid SDKで普通に検出する場合とほぼ同等の実装になるのではないかと思います:
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 |
package com.google.android.glass.touchpad; ... public class GestureDetector { private Context mContext; private boolean mAlwaysConsumeEvents = false; private BaseListener mBaseListener; private FingerListener mFingerListener; private ScrollListener mScrollListener; private TwoFingerScrollListener mTwoFingerScrollListener; public GestureDetector(Context context) { mContext = context; } public static boolean isForward(Gesture gesture) { switch (gesture) { case SWIPE_RIGHT: case TWO_SWIPE_RIGHT: return true; case SWIPE_DOWN: case SWIPE_LEFT: case SWIPE_UP: case TWO_SWIPE_DOWN: case TWO_SWIPE_LEFT: case TWO_SWIPE_UP: return false; default: throw new IllegalStateException("non-slidal event"); } } public static boolean isForward(float deltaX) { return deltaX > 0.0f; } public boolean onMotionEvent(MotionEvent event) { // TBD: actual detection return mAlwaysConsumeEvents; } public GestureDetector setAlwaysConsumeEvents(boolean enabled) { mAlwaysConsumeEvents = enabled; return this; } public GestureDetector setBaseListener(GestureDetector.BaseListener listener) { mBaseListener = listener; return this; } public GestureDetector setFingerListener(GestureDetector.FingerListener listener) { mFingerListener = listener; return this; } public GestureDetector setScrollListener(GestureDetector.ScrollListener listener) { mScrollListener = listener; return this; } public GestureDetector setTwoFingerScrollListener(GestureDetector.TwoFingerScrollListener listener) { mTwoFingerScrollListener = listener; return this; } public static interface BaseListener { public boolean onGesture(Gesture gesture); } public static interface FingerListener { public boolean onFingerCountChanged(int previousCount, int currentCount); } public static interface ScrollListener { public boolean onScroll(float displacement, float delta, float velocity); } public static interface TwoFingerScrollListener { public boolean onTwoFingerScroll(float displacement, float delta, float velocity); } } |
GDKのGestureクラスは単なるenumであり、こちらも当然Android SDKとは互換性がないのでこちらも定義しておきます。
1 2 3 4 5 |
package com.google.android.glass.touchpad; public enum Gesture { LONG_PRESS, SWIPE_DOWN, SWIPE_LEFT, SWIPE_RIGHT, SWIPE_UP, TAP, THREE_LONG_PRESS, THREE_TAP, TWO_LONG_PRESS, TWO_SWIPE_DOWN, TWO_SWIPE_LEFT, TWO_SWIPE_RIGHT, TWO_SWIPE_UP, TWO_TAP } |
最後にActivityレイヤーですべてのMotionEventを解析するようにします。
1 2 3 4 5 6 7 8 9 |
public class MainActivity extends Activity { ... @Override public boolean dispatchTouchEvent(MotionEvent ev) { mDetector.onMotionEvent(ev); return super.dispatchTouchEvent(ev); } ... } |
これで最初に挙げたGoogle Glass用のコードが動作するはずです。
GCM→Standalone GCM
GlassはGoogle Play Servicesを持っていないので、GCMを使用する場合にはStandalone版を使用することになります。Standalone版はAndroidでも特に問題なく使えるので、こちらを使用しておけばそのままGlassで動作させることができます。
Card→LayoutInflater
Cardはパラメータを受けとってGlass標準のレイアウトを作成するヘルパー的な役目をしていますので、簡単なものであればLayoutInflaterを使用して適当なカスタムレイアウトをロードさせるようにするのが適当でしょう。
Glass:
1 2 3 4 |
final Card c = new Card(this); c.setText("This is card."); c.setFootnote("Can you see this?"); final View v = c.getView(); |
このようなコードがある場合、Androidでは以下のようにエミュレーションを行なうことになるでしょう。この例ではaddImageなどのメソッドは定義していませんが、もっと複雑な例では必要に応じて追加することになると思います。
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 |
package com.google.android.glass.app; ... public class Card { private Context mContext; private String mText; private String mFootnote; public Card(final Context c) { mContext = c; } public CharSequence getFootnote(){ return mFootnote; } public Card setFootnote(final String footnote){ mFootnote = footnote; } public CharSequence getText(){ return mText; } public Card setText(final String text){ mText = text; } public View getView(){ final LayoutInflater inflater = (LayoutInflater)mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); final View v = inflater.inflate(R.layout.card_text_with_footnote, null, false); ((TextView)v.findViewById(R.id.text)).setText(mText); ((TextView)v.findViewById(R.id.footnote)).setFootnote(mFootnote); return v; } } |
メニュー
無理にカスタム設計するよりも、透明Activity+Option Menuによるシステム標準のエミュレーション機構に頼るのが最善です。ただ、これをそのままAndroidで動かすと見かけがかなり違ってきてしまいますが…
Glass:
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 |
public class MainActivity extends Activity { private GestureDetector mDetector; ... @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mDetector = createGestureDetector(); } ... @Override public boolean onGenericMotionEvent(MotionEvent event) { if (mDetector != null) { return mDetector.onMotionEvent(event); } return false; } private GestureDetector createGestureDetector() { final GestureDetector ret = new GestureDetector(this); ret.setBaseListener(new GestureDetector.BaseListener() { @Override public boolean onGesture(Gesture gesture) { final Context c = MainActivity.this; if (c != null) { if (gesture == Gesture.TAP) { final Intent intent = new Intent(this, MainMenuActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NO_HISTORY); startActivity(intent); return true; } } return false; } }); return ret; } } |
メニューを起動するための透明Activityについては以下のようになります。
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 |
// Transparent activity to launch option menus public class MainMenuActivity extends Activity { @Override public void onAttachedToWindow() { super.onAttachedToWindow(); openOptionsMenu(); } @Override public boolean onCreateOptionsMenu(final Menu menu) { getMenuInflater().inflate(R.menu.main, menu); return true; } @Override public boolean onOptionsItemSelected(final MenuItem item) { switch (item.getItemId()) { case R.id.exit_menu_item: ... } return true; } @Override public void onOptionsMenuClosed(final Menu menu) { finish(); } } |
VoiceTrigger関連
メタデータはそのままでOKですが、クラス参照は次のような形でスタブ化しておく必要があります。
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 |
package com.google.android.glass.app; ... public class VoiceTriggers { public static final String ACTION_VOICE_TRIGGER = "com.google.android.glass.action.VOICE_TRIGGER"; public static final String EXTRA_INPUT_SPEECH = "input_speech"; public static enum Commands { ADD_AN_EVENT, CALCULATE, CALL_ME_A_CAR, CAPTURE_A_PANORAMA, CHECK_ME_IN, CHECK_THIS_OUT, CONTROL_MY_CAR, CONTROL_MY_HOME, CREATE_A_3D_MODEL, EXPLORE_NEARBY, EXPLORE_THE_STARS, FIND_A_BIKE, FIND_A_DENTIST, FIND_A_DOCTOR, FIND_A_FLIGHT, FIND_A_HOSPITAL, FIND_A_PASSAGE, FIND_A_PLACE, FIND_A_PLACE_TO_STAY, FIND_A_PRODUCT, FIND_A_RECIPE, FIND_A_VIDEO, FIND_A_WEBSITE, FIND_REVIEWS, FIND_THE_EXCHANGE_RATE, FIND_THE_PRICE, FLIP_A_COIN, GIVE_ME_FEEDBACK, HELP_ME_RELAX, HELP_ME_SIGN_IN, KEEP_ME_AWAKE, LEARN_AN_INSTRUMENT, LEARN_A_SONG, LISTEN_TO, LOCATE_A_SATELLITE, LOG_A_MEAL, LOOK_UP_THE_DEFINITION, MAGNIFY_THIS, MAKE_A_REQUEST, MAKE_A_RESERVATION, PICK_A_CARD, PLAY_A_GAME, POST_AN_UPDATE, POST_A_QUESTION, RECOGNIZE_THIS, RECOGNIZE_THIS_SONG, RECORD_A_RECIPE, RECORD_A_VIDEO, REMEMBER_THIS, REMEMBER_WHERE_I_AM, REMIND_ME, ROLL_THE_DICE, SCAN_A_PRODUCT, SEND_MONEY, SHARE_MY_LOCATION, SHARE_THIS_WITH, SHOW_A_COMPASS, SHOW_ME_ANALYTICS, SHOW_ME_A_DEMO, SHOW_ME_MY_ACCOUNT, SHOW_ME_MY_SPEED, SHOW_ME_THE_NEWS, SHOW_ME_THE_WEATHER, SHOW_ME_TRANSIT_TIMES, SHOW_MY_PICTURES, SHOW_MY_VIDEOS, SHOW_SONG_LYRICS, START_A_BIKE_RIDE, START_A_FLIGHT, START_A_ROUND_OF_GOLF, START_A_RUN, START_A_STOPWATCH, START_A_TIMER, START_A_WORKOUT, START_BROADCASTING, START_COACHING, START_IMAGING, START_PRESENTING, TAKE_A_NOTE, TAKE_A_PICTURE, TEACH_ME_ABOUT, TRANSLATE_THIS, TUNE_AN_INSTRUMENT, TURN_THE_FLASHLIGHT_ON, WATCH_MY_SWING } } |
当然これだけではボイスコマンドを聞いてくれませんので、ボイスコマンドに対応するServiceをandroid.intent.action.MAIN/android.intent.category.LAUNCHERから別に起動できるようにするようにしておきます。
また、Tasker/AutoVoiceなどを併用することでボイスコマンドを使用して起動させることが可能になります。
※詳細な方法はlifehackerの記事によくまとまっているのでそちらをご参照ください 🙂
ポーティング
エミュレーションレイヤーを外して、非互換性があるようなら適当に直します。この際、アプリ自体が単純だったりレイヤーの設計がうまく行っていれば特にそれほど問題になることはないのですが、実際のポーティングでは以下のようなところが問題になりやすい感じがします。
OpenGL ES 2.0
Glassが搭載しているGPUはNexus Sと同じSGX540なので、Nexus 5とはかなり違います。このためシェーダーのコンパイルに失敗したりすることがあるので、このような場合はNexus S+CyanogenMod 11あたりで動作を確認するのが良いでしょう。
発熱
GlassのCPU自体はNexus Sと同等なのですが、フォームファクタが違いすぎるので無理をさせてしまうとすぐに高温になります。高温な状態になると”ok, glass”と交互に”Glass must cool down to run smoothly.”というメッセージが表示されてくるので注意しましょう。
まとめ
Google GlassとAndroid携帯端末はとても良く似ています。基本的にフロント面から遠くなればなるだけ共通項が増えてくるようなイメージを少しでも伝えられたでしょうか。ここに挙げた差分は自分で研究に参加している時にたまたま当たったものだけでありますが、大まかなところではこのくらいではないかと思います。懸念通りふわふわしたエントリになってしまいましたが、もしGoogle Glassで開発をしたくなったときこれが開発効率向上の役に立つことを祈ります。