AndroidでTwitter4Jを使うときのノウハウまとめ(後半その1)
思った以上に間があいてしまいましたが、後半戦です。前半はこちら。
目次
後半はこんな話をします。
- アイコンのURLをBitmapに変換しキャッシュする
- Timelineそのものをキャッシュする
- UserStreamを使用する
前半はこんな感じでした。
と、思っていたんですが…。Tumblrの文字数制限に引っかかってしまいました。仕方ないので後半は2分割します。
アイコンのURLをBitmapに変換しキャッシュする
Twitter4j#UserのgetProfileImageURLメソッドでアイコンのURLを取得することが出来ます。
が、AndroidのViewで「URLを受け取って画像を表示するView」はありません。ないので仕方ありません。ImageViewを継承した独自Viewを作ります。そのついでに一回読み込んだ画像はメモリ上にキャッシュしてしまいます。
方式としてはこんなイメージです。
ImageViewを継承したクラスを作成 └URLを受け取るメソッドを作る └URLをキーにキャッシュをチェック ├キャッシュからBitmapを取得し、setImageBitmapを呼び出す └URLからHTTP通信でデータをダウンロード └Bitmapに変換 └キャッシュに登録し、setImageBitmapを呼び出す
そんなわけで、以下の三つを作成していきます。
- HTTP通信を行い、取得したデータをBitmapに変換するAsyncTask
- キャッシュ用クラス
- ImageViewを継承したView
まずはURLを読み込みBitmapに変換するためだけのAsyncTaskを作成します。
public class URLToBitmap extends AsyncTask<String, Void, Bitmap> { private ConvertedListener mListener; public URLToBitmap(ConvertedListener listener) { mListener = listener; } @Override protected Bitmap doInBackground(String... urls) { HttpURLConnection connection = null; InputStream input = null; try { URL url = new URL(urls[0]); connection = (HttpURLConnection) url.openConnection(); connection.setDoInput(true); connection.connect(); input = connection.getInputStream(); return BitmapFactory.decodeStream(input); } catch(Exception e) { e.printStackTrace(); return null; } finally { if(input != null) { try { input.close(); } catch (IOException e) { e.printStackTrace(); } } if(connection != null) connection.disconnect(); } } @Override protected void onPostExecute(Bitmap result) { mListener.run(result); } public void execute(String param) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.HONEYCOMB_MR1) { super.execute(param); } else { super.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, param); } } public static interface ConvertedListener { public void run(Bitmap bitmap); } }
executeでやってる分岐の意味についてはこれを読んでください。
次はこのAsyncTaskを呼んだり呼ばなかったりするキャッシュ用のクラスを作ります。
Key:URL Value:BitmapのハッシュはLruCacheクラスを使って保持します。LruCacheってなに?って言うのは、リファレンスよりむしろWikipediaを読んだほうがわかりやすいと思います。
このLruCacheのコンストラクタではキャッシュするサイズを指定します。インスタンス作成時にLruCache#sizeOf(K key, V value)をオーバーライドすることでvalueのサイズをどうやって計算するか指定することが出来るので、Bitmap#getByteCountを渡してあげましょう。
キャッシュするサイズはActivityManagerからgetMemoryClassメソッドを呼べばその端末のアプリに割り当てられているヒープサイズを取得できるので、そこから計算するとOutOfMemoryErrorが防げたりするかもしれません。
public final class URLImageCache { private static LruCache<String, Bitmap> mCache; private static URLImageCache mInstance; private URLImageCache(Context cont) { int memClass = ((ActivityManager)cont.getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass(); int cacheSize = memClass * 1024 * 1024 / 8; mCache = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(String key, Bitmap value) { return value.getByteCount(); } }; } public static URLImageCache getInstance(Context cont) { if(mInstance == null) { mInstance = new URLImageCache(cont); } return mInstance; } public Bitmap get(String url) { return mCache.get(url); } public void put(final String url) { new URLToBitmap(new URLToBitmap.ConvertedListener() { @Override public void run(Bitmap bitmap) { if(bitmap != null) { put(url, bitmap); } } }).execute(url); } public void put(final String url, final URLToBitmap.ConvertedListener listener) { new URLToBitmap(new URLToBitmap.ConvertedListener() { @Override public void run(Bitmap bitmap) { if(bitmap != null) { put(url, bitmap); listener.run(bitmap); } } }).execute(url); } public void put(String url, Bitmap image) { mCache.put(url, image); } }
それじゃあ最後にImageViewを継承してちょろちょろっと付け足しただけのクラスを作りましょう。
public class URLImageView extends ImageView { public URLImageView(Context context) { super(context); } public URLImageView(Context context, AttributeSet attrs) { super(context, attrs); } public URLImageView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public void setURL(String url) { URLImageCache cache = URLImageCache.getInstance(getContext()); Bitmap image = cache.get(url); if(image != null) { setImageBitmap(image); return; } cache.put(url, new URLToBitmap.ConvertedListener() { @Override public void run(Bitmap bitmap) { setImageBitmap(bitmap); } }); } }
さて、独自Viewが出来たので適当にレイアウト用XMLにぶちこんでみましょう。
パッケージ名を書き忘れると表示されないので注意してください。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" > <test.education.URLImageView android:id="@+id/urlImageView" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout>
実際にActivityからURLをセットしようとするとこんな感じになります。
public class URLTestActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_url_test); // Googleのロゴ String imgURL = "https://www.google.co.jp/images/srpr/logo11w.png"; URLImageView urlImageView = (URLImageView) findViewById(R.id.urlImageView); urlImageView.setURL(imgURL); } }
ちゃんと通信できていればGoogleのロゴが出てくるはずです。
とは言え…多分素直にこんな形で使うことは滅多にないと思います。Twitterのアイコンなんか特に。
恐らく何らかのAdapterにURLImageViewを持たせてListViewあたりで表示する、ってのが一番多いでしょう。その場合、絶対にAdapter#getView内で未キャッシュのBitmapを取得しに行こうとしないほうが良いです。
getView内でBitmapを取得するために通信し、コールバックでsetImageBitmapを呼び出しても表示は変わりません。notifyDataSetChangedが呼ばれてないからです。
かと言って、先ほど作ったクラスにnotifyDataSetChangedを呼ばなければならない責任は一切ありませんし、責任がない以上はそれをやるべきではありません。AdapterだろうがActivityだろうがFragmentだろうがどこで呼ばれても問題なく動くべきです。
そうなると「1.コールバックとしてsetImageBitmapが呼ばれた後Adapter内でnotifyDataSetChangedを呼ぶ」か「2.事前にキャッシングを試みておく」のどちらかしかありません。
ただ、前者の場合は(URLImageViewだけでなく)Viewそのものを更新するので、それだけでもかなりの無駄があります。
となると結局後者しかない、ってお話です。どっちにしたってTwitterとは一回通信するためにバックグラウンドのスレッドを作成する必要があるので、裏でStatusやUserを受け取り色々と整形しつつアイコンのURLをURLImageCache#put(String url)にぶん投げとけばいいと思います。
仮に読み込みきれなかったとしても、適当にスクロールしているうちに読み込み終わってnotifyDataSetChangedを呼ばずとも自動で表示されるしね。(厳密には再度getViewが呼ばれるだけなんだけど)
[2014/08/04追記]
画像を非同期通信で取得する際の色々なノウハウをまとめた記事を別途作成しました。
大体似たようなことをしているんですが、速度や安全性が段違いなので上記の記事を参考に作ってみてください。
[2014/08/04追記ここまで]
Timelineそのものをキャッシュする
キャッシュ、と言うよりストレージの話です。
毎回毎回アプリの起動時にHomeTimelineやらMentionやらをTwitterと通信して取りに行くとあっという間にAPIのリミットが枯渇します。
一回取得したものは何らかの形で保存しておいて、なるべく通信量を減らしたいものです。
SDカードに保存するようなデータ(カメラで撮影した写真とか)ではなく、アプリ内部でのみ使うようなデータのストレージの方法としてはほとんどの場合以下の三つから選ぶことになります。
- SharedPreferences
- SQLiteによるDatabase
- FileInputStream / FileOutputStreamによるシリアライズ/デシリアライズ
それぞれ一長一短があるので場合によって使い分けられるようにしておきましょう。
- SharedPreferences
- メリット
- かんたん
- オーバーヘッドは一番少ない
- デメリット
- KeyがStringしかないのでIDEの恩恵をほとんど得られない
- 複数レコードの概念がない
- 不可能ではないが超絶面倒
- そもそもPreferenceだしね
- 多用すると管理が難しくなりがち
- メリット
- SQLiteによるDatabase
- シリアライズ/デシリアライズ
- メリット
- BeansのようなクラスであればSerializableを実装するだけで使える
- そのままIntent#putExtraとかBundle#putSerializableのような超重要なメソッドで使えるので、その手のクラスはとりあえずSerializableを実装しておくべき
- ちょっとした変更に(SQLiteよりは)強い
- 参考:Java オブジェクト直列化仕様:5 - 直列化可能オブジェクトのバージョン管理
- 完全に互換性のない型に変更したり、フィールドを削除したりするのはダメ
- デシリアライズに失敗しても改めて取得しなおせるようなものを保存するのがベター
- 頑張れば独自実装も可能(参考)
- ArrayList<T>などもSerializableを実装しているので柔軟なデータ構造をそのまま保存できる
- BeansのようなクラスであればSerializableを実装するだけで使える
- デメリット
- メリット
私の所感がかなり混じってますが、大体こんな感じです。
SharedPreferencesはまずないとして、SQLiteかシリアライズかになるんですが、折角StatusがSerializableを実装しているのでこれをシリアライズ/デシリアライズします。
public final class StatusesCache { private static final String path = ""; /** * シリアライズ * @param statuses * @param context */ public static void save(ArrayList<Status> statuses, Context context) { FileOutputStream fos = null; ObjectOutputStream oos = null; try { try { fos = context.openFileOutput(path, 0); } catch (FileNotFoundException e) { e.printStackTrace(); return; } try { if (fos != null) { oos = new ObjectOutputStream(fos); if (oos != null) oos.writeObject(statuses); } } catch (IOException e) { e.printStackTrace(); return; } } finally { if(fos != null) { try { if(oos != null) oos.close(); fos.close(); } catch (IOException e) { e.printStackTrace(); } } } } /** * デシリアライズ * @param context * @return */ @SuppressWarnings("unchecked") public static ArrayList<Status> load(Context context) { FileInputStream fis = null; ObjectInputStream ois = null; ArrayList<Status> obj = null; try { try { fis = context.openFileInput(path); } catch (FileNotFoundException e) { e.printStackTrace(); return null; } try { ois = new ObjectInputStream(fis); obj = (ArrayList<Status>) ois.readObject(); } catch (StreamCorruptedException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (RuntimeException e) { e.printStackTrace(); } } finally { if(fis != null) { try { if(ois != null) ois.close(); fis.close(); } catch (IOException e) { e.printStackTrace(); } } } return obj; } }
うだうだ言うよりコードを見せたほうが早い典型ですね。
今回はArrayList<Status>で決め打ってしまってますが、<? extends Serializable>にすれば更に汎用的に使えます。そうする場合はファイルパスをちゃんとメソッドで渡さないといけませんが。