Android2015. 4. 13. 18:04


http://developer.android.com/training/displaying-bitmaps/index.html 의 요약임을 밝힌다.

앱사용성에 이슈를 발생시키지 않으면서 메모리제한까지 피할 수 있는 방법에 대해 정리한다. 피하지 않으면 그 유명한 OOM(Out of memory)에 손도 못쓰고 당하게 될것이다.

모바일디바이스는 시스템리소스가 제한적인데 16MB정도의 적은 메모리만이 애플리케이션에 허용되는데 반해 비트맵은 많은 메모리를 사용하게 된다. 그리고 안드로이드 UI는 종종 여러개의 비트맵을 동시에 사용하는 경우가 있다. 이런것들이 어쩔 수 없이 메모리 이슈를 발생시킨다.


아주큰 비트맵을 효율적으로 로딩하기

애플리케이션의 메모리 제한에 문제없이 비트맵을 로딩하는 방법.

이미지를 표시할 UI콤포넌트는 대체적으로 이미지보다 작게 표시된다. 이 때는 이미지 원본사이즈가 아니라 표시될 콤포넌트의 크기에 맞게 로드하는것이 이득이다.

BitmapFactory클래스가 비트맵을 디코딩하기 위한 여러 메소드(decodeByteArray(), decodeFile(), decodeResource())들을 제공한다. 이 메소드들은 비트맵을 디코딩할때 메모리할당을 시도하기 때문에 OOM이 아주 쉽게... 간단하게 발생될수 있다. 이 메소드들에는 BitmapFactory.Options 인자를 받을 수 있는데 inJustDecodeBounds를 true를 주어서 디코딩시 메모리 할당을 피하면서 이미지의 가로/세로 사이즈를 알아낼 수 있다. 이를 이용해서 메모리 할당을 조절할 수 있다.


BitmapFactory.Options options = new BitmapFactory.Options();

options.inJustDecodeBounds = true;

BitmapFactory.decodeResource(getResources(), R.id.myimage, options);

int imageHeight = options.outHeight;

int imageWidth = options.outWidth;

String imageType = options.outMimeType;


인자 스케일다운된 버전을 메모리에 로드해보자

이미지의 사이즈를 알기 때문에 이미지을 풀사이즈를 사용할지 사이즈를 다운할지 결정할 수 있다. BitmapFactory.Options의 inSampleSize를 true로 하며 디코더가 더작은 샘플로 디코딩하도록 할 수 있다.가령 2048x1536사이즈의 이미지를 inSampleSize 4를 주면 512x384사이즈로 로딩한다. 이는 12MB의 메모리 사용량을 0.75MB로 줄이는 효과가 있다.

다음 코드는 inSampleSize를 정하는 코드 예이다.

public static int calculateInSampleSize (BitmapFactory.Options options, int reqWidth, int reqHeight) {

    // Raw height and width of image

    final int height = options.outHeight;

    final int width = options.outWidth;

    int inSampleSize = 1;

    if (height > reqHeight || width > reqWidth) {

        final int halfHeight = height / 2;

        final int halfWidth = width / 2;

        // Calculate the largest inSampleSize value that is a power of 2 and keeps both

        // height and width larger than the requested height and width.

        while ((halfHeight / inSampleSize) > reqHeight

                && (halfWidth / inSampleSize) > reqWidth) {

            inSampleSize *= 2;

        }

    }

    return inSampleSize;

}


다음은 스케일을 적용한 비트맵로딩 예이다. inJustDecodeBounds를 통해 원본의 크기를 얻은 후 필요한 사이즈에 맞도록 스케일하여 비트맵을 생성하여 반환한다.

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
       
int reqWidth, int reqHeight) {

   
// First decode with inJustDecodeBounds=true to check dimensions
   
final BitmapFactory.Options options = new BitmapFactory.Options();
    options
.inJustDecodeBounds = true;
   
BitmapFactory.decodeResource(res, resId, options);

   
// Calculate inSampleSize
    options
.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

   
// Decode bitmap with inSampleSize set
    options
.inJustDecodeBounds = false;
   
return BitmapFactory.decodeResource(res, resId, options);
}


다음 메소드 호출은 100, 100사이즈에 맞도록 비트맵 이미지를 스케일하여 적용하는 예이다.

mImageView.setImageBitmap(
    decodeSampledBitmapFromResource
(getResources(), R.id.myimage, 100, 100));


비동기로 비트맵 로딩하기


BitmapFactory.decodeXXX 류의 메소드들은 main UI스레드에서 호출되면 안된다. 성능이슈를 발생시킨다. 버벅거리게 될거고 앱 사용자들은 모두 떠날것이다. 

AsyncTask로 백그라운드에서 UI스래드와 분리되어 로딩할 수 있으며 이 때 동시에 수행시 발생하는 이슈에 대해서도 살펴보겠다.

AsyncTask는 별도의 스레드로 어떤 작업을 하고 그 결과를 UI thread에 반환할 수 있도록 해준다. 이 클래스를 extends하고 메소드를 오버라이드하면 되는데 다음 에제를 보자.

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
   
private final WeakReference<ImageView> imageViewReference;
   
private int data = 0;

   
public BitmapWorkerTask(ImageView imageView) {
       
// Use a WeakReference to ensure the ImageView can be garbage collected
        imageViewReference
= new WeakReference<ImageView>(imageView);
   
}

   
// Decode image in background.
   
@Override
   
protected Bitmap doInBackground(Integer... params) {
        data
= params[0];
       
return decodeSampledBitmapFromResource(getResources(), data, 100, 100));
   
}

   
// Once complete, see if ImageView is still around and set bitmap.
   
@Override
   
protected void onPostExecute(Bitmap bitmap) {
       
if (imageViewReference != null && bitmap != null) {
           
final ImageView imageView = imageViewReference.get();
           
if (imageView != null) {
                imageView
.setImageBitmap(bitmap);
           
}
       
}
   
}
}

WeakReference로  ImageView를 처리하는 이유는 해당 메모리릭을 염두에 둔것인데 일반참조를 사용하게 되면 imageView의 참조가 유지되어 GC대상에서 제외될수 있기 때문이다.

** WeakReference로 참조하는 객체는 언제든 메모리에서 해제될수 있기 때문에 사용할 때 get()메소드로 참조를 얻어온 후 null인지 체크한 후 사용해야 한다.

다음 예제는 아주 단순하게 비트맵을 비동기로 새로운 테스크를 만들어서 실행하는 예이다.


public void loadBitmap(int resId, ImageView imageView) {
   
BitmapWorkerTask task = new BitmapWorkerTask(imageView);
    task
.execute(resId);
}


ListView나 GridView는 childview를 재사용하는 로직으로 구현되어 있다. 이런 경우 AsyncTask로 로딩하게 되면 로딩이 완료되었을 때 view가 사용자의 스크롤로 인해 다른 뷰가 될 수 있다.

이 문제를 해결하기 위해서 Worker task를 갖는 Drawable객체를 사용하게 된다. 여기서는 BitmapDrawable를 사용하여 task가 완료되면 imageView에 이미지를 표시하게 된다.


static class AsyncDrawable extends BitmapDrawable {
   
private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;

   
public AsyncDrawable(Resources res, Bitmap bitmap,
           
BitmapWorkerTask bitmapWorkerTask) {
       
super(res, bitmap);
        bitmapWorkerTaskReference
=
           
new WeakReference<BitmapWorkerTask>(bitmapWorkerTask);
   
}

   
public BitmapWorkerTask getBitmapWorkerTask() {
       
return bitmapWorkerTaskReference.get();
   
}
}


AsyncDrawable은 WorkerTask를 WeakReference로 갖고 있고 getter를 노출하고 있다. BitmapWorkerTask를 실행하기 전에 AsyncDrawable를 생성하여 ImageView에 바인드한다.

public void loadBitmap(int resId, ImageView imageView) {
   
if (cancelPotentialWork(resId, imageView)) {
       
final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
       
final AsyncDrawable asyncDrawable =
               
new AsyncDrawable(getResources(), mPlaceHolderBitmap, task);
        imageView
.setImageDrawable(asyncDrawable);
        task
.execute(resId);
   
}
}


cancelPotentialWork메소드는 다른 테스크가 이미  imageview에 연관되어 있다면 이전 task를 취소한다. 동일한 테스크로 시도하게 되면 아무것도 하지 않는다.


public static boolean cancelPotentialWork(int data, ImageView imageView) {
   
final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);

   
if (bitmapWorkerTask != null) {
       
final int bitmapData = bitmapWorkerTask.data;
       
// If bitmapData is not yet set or it differs from the new data
       
if (bitmapData == 0 || bitmapData != data) {
           
// Cancel previous task
            bitmapWorkerTask
.cancel(true);
       
} else {
           
// The same work is already in progress
           
return false;
       
}
   
}
   
// No task associated with the ImageView, or an existing task was cancelled
   
return true;
}


getBitmapWorkerTask는 ImageView에 연결되어 있는 WorkerTask를 반환한다.


private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
   
if (imageView != null) {
       
final Drawable drawable = imageView.getDrawable();
       
if (drawable instanceof AsyncDrawable) {
           
final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
           
return asyncDrawable.getBitmapWorkerTask();
       
}
   
}
   
return null;
}


마지막 작업은 onPostExecute에서 task가 취소되었는지 확인하고 ImageView가 유효한지 확인 후 표시한다.


class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
   
...

   
@Override
   
protected void onPostExecute(Bitmap bitmap) {
       
if (isCancelled()) {
            bitmap
= null;
       
}


       
if (imageViewReference != null && bitmap != null) {
           
final ImageView imageView = imageViewReference.get();
           
final BitmapWorkerTask bitmapWorkerTask =
                    getBitmapWorkerTask
(imageView);

           
if (this == bitmapWorkerTask && imageView != null) {
                imageView
.setImageBitmap(bitmap);
           
}
       
}
   
}
}


ListView나 GridView에 아주 적합한 솔루션이다. 또는 child view를 재활용하는 뷰컴포넌트들에서 사용할 수 있는 패턴이다. GridView의 경우 getView()메소드가 테스크와 뷰를 연계해주는 어댑터역할을 한다.


비트맵 캐싱하여 사용하기

아주 많은 이미지를 로딩해야 하며 빈번하게 사용되는 경우라면 캐싱을 고려해야 한다. 메모리캐싱과 파일캐싱이 해당되겠다.

메모리 캐시

메모리를 좀더 사용되겠지만 속도면에서 많은 이득이 있다. LruCache가 비트맵을 캐시하는데 아주 적당하다. LruCache가 사용되기 전에는 SoftReference와 WeakReference를 이용해서 캐싱을 구현하였었는데 이는 Android 2.3의 gc에서 비효율을 야기한다고 한다. 머 무튼 LruCache를 사용하면 나중에 추가한 오브젝트는 LinkedHashMap으로 참조되고 최대개수가 넘어가면 오래된걸 제거하도록 되어 있다.

LruCache의 적절한 사이즈를 결졍하기 위해서 고려해야 할것들이 여러가지가 있겠으나 정해진 크기나 공식은 없다. 전적으로 당신의 분석에 따라서 알아서 결정해야한다. 캐시크기가 크면 메모리가 부족해질거고 캐시크기가 작으면 앱에 로드가 걸릴것이니 적당한 선에서 합의를 해야 함. 참고할만한 가이드는 아래 참고하고

  • How memory intensive is the rest of your activity and/or application?
  • How many images will be on-screen at once? How many need to be available ready to come on-screen?
  • What is the screen size and density of the device? An extra high density screen (xhdpi) device like Galaxy Nexus will need a larger cache to hold the same number of images in memory compared to a device likeNexus S (hdpi).
  • What dimensions and configuration are the bitmaps and therefore how much memory will each take up?
  • How frequently will the images be accessed? Will some be accessed more frequently than others? If so, perhaps you may want to keep certain items always in memory or even have multiple LruCache objects for different groups of bitmaps.
  • Can you balance quality against quantity? Sometimes it can be more useful to store a larger number of lower quality bitmaps, potentially loading a higher quality version in another background task.

다음은 LruCache로 구현한 샘플임


private LruCache<String, Bitmap> mMemoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
   
...
   
// Get max available VM memory, exceeding this amount will throw an
   
// OutOfMemory exception. Stored in kilobytes as LruCache takes an
   
// int in its constructor.
   
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

   
// Use 1/8th of the available memory for this memory cache.
   
final int cacheSize = maxMemory / 8;

    mMemoryCache
= new LruCache<String, Bitmap>(cacheSize) {
       
@Override
       
protected int sizeOf(String key, Bitmap bitmap) {
           
// The cache size will be measured in kilobytes rather than
           
// number of items.
           
return bitmap.getByteCount() / 1024;
       
}
   
};
   
...
}

public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
   
if (getBitmapFromMemCache(key) == null) {
        mMemoryCache
.put(key, bitmap);
   
}
}

public Bitmap getBitmapFromMemCache(String key) {
   
return mMemoryCache.get(key);
}


비트맵을 로드하기전에 캐시에 비트맵이 있는지 확인하고 로드한다.

public void loadBitmap(int resId, ImageView imageView) {
   
final String imageKey = String.valueOf(resId);

   
final Bitmap bitmap = getBitmapFromMemCache(imageKey);
   
if (bitmap != null) {
        mImageView
.setImageBitmap(bitmap);
   
} else {
        mImageView
.setImageResource(R.drawable.image_placeholder);
       
BitmapWorkerTask task = new BitmapWorkerTask(mImageView);
        task
.execute(resId);
   
}
}


BitmapWorkerTask는 로드가 완료되면 메모리캐시에 추가한다.


class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
   
...
   
// Decode image in background.
   
@Override
   
protected Bitmap doInBackground(Integer... params) {
       
final Bitmap bitmap = decodeSampledBitmapFromResource(
                getResources
(), params[0], 100, 100));
        addBitmapToMemoryCache
(String.valueOf(params[0]), bitmap);
       
return bitmap;
   
}
   
...
}


디스크 캐시 이용하기

메모리캐시만으로는 부족하다. 너무 많이 사용하는것 이외에도 전화가 온다던가 하여 앱이 백그라운드상태가 되었을 때 앱은 종료가 될 수 있고 그럴경우 캐시도 제거될것이다. 앱이 다시 로드되면 또다시 이미지를 로드해야 할것이다. 이를 개선하기 위해 디스크에 캐시를 하고 디스크에 캐시가 있으면 디스크에서 읽어들이는 방법이 있다. 메모리캐시보다는 느리지만 네트워크보다는 빠르며 메모리캐시와 함께 사용해도 될것이다.

** 갤러리앱에서 관리하는 이미지들을 읽어들일때는 ContentProvider를 이용하면 더 빠르게 접근이 가능하다.

안드로이드 소스에서 사용중인 DiskLruCache를 사용하는 샘플코드이다.


private DiskLruCache mDiskLruCache;
private final Object mDiskCacheLock = new Object();
private boolean mDiskCacheStarting = true;
private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
private static final String DISK_CACHE_SUBDIR = "thumbnails";

@Override
protected void onCreate(Bundle savedInstanceState) {
   
...
   
// Initialize memory cache
   
...
   
// Initialize disk cache on background thread
   
File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);
   
new InitDiskCacheTask().execute(cacheDir);
   
...
}

class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
   
@Override
   
protected Void doInBackground(File... params) {
       
synchronized (mDiskCacheLock) {
           
File cacheDir = params[0];
            mDiskLruCache
= DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
            mDiskCacheStarting
= false; // Finished initialization
            mDiskCacheLock
.notifyAll(); // Wake any waiting threads
       
}
       
return null;
   
}
}

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
   
...
   
// Decode image in background.
   
@Override
   
protected Bitmap doInBackground(Integer... params) {
       
final String imageKey = String.valueOf(params[0]);

       
// Check disk cache in background thread
       
Bitmap bitmap = getBitmapFromDiskCache(imageKey);

       
if (bitmap == null) { // Not found in disk cache
           
// Process as normal
           
final Bitmap bitmap = decodeSampledBitmapFromResource(
                    getResources
(), params[0], 100, 100));
       
}

       
// Add final bitmap to caches
        addBitmapToCache
(imageKey, bitmap);

       
return bitmap;
   
}
   
...
}

public void addBitmapToCache(String key, Bitmap bitmap) {
   
// Add to memory cache as before
   
if (getBitmapFromMemCache(key) == null) {
        mMemoryCache
.put(key, bitmap);
   
}

   
// Also add to disk cache
   
synchronized (mDiskCacheLock) {
       
if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {
            mDiskLruCache
.put(key, bitmap);
       
}
   
}
}

public Bitmap getBitmapFromDiskCache(String key) {
   
synchronized (mDiskCacheLock) {
       
// Wait while disk cache is started from background thread
       
while (mDiskCacheStarting) {
           
try {
                mDiskCacheLock
.wait();
           
} catch (InterruptedException e) {}
       
}
       
if (mDiskLruCache != null) {
           
return mDiskLruCache.get(key);
       
}
   
}
   
return null;
}

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
public static File getDiskCacheDir(Context context, String uniqueName) {
   
// Check if media is mounted or storage is built-in, if so, try and use external cache dir
   
// otherwise use internal cache dir
   
final String cachePath =
           
Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                   
!isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                            context
.getCacheDir().getPath();

   
return new File(cachePath + File.separator + uniqueName);
}


** 위 예제에서 Lock 객체는 디스크 작업중에 캐시에 접근하는것을 막아준다.

메모리캐시가 UI thread에서 확인되는 반면 디스크캐시는 백그라운드에서 확인한다. 디스크작업은 절대 UI thread에서 구동되면 안된다. 이미지프로세싱이 끝나면 비트맵은 메모리와 디스크캐시 2군데 추가된다.


Configuration change에 대한 처리


런타입에 설정이 변경되게 되면 가령 스크린오리엔테이션 안드로이드는 액티비티를 제거하였다가 재시작한다. 이 때 이미지처리를 간결하게 하여 사용성을 해쳐서는 안될것이다.


Fragment를 사용하는 경우 Fragment에 setRetainInstance(true)를 호출함으로써 메모리캐시의 참조를 통해 캐시를 유지하고 참조할 수 있다.


private LruCache<String, Bitmap> mMemoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
   
...
   
RetainFragment retainFragment =
           
RetainFragment.findOrCreateRetainFragment(getFragmentManager());
    mMemoryCache
= retainFragment.mRetainedCache;
   
if (mMemoryCache == null) {
        mMemoryCache
= new LruCache<String, Bitmap>(cacheSize) {
           
... // Initialize cache here as usual
       
}
        retainFragment
.mRetainedCache = mMemoryCache;
   
}
   
...
}

class RetainFragment extends Fragment {
   
private static final String TAG = "RetainFragment";
   
public LruCache<String, Bitmap> mRetainedCache;

   
public RetainFragment() {}

   
public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) {
       
RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG);
       
if (fragment == null) {
            fragment
= new RetainFragment();
            fm
.beginTransaction().add(fragment, TAG).commit();
       
}
       
return fragment;
   
}

   
@Override
   
public void onCreate(Bundle savedInstanceState) {
       
super.onCreate(savedInstanceState);
       
setRetainInstance(true);
   
}
}





Posted by 삼스