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

HTTP経由で画像を取得、ついでにサイズを自動で補正するメソッドを作成しました。

public class ImageUtil {
    public static Bitmap getBitmapFromHttp(String url, Point maxSize, boolean isSaveCache) {
        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();
        }
        
        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;
        
        Bitmap bitmap = BitmapFactory.decodeByteArray(ba, 0, ba.length, opts);
    
        if(isSaveCahce) ImageCache.put(url, bitmap);
        
        return bitmap;
        
    }
}

そのままじゃ呼べないのでそれをラップしたAsyncTaskを作成しました。

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);
    }

    @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);
        }
    }
}

画像をキャッシュするための仕組みも作成しました。

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];
        }
    }

}

URLを受け取って画像を表示するImageViewを作成する(ListViewっつーかArrayAdapter対応版)

実際にURLを受け取り、キャッシュをチェックしたりリクエストを飛ばしたりするImageViewを作成します。

public class UrlImageView extends ImageView {

    /** width / height */
    private Point _maxSize;

    /**
     * UrlImageView
     * @param context
     * @param width
     * @param height
     */
    public UrlImageView(Context context, int width, int height) {
        super(context);
        _maxSize = new Point();
        _maxSize.x = width;
        _maxSize.y = height;
    }

    /**
     * UrlImageView
     * @param context
     * @param attrs
     */
    public UrlImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        int[] attrsArray = new int[] { android.R.attr.layout_width, android.R.attr.layout_height };
        TypedArray t = context.obtainStyledAttributes(attrs, attrsArray);
        _maxSize = new Point();
        try {
            _maxSize.x = t.getDimensionPixelSize(0, ViewGroup.LayoutParams.WRAP_CONTENT);
            _maxSize.y = t.getDimensionPixelSize(1, ViewGroup.LayoutParams.WRAP_CONTENT);
        } catch(UnsupportedOperationException e) {
            _maxSize = LayoutUtil.getDisplaySize(context);
        }
        t.recycle();
    }

    /**
     * UrlImageView
     * @param context
     * @param attrs
     * @param defStyle
     */
    public UrlImageView (Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        int[] attrsArray = new int[] { android.R.attr.layout_width, android.R.attr.layout_height };
        TypedArray t = context.obtainStyledAttributes(attrs, attrsArray, defStyle);
        _maxSize = new Point();
        try {
            _maxSize.x = t.getDimensionPixelSize(0, ViewGroup.LayoutParams.WRAP_CONTENT);
            _maxSize.y = t.getDimensionPixelSize(1, ViewGroup.LayoutParams.WRAP_CONTENT);
        } catch(UnsupportedOperationException e) {
            _maxSize = LayoutUtil.getDisplaySize(context);
        }
        t.recycle();
    }

    /**
     * <p>URLから画像を取得</p>
     * <p>キャッシュにヒットしなかった場合、実際に画像をセットするのはcallbackの役割となる。</p>
     * @param url
     * @param key callbackで対象のViewを特定するためのKey。このメソッドを呼び出すと{@link View#setTag(Object) setTag}にてセットされる。
     * @param forceLoad trueの場合はキャッシュチェックを行わない。
     * @param isSaveCache trueの場合はキャッシュに保存する。
     * @param callback 画像取得後のコールバック。このメソッドに渡したkeyとHttpImageLoaderで読み込んだBitmapが渡されてくる。
     */
    public synchronized void setUrlImage(final String url, final Object key, final boolean forceLoad
            , final boolean isSaveCache, final ImageCallback callback) {
        if(key == null) throw new IllegalArgumentException(String.format("keyがnullです。 URL:%s", url));

        setTag(key);

        // cache check
        if(!forceLoad) {
            Bitmap bitmap = ImageCache.get(url);

            if(bitmap != null
                && _maxSize.x < (bitmap.getWidth() * 2)
                && _maxSize.y < (bitmap.getHeight() * 2) ) {
                setImageBitmap(bitmap);
                return;
            }
        }

        setImageBitmap(null);

        HttpImageLoader loader = new HttpImageLoader(key, _maxSize, callback);
        loader.setSaveCache(isSaveCache);
        loader.execute(url);
    }

    /**
     * 指定された最大サイズの取得
     * @return maxSize
     */
    public Point getMaxSize() {
        return _maxSize;
    }

    /**
     * 最大サイズの指定
     * @param maxSize
     */
    public void setMaxSize(Point maxSize) {
        _maxSize = maxSize;
    }

}

最適化に使用するPointですが、基本的にはコンストラクタで生成します。

XMLに記述されたViewをinflateすると、AttributeSetが一緒にくる方のコンストラクタが呼ばれます。

このAttributeSetの中にはandroid:layout_widthやandroid:layout_heightなどの値が入っています。

public UrlImageView(Context context, AttributeSet attrs) {
    super(context, attrs);
    int[] attrsArray = new int[] { android.R.attr.layout_width, android.R.attr.layout_height };
    TypedArray t = context.obtainStyledAttributes(attrs, attrsArray);
    _maxSize = new Point();
    try {
        _maxSize.x = t.getDimensionPixelSize(0, ViewGroup.LayoutParams.WRAP_CONTENT);
        _maxSize.y = t.getDimensionPixelSize(1, ViewGroup.LayoutParams.WRAP_CONTENT);
    } catch(UnsupportedOperationException e) {
        _maxSize = LayoutUtil.getDisplaySize(context);
    }
    t.recycle();
}

AttributeSetとAttributeのIDの配列をContext#obtainStyledAttributesに渡すと、TypedArrayと言うクラスが返ってきます。これにはXMLをパースした結果が入っています。

TypedArray#getDimensionPixelSizeを使用すれば、layout_width / layout_heightに記述された値をピクセル単位に変換して取得することができます。が、しかし、getDimensionPixelSizeでwrap_content / match_parentの値を取得しようとするとUnsupportedOperationExceptionが発生します。

そうなるともうお手上げなので、端末のサイズをそのまま放り込んでおきます。OnGlobalLayoutListenerを上手く使えばなんとかなりそうな気もしますが、テストが面倒臭いそこまですることもないかなーと。

端末のサイズよりでかくなるこたぁないだろう、と言う思いからこんな風にしましたが、ScrollViewとか使ってたらそれ以上のサイズは普通にありそうですね。

まぁ、そんな場合はPointを渡さないですむメソッドとか作っておけばいいんじゃないですか?(丸投げ)

ArrayAdapterでUrlImageViewを使うときの問題点

適当なサンプルを作ってみますか。

public class TestData {
    private String url;
    
    public String getUrl() {
        return this.url;
    }
    
    public void setUrl(String url) {
        this.url = url;
    }
}

public class TestAdapter extends ArrayAdapter<TestData> {
    
    static class ViewHolder {
        UrlImageView imgIcon;
        
        public ViewHolder(View v, LayoutInflater inflater) {
            v = inflater.inflate(R.layout.test);
            this.imgIcon = (UrlImageView) v.findViewById(R.id.imgIcon);
        }
        
    }
    
    private LayoutInflater _layoutInflater;
    
    public TestAdapter(Context context, List<TestData> datas) {
        super(context, 0, datas);
        _layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }
    
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        
        View v = convertView;
        ViewHolder vh = null;
        
        if(v == null) {
            vh = new ViewHolder(v, _layoutInflater);
            v.setTag(vh);
        } else {
            vh = (ViewHolder) v.getTag();
        }
        
        TestData data = getItem(position);
        String iconUrl = data.getUrl();
        
        // vh.imgIconに画像をセットする
        final UrlImageView imgIcon = vh.imgIcon;
        imgIcon.setUrlImage(iconUrl, iconUrl, false, true , new ImageCallback() {
            @Override
            public void call(Object key, Bitmap bitmap) {
                if(bitmap != null) {
                    imgIcon.setImageBitmap(bitmap);
                }
            }
        });
        
        return v;
    }
    
}

実際にこの手の仕組みを実装したことがある人ならすぐにわかると思いますが、これは思った通りの結果になりません。

と言うのも、セットするiconUrlがガンガン書き換わってしまうからです。

getViewの引数であるconvertViewには画面上に表示しきれなくなったViewが入っています。convertViewがnullでなければViewHolderの方は使いまわしますし、nullだったら新しくインスタンスを作ります。何行も表示されているように見えて、実は画面上に表示されているViewを何度も何度も繰り返し使い回しているだけです。

つまり、String iconUrlはgetItem(position)から取得しているので実際の行数と一致しますが、vh.imgIconはpositionと一対一対応ではない、と言うことになります。

何かの画像を非同期で読み込み中にがーっとスクロールしたりなんかすると、ImageCallbackにはどのタイミングで投げたかわからないURLのBitmapが返ってきてしまいます。そうなれば当然現在表示しているItemのiconUrlとの同期が取れていない、なんて風に見えてしまいます。

View#findViewWithTagを使ってimgIconを特定する

じゃあどうするのって話なんですが、ずっと説明してこなかったImageCallbackのObject keyの出番です。

まずはUrlImageViewであんまり細かく説明しなかったsetUrlImageメソッドの中身を見てみましょう。

public synchronized void setUrlImage(final String url, final Object key, final boolean forceLoad
        , final boolean isSaveCache, final ImageCallback callback) {
    if(key == null) throw new IllegalArgumentException(String.format("keyがnullです。 URL:%s", url));

    setTag(key);

    // cache check
    if(!forceLoad) {
        Bitmap bitmap = ImageCache.get(url);

        if(bitmap != null
            && _maxSize.x < (bitmap.getWidth() * 2)
            && _maxSize.y < (bitmap.getHeight() * 2) ) {
            setImageBitmap(bitmap);
            return;
        }
    }

    setImageBitmap(null);

    HttpImageLoader loader = new HttpImageLoader(key, _maxSize, callback);
    loader.setSaveCache(isSaveCache);
    loader.execute(url);
}

setTagはView#setTagです。ViewHolderの保存にも使ってますね。

ここでTagをセットしておくと、後々View#findViewWithTagと言うメソッドでそのTagからViewを特定することができます。

HttpImageLoaderにもkey(Tag)を渡していますが、何の操作もしないでそのままコールバックに渡しています。

つまり、Object keyは「HttpImageLoaderが呼び出された瞬間のキー情報」なわけです。ImageCallbackではこれを使ってBitmapを使う対象を再度特定してもいいですし、しなくてもいいです。ArrayAdapterだとしなきゃいけないんですが。

具体的にどうするのか。getViewをこんな風に書き換えます。

@Override
public View getView(int position, View convertView, final ViewGroup parent) {
    
    View v = convertView;
    ViewHolder vh = null;
    
    if(v == null) {
        vh = new ViewHolder(v, _layoutInflater);
        v.setTag(vh);
    } else {
        vh = (ViewHolder) v.getTag();
    }
    
    TestData data = getItem(position);
    String iconUrl = data.getUrl();
    
    // vh.imgIconに画像をセットする
    vh.imgIcon.setUrlImage(iconUrl, iconUrl.hashCode(), false, true , new ImageCallback() {
        @Override
        public void call(Object key, Bitmap bitmap) {
            if(bitmap == null) return;
            View tmp = parent.findViewWithTag(key);
            if(tmp != null && tmp instanceof UrlImageView) {
                ((UrlImageView) tmp).setImageBitmap(bitmap);
            }
        }
    });
    
    return v;
}

getViewメソッドのViewGroup parentは「getViewで取得したViewを格納しているViewGroup」です。まぁおおよそ「現在画面上にリストとして表示されている全View」ぐらいの認識を持っておけば十分でしょう。こいつをベースにfindViewWithTagしてやれば本当にセットしなければならないUrlImageViewを再特定できるわけです。

もちろんViewGroup parentの中身もスクロールに応じてガンガン書き換わるので、findViewWithTagしてもそのTagを持っているUrlImageViewがいない可能性があります。そうだとしてもView tmpがnullになるだけですし、parentにないってことは画面上にもないと考えていいです。よって、何もしなければそれでOKです。

注意する点としては、findViewWithTagで取得できるViewが複数存在しても、どちらか一方のViewしか返してくれないと言うことです。それは仕方ないかな、と思うんですが。

今回のサンプルケースで言えば、URLは全部違う値じゃないといけません。同じURLが同時に画面内に存在すると、どっちか一つのImageがnullになったままになります。

また、一つのView内に複数のUrlImageViewを持たせた場合でも、同じkeyを使うと同様の事象が発生します。

とは言え、わざわざObjectで渡せるようにしているので、ちょっと頭を使えばどうとでもなるはずです。また、キャッシュにヒットしたならわざわざコールバックを呼ばずにそのままセットするようにもしています。(流石に何度も連続でfindViewWithTagを使うと結構重いってのが一番の理由ですが)

まとめ

と言うわけで、これらを駆使することでもうネット上の画像リソースを扱うのも怖くありません。

なんでこんな簡単なことをするためだけにここまで用意しなきゃいけないのかとか、もう、なんででしょうね?

参考