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ではとりあえず我慢、でしょうか。。。
AndroidアプリでSMS受信を偽装する方法
今日のGoogle Developer Day 2010のセッションで、IMoNiの作者@t_eggさんが、「IMoNiはSMSのContentProviderにデータを突っ込むことで、iモード.netメールの受信通知をSMSとして(実際に受信している訳ではなく、仮想的に)受信させている」ということを説明されていました。
これについて、興味があったので自分でも実装してみました。
また、ContentProviderに突っ込む以外でも、SMSの受信サービスを直接呼び出すことでSMSを仮想的に受信させる方法を見つけたので、それについても記しておきます。
SMSのContentProviderに突っ込む方法
「SMSのContentProviderに突っ込む」と一言でいっても、SMSのContentProviderはSDKのandroid.jarには存在しないので、詳細はOSのソースを追っていく必要があります。
URIとかカラム名の詳細は、
android.provider.Telephony
クラスを参照します。
これによると、insertすべきSMS受信BOXのContent URIは "content://sms/inbox" で、"address","date","read","subject","body"あたりのカラムにデータを突っ込めば良さそうということが分かります。
ContentValues values = new ContentValues(); values.put("address", "090xxxxxxxx"); values.put("date", System.currentTimeMillis()); values.put("read", false); values.put("subject", "test subject"); values.put("body", "test body"); getContentResolver().insert(Uri.parse("content://sms/inbox"), values);
この書き込みをするためにはAndroidManifest.xmlに以下のパーミッション設定が必要です。
<uses-permission android:name="android.permission.WRITE_SMS" /> <uses-permission android:name="android.permission.READ_SMS" />
このソースを実機で実行してみると、以下のような感じでちゃんと受信できていることが分かります。
受信元は数字じゃなくてもちゃんと表示されてますね。
もう一つの方法 - 直接SMS受信用のServiceを呼ぶ
さて上記のContentProviderに突っ込む方法でSMSの一覧に表示させることはできるのですが、一つ問題があって、ステータスバーへの通知が表示されません。
もちろん、自力で別途Notificationを発行すれば出ますが、出来ればこの辺の処理も本来行うべきクラスに行わせたいところです。
そこでもう一つの方法ですが、SMSの受信サービスに直接Intentを発行する、ということが出来ます。
呼び出す対象は「メッセージ」アプリのサービスクラスである、
com.android.mms.transaction.SmsReceiverService
クラスです。
このSmsReceiverServiceに発行するIntentには、SMS受信を表すActionである「android.provider.Telephony.SMS_RECEIVED」を指定します。
さらに、ここで肝となるのは、受信したSMSを表すバイト列を、"pdus"というキー名のextraにセットしてあげる必要がある点です。僕が調べた限り、このデータを一発で組み立ててくれるような便利なメソッドとかは存在しないので、SMSのフォーマットを調べた上で、自力でバイト列を作成してあげる必要があります。
以下のコードは、SMS and the PDU formatなどを参考にして、SMSのPDUバイト列を組み立てて受信サービスに渡してあげるサンプルソースです。
処理中で、GsmAlphabetというクラスを使ってますが、これはAndroid OSのcom.android.internal.telephonyパッケージに存在する内部ユーテリティクラスです。同パッケージ中のEncodeExceptionクラスと一緒に自分のプロジェクトにコピーしてやればそのまま使えるはずです。
public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); sendSms(this, "09000000000", "これはてすとだよ!"); } private static void sendSms(Context context, String sender, String body) { byte[] pdu = null; byte[] scBytes = PhoneNumberUtils .networkPortionToCalledPartyBCD("0000000000"); byte[] senderBytes = PhoneNumberUtils .networkPortionToCalledPartyBCD(sender); int lsmcs = scBytes.length; byte[] dateBytes = new byte[7]; Calendar calendar = new GregorianCalendar(); dateBytes[0] = reverseByte((byte) (calendar.get(Calendar.YEAR))); dateBytes[1] = reverseByte((byte) (calendar.get(Calendar.MONTH) + 1)); dateBytes[2] = reverseByte((byte) (calendar.get(Calendar.DAY_OF_MONTH))); dateBytes[3] = reverseByte((byte) (calendar.get(Calendar.HOUR_OF_DAY))); dateBytes[4] = reverseByte((byte) (calendar.get(Calendar.MINUTE))); dateBytes[5] = reverseByte((byte) (calendar.get(Calendar.SECOND))); dateBytes[6] = reverseByte((byte) ((calendar.get(Calendar.ZONE_OFFSET) + calendar .get(Calendar.DST_OFFSET)) / (60 * 1000 * 15))); try { ByteArrayOutputStream bo = new ByteArrayOutputStream(); bo.write(lsmcs); bo.write(scBytes); bo.write(0x04); bo.write((byte) sender.length()); bo.write(senderBytes); bo.write(0x00); try { // ascii文字のみの場合こっち byte[] bodyBytes = GsmAlphabet.stringToGsm7BitPacked(body); bo.write(0x00); bo.write(dateBytes); bo.write(bodyBytes); } catch (EncodeException e) { // 2バイト文字を含む場合こっち System.out.println("try UCS2"); try { byte[] textPart = body.getBytes("utf-16be"); bo.write(0x0b); bo.write(dateBytes); bo.write(textPart.length); bo.write(textPart); } catch (UnsupportedEncodingException e1) { } } pdu = bo.toByteArray(); } catch (IOException e) { } Intent intent = new Intent(); intent.setClassName("com.android.mms", "com.android.mms.transaction.SmsReceiverService"); intent.setAction("android.provider.Telephony.SMS_RECEIVED"); intent.putExtra("pdus", new Object[] { pdu }); context.startService(intent); } private static byte reverseByte(byte b) { return (byte) ((b & 0xF0) >> 4 | (b & 0x0F) << 4); }
このソースは単にサービスを呼び出しているだけなので、Permissionの設定は特に必要ありません。
こいつを実機で実行してみると、以下のようにちゃんと受信通知まで表示してくれます。
まるで本当にSMSを受信してるようです。
ただ、当然ながらメッセージアプリがアンインストールされちゃったりしている場合は、Serviceが反応しないのでこの方法は使えません。(レアケースだとは思いますが)
まとめ
- SMSのContentProviderにデータを直接突っ込んでSMS受信を偽装する方法
- SMSの受信サービスを直接呼び出してSMS受信を偽装する方法
を紹介しました。
これを使えば、アプリからの様々な通知をSMSで置き換えることができるので、うまく使えばユーザービリティを高められると思います!
Live Wallpaperで動画ファイルをそのまま表示する(とりあえずできたよ編)
Android OS 2.1からLive Wallpaper(ライブ壁紙)の機能が追加されていますが、いざLive Wallpaperを自分で作る、となると結構面倒です。
実装方法の詳細は、「Android 2.1の新機能「Live Wallpaper」で作る、美しく燃える“待ち受け”」に詳しいですが、WallpaperService.Engineの各種メソッドをオーバーライドして、SurfaceHolderを経由してCanvasに描画処理を行うことで、アニメーションを実現するのが常套手段のようです。
ですがこの方法はかなり面倒です。単にアニメーションを再生したいだけなのに、Javaでゴリゴリプログラムを書きたくない。
そもそも、mp4とかの動画ファイルが素材としてあるんだったらそれをそのまま表示できないのでしょうか?
というわけで色々試してみます。
MediaPlayerクラスで再生している動画はSurfaceHolderに対して表示できるので、まずはその方法で試してみます。
MediaPlayerにSurfaceHolderをそのままsetDiaplayしてやる
public class LiveWallpaperTest extends WallpaperService { ~~ class LiveEngineTest extends Engine { private MediaPlayer mp; @Override public void onCreate(SurfaceHolder holder) { super.onCreate(holder); holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); mp = new MediaPlayer(); mp.setDisplay(holder); mp.setOnPreparedListener(new OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { mp.start(); } }); try { mp.setDataSource(LiveWallpaperTest.this, Uri.parse("content://media/external/video/media/1")); } catch (Exception e) { Log.e(TAG, "error"); } mp.setOnCompletionListener(new OnCompletionListener() { @Override public void onCompletion(MediaPlayer mp) { mp.stop(); } }); } // MediaPlayerをpreparaeする部分は省略 } ~~ }
ダメでした。logcatには以下のようなエラーが出力されます。
E/AndroidRuntime( 754): java.lang.UnsupportedOperationException: Wallpapers do not support keep screen on E/AndroidRuntime( 754): at android.service.wallpaper.WallpaperService$Engine$2.setKeepScreenOn(WallpaperService.java:202) E/AndroidRuntime( 754): at android.media.MediaPlayer.updateSurfaceScreenOn(MediaPlayer.java:895) E/AndroidRuntime( 754): at android.media.MediaPlayer.setDisplay(MediaPlayer.java:573)
ということで、MediaPlayer.setDisplayの中で自動的に、SurfaceHolder.setKeepScreenOnが呼ばれますが、WallpaperのSurfaceHolderはこのメソッドをサポートしていないのです。
ここで一工夫します。Wallpaper用のSurfaceHolderをそのまま使っていると上記の例外が避けられないので、独自のSurfaceHolderのWrapperを自分でこしらえます。
独自のSurfaceHolder WapperをMediaPlayerにsetDiaplayしてやる
class MySurfaceHolder implements SurfaceHolder { private SurfaceHolder surfaceHolder; public MySurfaceHolder(SurfaceHolder surfaceHolder) { this.surfaceHolder = surfaceHolder; } @Override public void addCallback(Callback callback) { surfaceHolder.addCallback(callback); } @Override public Surface getSurface() { return surfaceHolder.getSurface(); } @Override public Rect getSurfaceFrame() { return surfaceHolder.getSurfaceFrame(); } @Override public boolean isCreating() { return surfaceHolder.isCreating(); } @Override public Canvas lockCanvas() { return surfaceHolder.lockCanvas(); } @Override public Canvas lockCanvas(Rect dirty) { return surfaceHolder.lockCanvas(dirty); } @Override public void removeCallback(Callback callback) { surfaceHolder.removeCallback(callback); } @Override public void setFixedSize(int width, int height) { surfaceHolder.setFixedSize(width, height); } @Override public void setFormat(int format) { surfaceHolder.setFormat(format); } @Override public void setKeepScreenOn(boolean screenOn) { return; } @Override public void setSizeFromLayout() { surfaceHolder.setSizeFromLayout(); } @Override public void setType(int type) { surfaceHolder.setType(type); } @Override public void unlockCanvasAndPost(Canvas canvas) { surfaceHolder.unlockCanvasAndPost(canvas); } }
見ての通り、setKeepScreenOnメソッドの実装を空にしている以外は、全てもとのSurfaceHolderに丸投げするだけです。
こいつを使ってもう一度動画を再生してみましょう。
public class LiveWallpaperTest extends WallpaperService { ~~ class LiveEngineTest extends Engine { private MediaPlayer mp; @Override public void onCreate(SurfaceHolder holder) { super.onCreate(holder); holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); mp = new MediaPlayer(); mp.setDisplay(new MySurfaceHolder(holder)); mp.setOnPreparedListener(new OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { mp.start(); } }); try { mp.setDataSource(LiveWallpaperTest.this, Uri.parse("content://media/external/video/media/1")); } catch (Exception e) { Log.e(TAG, "error"); } mp.setOnCompletionListener(new OnCompletionListener() { @Override public void onCompletion(MediaPlayer mp) { mp.stop(); } }); } @Override public void onDestroy() { super.onDestroy(); if (mp != null) { mp.stop(); mp.release(); } } @Override public void onVisibilityChanged(boolean visible) { super.onVisibilityChanged(visible); if (visible) { play(); } } private void play() { if (mp.isPlaying()) { mp.stop(); } try { mp.prepareAsync(); } catch (IllegalArgumentException e) { Log.e(TAG, "error"); } catch (SecurityException e) { Log.e(TAG, "error"); } catch (IllegalStateException e) { Log.e(TAG, "error"); } } } ~~ }
さてどうでしょうか?
見事、Live Wallpaperとして動画が表示されています!
まとめ
- Live wallpaperのSurfaceHolderにMediaPlayerで再生した動画を表示しようとするとエラーでこける。
- SurfaceHolderをそのまま使うんじゃなくて、独自のWrapperを用意すればMediaPlayerで再生した動画をそのままライブ壁紙に使える。
という話でした。
単に動画を表示するだけのライブ壁紙はつまらないですが、面白いアプリを作る一手段としてなら使えるんじゃないでしょうか。(電池をどれくらい食うか心配ですが。。)
Androidの開発効率化Tips
2010/09/06追記:id:gaeさんよりXMLのファイル名はキャメルケースにできない、という指摘を頂きました。XMLのファイル名は未検証のまま書いてしまっていたのでそれに関する記述を削除させて頂きました。
Androidの開発にもだいぶ慣れてきて、スムーズに開発できるようになってはいるのですが、冷静に自分の実装プロセスを分析するとまだまだ非効率だなあと感じる点が多々あったりします。効率化のためにADTを改造するかな、とも思ったのですが、それ以前に既存のEclipse+ADTの機能を活用して効率化できる部分がまだあるのではないかと考えて調査してみました。
その中で、これは使えそうかな、というEclipse+ADTでのAndroid開発効率化の小技をいくつか紹介します。
GotoFileプラグイン
Androidで開発をしているとファイルを行ったり来たりすることが非常に多くなります。LayoutファイルとJavaソースの行ききだったり、似たような処理をしているところをコピって持ってきたり、、、。そこで使えるのが、Eclipse上で素早くファイル検索できるGotoFileプラグインです。オリジナルの配布サイトからはダウンロード出来なくなっているようですが、id:kusakariさんが表示数制限版を公開されています。
このプラグインをインストールすると、Ctrl-Alt-Nで検索窓が出てきます。Eclipse標準機能のCtrl-Shift-Tでも似たようなことができますが、GotoFileプラグインが特徴的なのは曖昧検索ができることです。
たとえば、hello_world.xmlのファイルに移動したければ「hxml」でヒットします。仮にファイルの一部分しか覚えていなくてもかなりスムーズにファイル間を移動することができます。
Extract Android String
Androidの開発で、もう一つ面倒なのがstrings.xmlファイルの存在です。
他言語化するために、基本的に文言は全てstring.xmlに抽出しますが、文言が出てくるたびにstrings.xmlを開いて編集、とやっていると非常に効率が悪いです。
あまり知られていないですが、ADTにはこれを解消するための便利な機能が用意されています。
Javaファイル、もしくはリソースXMLファイルで文言部分を選択した状態で、「Refactor->Android->Extract Android String」のメニューを選ぶと、指定された文字列をstrings.xmlに抜き出すことが出来ます。Alt-Shift-A, Sのショートカットで呼び出すとさらに便利です。
僕はこの機能で1日で1度もstrings.xmlを開かなくても開発できるようになりました。
キャメルケースを活用する
これはAndroid開発に限らないですが、Eclipseの補完機能をフル活用するために、キャメルケースで補完するのが良いです。
たとえばOnItemLongClickListenerだったらOILCと打ってCtrl-Shiftすれば、候補表示を飛ばして一発で補完できます。OnItemと打って、次に候補から選んで・・・とするよりもキーストロークを大幅に減らすことができます。
これはR.javaの各フィールドについても言えることで、R.strings.XXXXなどのXXXX部分を補完する際にも使えるので、stringsのキー名やandroid:id、layoutやdrawableのファイル名は、全てキャメルケースにしておくことをお勧めします。特にstringsのキー名とかは数が多くなってくると、必然的に長くなってくると思うので・・・
僕はいままでアンダースコアで区切っていたのですが、補完時に不便なのでキャメルケースに切り替えました。
まとめ
Eclpse+ADTにもまだまだ知らない便利機能が多いので、みんなでTipsを共有するといいと思うな!(おい
IS01専用Androidアプリ「LEDモールス」を公開しました
ものすごく久しぶりのブログになってしまいましたが生きてます。
最近は社内でスマートフォン関連の開発をやってまして、どっぷりとAndroidに浸かっています。
機種依存やら何やらに悩まされたり、Android開発の奥深さに毎度唸らされる毎日です。
で、今日はシャープとアンドロイダーが開催した「SHARP Androidアプリ開発 テクニカルセッション」というセミナーにいってきました。これはメーカーの開発者の方がいる中で、皆PC持参でひたすら実機を触るという素晴らしいイベント(※講演もありました)だったのですが、このセミナー中に1個お遊びのアプリを作ってみたので公開します。
LEDフラッシュでモールス信号を発信できるアプリです。
LEDの制御はIS01専用(というかシャープ専用)の拡張APIなので、いまのところIS01しか動きませんが、船舶間での通信、難破・遭難時の救難信号、符号理論の勉強などに是非ご活用ください!
というわけで僕はAndroid端末を持っていないので本当に公開できているか分からないのですが、「LEDモールス」という名前でマーケット公開中です。ダウンロードは(多分)↓こちらから。
【LEDモールス】(IS01専用アプリ)
http://market.android.com/search?q=org.firemobilesimulator.morse
適当なソースコードなのでさらしておきます。
package org.firemobilesimulator.morse; import java.util.Hashtable; import jp.co.sharp.android.hardware.FlashLight; import android.app.Activity; import android.os.Bundle; import android.text.Editable; import android.util.Log; import android.view.View; import android.widget.EditText; import android.widget.Toast; public class TopActivity extends Activity { private static final String TAG = "Morse"; private static final int INTERVAL = 1000; private static final int LONG = 1000; private static final int SHORT = 200; private EditText mEditText; private static Hashtable<String, String> mTable; static { mTable = new Hashtable<String, String>(); mTable.put("A", "・−"); mTable.put("B", "−・・・"); mTable.put("C", "−・−・"); mTable.put("D", "−・・"); mTable.put("E", "・"); mTable.put("F", "・・−・"); mTable.put("G", "−−・"); mTable.put("H", "・・・・"); mTable.put("I", "・・"); mTable.put("J", "・−−−"); mTable.put("K", "−・−"); mTable.put("L", "・−・・"); mTable.put("M", "−−"); mTable.put("N", "−・"); mTable.put("O", "−−−"); mTable.put("P", "・−−・"); mTable.put("Q", "−−・−"); mTable.put("R", "・−・"); mTable.put("S", "・・・"); mTable.put("T", "−"); mTable.put("U", "・・−"); mTable.put("V", "・・・−"); mTable.put("W", "・−−"); mTable.put("X", "−・・−"); mTable.put("Y", "−・−−"); mTable.put("Z", "−−・・"); mTable.put("1", "・−−−−"); mTable.put("2", "・・−−−"); mTable.put("3", "・・・−−"); mTable.put("4", "・・・・−"); mTable.put("5", "・・・・・"); mTable.put("6", "−・・・・"); mTable.put("7", "−−・・・"); mTable.put("8", "−−−・・"); mTable.put("9", "−−−−・"); mTable.put("0", "−−−−−"); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); mEditText = (EditText) findViewById(R.id.input); } public void morse(View view) { Editable edit = mEditText.getText(); String s = edit.toString(); FlashLight mFlashLight = new FlashLight(); // 通常の点灯時は、ライトの色のみ指定します int len = s.length(); for (int i = 0; i < len; i++) { String oneChar = s.substring(i, i + 1).toUpperCase(); Log.d(TAG, "morse:" + oneChar); String code = mTable.get(oneChar); if (code != null) { int codeLen = code.length(); for (int j = 0; j < codeLen; j++) { char c = code.charAt(j); int time = 0; switch (c) { case '−': time = LONG; break; case '・': time = SHORT; break; } mFlashLight.setFlashLightOn(FlashLight.LIGHT_COLOR_WHITE); try { Thread.sleep(time); mFlashLight.setFlashLightOff(); Thread.sleep(INTERVAL); } catch (InterruptedException e) { Log.e(TAG, "sleep error", e); } } } else { Log.w(TAG, "Unknown Character"); } } Toast.makeText(this, "モールス信号を発信しました", Toast.LENGTH_LONG); } }