AndroidでTwitter4Jを使うときのノウハウまとめ(後半その1)

思った以上に間があいてしまいましたが、後半戦です。前半はこちら。

目次

後半はこんな話をします。

  1. アイコンのURLをBitmapに変換しキャッシュする
  2. Timelineそのものをキャッシュする
  3. UserStreamを使用する

前半はこんな感じでした。

  1. Twitterとの通信を行う際の注意点
  2. AndroidアプリでOAuth認証を行う
  3. Twitterから情報を取得する - PagingとCursor
  4. StatusをListViewで表示する

と、思っていたんですが…。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
    • メリット
      • かんたん
      • オーバーヘッドは一番少ない
    • デメリット
      • KeyがStringしかないのでIDEの恩恵をほとんど得られない
      • 複数レコードの概念がない
        • 不可能ではないが超絶面倒
        • そもそもPreferenceだしね
      • 多用すると管理が難しくなりがち
  • SQLiteによるDatabase
    • メリット
      • 大量データに強い
        • 1000万件ぐらいなら余裕
        • 通信に関わるオーバーヘッドがないので思ってる以上に高速で動いてくれる
      • JavaからDBを操作するノウハウがほとんどそのまま使える
      • 面倒な部分(DAO作ったりとか)に関するライブラリが豊富
    • デメリット
      • 型の概念がほとんどない
        • TEXTが便利すぎるのが悪い
        • SQLiteJavaの型変換ロジックがほぼ確実に必要になる
      • ちょっとした変更に弱い
        • 後からフィールドを足したり削ったりするだけでも大変
          • 既にアプリをリリース済みだったりしたらもう考えたくないですね
        • 当たり前ですが、最初からかなり厳密に設計しないと絶対にどこかでウワーッってなります
  • シリアライズ/デシリアライズ
    • メリット
    • デメリット
      • IOExceptionがうざい
        • デメリットって言うか…
      • やや遅い
        • SQLiteを使うことを放棄したでかいオブジェクトばっかり保存してるからかも
        • 実用できないほど遅いわけじゃなく、上記二つよりは流石に遅いってレベル
      • シリアライズ不可になると取り返しがつかない
      • 所持しているフィールドの型がすべてSerializableを実装してないといけない
        • 自作のクラスはともかく、外部ライブラリになるとどうしようもないことがある
        • View関連も無理
        • 継承元のクラスや実装しているインターフェースも絡んでくるので意外と面倒臭い

私の所感がかなり混じってますが、大体こんな感じです。

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>にすれば更に汎用的に使えます。そうする場合はファイルパスをちゃんとメソッドで渡さないといけませんが。

後編その2へつづく!