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側プログラムがテキストファイルに書きだした取得された傾きセンサーの値と、ボールが追加されたかどうかのフラグを参照して、それに応じた力学アニメーションを行うようになっています。
CV最先端ガイド勉強会でSVMについて発表してきました
本日開催された第9回「コンピュータビジョン最先端ガイド」勉強会@関東でSVMの章を発表してきました。
SVMはPRML読書会でも発表しているので、資料は半分くらい流用です。悪しからず。
SVMをちゃんと勉強しようと思うと、双対定理から二次計画問題、カーネル法などかなり幅広い知識が必要で、2回目の発表でも、自分自身勉強不足を痛感させられます。今回は特に、ラグランジュ未定定数法と双対定理については、内容を諳んじれるように頭に叩き込むようにしました。未だに参考書がないと内容が出てこないので。またlibsvmなどのライブラリを自分でも使い始めたので、パラメータとSVMの汎化性能については身を持って知ることができた点は良かったと思っています。
勉強会では、特にカーネルの選び方について、参加者の間でも熱い議論が交わされました。僕は資料に「ガウスカーネルだけで問題ないでしょ?」的なことを書いていたんですが、僕の前の発表でその説は叩きのめされ、僕も考えを改めることになりました。
たとえば、第3章6節には、畳み込みカーネルという(実数ベクトル以外の)構造化データに対するカーネルの例が上げられています。このような例を見たのは初めてで、なるほどガウスカーネル以外のカーネルも重要なのだなあと実感することができました。
また、実数ベクトルではガウスカーネルが使われることが多いと思うのですが、多項式カーネルの方が綺麗に線形分離できるケースもあるだろうという意見もありました。まあそういうケースもあるかもしれない、とは思うのですが、いまいち実例が思いつきません。例えば、スイスロールを例に上げると、ちょうどスイスロール上の境界線で線形分離できるようなカーネルを見つける、みたいなイメージでしょうか。このあたりも今後実践経験で学んでいければよいな、と思います。
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のテストを行う方法について書きました。
これで何が嬉しいかといういうと、端末ごとのデータに依存せずにテストを実行できることです。一度テストデータとコードを書いてしまえば、どんな機種でも実行できるようにようになるので、品質を上げるにに必ず役立つと思ってます。
RobotiumでAndroidアプリのシナリオテストを自動化する
Androidアプリのテスト自動化について色々調査していたら、Robotiumというテストツールを見つけました。このツール、便利なんですが、国内ではまだあまり知られてないみたいなので紹介してみます。
Robotiumとは
RobotiumとはAndroidアプリケーションのブラックボックスレベルのテストを自動化するためのTest Frameworkです。
Android版Seleniumというのが謳い文句のようです。
Android SDKが提供しているActivityInstrumentationTestCase2では複数のActivityにまたがるようなテストが難しいことで知られていますが、RobotiumはActivityをまたがるテストを簡単に自動化することができます。また、ユーザー操作をエミュレートする関数が豊富に用意されていて、従来は複雑になりがちだったテストコードを簡単に記述することができます。
以下、Robotiumを利用したテストコードの例です。
public void testAddNote() throws Exception { //メニューボタンから"Add note"を選択 solo.clickOnMenuItem("Add note"); //NoteEditorアクティビティが起動していることを確認 solo.assertCurrentActivity("Expected NoteEditor activity", "NoteEditor"); //0番目のテキストフィールドに"Note 1"と入力する solo.enterText(0, "Note 1"); //"NotesList"アクティビティに戻る solo.goBackToActivity("NotesList"); //現在の画面上にから"Note 1"というテキストを探す boolean actual = solo.searchText("Note 1"); //テキストが存在すればテストOK assertEquals("Note 1 is not found", expected, actual); }
このような形でユーザーの操作をもとに、直感的にテストコードを書くことができます。
さっそく試してみる
1. サンプルプロジェクトのダウンロード
RobotiumのサイトからExampleTestProject_v8.zip (2010/10/19現在の最新)を落とします。そのままEclipseでプロジェクトをインポートします。
感想
ユーザー操作に関するテストコードをかなりの部分ラップしてくれるので、その点使い勝手は非常に良いと思います。Listのxx個目をクリック→メニューボタンのxxxxをクリック→xxxxxのテキストが表示されているか確認、といったようなテストケースをほぼ一瞬で書くことができます。
ネックなのは信頼性で、内部の実装を見るとリフレクションを使ってWindowManagerからビューを取得したりと、かなりエグいことをやっているので、将来的にOSがバージョンアップされたいったときの動作や、機種別の動作は不安が残ります。また最新のバージョンでも、僕が試した限りでは Solo.goBack() が動作しなかったため、Solo.goBackToActivity("NotesList") に書き直したりしました。
というわけで、まだ信頼性に不安があるので、Robotiumだけでテストを書くというわけにはいかないとのが僕の感想です。自動テストのメインはAndroidSDK標準のテストフレームワークで作った単体テストで行い、サブ的に主要なシナリオテストをRobotiumで作成する、というのはアリかと思います。
数値キーでCursorJoinerを使う
android.databaseパッケージにCursorJoinerというクラスがあります。これは何かというと2つの異なるCursorのデータをJoinっぽく扱うことができるクラスで、たとえばContent Provider同士のデータをJoinさせてあげるときに使うことができます。
使い方は、リファレンスにある通り、
CursorJoiner joiner = new CursorJoiner(cursorA, keyColumnsofA, cursorB, keyColumnsofB); for (CursorJointer.Result joinerResult : joiner) { switch (joinerResult) { case LEFT: // handle case where a row in cursorA is unique break; case RIGHT: // handle case where a row in cursorB is unique break; case BOTH: // handle case where a row with the same key is in both cursors break; } }
みたいな感じで使うわけですが、実際こいつが何をやってるかというと、2つのカーソルのJoin対象のキーカラムを比較して、小さい方のカーソルを順番に進めていって、イテレーションのループの中で両方のカーソルに存在するデータなのか、どちらか一方のカーソルにしか存在しないデータなのかの情報を提供してあげてるだけです。
使いどころは結構あって、AndroidのOSが持っているデータベースは、基本的には直接データベースをSQLで触ることができずContent Provider経由で触ることになるので、自前のDBとOSのDB(あるいは他アプリのDB)をJoinさせたいときは、このCursorJoinerを使って擬似的にJoinしてあげるのが、ベターなのかなあと思ってます。
既存のCursorJoinerの問題点
こんな感じで割と便利なCursorJoinerですが、一つ問題があります。
エンジニア脳: android CursorJoinerでも触れられていますが、両カーソルのキー値を比較するときに、キーが数値型であっても、文字列比較してしまっているという点です。
これはどういうことかというと、たとえばCursorAのキーが1,5,10と並んでいて、CursorBのキーが1,10,20と並んでいるときに、キー=1のデータはResult.BOTH(両方のカーソルにあるデータ)として取得できるのですが、両Cursorを順番に進めていく過程で、CursorA=5,CursorB=10の時に5>10と判定してしまって、小さい(と判断された)CursorBを20に進めてしまうのです。
そのため、CursorA=10,CursorB=10となるタイミングがなくなって、キー=10はResult.BOTHで取得することができません。
キー値は大体Long型であることが多いのでこれは困ります。
というわけで、数値型のキーを扱えるようにCursorJoinerを修正してみます。
(不幸なことにCursorJoinerはfinalなクラスで、継承できないので、CursorJoinerをマルっとコピーして必要な部分だけ修正します。)
CursorJoiner修正版
import java.util.Iterator; import android.database.Cursor; /** * CursorJoinerクラスがもつ、Int型のキー同士でJoinさせる際の問題点を修正したクラス */ public final class CursorJoinerWithIntKey implements Iterator<CursorJoinerWithIntKey.Result>, Iterable<CursorJoinerWithIntKey.Result> { private Cursor mCursorLeft; private Cursor mCursorRight; private boolean mCompareResultIsValid; private Result mCompareResult; private int[] mColumnsLeft; private int[] mColumnsRight; private int[] mValues; public enum Result { RIGHT, LEFT, BOTH } public CursorJoinerWithIntKey(Cursor cursorLeft, String[] columnNamesLeft, Cursor cursorRight, String[] columnNamesRight) { if (columnNamesLeft.length != columnNamesRight.length) { throw new IllegalArgumentException("you must have the same number of columns on the left and right, " + columnNamesLeft.length + " != " + columnNamesRight.length); } mCursorLeft = cursorLeft; mCursorRight = cursorRight; mCursorLeft.moveToFirst(); mCursorRight.moveToFirst(); mCompareResultIsValid = false; mColumnsLeft = buildColumnIndiciesArray(cursorLeft, columnNamesLeft); mColumnsRight = buildColumnIndiciesArray(cursorRight, columnNamesRight); mValues = new int[mColumnsLeft.length * 2]; } public Iterator<Result> iterator() { return this; } private int[] buildColumnIndiciesArray(Cursor cursor, String[] columnNames) { int[] columns = new int[columnNames.length]; for (int i = 0; i < columnNames.length; i++) { columns[i] = cursor.getColumnIndexOrThrow(columnNames[i]); } return columns; } public boolean hasNext() { if (mCompareResultIsValid) { switch (mCompareResult) { case BOTH: return !mCursorLeft.isLast() || !mCursorRight.isLast(); case LEFT: return !mCursorLeft.isLast() || !mCursorRight.isAfterLast(); case RIGHT: return !mCursorLeft.isAfterLast() || !mCursorRight.isLast(); default: throw new IllegalStateException("bad value for mCompareResult, " + mCompareResult); } } else { return !mCursorLeft.isAfterLast() || !mCursorRight.isAfterLast(); } } public Result next() { if (!hasNext()) { throw new IllegalStateException("you must only call next() when hasNext() is true"); } incrementCursors(); assert hasNext(); boolean hasLeft = !mCursorLeft.isAfterLast(); boolean hasRight = !mCursorRight.isAfterLast(); if (hasLeft && hasRight) { populateValues(mValues, mCursorLeft, mColumnsLeft, 0 /* start filling at index 0 */); populateValues(mValues, mCursorRight, mColumnsRight, 1 /* start filling at index 1 */); switch (compareInts(mValues)) { case -1: mCompareResult = Result.LEFT; break; case 0: mCompareResult = Result.BOTH; break; case 1: mCompareResult = Result.RIGHT; break; } } else if (hasLeft) { mCompareResult = Result.LEFT; } else { assert hasRight; mCompareResult = Result.RIGHT; } mCompareResultIsValid = true; return mCompareResult; } public void remove() { throw new UnsupportedOperationException("not implemented"); } private static void populateValues(int[] values, Cursor cursor, int[] columnIndicies, int startingIndex) { assert startingIndex == 0 || startingIndex == 1; for (int i = 0; i < columnIndicies.length; i++) { values[startingIndex + i * 2] = cursor.getInt(columnIndicies[i]); } } private void incrementCursors() { if (mCompareResultIsValid) { switch (mCompareResult) { case LEFT: mCursorLeft.moveToNext(); break; case RIGHT: mCursorRight.moveToNext(); break; case BOTH: mCursorLeft.moveToNext(); mCursorRight.moveToNext(); break; } mCompareResultIsValid = false; } } private static int compareInts(int... values) { if ((values.length % 2) != 0) { throw new IllegalArgumentException("you must specify an even number of values"); } for (int index = 0; index < values.length; index += 2) { int comp = values[index] - values[index + 1]; if (comp != 0) { return comp < 0 ? -1 : 1; } } return 0; } }
これで、数値型のキーでCursorをJoinさせることができるようになります。
(ただこいつを使ってしまうと、今度は逆に文字列キーのJoinができなくなってしまうので、注意する必要がありますが)
おまけ MatrixCursor
CursorJoinerを使って、両カーソルに存在するデータだけを新たなカーソルとして取得したいときは、僕はMatrixCursorを使ってます。
他で使っているCursorAdapterを使い回したいときとかに便利です。
MatrixCursor result = new MatrixCursor(COLUMNS_OF_A); CursorJoinerWithIntKey joiner = new CursorJoinerWithIntKey(cursorA, keyColumnsofA, cursorB, keyColumnsofB); for (CursorJointerWithIntKey.Result joinerResult : joiner) { switch (joinerResult) { case BOTH: String[] values = cursor2values(cursorA); // 注:cursor2valuesはCursorをString[]に変換する独自のメソッドです。 result.addRow(values); break; } } return result;
まとめ
- ContetProvider同士のデータをJoinさせたいときはCursorJoinerが使える。
- CursorJoinerはそのままでは数値型のキーをJoinして使えないが、今回の修正版を使うことでそれができるようになる。
- CursorJoinerを使った結果をCursorで取得したいときはMatrixCursorが使える。
という感じでした。
データをJoinして扱いたい場合、パフォーマンスに拘った方法でやろうとすると、現状では上記が精一杯の対応なのではないかと思います。
実際にSQLでJoinされている訳ではないので、大量のデータを扱いたい場合はまだまだな感がありますが、現行のAndroid OSではとりあえず我慢、でしょうか。。。