SlimyTwitterFall

From Usipedia
Jump to: navigation, search
On Nexus S
On Android 3.0 tablet

TwitterのStreaming APIで取得できるタイムラインの一つ,statuses/sample をTwitter4jを用いて取得し,SurfaceViewを使ってなめらかなアニメーションで表示させる展示用アプリケーションです. 流れている様子が滝に似ているのでアプリ名にFallをいれました. ユーザーの通常のタイムラインを表示する機能もついています.

Contents

紹介動画

ダウンロード

使い方

SlimyTwitterFallMenuLogin.png

アプリを起動したら,メニューからLoginを選択してブラウザを開きます.

SlimyTwitterFallBrowserOAuth.png

認証ページが開くので,あなたのTwitterのIDとパスワードを入力してください.(OAuthを使っているので,この情報が私に伝わることはありません).許可ボタンを押すとあなたのPINコードが表示されるので,長押ししてコピーします.

SlimyTwitterFallEnterPIN.png

ブラウザ画面からこのアプリに戻るために何回か戻るボタンを押します(Operaを開いた場合は3回).PINを求めるダイアログが開くので,先ほどのPINコードを貼付けます.OKボタンを押してしばらくするとタイムラインが流れ始めます.画面をタップすると一時停止,もう一度タップすると再開できます.


FAQ

流れが突然止まった

後述するSkImageDecoderのエラーで突然TLが止まる事があります.止まったときは画面を二度タップしてみてください(二度タップするとタイムラインをリセットできます).

このアプリを起動してからnetstatを確認するとTIME_WAITなコネクションが爆発的に増えてる

大量のアイコン画像をダウンロードしているためしょうがない現象です.

AndroidのTCP接続ではアクティブクローズした場合TIME_WAITに数分間留まる仕様なのでnetstatの結果は増え続けます.そうして使用可能ポートを使い尽くしたら新しい接続を確立できなくなります.しかし心配する必要はなく,その状態に陥ったらアプリを終了してしばらく放置しておけば必ず元の状態に戻ります.

技術的な話

携帯端末では1アプリが使えるメモリ容量は非常に限られています.Androidの場合16MBまでしか使えず,新たなオブジェクトをメモリに割り当てる事が出来なくなったらOutOfMemoryErrorが起こります.

statuses/sample は使用に制限が課されない最も投稿頻度が少ないstatusesですが,全世界投稿の1%とはいってもたくさんの投稿があります.その全ての投稿に対してアイコン画像を表示して,テキストを見やすいように改行して,SurfaceViewでアニメーションさせたら普通の方法ではすぐにOutOfMemoryErrorが起こることは簡単に想像できるでしょう.

これはSlimyTwitterFallを長時間稼動に対応させるまでの過程を説明した資料です.

※編集中です

上から押し込まれて抵抗があるかのように動くスクロールについて

ローパスフィルターをかませるとあのような,目標地点に徐々に近づく動きになります.

投稿が追加されるたびに goalY += アイコンの縦幅
描画タイミングのたびに y += (goalY - y) * 0.1;

これでyは徐々にgoalYに近づきます.

Canvasのテキストを画面サイズに合わせて2行にして表示したい

次のようにすると表示したいテキスト(status.getText())を画面の横幅に合わせてmessage1(1行目)とmessage2(2行目)に分割できます.

Paint text = new Paint();
text.setAntiAlias(true);
text.setTextSize(24);
text.setColor(Color.rgb(rnd.nextInt(255), rnd.nextInt(255), rnd.nextInt(255)));
String message1 = status.getText(); // 表示したいテキスト(1行では表示できないかもしれない)
String message2 = "";
float textWidth = text.measureText(message1); // measureTextで実際の幅を測定出来る
if (textWidth > width - iconWidth) { // widthは画面の幅,iconWidthは正方形アイコンの横幅と縦幅
	int textLength = message1.length();
	for (int i = 15; i < textLength; i++) {
		if (text.measureText(message1, 0, i) > width - 2 * iconWidth) {
			message1 = status.getText().substring(0, i);
			message2 = status.getText().substring(i);
			break;
		}
	}	
}

try&errorで調整しているため負荷がかかっています.もっといい方法がありそうです.

画像をダウンロードしてBitmapにしたい

指定URLから画像をダウンロードしてBitmapにする方法はいくつかありますが,素直に実装すると大量の画像を連続的に処理する必要がある状況でSkImageDecoderのエラーがNDKの領域で発生します.

この問題の解決方法はAndroid: Problem/bug with ThreadSafeClientConnManager downloading imagesで議論されていますが,これを使っても落ちにくくなるだけで,いずれ必ず落ちてしまいます.最終的に私は解決方法を見つけることが出来ませんでした.なぜこのようなエラーが出るのかよく分かりません.BitmapFactoryのデコード部分はNDKで実装されていて,それのメモリ管理部分でリークしている気はするのですが・・・.どなたか分かる方がいらっしゃったら教えてください.次に私が試行錯誤した結果を載せておきます.

次の各方法では以下の変数を断りなく使います.

Context context = getApplicationContext(); // Activityで最初から持っておく
 
Bitmap icon; // Twitterのユーザーのアイコン
BitmapFactory.Options opts = new Options();
opts.inPurgeable = true;
 
URL url = status.getUser().getProfileImageURL(); // Twitterのユーザーのアイコンのアドレス
  • 方法1:最も素直な実装

URLのString → HttpURLConnection → InputStream → BitmapFactory.decodeStream という流れですが,これだとすぐに落ちます.

HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
connection.connect();
 
InputStream in = connection.getInputStream();
icon = BitmapFactory.decodeStream(in, null, opts);
in.close();
connection.disconnect();
  • 方法2:StackOverFlowでの結論

HttpURLConnection ではなく HttpGet などを使うと少し寿命が伸びます.

HttpGet httpRequest = new HttpGet(url.toURI());
HttpClient httpclient = new DefaultHttpClient();
HttpResponse response = (HttpResponse)
httpclient.execute(httpRequest);
 
HttpEntity entity = response.getEntity();
BufferedHttpEntity bufHttpEntity = new
BufferedHttpEntity(entity);
InputStream in = bufHttpEntity.getContent();
icon = BitmapFactory.decodeStream(in, null, opts);
  • 方法3:ローカルストレージ上で画像をデコードする

ローカルに保存すると削除の手間がかかりますし,端末の負荷も高くなりますが,エラーが出るよりはマシです.しかし,この方法でも同じエラーが方法2と同じくらいの頻度で出ました.

byte[] readBytes = new byte[2048];
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
connection.connect();
 
InputStream in = new BufferedInputStream(connection.getInputStream());
FileOutputStream fos = context.openFileOutput(id, Context.MODE_PRIVATE);
OutputStream out = new BufferedOutputStream(fos);
int read;
while (true) {
	read = in.read(readBytes);
	if (read <= 0) {
		break;
	}
	out.write(readBytes, 0, read);
}
out.flush();
in.close();
out.close();
connection.disconnect();
icon = BitmapFactory.decodeStream(context.openFileInput(id), null, opts);
  • 方法4:ダウンロードしたデータをbyte配列にしてデコードする

これでも同様のエラーが出ますが,他の方法よりはマシな気がするのでこの方法を採用しています.

byte[] readBytes = new byte[2048];
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
connection.connect();
 
InputStream in = new BufferedInputStream(connection.getInputStream());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStream out = new BufferedOutputStream(baos, 2048);
int read;
while (true) {
	read = in.read(readBytes);
	if (read <= 0) {
		break;
	}
	out.write(readBytes, 0, read);
}
out.flush();
in.close();
out.close();
connection.disconnect();
 
byte[] iconData = baos.toByteArray();
icon = BitmapFactory.decodeByteArray(iconData, 0, iconData.length, opts);

実際,このエラーが出るとSlimyTwitterFallではTLの流れが不自然に止まるくらいしか被害がありません.ということで,別スレッドからTLの流れを表面的に監視し,止まっていたら再開する仕組みを実装して長時間稼動を実現しました.けど気持ち悪いエラーであることに違いはないのでなんとかしたいものです・・・.

大量の投稿を扱いながらonStatusの中でアイコンをダウンロードするとTwitter4jのタスクのキューが増えすぎて落ちる

どの時間帯でも statuses/sample はすごい頻度で更新されます. Twitter4jは投稿を処理するたびに新たにスレッドを作ってその中で UserStreamListener の onStatus をコールバックします(恐らく). 当然,onStatusの中で同時に実行できる数が制限されている処理,具体的にはアイコンのダウンロードを行うと,未処理のスレッドがどんどんたまっていきいずれアプリのメモリ許容値を超えて落ちます.

次の画像はtwitter4j.internal.async.DispatcherImplのタスクを貯めておくキュー(LinkedList<Runnable> q)に未処理のタスクが溜まりすぎて落ちる直前のメモリの状態です.

アプリが落ちる直前のヒープをダンプしてMATのメモリリーク推測機能で確認してみた様子

キューが18.2MBまで膨れ上がっています.TCPの同時接続数が制限されている以上全ての投稿を処理するのは不可能なので,何らかのフィルターを加えて制限する必要があります. おすすめは次の端末のロケールと同じ投稿のみを表示する制限です.

端末のロケールと同じ投稿のみを表示したい

Twitterの投稿の言語情報とJavaのLocale#getLanguage()は両方ともISO 639-1のISO言語コードなはずで(厳密には未確認),与えられる文字は全て小文字なので onStatus で次のようにすれば発言をユーザーが設定している言語のみに絞れます.

myLocale = Locale.getDefault().getLanguage();
 
@Override
public void onStatus(Status status) {
	if(myLocale.equals(status.getUser().getLang())){
		// process
	}
}
Namespaces
Variants
Views
Actions
Categories