비트맵 캐싱하기

하나의 비트맵을 유저 인터페이스(UI)에 불러오는 것은 복잡하지 않으나, 많은 수의 이미지를 한 번에 불러와야 할 때 이는 많이 복잡한 문제가 됩니다. 많은 경우에 (예를 들면 ListView, GridView, ViewPager와 같은 요소들) 곧 스크롤될 이미지들과 합쳐 현재 스크린에 올라올 또는 올라올 이미지의 수는 가히 무제한이라 할만 합니다.

메모리 사용은 하위 뷰들이 화면에서 사라짐에 따라 재활용되는 것을 통해 계속 줄어들게 됩니다. 가비지 콜렉터 또한 더 이상 사용하지 않는다고 가정하여 불러진 이미지를 처리학 됩니다. 이것은 전반적으로 괜찮습니다. 그러나 연속적이고 빠른 UI 로딩을 위해서는 이미지들을 다시 화면에 올릴 떄마다 다시 이미지를 불러오고 처리하는 과정을 피해야 합니다. 메모리와 보조저장장치의 캐시(cache)는 빠르게 이미지를 불러옴으로써 이러한 작업에 도움을 줄 수 있습니다.

메모리 캐시 사용하기

메모리 캐시는 어느 매모리를 차지하는 대신에 빠른 비트맵으로의 접근을 제공합니다. LruCache 클래스는 비트맵을 캐싱하는데 상당히 적합합니다.(API 레벨 4이하의 경우 Support Library에서 사용가능합니다) 최근 참조된 객체를 강한 참조(Strong reference)인 LinkedHashMap에 넣고, 그리고 메모리가 할당된 크기를 넘어서기 전에 가장 덜 사용된 객체를 처리합니다.

참고: 과거 유명한 메모리 캐시 적용법은 약한 참조 ( SoftReference 또는 WeakReference)를 사용하는 것이었으나, 이는 추천되지 않습니다. API 레벨 9 (안드로이드 2.3)부터 가비지 콜렉터는 더 공격적으로 약한 참조를 처리합니다.즉 약한 참조가 더이상 효력을 없게 만듭니다. 또한 API 레벨11 (안드로이드 3.0)이전의 경우 비트맵 예측하기 어려운 방식으로 데이터를 순수 메모리에 넣음으로써, 잠재적으로 쉽게 메모리 한계를 넘겨 오류를 야기합니다.

  • 적정 크기의LruCache 를 정하기 위해서, 여러 요인들이 고려되어야 합니다. 예를들면:

  • 애플리케이션 또는 액티비티가 나머지 메모리를 얼마나 사용할 것인가?

  • 얼만아 많은 이미지가 한 번에 화면에 올라갈 것인가?

  • 기기의 화면 사이즈와 밀도는 어느정도인가? 갤럭시 넥서스와 같은 Extra High Density Screen(xhdpi)는 넥서스 S (hdpi)와 같은 기기와 비교했을 때 같은 수의 이미지를 저장하기 위해 더 많은 메모리가 필요합니다.

  • 비트맵의 사이즈와 구성은 어떠한가, 그래서 얼마나 많은 메모리가 각각 소요될 것인가?

  • 얼마나 자주 이미지에 접근할 것인가? 다른 것들보다 더 빈번히 접근하는가? 만약 그렇다면, 여러 비트맵 그룹의 LruCache 에 저장하거나 항상 메모리에 올려 놓고 싶을 것이다.

  • 양과 질 사이의 균형을 맞출 수 있는가? 종종 많은 수의 낮은 질의 비트맵을 저장하는 것이 유용하다. 잠재적으로 고화질의 이미지를 불러오는 것은 또다른 백그라운드 작업이다.

모든 애플리케이션에 적합한 특정 사이즈 또는 그를 얻는 수식은 없다.오로지 스스로의 메모리 사용에 대한 분석에 따라 적합한 해결책을 낼 수 밖에 없다.너무 작은 캐시는 특별한 이익 없이 추가적인 과부하만 야기하며, 너무 많은 캐시는 메모리 부족 오류를 만들거나 다른 구성요소에게 너무 작은 가용 메모리 만을 남기게 된다.

아래는 LruCache 를 설정하는 예제 소스코드이다.

private LruCache<String, Bitmap> mMemoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...

    // 최대의 가용 메모리양을 구한다. 이를 초과하는 메모리 사용은 메모리 부족 오류를 야기하게 된다.
    // 킬로바이트 단위의 정수 값으로 LruCache를 설정한다. 
    final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

    // 최대 가용 메모리의 1/8에 해당하는 만큼 캐시사이즈를 할당한다
    final int cacheSize = maxMemory / 8;

    mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap bitmap) {
            // 아이템 수가 아닌 킬로바이트 단위로 캐시 사이즈를 측정합니다.
            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);
}

위 예제에서 가용 메모리의 1/8이 캐시로 할당하였습니다. 일반적인 hdpi급 기기에서 이는 최소 4MB정도의 캐시 입니다. 전체 화면의 GridView 는800X400정도의 이미지로 채워지는데 이는 대략 1.5MB입니다.그러므로 이 경우 대략 2.5장의 이미지가 메모리에 캐시로 저장됩니다.

이미지 뷰에 비트맵을 올릴 때 LruCache 가 가장 먼저 확인됩니다. 만약에 발견한다면 이것을 즉각적으로 사용하여 이미지 뷰에 올립니다. 그렇지 않는다면 백그라운드 쓰레드를 통해 이미지를 생성합니다.

아래는 이를 적용한 소스코드입니다.

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는 'UI 쓰레드 밖에서 비트맵 파일 가공하기' 에서 작성된 소스코드입니다.

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

디스크 캐시 이용하기

메모리 캐시는 최근 보여진 비트맵들에 대한 빠른 접근에 있어서 사용도가 좋습니다. 그러나 이에만 의존해서는 안됩니다. GridView처럼 더 큰 데이터 셋을 다루는 경우 쉽게 가용 메모리캐시를 모두 소모하게 됩니다. 전화가 오는 경우라던가 다른 일을 처리함으로써 애플리케이션은 방해받을 수 있고 백그라운드에서 메모리 캐시를 부술 수 있습니다. 이로인해 사용자가 다시 애플리케이션을 사용할 때 (resume) 애플리케이션은 각각의 이미지를 모두 다시 처리해야 할 수 있습니다.

디스크 캐시는 이러한 경우에 사용될 수 있습니다. 더이상 메모리 캐시 사용이 불가 할 때 처리된 비트맵을 유지하며, 불러오는시간을 줄일 수 있습니다. 물론 메모리에서 불러오는 것보다 느리며, 디스크에서 불러오는 속도가 예상하기 어렵기에 백그라운드 쓰레드에서 처리되어야 합니다.

참고: 더 자주 이미지에 접근한다면, contetnProvider가 더 적합한 이미지 캐시 저장장소 일 수 있습니다. 예를 들면 이미지 갤러리ㅢ 경우가 그렇습니다.

contetnProvider API 문서를 살펴보면, 다른 애플리케이션과 데이터를 나눌 경우에만 사용하는 것을 권장합니다. 원본 문서에서의 more frequently access는 다른 어플리케이션에서의 접속을 내포하지 않을까 합니다.

아래의 예제코드는 DiskLruCache를 사용합니다. DIskLruCache의 코드는 Android source 확인할 수 있습니다. 메모리 캐시에 더하여 디스크 캐시를 적용한 코드입니다.

//DiskLruCache코드를 얻기 위해 원본 문서에서 제공되는 Android source 따라 들어가나오는 소스코드를 실제 안드로이드 스튜디오에서 작동해보니 charsets라는 라이브러리 문제로 정상작동하지 않았습니다. 조금 더 찾아보다 새로운 DiskLruCache를 Android source에서 찾을 수 있었으나 그 형식에서 조금 차이가 납니다. 특히 단위를 byte를 사용하며 , mDiskCache를 open()할 때 인자로서 2개 (File, Size)가 아니라, 4개 (File, Version, fileSize, MaxSize)를 입력해야합니다.

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) {
    ...
    // 앞서 작성한 메모리 캐시 코드가 들어갑니다. 
    ...
    // 백그라운드 쓰레드에서 디스크 캐시를 초기화 합니다.
    //Context와 고유 이름을 파라미터로 넘깁니다. 
    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; // 초기화가 완료됩니다. 
            mDiskCacheLock.notifyAll(); // 완료되었음으로 작업으로 연결된 쓰레드에게 알립니다. 
        }
        return null;
    }
}

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

    @Override
    protected Bitmap doInBackground(Integer... params) {
        final String imageKey = String.valueOf(params[0]);

        // 디스크 캐시에서 필요한 비트맵을 가져옵니다.
        Bitmap bitmap = getBitmapFromDiskCache(imageKey);

        if (bitmap == null) { //캐시에 없을경우 일반적으로 디코딩 하는 방법을 사용합니다. 
            final Bitmap bitmap = decodeSampledBitmapFromResource(
                    getResources(), params[0], 100, 100));
        }

        // 디코딩된 비트맵은 해당 키와 함께 캐시처리 됩니다. 
        addBitmapToCache(imageKey, bitmap);

        return bitmap;
    }
    ...
}

public void addBitmapToCache(String key, Bitmap bitmap) {
    // 메모리캐시에 저장합니다.
    if (getBitmapFromMemCache(key) == null) {
        mMemoryCache.put(key, bitmap);
    }

    // 디스크 캐시에도 저장합니다. 
    synchronized (mDiskCacheLock) {//백그라운드에서 디스크 캐시가 시행되었을 때를 기다립니다. 
        if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {
            mDiskLruCache.put(key, bitmap);
        }
    }
}

public Bitmap getBitmapFromDiskCache(String key) {
    synchronized (mDiskCacheLock) {
        //백그라운드에서 디스크 캐시가 시행되길 기다립니다.
        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) {
    // 사용을 해보고 외부저장소의 캐시가 작동하지 않는다면 내부 저장솔ㄹ 사용하십시오. 
    final String cachePath =
            Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                    !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                            context.getCacheDir().getPath();

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

참고: 디시크 캐시를 초기화하는 것은 디스크 작업을 진행함으로 메인 쓰레드가 아닌 곳에서(백그라운드 쓰레드) 진행되어야 합니다. 그러나 그럴경우 초기화 이전에 디스크 캐시에 접근하는 경우가 생겨납니다. 이를 처리하기 위해 위의 적용에서 , lock객체를 (mDiskCacheLock)통해 캐시가 초기화되기 전에 접근하는 것을 방지합니다.

메모리 캐시가 UI 쓰레드에서 동작하지만, 디스크 캐시는 백그라운드 쓰레드에서 동작합니다. 디스크 작업은 절대 UI 쓰레드(메인 쓰레드)에서 동작해서는 안됩니다. 이미지 처리가 완료되면, 그 마지막 비트맵이 메모리와 캐시에 미래 사용을 위해 저장됩니다.

구성(Configuration)변화 다루기

화면 방향의 변화처럼 실행 상황(Runtime configuration)의 변화는 안드로이드가 시행되고 있는 액티비티를 파괴하고 새로운 구성으로 재 실행하도록 합니다. (이와 관련한 더욱 자세한 행동지침은, 실행 상황 변화 다루기(Handling Runtime Changes)에서 확인하십시오) 구성, 상황의 변화가 발생할 때, 사용자들의 부드럽고 빠른 경험을 위해서 모든 이미지들의 재처리를 기피하고 싶을 겁니다.

운이 좋게도, 메모리 캐시 사용하기(Use Memory Cache)에서 생성된 비트맵 메모리가 있습니다. 이 캐시는 setRetainInstance(true))를 통해 보존된 프래그먼트(fragment)를 사용하는 액티비티의 인스턴스를 통해 전달 될 수 있습니다. 액티비티가 재 생성되고 나서, 이 얻어진 프래그먼트는 존재하던 캐시와 연결되고 캐시 객체에 접근할 수 있게됩니다. 이로 다시 이미지 뷰 객체에 구성될 때 더 빠르게 넣어질 수 있습니다.

아래는 구성변화에 따라 프래그먼트를 사용하여 LruCache를 유지하는 예제입니다.

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) {
            ... // 시가 없을 경우 새로 형성합니다.
        }
        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);
        //화면 구성변화시 재생성될지 여부를 결정합니다. 프래그먼트의 백스텍에 추가 되지 않은 경우에만 사용가능합니다. 
        //그러지 않을 경우 생명주기에 변화가 생깁니다.
    }
}

이 코드를 실험해 보기 위해 기기를 각 프그먼트 유지를 한 경우와 하지 않은 경우로 나누어 회전해 보십시오. 아주 족음의 또는 느껴지 지 않는 수준의 진행이 프래그먼트를 유지했을 떄 느껴질 것입니다. 만약 메모리캐시에 이미지가 없다면 디스크에 있기를 바랍니다 그렇지 않다면 일반적인 수준으로 작업즐이 진행 됩니다조금

results matching ""

    No results matching ""