Android: 音声エディタをリリース!

Twitterのほうで先に報告しましたが、私個人の開発アプリとしての第一弾として、音声エディタをAndroid Marketにリリースしました。

音声エディタ スクリーンショットその1

音声エディタ スクリーンショットその2

どういうアプリかといいますと、Googleの音声検索アプリを使って、自分でしゃべった言葉を文字列に変換します。
ただ、音声検索は文脈を理解できないため、句読点や、「〜」「…」「!」「?」「・」という日本人がよく使う記号をソフトウェアキーボードなしで入力できるようにしました(これだけのために毎回ソフトウェアキーボード開くのが鬱陶しいため)。

入力した文字列は、コピーボタンを押してクリップボードにコピーするのはもちろんできますが、連携ボタンを押すことでAndroidならではの機能「他のアプリにデータを渡す」ということができます。
これはどういうことかというと、自分でしゃべった内容をすぐにメールにしたり、ツイッタークライアントに渡して本当に呟けるということです。WordPressとか入れてるとしゃべった内容でブログの更新もできます!(但し、タイトルは手入力で)

対応機種というか、対応OSはバージョン1.5以上にしていますが、まぁ日本のAndroidOSはほとんどが1.6以上なので、実質ほとんど大丈夫だと思います。

■検証端末
・Softbank X06HT(Desire) 私のケータイ
・Docomo HT-03A 会社の先輩のケータイ

HT-03Aで動作したので、他でも問題ないはず。
問題がありましたら、フィードバックかTwitter上で報告お願いします。
twitter id: patorash

もしAndroidケータイを使っていましたら、Androidのブラウザで以下のリンクをクリックするか、Android Marketで「音声エディタ」で検索して、インストールしてみてください!ヨロシクお願いします。

Androidアプリ 音声エディタ
(PCからクリックしたら、Not Foundに移動するのでご注意を)


Android: 暗黙的Intent呼び出し

個人でAndroidアプリを思いつきで作ったのですが(まだリリースしてない)、そのときに他のアプリを呼び出したいなぁと思ったのだけれど、やり方がわからなかったんで調べました。自分で作ったアクティビティだけに限りませんが、特定のアクティビティを呼び出すのを明示的Intent呼び出しというのに対して、とりあえずIntentを作ってデータを放って、ユーザ側にアプリを選択させるのを暗黙的Intent呼び出しというらしいですね。

参考にさせてもらったのは以下のサイトです。

Intent(インテント)連携をまとめてみる – コードを貼り付けながら。

今回はEditTextに入力された内容を、メーラーやtwitterクライアントに対して渡すというのを実装してみました。

// ActivityのonCreate内
// 他のインテントを呼び出す
btnCollaboration.setOnClickListener(new OnClickListener() {
	public void onClick(View v) {
		String data = editText.getText().toString();
		if (data.length() > 0) {
			try {
				// メーラーやtwitterクライアントなどを呼び出す
				Intent intent = new Intent();
				intent.setAction(Intent.ACTION_SEND);
				intent.setType("text/plain");
				intent.putExtra(Intent.EXTRA_TEXT, data);
				startActivity(Intent.createChooser(intent, getString(R.string.txt_please_select)));
			} catch (ActivityNotFoundException e) {
				e.printStackTrace();
				// 呼び出せるActivityが存在しない
				Toast.makeText(VoiceEditor.this, R.string.txt_no_collaboration_found, Toast.LENGTH_SHORT).show();
			}
		} else {
			// EditTextにデータがないのでToast呼び出し
			Toast.makeText(VoiceEditor.this, R.string.txt_no_text, Toast.LENGTH_SHORT).show();
		}
	}
});

全部を自分で実装しなくても、入り口だけ作ってあとは他のアプリにデータを渡せるってのは、便利ですねぇ。


Android: MapActivityでダブルタップする

MapActivityを継承したアクティビティで、ダブルタップしても地図がズームインしないので、これはGoogleMapの仕様に合わせたいなぁと試行錯誤した結果を記します。正直、しんどかった。

まぁ、似たようなことを考えている人は世の中にもおられるもので、そこを参考にさせてもらいました。

特定のタップイベント時にMapViewから座標を取得する

まぁ上記のサイトにも書かれているのですが、OnGestureListenerをimplementsしただけでは、MapActivityはタップイベントを拾ってくれません。理由はMapViewが先に拾っちゃうからです。原因がわかったとしても、じゃあどうすればいいか。それがなかなかわからなかったんですが、上記のサイトでヒントを書いてくれてるので、それを参考にします(上記のサイトのは俺がいうのも何だけど、完璧ではなかった…)

下のソースは実際に使ってるものの、処理をかなりパッサリと落として抽出したものです。

public class Place extends MapActivity implements LocationListener,
	OnGestureListener,
	OnDoubleTapListener{
	private boolean mZoom = false;
	private boolean mDoubleTap = false;
	private boolean mSingleTap = true;
	private MapView mMapView;
	private MapController mMapController;
	
    @Override
	protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.place);
        mGDetector = new GestureDetector(this, this);

		mMapView = (MapView)findViewById(R.id.mapview);
		mMapView.setBuiltInZoomControls(true);
		ZoomButtonsController zbc = mMapView.getZoomButtonsController();
		zbc.setOnZoomListener(new OnZoomListener() {
			@Override
			public void onZoom(boolean zoomIn) {
				mZoom = true;
				if(zoomIn){
					mMapController.zoomIn();
				} else {
					mMapController.zoomOut();
				}
			}
			
			@Override
			public void onVisibilityChanged(boolean visible) {
				
			}
		});
		// ...略
	}
	
	@Override
	public boolean dispatchTouchEvent(MotionEvent ev) {
		super.dispatchTouchEvent(ev);
		return onTouchEvent(ev);
	}

	@Override
	public boolean onDoubleTap(MotionEvent e) {
		mDoubleTap = true;
		return false;
	}

	@Override
	public boolean onDoubleTapEvent(MotionEvent event) {
		if (mDoubleTap) {
			mDoubleTap = false;
			GeoPoint gp = getGeoPointByPoint((int)event.getX(), (int)event.getY());
			GeoPoint cgp = mMapView.getMapCenter();
			GeoPoint point = new GeoPoint(
					(gp.getLatitudeE6() + cgp.getLatitudeE6()) / 2,
					(gp.getLongitudeE6() + cgp.getLongitudeE6()) / 2);
			if (mMapController.zoomIn()) {
				mMapController.setCenter(point);
			}
		}
		return false;
	}

	@Override
	public boolean onDown(MotionEvent event) {
		mSingleTap = false;
		return false;
	}

	@Override
	public boolean onSingleTapConfirmed(MotionEvent event) {
		mSingleTap = true;
		if (mZoom) {
			// zoomIn, zoomOutしていたら、処理をしない
			mZoom = false;
		} else {
			GeoPoint gp = getGeoPointByPoint((int)event.getX(), (int)event.getY());
			mMapController.animateTo(gp);
		}
		return false;
	}

	@Override
	public boolean onTouchEvent(MotionEvent event) {
		if (mGDetector.onTouchEvent(event)) {
			return true;
		}
		return super.onTouchEvent(event);
	}
	
	private GeoPoint getGeoPointByPoint(int x, int y) {
		Projection projection = mMapView.getProjection();
		return projection.fromPixels(x, y);
	}
}

まず、イベントを全部横取りするために、dispatchTouchEventメソッドを設定します。最初にMapActivityが持っているイベント類を全て実行させるために、super.dispatchTouchEventを実行します。その後、実行したいthis.onTouchEventにイベントを渡します。

this.onTouchEventの中で、GestureDetector.onTouchEventをやっておきます。

重要になるのが、onSingleTapConfirmedメソッド。これは簡単にいうと、シングルタップイベントに該当します。シングルタップで実行したい処理をここに書いてしまいます。これでシングルタップイベントは完成っぽく見えるけど、実は違います。あとで書きますが、罠が潜んでます。

次に、onDoubleTapEvent。これはダブルタップイベントという名前だけあって、そういうイベントなんですが、何故かonDoubleTapメソッドの後に数回コールされてしまうので、onDoubleTap内で現在ダブルタップ中というフラグを立てて、onDoubleTapEventでフラグが経っていたら処理するというふうにします。これで、ダブルタップイベントは完成。

これで、GoogleMapをダブルタップしたら、地図は拡大。シングルタップしたところが画面中央に来てハッピーかと思いきや、なんと、ZoomControlをクリックしたら、シングルタップイベントが走ってしまい、ズームコントロールを押した場所が画面中央に来てしまいます。これはどげんかせんといけませんな。

で、onCreateの中でやっているんですが、ZoomButtonsControllerのインスタンスを生成して、ズームボタンを押したときのイベントを自分で定義します。setOnZoomListenerというやつですね。ここで、onZoomイベントに来たら、現在ズーム処理中のフラグを立てます。あとは拡大・縮小の処理を実行します。

onSingleTapConfirmedメソッドで、ズーム中のフラグが立っていたら、処理をしないようにします。そうすれば、シングルタップOK, ダブルタップOK, ズームコントロールOKになります。ふぅ〜、やれやれだぜ。


Android: 画面に合わせて画像を縮小して読み込む

Nexus OneやDesireではエラーが起きなくなったのに、HT-03AではOut of Memoryによる強制終了が頻発。これをどうやったら解決できるのか?色々と考えたけれど、DDMSを使ってHEAPのメモリ使用量を見たら、圧倒的に画像が占めているぽかったので、Bitmap自体のメモリ使用率を下げること以外に方法はないのだろうと。じゃあ、どうすればいいか?読み込む画像サイズを、BitmapFactiory.decodeStream()で読み込むタイミングで大きすぎる画像は小さくして読み込んでやれば、使用するメモリ量は少なくて済むだろうと。なんでも、Xperiaで取った写真を読み込んだだけでOut of Memoryが発生したりするから、サイズを最適化したらいいというのを見て、ネット上の画像でもできるだろうと判断。通信は複数回になっているのかもしれんが、まぁわからん。

しかし、やってみたんだが、思ったよりもいい結果が得られなかった。

// AsyncTask.doInBackgroundの中。引数はurls

HttpGet httpRequest = new HttpGet(urls[0]); 
HttpClient httpclient = new DefaultHttpClient();
HttpResponse response = (HttpResponse) httpclient.execute(httpRequest); 
HttpEntity entity = response.getEntity();
BufferedHttpEntity bufHttpEntity = new BufferedHttpEntity(entity);
InputStream is = bufHttpEntity.getContent();

BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
// 画像の大きさだけ取得
image = BitmapFactory.decodeStream(is, null, opts);
is.close();

Log.d(TAG, "before opts.outWidth = " + String.valueOf(opts.outWidth));
Log.d(TAG, "before opts.outHeight = " + String.valueOf(opts.outHeight));

// DisplayMetricsをActivity側でキャッシュしておいたと仮定
DisplayMetrics metrics = MetrixCache.get();
if (metrics != null) {
	Log.d(TAG, "run metrics.widthPixels = " + String.valueOf(metrics.widthPixels));
	Log.d(TAG, "run metrics.heightPixels = " + String.valueOf(metrics.heightPixels));
	int zoomWidth = (int)Math.floor(opts.outWidth / metrics.widthPixels);
	int zoomHeight = (int)Math.floor(opts.outHeight / metrics.heightPixels);
	opts.inSampleSize = Math.max(zoomWidth, zoomHeight);
}
// 今度は画像を読み込む
opts.inJustDecodeBounds = false;
				
is = bufHttpEntity.getContent();
// 最適な画像サイズで読み込む
image = BitmapFactory.decodeStream(is, null, opts);
is.close();
return image;

opts.inSampleSizeはintで指定するため、値が2になった途端に、縦横比は半分に(画像サイズは1/4)になるので、やけに荒くなる。しかもぎりぎりで2に届かないものはオリジナルで読み込まれるので、メモリ消費もでかくて役にたたんと判断。判断というか、HT-03Aの強制終了が起きたから、しゃーない。あと、inputStreamを2度使うから、やっぱり2回通信が発生しているのかもしれない。確かめてないけど。とにかく、別の手段を考える。

いったん画像を読み込んで、機種の解像度に合わせて画像をコピーし直す方式にしてみた。

HttpGet httpRequest = new HttpGet(urls[0]); 
HttpClient httpclient = new DefaultHttpClient();
HttpResponse response = (HttpResponse) httpclient.execute(httpRequest); 
HttpEntity entity = response.getEntity();
BufferedHttpEntity bufHttpEntity = new BufferedHttpEntity(entity);
InputStream is = bufHttpEntity.getContent();

image = BitmapFactory.decodeStream(is);
is.close();
DisplayMetrics metrics = MetrixCache.get();
if (metrics != null) {
	// 画像の大きさを最適化する
	Log.d(TAG, "before image.getWidth() = " + String.valueOf(image.getWidth()));
	Log.d(TAG, "before image.getHeight() = " + String.valueOf(image.getHeight()));
	float s_x = (float)image.getWidth() / (float)metrics.widthPixels;
	float s_y = (float)image.getHeight() / (float)metrics.heightPixels;
	float scale = Math.max(s_x, s_y);
	if (scale > 1){
		int new_x = (int)(image.getWidth() / scale);
		int new_y = (int)(image.getHeight() / scale);
		Log.d(TAG, "new_x = " + String.valueOf(new_x));
		Log.d(TAG, "new_y = " + String.valueOf(new_y));
		image = Bitmap.createScaledBitmap(
				image,
				new_x,
				new_y,
				false);
		Log.d(TAG, "after image.getWidth() = " + String.valueOf(image.getWidth()));
		Log.d(TAG, "after image.getHeight() = " + String.valueOf(image.getHeight()));
	}
}
return image;

あれだけ頻発していたHT-03AでのOut of Memoryが影を潜めた。さらに、画面の解像度に合わせて画像サイズを最適化するため、そんなに画像も汚くはない。この方式ならば、フォトフレームなどで使うにしても、きれいな画像が表示できそうだ。今のところは、これが一番よさそうなので、採用!!


Android: GridView更新後にスクロール位置を保つ

Android: GridViewに次へボタンを仕込むを書いたときの課題として、『次へボタンを押した後にスクロール位置を保持する』というのがあったのですが、それが出来ましたので、備忘録として書いておきます。

  1. 次へボタンが押されたら、先にGridView.getLastVisiblePosition()で現在位置を取得し、アクティビティのメンバ変数(mPosition)などに保存
  2. 今まで通り、次のデータを取得して、GridView.setAdapter(adapter)する
  3. その直後、GridView.setSelection(mPosition)を実行

という感じですねー。これで、次へボタンが押されたら、次のデータが画面に表示されています。今回はそのほうが都合がよかったのでそうしたのですが、もし次のデータでなく、次へボタンを押す前のデータが表示されてて下にスクロールしたら次のデータが見るようにしたい!ということだったら、getLastVisiblePosition()ではなく、getFirstVisiblePosition()を使いましょう。