スマホから送信したNotificationでAndroid Wear上のActivityを開く方法
最近Android Wearをいじっているのでその話題。スマホからWearに対して送信したNotificationで、Android Wear上のActivityを開く方法について書きます。
Google カメラアプリの挙動
先日、Googleのカメラアプリがアップデートされて、Wearからシャッターを切れるようになりました。この挙動がわりと面白くて、スマホ側でカメラアプリを起動するとWearに通知が表示され、その通知をクリックすると、Wear上でシャッターを押すための専用Activityが起動される流れになっています。
この挙動を実装しようとすると、なかなか一筋縄ではいきません。現状のNotification APIの仕様ではスマホ側から発行したNotificationは、スマホ側のActivityしか開くことができないからです。
これを解決する方法の一つが、スマホからWearへの通知をNotification APIではなくData APIなどのデータ送受信APIで行い、Wear側でNotification APIを使って通知を発行するという方法です。Googleのカメラアプリもこの方法を使っているようです。
実装方法
実際のコードは以下のようになります。(プロジェクト全体はthorikawa/WearNotificationSample · GitHubにあげてあります)
スマホ側では、Data APIを利用してデータ変更を行います。
PutDataMapRequest dataMapRequest = PutDataMapRequest.create(Constants.NOTIFICATION_PATH); dataMapRequest.getDataMap().putString(Constants.NOTIFICATION_TITLE, "This is the title"); dataMapRequest.getDataMap().putString(Constants.NOTIFICATION_CONTENT, "This is a notification with some text."); // Set timestamp so that it always trigger onDataChanged event dataMapRequest.getDataMap().putLong(Constants.NOTIFICATION_TIME, System.currentTimeMillis()); PutDataRequest putDataRequest = dataMapRequest.asPutDataRequest(); PendingResult<DataApi.DataItemResult> pendingResult = Wearable.DataApi.putDataItem(mGoogleApiClient, putDataRequest);
一方、Wear側ではデータの変更イベントをServiceで受け取るようにします。
AndrodManifest.xmlでデータ変更時に発行されるActionを受け取るServiceを定義します。
<service android:name=".NotificationUpdateService" android:exported="true"> <intent-filter> <action android:name="com.google.android.gms.wearable.BIND_LISTENER" /> </intent-filter> </service>
このServiceはWearableListenerServiceを継承しており、onDataChangedでスマホ側からのデータ変更を検知し、実際にWear上で表示されるNotificationを発行します。その際にaddActionやsetContentIntentなどで、Wear上のActivityに対するPendingIntentを設定してやれば、NotificationをクリックしてWear上でActivityを開くことができます。
public class NotificationUpdateService extends WearableListenerService { ... @Override public void onDataChanged(DataEventBuffer dataEvents) { for (DataEvent dataEvent : dataEvents) { if (dataEvent.getType() == DataEvent.TYPE_CHANGED) { if (Constants.NOTIFICATION_PATH.equals(dataEvent.getDataItem().getUri().getPath())) { DataMapItem dataMapItem = DataMapItem.fromDataItem(dataEvent.getDataItem()); String title = dataMapItem.getDataMap().getString(Constants.NOTIFICATION_TITLE); String content = dataMapItem.getDataMap().getString(Constants.NOTIFICATION_CONTENT); sendNotification(title, content); } } } } private void sendNotification(String title, String content) { // this intent will open the activity when the user taps the "open" action on the notification Intent viewIntent = new Intent(this, MyActivity.class); PendingIntent pendingViewIntent = PendingIntent.getActivity(this, 0, viewIntent, 0); NotificationCompat.Builder builder = new NotificationCompat.Builder(this) .setSmallIcon(R.drawable.ic_launcher) .setContentTitle(title) .setContentText(content) .addAction(R.drawable.ic_launcher, "Open", pendingViewIntent) .setLocalOnly(true) .extend(new NotificationCompat.WearableExtender().setContentAction(0).setHintHideIcon(true)); Notification notification = builder.build(); NotificationManagerCompat notificationManagerCompat = NotificationManagerCompat.from(this); notificationManagerCompat.notify(notificationId++, notification); } ... }
まとめ
このような感じで、Wearで利用できるAPIは限られていますが、組み合わせによってはまだまだ面白い使い方ができそうです。
ライブラリに依存するプロジェクトのテストプロジェクトをantで実行できない件+AOSPにパッチを送ってみた件
Android SDK r14以降で、テストプロジェクトおよび、テスト対象の本体プロジェクトの両方がライブラリプロジェクトのクラスを呼び出している場合、antでのテスト実行で実行が失敗します。このままではJenkinsでテストを自動実行するときなどに困ってしまうので、Android SDKのbuild.xmlを書き換えて対応します。
- 修正済みbuild.xml(r16用) … Android SDK配下のtools/ant/build.xmlをこれで置き換えてください。
以下この問題の詳細についてです。
問題の概要
問題が起こるのは、以下のようなプロジェクト構成の場合です。
- ライブラリプロジェクト
- 本体プロジェクト(ライブラリプロジェクトのクラスを呼び出す)
- テストプロジェクト(ライブラリプロジェクトのクラスを呼び出す)
このようなプロジェクト構成をビルドしようとした場合に、取り得る手段は2通り考えられます。
- 本体プロジェクトのビルドパスにライブラリプロジェクトを追加して、本体プロジェクト側でライブラリプロジェクトをExportすることでテストプロジェクトは、ライブラリプロジェクトを参照させる
- 本体プロジェクト・テストプロジェクト両方のビルドパスにライブラリプロジェクトを追加する
ただ、これは両方ともうまくいきません。1はEclipse上ではうまく動きますが、antビルド時にはテストプロジェクトのコンパイルエラーが発生します。2はantテスト実行の際に、実行時例外が発生します。以下詳細を見てみます。
1のExport設定はEclipseのビルドパス設定で行うことができます。
この設定を行えば、Eclipse上でのテスト実行はうまくいきます。しかし、このExport設定が有効なのはEclipse上に限った話で、antからビルドしする際には読み込んでくれません。
そのため、テストプロジェクトをantでビルドしようとするとクラス解決がうまくいかず、コンパイルエラーになります。
$ ant clean debug install test -compile: [javac] Compiling 2 source files to /workspace/AndroidExampleTest/bin/classes [javac] /workspace/AndroidExampleTest/src/com/polysfactory/antbuild/sample/test/TestCaseClass.java:5: シンボルを見つけられません。 [javac] シンボル: クラス LibraryClass [javac] 場所 : com.polysfactory.andbuild.library の パッケージ [javac] import com.polysfactory.andbuild.library.LibraryClass; [javac] ^ [javac] /workspace/AndroidExampleTest/src/com/polysfactory/antbuild/sample/test/TestCaseClass.java:10: シンボルを見つけられません。 [javac] シンボル: 変数 LibraryClass [javac] 場所 : com.polysfactory.antbuild.sample.test.TestCaseClass の クラス [javac] String s1 = LibraryClass.test(); [javac] ^ [javac] エラー 2 個
2の両方にライブラリプロジェクトを参照する方法は、Eclipseでもantでも、テスト実行に失敗します。
以下のような実行時例外が発生するはずです。
test: [echo] Running tests ... [exec] [exec] com.polysfactory.antbuild.sample.test.TestCaseClass:. [exec] Error in testCase: [exec] java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation [exec] at com.polysfactory.antbuild.sample.LibraryClassCaller.callLibraryClass(LibraryClassCaller.java:7) [exec] at com.polysfactory.antbuild.sample.test.TestCaseClass.testCase(TestCaseClass.java:12) [exec] at java.lang.reflect.Method.invokeNative(Native Method) [exec] at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:169) [exec] at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:154) [exec] at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:529) [exec] at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1448) [exec] [exec] Test results for InstrumentationTestRunner=..E [exec] Time: 0.086 [exec] [exec] FAILURES!!! [exec] Tests run: 2, Failures: 0, Errors: 1 [exec] [exec]
これは、本体プロジェクトから参照しているライブラリクラスと、テストプロジェクトから参照しているライブラリクラスが異なるために発生するエラーです。apkを解凍してみれば分かりますが、ライブラリプロジェクトのクラス群が本体プロジェクトのapkと、テストプロジェクトのapkの両方に含まれてしまっています。
Android SDK r13以前ではこの方法でうまくいっていました。Android SDK r14からAndroidのライブラリプロジェクトの扱い方が変わり、このような現象が発生するようになったようです。
ライブラリプロジェクトのクラス群は、本体apkのみに含まれて、テストapkには含まれないのが正しい動きです。しかしAndroid SDKに含まれるbuild.xmlは、そのようなビルドプロセスをサポートしていない為、この問題を解決するにはAndroid SDKのbuild.xmlを書き換える必要があります。
解決方法
build.xmlを書き換えます。antのビルドプロセスとして、テストプロジェクトのビルド->subantの呼び出しで本体プロジェクトのビルド->subantの呼び出しでライブラリプロジェクトのビルドとなっていますが、antではsubantの呼び出しをまたいで変数を共有できないので、一時ファイルにライブラリプロジェクトのjarのパスを保存しておくアプローチを取ります。
↓r16のbuild.xmlをベースにしたdiffファイル
< <!-- Libraries property file --> < <property name="out.libraries.prop.file.name" value="libraries.prop" /> 452c450 < --- > 524d521 < 554,571d550 < < <!-- save libraries' jar paths to a temporary file --> < <propertyfile file="${out.absolute.dir}/${out.libraries.prop.file.name}"> < <entry key="libraries.jars" value="${toString:project.libraries.jars}" /> < </propertyfile> < < <!-- load tested project's libraries path if this is test project --> < <if> < <condition> < <and> < <isset property="tested.project.absolute.dir" /> < </and> < </condition> < <then> < <property file="${tested.project.absolute.dir}/${out.dir}/${out.libraries.prop.file.name}"/> < </then> < </if> < 626d604 < 628c606 < value="${tested.project.absolute.dir}/bin/classes;${libraries.jars}" --- > value="${tested.project.absolute.dir}/bin/classes"
AOSPにパッチ送ってみた
せっかくAndroid SDKを修正したので、これを機にAndroid Open Source Projectにパッチを送ってみました。もし応援してくれる人がいたらスターとかつけちゃってください!
https://android-review.googlesource.com/33230
AndroidとOpenCVで試す特定物体認識
6月2日に開催されたDevLOVEさんと弊社の共同開催勉強会で、「Android×ComputerVision」というお題で発表してきました。
要はOpenCVをAndroidアプリに組み込んで特定物体認識を試そう、というもの。
資料は以下です。
ソースはgithubで公開してます。
https://github.com/thorikawa/AndroidObjectRecognition/
概要
資料にも記載していますが、カメラのプレビュー画像からSURFの特徴点を検出して、LSHで再近傍検索→特定物体認識というのを毎フレーム行っています。
「物体」はCDのジャケット画像を5枚の内から認識して、それぞれの画像にあった音を鳴らす、というデモを行い、うまく認識することができました。
構成・ビルド方法
チェックアウト後のソースは
- OpenCVをAndroidから利用しやすくするためのOpenCVプロジェクト(android-jni以下)
- アプリのプロジェクト(apps/ObjectRecognition以下)
- 検出対象の画像から特徴点を検出しておくユーティリティ(dump_keypoints.cpp)
の3つから構成されています。
上記1番はAndroidライブラリプロジェクトとなっており、2番から参照されているため、Eclipseでビルドする場合は、両プロジェクトをimportしておく必要があります。
また、検出対象となる物体の特徴ベクトルはapps/ObjectRecognition/assets/keypoints以下にテキストファイルが配置されています。追加したい場合は、dump_keypointsでダンプした結果をここに追加し、2番のJavaソースで読み込んでいる箇所を修正してみてください。
チェックアウト後のソースには、ビルド後のバイナリも含まれていますが、自分でビルドしたい場合、
apps/ObjectRecognition
に移動してantコマンドを実行することでAndroidアプリをビルドすることができます。
$ cd ${checkoutdir}/apps/ObjectRecognition $ ant debug
改変方法
ソースを自分でいじりたい場合、アプリ側のJavaソースだけならチェックアウト後のソースをそのまま変更することができます。
c++部分のソースをいじりたい場合は、AndroidをターゲットにしてOpenCVをビルドできる環境が必要です。
まだ環境を構築していない人は、
http://opencv.willowgarage.com/wiki/Android
を参考に環境構築してみてください。
課題と考察
SURFの特徴点検出がとにかく遅いです。濃淡の差が細かく激しい画像だと、1回の検出処理に約3sec程かかります。
OpenCVは、他にもデフォルトでSTARやFASTなどの特徴点検出アルゴリズムを搭載しており、これらの方がSURFよりも速いのですが、精度のチューニングが間に合わなかったため、SURFで実装しています。
実用化に向けては、速度と精度のバランスを取ったチューニングを行う必要があります。(←そりゃそうだ)
また、今回は毎回LSHに突っ込む特徴ベクトルを毎回読み込んでいるため、
- 特徴ベクトルのデータが膨大
- 特徴ベクトルの読み込みに時間がかかる
という問題があります。
原理的には計算されたハッシュ値だけをデータとして持たせておけば再近傍の計算はできるはずなので、その部分も課題となります。
MacとNexus OneでAndroid Oepn Accessoryを試してみる
先日サンフランシスコで行われたGoogle I/OのKeynoteでは様々な発表がありましたが、その中で、僕が特に面白いと思ったのが「Android Open Accessory」です。
簡単に言うと、Androidデバイスに接続するUSB周辺機器を、誰でも簡単に作ることができる仕組みです。
Google I/Oではこの仕組みを試すための開発ボード「ADK」が無償で配られたり、日本のメーカーからも発売されたりしていますが、電子工作ということで敷居が高いと感じている人も多いんじゃないかと思います。
しかし、Android Open Accessoryが適用できるのは、別に電子工作に限った話ではありません。PCとAndroid端末さえあれば、今すぐに試してみることができます。というわけで、早速デモを作ってみたので簡単に解説していきます。
用意するもの
今回作るものの概要
Mac PCにUSBで接続されたNexus One上のAndroidアプリと、Mac PC上の実行ファイルとの間で、USBポートを介してデータの通信を行います。
ただ単にデータ通信するだけでは面白くないので、KeyNoteのデモのように、端末の動きをゲームに反映させてみます。
アーキテクチャは以下の図のような感じで、Androidの傾きセンサーやボタンクリックを検出して、その値をUSBを介して通信し、最終的にJavaScriptで作ったゲームに端末の傾きの値を反映させたり、アプリのボタンをクリックするとボールが追加されるようにします。
JavaScriptのゲームはBox2DJSのデモをちょこっと改造した力学演算系のゲームです。
ソース全体
github: https://github.com/thorikawa/android-openaccessory-with-pc-sample
にアップロードしています。
アプリ側のソース
android-app-usbフォルダがアプリ側のソースとなっています。
基本的には、USB Accessory | Android Developersに記載されている内容に準拠します。
AndroidManifestの記述
USB周辺機器(今回はPC)が端末に接続されたイベントを捕捉できるように、AndroidManifestにIntent-Filterを記述します。
<activity android:name=".Top" android:label="@string/app_name" <intent-filter> <action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" /> </intent-filter> <meta-data android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" android:resource="@xml/accessory_filter" /> </activity>
meta-dataのresourceにxmlファイルを指定していますが、これは別途作成します。
filtering用のxmlファイル作成
/res/xml以下に、上記Intent-Filterのmeta-dataに指定したaccessory_filter.xmlを作成します。
<?xml version="1.0" encoding="utf-8"?> <resources> <usb-accessory manufacturer="Poly's Factory" model="Android Oepn Accessory Demo" version="1.0" /> </resources>
ここで指定するManufacturer・Model・Versionによって、Activityが扱えるUSB Accessoryがフィルタリングされます。後で解説するPC側のプログラムでも、同じ値を指定します。
Activity作成
Activity側では、接続されたUSB Accessoryと通信するために、2通りの方法でUSB Accessoryオブジェクトを取得できます。
1.Intentから取得する場合
USB機器接続時のIntentからActivityが呼び出された場合、IntentからUSB Accessoryoオブジェクトを取得することができます。
Intent intent = getIntent();
if (UsbManager.ACTION_USB_ACCESSORY_ATTACHED.equals(intent.getAction())) {
mUsbAccessory = UsbManager.getAccessory(intent);
}
2.任意のタイミングで取得する場合
USB機器接続後に、任意のタイミングでUSB Accessoryオブジェクトを取得することもできます。
mUsbManager = UsbManager.getInstance(this); UsbAccessory[] accessoryList = mUsbManager.getAccessoryList(); for (UsbAccessory usbAccessory : accessoryList) { // 条件に合致するusbAccessoryを選択 .... }
USB Accessory取得後はUsbManager.openAccessory()で、ParcelFileDescriptorを取得することで、普通のファイルストリームと同じように扱うことができます。
ParcelFileDescriptor pfd = mUsbManager.openAccessory(mUsbAccessory); FileDescriptor fd = pfd.getFileDescriptor(); FileInputStream fis = new FileInputStream(fd); FileOutputStream fos = new FileOutputStream(fd); fos.write(....);
実際のデモプログラムの中では、傾きセンサーで取得された値と、ボール追加ボタンが押されたかどうかを、バイト列にしてFileOutputStreamに出力しています。
PC側のソース
pc-usb.cがPC側のソースになっています。libusbとリンクする必要があるので "gcc -lusb-1.0 pc-usb.c" などでビルドして下さい。
PC側では、Android Open Accessory専用のプロトコルである、Android accessory protocolで通信を行う必要があります。
プロトコルの内容は、Android Open Accessory Development Kit | Android Developersを見ればわかりますが、Using Android in Industrial Automation » Turn your Linux computer into a huge Android USB Accessoryの記事で、Cとlibusbを使ったサンプルソースを公開している方がいらっしゃるので、ここではそれを参考にしてプログラムを書いてみます。
Android端末との接続
まず、USBで繋がっているAndroid端末の接続ハンドルを取得します。
#define VID 0x18D1 #define PID 0x4E12 libusb_init(NULL); libusb_device_handle* handle = libusb_open_device_with_vid_pid(NULL, VID, PID);
ここでVendorIDとProductIDを指定して、ハンドルをオープンしていますが、このIDは機種ごとに異なり、また同じ端末でも、USBデバッグモードとそうでない時で異なるので注意してください。
上記のIDはNexus Oneのデバッグモードで指定するIDです。
VendorID・ProductIDは、Mac OSであれば
/Developer/Applications/Utilities/USB Prober.app/Contents/MacOS/USB Prober
などを使って簡単に調べることができます。
Accessory Modeのset up
次に、Android端末はそのままではAccessoryと通信を行うモードになっていないので、モードを変更します。
Attempt to start the device in accessory modeに記載されている通信を、libusbを介して行います。
手順は、(1)端末がAndroid accessory protocolに対応しているかどうかの判定 (2)Accessory Identificationの送信 (3)端末をAccessory modeに変更 です。
(1)端末がAndroid accessory protocolに対応しているかどうかの判定
libusb_control_transfer(handle,0xC0,51,0,0,ioBuffer,2,0);
ioBufferにサポートするaccessory protocolのバージョンが返り、この値が0以外ならば端末がaccessory protocolをサポートしていると判断します。(現在サポートしている場合のバージョンは"1"のみ存在する)
(2)Accessory Identificationの送信
libusb_control_transfer(handle,0x40,52,0,0,(char*)manufacturer,strlen(manufacturer),0); libusb_control_transfer(handle,0x40,52,0,1,(char*)modelName,strlen(modelName)+1,0); libusb_control_transfer(handle,0x40,52,0,2,(char*)description,strlen(description)+1,0); libusb_control_transfer(handle,0x40,52,0,3,(char*)version,strlen(version)+1,0); libusb_control_transfer(handle,0x40,52,0,4,(char*)uri,strlen(uri)+1,0); libusb_control_transfer(handle,0x40,52,0,5,(char*)serialNumber,strlen(serialNumber)+1,0);
この命令によって、USB機器を識別するためのManifactorer・Model・Versionなどの情報が端末に送信されます。accessory_filter.xmlに記載されたフィルタリング情報は、ここで送信された値と一致するかで判定します。
(3)端末をAccessory modeに変更
libusb_control_transfer(handle,0x40,53,0,0,NULL,0,0);
Accessory Modeでのデータ送受信
以上で端末がAccessory Modeに変更されました。
このタイミングで端末のProduct IDが変更されるので
libusb_close(handle);
によって、Accessory Mode変更前の接続ハンドルをクローズし、
#define ACCESSORY_PID 0x2D01 handle = libusb_open_device_with_vid_pid(NULL, VID, ACCESSORY_PID)
によって、新しいAccessory ModeのPIDで接続ハンドルを作り直します。
ここまで行えば、後はlibusb_bulk_transfer関数を通じて、端末とバイトストリームをやりとりすることができます。
#define IN 0x83 #define OUT 0x03 #define BUFFER 1024 unsigned char buffer[BUFFER]; static int transferred; while (libusb_bulk_transfer(handle, IN, buffer, BUFFER, &transferred, 0) == 0) { ... }
ここで定数INとOUTは、それぞれAndroid側のUSBインターフェースを識別する値で、これも端末ごとに異なっています。今回はNexusOneの値を採用しています。
他の端末の場合、ProductIDと同じようにUSB Proberなどで確認してください。
PC側プログラムではここで読み込んだ値を、JavaScriptからも読み込めるように、一旦テキストファイルに書きだしています。
JavaScriptゲーム側
box2d-with-usbフォルダ以下が、ブラウザで実行するJavaScriptゲームのソースになっています。
詳細は省きますが、PC側プログラムがテキストファイルに書きだした取得された傾きセンサーの値と、ボールが追加されたかどうかのフラグを参照して、それに応じた力学アニメーションを行うようになっています。
Androidアプリで使える便利なUIライブラリ
Androidアプリと言えばUI命!、ということでギークな方々が作られている便利なUIライブラリを見つけられる限り、スクリーンショット付きでまとめてみます。
皆様いずれもソースと一部サンプルアプリを公開されているのですぐにでも試してみることができます。
(作者の方々、載せることに問題があるようでしたらお手数ですがご一報くださいませ)
Quick Action
- 公式Twitterアプリ風にタッチした箇所に吹き出しを表示できる
- レイアウトもカスタマイズ可能
Y.A.M の 雑記帳: Android Quick Action の Android ライブラリプロジェクトを作ってみた
Drag and Drop ListView
- ドラッグアンドドロップで並び替え可能なリストビュー
- 似たようなのは色々あるけどこれが一番使いやすかった!
Calendar
- ビュー上で祝日を判定・表示可能なカレンダービュー
- 上下左右のフリックで月を切り替えることもできるので、フリック操作を実現したい人はその部分だけでも参考になるかも
3D ListView
- 3Dで回転しながらスクロールするリストビュー
- 個人的には大好きなんですが、使い所が思いつかないw
Android Tutorial: Making your own 3D list – Part 3 (final part) | Developer World
ChartLibrary
- チャートライブラリ
- JFreeChartをAndroid用に拡張したもの
- 棒グラフ・折れ線グラフ・円グラフなど必要そうなものは大体完備しており、かなり高機能
- LGPLライセンス
Zoomable ImageView
- 長押しすると、タッチでズームイン・ズームアウトできるようになるImageView
- 画像が画面より大きくなると表示箇所を移動できるようになる
- 移動時に淵までいくとバウンドするアニメーション付き
Drag and Drop ImageView
- 画像をドラッグアンドドロップしてボタンをたたく
- 下のスクリーンショットだと分からないですが、画像をドロップしたときに回転するのです。そのアニメーションが素敵
Color Picker(11/30追記)
- もういっちょカラーピッカー
- こちらはリング系
- @yanzmさんに教えて頂きました!
Vertical Slider(11/30追記)
- 縦方向のスライダー
- @yyaammaaさんに教えて頂きました!
他にも
こんな便利なのあるよ!っていうのがあったら教えてください!
Android MockとRoboGuiceでTDD
Androidアプリ開発(に限った話ではないですが)でTDDしたいと思ったときに、テスト対象クラスのフィールドをモックで差し替えたい、と思うことがしばしばあります。依存するクラスの振る舞いを固定化することで、テスト対象オブジェクトの振る舞いだけに着目したテストケースを書くことができるからです。
そんな時に、DIコンテナ上でコードを書いていると便利です。以前、少しだけSeasar2+EasyMockでテストを書いていたことがあったのですが、作成したモックオブジェクトの差し替えを、ほぼ全てSeasar2がやってくれたのでものすごく便利でした。
Android開発でもSeasar2+EasyMockくらい簡単にテストを書きたい!
ということで、
- Android Mockでモックオブジェクトとその振る舞いを定義
- RoboGuiceでモックオブジェクトをテスト対象クラスにインジェクト
ということをやってみました。
準備
RoboGuice導入
roboguice - Project Hosting on Google Code
RoboGuiceは、Android Framework上でDIを実現するためのフレームワークです。Google GuiceというDIライブラリを拡張してAndroidに対応させています。
導入方法については
と、それを受けた
が詳しいのでそっちを参照で。
Android Mock導入
android-mock - Project Hosting on Google Code
Android Mockは、Androidで使えるEasyMockです。
導入方法については、Androdテスト部の方が翻訳されたドキュメント
が詳しいのでそちらを参照してください。
テスト対象コード
さて準備については丸投げしたところで(おい)、いよいよDIを活用したテストコードを書いてみます。
今回テストするのは以下のようなActivityです。
TopActivity
public class TopActivity extends GuiceActivity { @InjectView(R.id.text) private TextView textView; @Inject private Person person; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); if (person.getName().equals("droid")) { person.setColor("green"); } else { person.setColor("white"); } textView.setText(person.getName()); } }
機能は単純で、onCreate()で、personフィールドにセットされたオブジェクトのnameが"droid"だったらsetColor("green")を呼び出し、そうでなければsetColor("white")を呼び出す、というものです。
@Injectが指定されているpersonフィールドへの代入は、Roboguiceが行ってくれていて、「Personインターフェースのinject要求に対しては、Droidクラスを差し込め」ということをApplicationクラス(とそこから呼び出されるModuleクラス)で指定しています。
MyApplication.java
public class MyApplication extends GuiceApplication { @Override protected void addApplicationModules(List<Module> modules) { modules.add(new MyModule()); } }
MyModule.java
public class MyModule extends AbstractAndroidModule { @Override protected void configure() { bind(Person.class).to(Droid.class); } }
さて、先ほどのActivityのテストを書こうとしたときに
- personフィールドがdroidのときと、そうでないときの両ケースをテストしたい
- setColorに対して期待値通りの引数が指定されて呼び出されていることを確認したい
ということが実現できると、テストケースとしてはバッチリです。
これをAndroid MockとRoboGuiceで実現します。
テストコード
public class TestCase extends ActivityUnitTestCase<TopActivity> { final class MyMockModule extends AbstractAndroidModule { private Person mockPerson; public void setMock(Person person) { this.mockPerson = person; } @Override protected void configure() { bind(Person.class).toInstance(mockPerson); } } final class MyMockApplication extends GuiceApplication { private Module myModule; public void setMyModule(Module myModule) { this.myModule = myModule; } @Override protected void addApplicationModules(List<Module> modules) { if (myModule == null) { throw new IllegalArgumentException("Please call setMyModule before running the tests!"); } modules.add(myModule); } MyMockApplication(Context context) { super(); attachBaseContext(context); } } public TestCase() { super(TopActivity.class); } @Override protected void setUp() throws Exception { super.setUp(); } @MediumTest @UsesMocks(Droid.class) public void testMockDroid1() { // create a mock and learn it's behavior Droid mockDroid = AndroidMock.createMock(Droid.class); AndroidMock.expect(mockDroid.getName()).andStubReturn("mock"); mockDroid.setColor("white"); AndroidMock.replay(mockDroid); // set up mock application object Context context = getInstrumentation().getTargetContext(); MyMockApplication application = new MyMockApplication(context); MyMockModule myMockModule = new MyMockModule(); myMockModule.setMock(mockDroid); application.setMyModule(myMockModule); setApplication(application); // start activity Intent intent = new Intent(context, TopActivity.class); TopActivity activity = startActivity(intent, null, null); TextView textView = (TextView) activity .findViewById(com.polysfactory.roboguice_and_mock.R.id.text); // verify assertEquals("mock", textView.getText()); AndroidMock.verify(mockDroid); } @MediumTest @UsesMocks(Droid.class) public void testMockDroid2() { // create a mock and learn it's behavior Droid mockDroid = AndroidMock.createMock(Droid.class); AndroidMock.expect(mockDroid.getName()).andStubReturn("droid"); mockDroid.setColor("green"); AndroidMock.replay(mockDroid); // set up mock application object Context context = getInstrumentation().getTargetContext(); MyMockApplication application = new MyMockApplication(context); MyMockModule myMockModule = new MyMockModule(); myMockModule.setMock(mockDroid); application.setMyModule(myMockModule); setApplication(application); // start activity Intent intent = new Intent(context, TopActivity.class); TopActivity activity = startActivity(intent, null, null); TextView textView = (TextView) activity .findViewById(com.polysfactory.roboguice_and_mock.R.id.text); // verify assertEquals("droid", textView.getText()); AndroidMock.verify(mockDroid); } }
肝となるのは、
final class MyMockModule extends AbstractAndroidModule { ... @Override protected void configure() { bind(Person.class).toInstance(mockPerson); } }
の部分です。このbindによって、Personクラスの@Inject要求に対しては、モックオブジェクトがセットされるようになります。
モックの振る舞いは、各テストメソッドの
Droid mockDroid = AndroidMock.createMock(Droid.class); AndroidMock.expect(mockDroid.getName()).andStubReturn("droid"); mockDroid.setColor("green"); AndroidMock.replay(mockDroid); ... AndroidMock.verify(mockDroid);
の部分で指定しており、モックに対する動作を検証することもできます。
仮にDI(RoboGuice)を使わずに、personフィールドをonCreateの中でnewしていたら、このように簡単にテストを書くことはできません。personフィールドに何がセットされるかについては、newしたクラスの中身が完全に把握していないと分からないからです。
課題
@InjectViewや@InjectExtraのフィールドにモックインジェクションが出来ない
今回のテストでは、@Injectを指定したフィールドに対してモックをセットしています。
@Injectによるインジェクションは、実体に対するマッピングを自分でカスタマイズすることが出来るので、モックの差し替えが容易でしたが、@InjectViewや@InjectExtraなどのRoboguiceが提供するその他のインジェクションタイプに対しては、モックの差し替えはできなそうでした。(Roboguice自体に、モックと差し替えられるような拡張ポイントを用意してあげる必要があると思います。)
実際の開発では、@InjectViewや@InjectExtraもかなり便利なので、是非ともモックに差し替えてテストを書いてみたいところです。
RoboGuiceの性能
上記の方法は元々のアプリケーションがRoboGuiceで作られていることが前提となっています。
RoboGuiceの性能に関しては、Androidで動かすことを前提に、かなり気を使ったコードを書いているように見えますが、DIの宿命としてリフレクションを多用していますし、性能は気になります。
ただこれも、「推測するな、計測せよ」の原則に則ってきちんと計測すべきですね^^;
上記について詳しい方、いらっしゃいましたらアドバイス下さい!!
AndroidでContentProviderのモックを使ったテストを行う
ContentProviderからデータを取得しているアプリのテストコードを書くときに、テストデータとして端末内のデータを使わずにモックのデータを使いたいということは多いと思います。
今回は端末内の画像を一覧表示するアプリケーションを例にして、このようなテストコードの書き方を説明します。
テスト対象のActivity
以下のようなActivityをテストすることを考えます。
package com.polysfactory.mocktest; import android.app.ListActivity; import android.database.Cursor; import android.os.Bundle; import android.provider.MediaStore.Images; import android.provider.MediaStore.Images.ImageColumns; import android.widget.ArrayAdapter; public class MyActivity extends ListActivity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Cursor cursor = getContentResolver().query(Images.Media.EXTERNAL_CONTENT_URI, new String[] { ImageColumns.TITLE }, null, null, null); if (cursor != null) { String[] names = new String[cursor.getCount()]; int i = 0; while (cursor.moveToNext()) { names[i++] = cursor.getString(0); } cursor.close(); setListAdapter(new ArrayAdapter<String>(this, android.R.layout.simple_expandable_list_item_1, names)); } } }
見ての通り、Adnroid標準のContentProviderであるMediaStoreから画像コンテンツの情報を全て取得して、タイトルをListViewの表示させるだけのActivityです。
このActivityを起動すると以下のような画面が表示されます。
こいつのテストをするとなると、Listの1件目にxxxxという画像名称が表示されていることを確認したい、というケースが出てきます。
当然、端末ごとに保存されている画像は異なりますが、極力端末に依存させないよう、Activityからはダミーのデータにアクセスさせて、それが表示されているかをテストする必要があるわけです。
テストプロジェクトの概要
今回のテストでは、MediaStoreのダミーとなるContentProviderを自作します。そしてMediaStoreのContent URIを指定された場合にそのダミーを見にいくようなContentResolverとContextのモックを作成し、Activityにインジェクトします。
そうすることでActivity側のコードを変えることなく、端末のデータベースを参照せずに、モックとして用意したデータを使ってテストを行うことが出来るようになるのです。
超適当クラス図にするとこんな感じです。
以下それぞれのクラスについて説明していきます。
ContentProviderのモックを準備する
いきなり肝ですが、MediaStoreのダミーとして動作するContentProviderのモックを用意してあげます。これはContentProviderを継承したクラスを自作します。
ここでのポイントはqueryメソッドをオーバーラードするときに、データベースから取得したCursorを返さずに、MatrixCursorクラスを使って仮想のカーソルを返してあげるところです。
public class MockImagesProvider extends ContentProvider { .... @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { MatrixCursor result = new MatrixCursor(projection); String[] values = new String[projection.length]; for (int i = 0; i < projection.length; i++) { ... values[i] = "hogehoge"; ... } result.addRow(values); return result; } ... }
これによって、テストデータをデータベースから読まこませず、ファイルから読み込んだ値などを返すことができます。
今回はサンプルのため、テストデータをソース中に書き込みます。以下、このクラスの全体ソースです。
public class MockImagesProvider extends ContentProvider { private static final String[] IMAGE_COLUMNS = { ImageColumns._ID, ImageColumns._COUNT, ImageColumns.DATA, ImageColumns.DATE_ADDED, ImageColumns.DATE_MODIFIED, ImageColumns.DISPLAY_NAME, ImageColumns.SIZE, ImageColumns.TITLE }; private static final String[] VALUE1 = { "1", "1", "/sdcard/hoge.jpg", "1000", "1000", "display_name_test", "100", "title_test" }; public static final int IMAGE = 1; public static final int IMAGE_ID = 2; @Override public boolean onCreate() { return true; } @Override public Uri insert(Uri uri, ContentValues values) { return null; } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { MatrixCursor result = new MatrixCursor(projection); String[] values = new String[projection.length]; for (int i = 0; i < projection.length; i++) { for (int j = 0; j < IMAGE_COLUMNS.length; j++) { if (IMAGE_COLUMNS[j].equals(projection[i])) { values[i] = VALUE1[j]; } } } result.addRow(values); return result; } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; } @Override public String getType(Uri uri) { return null; } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; } }
CotentResolverのモックを作成する
第2の肝です。MediaStoreから画像の情報を取得するときのURIはcontent://media/external/imagesというURIになります。
ContentResolverのモックを作成して、content://mediaのURIに対しては、先ほど作成したMockImagesProviderにルーティングするように実装します。
public class MyMockContentResolver extends MockContentResolver { public MyMockContentResolver(Context context) { ContentProvider provider = new MockImagesProvider(); ProviderInfo providerInfo = new ProviderInfo(); providerInfo.authority = "media"; providerInfo.enabled = true; providerInfo.isSyncable = false; providerInfo.packageName = MockImagesProvider.class.getPackage().getName(); provider.attachInfo(context, providerInfo); super.addProvider("media", provider); } }
Contextのモックを作成する
ActivityにセットするためのContextのモックを作成します。
ContextWrapperを継承して、getContentResolver()だけをオーバーライドしてあげることで、ContentResolverを取得する部分だけをモックの動作に置き換えることができます。
public class MyMockContext extends ContextWrapper { private ContentResolver contentResolver; public MyMockContext(Context context) { super(context); contentResolver = new MyMockContentResolver(context); } @Override public ContentResolver getContentResolver() { return contentResolver; } }
テストケース
最後にテストを実行する部分のコードです。ActivityのテストはActivityInstrumentationTestCase2が使われることが多いですが、ActivityInstrumentationTestCase2はテスト対象のActivityに対するContextのインジェクションができないという制約があるため、代わりにActivityUnitTestCaseを使います。
ActivityUnitTestCase.setActivityContextメソッドを使うことで、先ほど作ったMockContextをActivityにセットすることが出来ます。
public class MockTestSample extends ActivityUnitTestCase<MyActivity> { public MockTestSample() { super(MyActivity.class); } @Smoke public void testSample() { Context mockContext = new MyMockContext(getInstrumentation().getTargetContext()); setActivityContext(mockContext); startActivity(new Intent(), null, null); final MyActivity activity = getActivity(); ListView listView = activity.getListView(); int count = listView.getCount(); String item0 = (String)listView.getAdapter().getItem(0); assertNotNull(activity); //表示されているデータが1件であることを確認する assertEquals(1, count); //表示されている画像名がtitle_testであることを確認する assertEquals("title_test", item0); } }
まとめ
ContentResolver、ContentResolver、Contextという長い道のりを経て、モックのデータでActivityのテストを行う方法について書きました。
これで何が嬉しいかといういうと、端末ごとのデータに依存せずにテストを実行できることです。一度テストデータとコードを書いてしまえば、どんな機種でも実行できるようにようになるので、品質を上げるにに必ず役立つと思ってます。