【Android】非同期通信で画像を取得する際のノウハウまとめ(1)

以前AndroidでTwitter4Jを使うときのノウハウまとめとか言う記事でさらさらっと、お茶漬け感覚で解説したんですが、色々といい方法を新たに見つけたのでまとめておきます。

以前の方法での問題点

ざっと列挙すると、こんな感じです。

と言うわけで、順番に潰していきます。

下準備

とりあえずHttpURLConnectionを作ってStreamを貰い、Bitmapに変換するメソッドを作りましょう。

public class ImageUtil {
    public static Bitmap getBitmapFromHttp(String url) {
        HttpURLConnection con = null;
        InputStream in = null;

        try {
            con = (HttpURLConnection) new URL(url).openConnection();
            con.setDoInput(true);
            con.setConnectTimeout(5000);
            con.setReadTimeout(30000);
            con.setUseCaches(true);
            in = con.getInputStream();
            
            return BitmapFactory.decodeStream(in);
            
        } catch(Exception e) {
            e.printStackTrace();
            return null;
        } finally {
            if(in != null) 
                try { in.close(); } catch(IOException e) { e.printStackTrace(); }

            if(con != null) con.disconnect();
        }
    }
}

これまた以前に説明した気がしますが、Http通信はUIスレッドで行うと例外が発生します。

そんなわけで、このメソッドを呼ぶAysncTaskも作成しておきましょう。

public class HttpImageLoader extends AsyncTask<String, Void, Bitmap> {

    /**
     * {@link HttpImageLoader HttpImageLoader}#onPostExecuteで呼び出されるコールバック
     * @see HttpImageLoader
     */
    public interface ImageCallback {
        /**
         * Bitmap読み込み後のコールバック
         * @param key {@link HttpImageLoader HttpImageLoader}に渡したKey。
         * @param bitmap {@link HttpImageLoader HttpImageLoader}で読み込まれたBitmap。nullの場合がある。
         */
        void call(Object key, Bitmap bitmap);
    }

    private final ImageCallback _callback;
    private final Object _key;
    
    public HttpImageLoader(Object key, ImageCallback callback) {
        _key = key;
        _callback = callback;
    }

    @Override
    protected Bitmap doInBackground(String... param) {
        return ImageUtil.getBitmapFromHttp(param[0]);
    }

    @Override
    protected void onPostExecute(Bitmap result) {
        _callback.call(_key, result);
    }

    @SuppressLint("NewApi")
    public void execute(String param) {
        if (Build.VERSION.SDK_INT <= 12) {
            super.execute(param);
        } else {
            super.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, param);
        }
    }
}

実際にデコードしたBitmapをどうするかはHttpImageLoader#ImageCallbackに委譲します。

Object keyとはなんぞや?と思うかもしれませんが、これはかなり後の方で説明します。と言うか、ArrayAdapterで使えるようにするためだけの配慮なので、今は気にしなくていいです。

デコード結果を安定させる

問題点にあげた通り、HttpURLConnectionから貰ったStreamをそのままBitmapFactoryに渡すのはあまりオススメできません。

一旦byteの配列に変換し、BitmapFactory#decodeByteArrayでBitmapに変換させると安定するそうです。

public class ImageUtil {
    public static Bitmap getBitmapFromHttp(String url) {
        HttpURLConnection con = null;
        InputStream in = null;
        
        byte[] ba = null;
        
        try {
            con = (HttpURLConnection) new URL(url).openConnection();
            con.setDoInput(true);
            con.setConnectTimeout(5000);
            con.setReadTimeout(30000);
            con.setUseCaches(true);
            in = con.getInputStream();
            
            //return BitmapFactory.decodeStream(in);
            
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            int read = 0;
            
            try {
                while((read = in.read()) != -1) {
                    bos.write(read);
                }
                ba = bos.toByteArray();
            } finally {
                bos.close();
            }
            
        } catch(Exception e) {
            e.printStackTrace();
            return null;
        } finally {
            // byteの配列に変換した後はHttpURLConnectionを片付けてしまっても良い
            if(in != null) 
                try { in.close(); } catch(IOException e) { e.printStackTrace(); }

            if(con != null) con.disconnect();
        }
        
        return BitmapFactory.decodeByteArray(ba, 0, ba.length);
        
    }
}

変換するBitmapのサイズを最適化する

インターネットに転がっている画像は当然ながらスマートフォンタブレットに合わせたものだけではありません。

使っている端末のサイズより大きい画像をデコードすればそれだけ無駄にメモリを喰ってしまいますし、そもそも表示したいレイアウトによって欲しいサイズが変わってくるでしょう。

と言うわけで、なるべく適切なサイズでデコードするようにします。BitmapFacotryの各デコード用メソッドの引数に使えるBitmapFactory.Optionsを駆使することで可能です。

適切なサイズとは何か?を考えていくと、色々と必要なものがあるんですが、そもそもまず読み込もうとしている画像のサイズを知らないといけません。

そのために便利なのがBitmapFactory.Options#inJustDecodeBoundsと言うbooleanのプロパティです。これをtrueにしておくと、実際にはBitmapへの変換は行わず、メソッドで渡したBitmapFactory.Optionsに各プロパティだけを与えます。

BitmapFactory.OptionsにはoutHeightoutWidthと言ったプロパティが用意されているので、そこからサイズだけ頂いてしまおうと言う寸法です。

public static Bitmap getBitmapFromHttp(String url) {
    HttpURLConnection con = null;
    InputStream in = null;
    
    byte[] ba = null;
    
    // 省略
    
    BitmapFactory.Options opts = new BitmapFactory.Options();
    opts.inJustDecodeBounds = true;
    
    BitmapFactory.decodeByteArray(ba, 0, ba.length, opts);
    
    int imageWidth = opts.outWidth;
    int imageHeight = opts.outHeight;
    
    //TODO: サイズを最適化してBitmapを返す
    
}

じゃあ次はどうやって最適化するのかなんですが、これまたBitmapFactory.Optionsで設定できます。inSampleSizeと言うプロパティです。

これは実際にサイズを指定するのではなく、デコードする際に画像の大きさを「1 / inSampleSize」に縮小してくれます。「the decoder uses a final value based on powers of 2, any other value will be rounded down to the nearest power of 2. 」と書いてあるので、どんな値を入れても最終的には2^nの値になるようです。

最適化する方法はわかったので、次はどのサイズに最適化するか考えましょう…って言っても考えてどうにかなるものでもないです。とりあえずはPointで適当な値を受け取るようにして、それを基準に最適化できるようにしておきましょう。

public static Bitmap getBitmapFromHttp(String url, Point maxSize) {
    HttpURLConnection con = null;
    InputStream in = null;
    
    byte[] ba = null;
    
    // 省略
    
    BitmapFactory.Options opts = new BitmapFactory.Options();
    opts.inJustDecodeBounds = true;
    
    BitmapFactory.decodeByteArray(ba, 0, ba.length, opts);
        
    // 画像と最大サイズの比率を求める
    float scaleW = (float) opts.outWidth / maxSize.x;
    float scaleH = (float) opts.outHeight / maxSize.y;
    
    // どちらかが2倍以上大きかったら縮小する
    if(scaleW > 2 && scaleW > 2) {
        // WidthとHeightで値が大きいほうを基準にする
        int scale = (int) Math.floor((scaleW > scaleH ? scaleH : scaleW));
        
        for (int i = 2; i <= scale; i *= 2) {
            opts.inSampleSize = i;
        }
    }
    
    opts.inJustDecodeBounds = false;
    
    return BitmapFactory.decodeByteArray(ba, 0, ba.length, opts);
    
}

HttpImageLoaderもささっと直しましょう。実際にどんな値を渡すべきかは、まぁこの段階で考えても仕方ないです。わかんないし。

public class HttpImageLoader extends AsyncTask<String, Void, Bitmap> {

    /**
     * {@link HttpImageLoader HttpImageLoader}#onPostExecuteで呼び出されるコールバック
     * @see HttpImageLoader
     */
    // 省略

    private final ImageCallback _callback;
    private final Object _key;
    private final Point _maxSize;
    
    public HttpImageLoader(Point maxSize, Object key, ImageCallback callback) {
        _maxSize = maxSize;
        _key = key;
        _callback = callback;
    }

    @Override
    protected Bitmap doInBackground(String... param) {
        return ImageUtil.getBitmapFromHttp(param[0], _maxSize);
    }

    // 省略
}

キャッシュ処理を実装する

LruCacheも当然使いますが、一旦読み込んだ画像をファイルとして内部にストレージしておく機能も一緒に実装します。

public final class ImageCache {

    /** LruCache */
    private static LruCache<String, Bitmap> _cache;
    /** キャッシュ用ディレクトリパス */
    private static String CACHE_DIR;

    /**
     * LruCacheの初期化及びキャッシュ用ディレクトリの決定
     * @param context MemoryClassの取得とキャッシュ用ディレクトリのパスを取得
     */
    public static void init(Context context) {

        // LruCache初期化
        if(_cache == null) {
            int memClass = ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass();
            int cacheSize = memClass * 1024 * 1024 / 8;

            _cache = new LruCache<String, Bitmap>(cacheSize) {
                @Override
                protected int sizeOf(String key, Bitmap value) {
                    return value.getRowBytes();
                }
            };
        }

        CACHE_DIR = context.getCacheDir() + "/";
    }

    /**
     * 取得
     * @param key
     * @return Bitmap
     */
    public static Bitmap get(String key) {
        if(key == null) return null;

        Bitmap b = _cache.get(key);

        // LruCacheになかったらキャッシュファイルをチェック
        if(b == null) {
            b = getFromCacheFile(key);
            // キャッシュファイルに存在した場合はLruCacheに登録する
            if(b != null) _cache.put(key, b);
        }

        return b;
    }

    /**
     * 登録
     * @param key
     * @param bitmap
     */
    public static void put(String key, Bitmap bitmap) {
        if(key == null || bitmap == null) return;
        _cache.put(key, bitmap);

        // キャッシュファイルに登録しておく(全上書き)
        putCacheFile(key, bitmap);
    }

    /**
     * キャッシュファイルからBitmapを生成
     * @param key
     * @return ファイルがあればそれをデコードしたもの。なければnull。
     */
    private static Bitmap getFromCacheFile(String key) {
        File cacheFile = new File(CACHE_DIR + getPath(key));
        if(!cacheFile.exists()) return null;

        FileInputStream fis = null;

        try {
            fis = new FileInputStream(cacheFile);
            return BitmapFactory.decodeStream(fis);
        } catch(IOException e) {
            e.printStackTrace();
            return null;
        } finally {
            if(fis != null) {
                try { fis.close(); } catch(IOException e) { e.printStackTrace(); }
            }
        }
    }

    /**
     * キャッシュファイル登録
     * @param key
     * @param bitmap
     */
    private static void putCacheFile(String key, Bitmap bitmap) {
        FileOutputStream fos = null;

        try {
            fos = new FileOutputStream(CACHE_DIR + getPath(key));
            bitmap.compress(CompressFormat.JPEG, 100, fos);
        } catch(IOException e) {
            e.printStackTrace();
        } finally {
            try { fos.close(); } catch(IOException e) { e.printStackTrace(); }
        }
    }

    private static String getPath(String key) {
        if(!key.contains("/")) return key;

        String[] keys = key.split("/");
        int length = keys.length;

        switch (length) {
        case 1:
            return keys[0];
        default:
            return keys[length - 2] + "_" + keys[length - 1];
        }
    }

}

ポイントはいくつかありますが、LruCacheの初期化に関しては以前の記事で書いたので割愛します。

Androidではキャッシュ用のフォルダが各アプリごとに用意されています。この記事が非常にわかりやすいので細かい説明はやはり割愛します。ただ、補足するべき点が一つだけあって、メソッド名に「External」とついているディレクトリを使用する場合は以下のパーミッションを追加しておく必要があります。

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

このパーミッションがないとメソッドを呼び出してもnullが返って来ます。いや、例外を投げてこいよ…。

後説明すべきことは、Bitmapを保存するときはBitmap#compressを使うのが一番楽、ってぐらいでしょうか。

改めて説明することかどうかアレなんですが、initメソッドを呼ばないと_cacheがずっとnullなのでどっかのタイミングで呼ばないと使えません。Applicationを継承したクラスがあるならそこのonCreate、なければ最初に呼び出されるActivityのonCreateでいいんじゃないでしょうか。シングルトンパターンを使ったり、isInitみたいな変数を用意すればもうちょっとスマートにいけますが、面倒臭がってやめてしまいました。(最悪)

それじゃあ先ほど作成したHttp経由で画像を引っ張ってくるクラス / メソッドにキャッシュ登録の処理を組み込みましょう。

public static Bitmap getBitmapFromHttp(String url, Point maxSize, boolean isSaveCache) {
    // 省略
    
    Bitmap bitmap = BitmapFactory.decodeByteArray(ba, 0, ba.length, opts);
    
    if(isSaveCahce) ImageCache.put(url, bitmap);
    
    return bitmap;
    
}

public class HttpImageLoader extends AsyncTask<String, Void, Bitmap> {

    /**
     * {@link HttpImageLoader HttpImageLoader}#onPostExecuteで呼び出されるコールバック
     * @see HttpImageLoader
     */
    public interface ImageCallback {
        /**
         * Bitmap読み込み後のコールバック
         * @param key {@link HttpImageLoader HttpImageLoader}に渡したKey。
         * @param bitmap {@link HttpImageLoader HttpImageLoader}で読み込まれたBitmap。nullの場合がある。
         */
        void call(Object key, Bitmap bitmap);
    }

    private final ImageCallback _callback;
    private final Object _key;
    private final Point _maxSize;
    private boolean _isSaveCache;
    
    public HttpImageLoader(Point maxSize, Object key, ImageCallback callback) {
        _maxSize = maxSize;
        _key = key;
        _callback = callback;
    }
    
    public void setSaveCache(boolean isSaveCache) {
        _isSaveCache = isSaveCache;
    }

    @Override
    protected Bitmap doInBackground(String... param) {
        return ImageUtil.getBitmapFromHttp(param[0], _maxSize, _isSaveCache);
    }

    // 省略
}

まとめ

コードが長いせいで全然文字数が足りません。

と言うわけで二分割します。次回で「URLを受け取って画像を表示するImageViewを作成する(ListViewっつーかArrayAdapter対応版)」を解説しようと思います。

参考