비트맵 캐싱

참고: 대부분의 경우 Glide 라이브러리를 사용하여 앱에서 비트맵을 가져오고 디코딩하고 표시하는 것을 추천합니다. Glide는 이러한 작업을 비롯하여 Android에서 비트맵과 기타 이미지를 사용하는 다른 관련 작업을 처리할 때 대부분의 복잡성을 추상화합니다. Glide 사용 및 다운로드에 관한 자세한 내용은 GitHub의 Glide 저장소를 참고하세요.

단일 비트맵을 사용자 인터페이스(UI)에 로드하는 것은 간단하지만 한 번에 더 큰 이미지 집합을 로드해야 하면 더 복잡해집니다. 많은 경우(ListView, GridView 또는 ViewPager와 같은 구성요소를 사용하는 경우) 화면에 곧 스크롤될 수 있는 이미지 수가 합산된 화면 내 총 이미지 수는 기본적으로 제한되지 않습니다.

이미지가 화면에서 사라질 때 하위 뷰를 재활용함으로써 이런 구성요소를 사용하여 메모리 사용량을 줄입니다. 또한 가비지 컬렉터는 개발자가 오랫동안 활성화된 참조는 유지하지 않는 것으로 간주하며 로드된 비트맵을 해제합니다. 이러한 것들은 모두 훌륭하지만, UI를 끊김 없이 빠르게 로드하도록 유지하려면 이미지가 스크린으로 다시 표시될 때마다 매번 계속 처리하는 것을 피해야 합니다. 메모리와 디스크 캐시를 사용하면 구성요소가 처리된 이미지를 빨리 다시 로드하여 이 부분에 종종 도움을 줄 수 있습니다.

이 과정에서는 여러 비트맵을 로드할 때 메모리 및 디스크 비트맵 캐시를 사용하여 UI의 응답성 및 유동성을 개선하는 방법에 관해 설명합니다.

메모리 캐시 사용

메모리 캐시는 중요한 애플리케이션 메모리를 사용하는 대신 비트맵에 빠르게 액세스할 수 있습니다. LruCache 클래스(API 수준 4를 다시 사용하려고 할 때도 지원 라이브러리에서 사용 가능함)는 비트맵을 캐싱하는 작업과 최근에 참조된 객체를 강한 참조 LinkedHashMap에 유지하는 작업, 캐시가 지정된 크기를 초과하기 전에 가장 오래전에 사용된 항목을 제거하는 작업에 특히 적합합니다.

참고: 이전에는 SoftReference 또는 WeakReference 비트맵 캐시가 메모리 캐시 구현으로 많이 사용되었지만, 지금은 권장되지 않습니다. Android 2.3(API 수준 9)부터 가비지 컬렉터가 소프트/약한 참조를 적극적으로 수집하여 이러한 참조의 효과를 크게 떨어뜨립니다. 또한 Android 3.0(API 수준 11) 이전에는 비트맵의 백업 데이터가 예측할 수 있는 방식으로 해제되지 않은 네이티브 메모리에 저장되어 애플리케이션이 잠시 메모리 제한을 초과하여 비정상 종료될 수 있는 가능성이 잠재되어 있었습니다.

LruCache에 적합한 크기를 선택하려면 다음과 같은 몇 가지 요인을 고려해야 합니다.

  • 활동 및 애플리케이션의 나머지 부분이 얼마나 많은 메모리를 사용하나요?
  • 한 번에 몇 개의 이미지가 화면에 표시되나요? 화면에 표시할 준비가 되어야 할 이미지 수는 몇 개인가요?
  • 기기의 화면 크기와 밀도는 어떻게 되나요? Galaxy Nexus와 같은 초고밀도 화면(xhdpi) 기기는 메모리에 동일한 수의 이미지를 저장하려면 Nexus S(hdpi)와 같은 기기와 비교해 볼 때 더 큰 캐시가 필요합니다.
  • 비트맵의 크기와 구성은 어떻게 되며 그에 따른 각각의 메모리 사용량은 어떻게 되나요?
  • 이미지는 얼마나 자주 액세스되나요? 다른 이미지보다 더 자주 액세스되는 이미지가 있나요? 그런 경우 특정 항목을 항상 메모리에 두거나 서로 다른 그룹의 비트맵에 여러 개의 LruCache 객체를 두고 싶을 수 있습니다.
  • 품질과 수량의 균형을 맞출 수 있나요? 때로 더 낮은 품질의 비트맵을 다수 저장하여 잠재적으로 다른 백그라운드 작업에 더 높은 품질 버전을 로드할 수 있도록 하는 것이 더 유용할 수 있습니다.

모든 애플리케이션에 적합한 특정 크기나 수식은 없으며 사용량을 분석하여 적합한 해결책을 찾아야 합니다. 캐시가 너무 작으면 아무런 이점 없이 추가 오버헤드가 발생하고 캐시가 너무 크면 또다시 java.lang.OutOfMemory 예외가 발생하여 앱의 나머지 부분에 사용할 메모리가 거의 남아 있지 않을 수 있습니다.

다음은 비트맵에 LruCache를 설정하는 예입니다.

Kotlin

private lateinit var memoryCache: LruCache<String, Bitmap>

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    // Get max available VM memory, exceeding this amount will throw an
    // OutOfMemory exception. Stored in kilobytes as LruCache takes an
    // int in its constructor.
    val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()

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

    memoryCache = object : LruCache<String, Bitmap>(cacheSize) {

        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            // The cache size will be measured in kilobytes rather than
            // number of items.
            return bitmap.byteCount / 1024
        }
    }
    ...
}

Java

private LruCache<String, Bitmap> memoryCache;

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

    memoryCache = 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) {
        memoryCache.put(key, bitmap);
    }
}

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

참고: 이 예에서는 애플리케이션 메모리의 1/8이 캐시로 할당됩니다. 일반/hdpi 기기의 경우 최소 4MB(32 나누기 8) 정도입니다. 해상도가 800x480인 기기에서 이미지로 채워진 전체 화면 GridView는 약 1.5MB(800*480*4바이트)를 사용하므로 최소 약 2.5페이지의 이미지를 메모리에 캐시합니다.

비트맵을 ImageView에 로드할 때 먼저 LruCache가 확인됩니다. 만약 항목이 발견되면 ImageView 업데이트에 즉시 그 항목이 사용되고, 발견되지 않으면 이미지 처리를 위한 백그라운드 스레드가 생성됩니다.

Kotlin

fun loadBitmap(resId: Int, imageView: ImageView) {
    val imageKey: String = resId.toString()

    val bitmap: Bitmap? = getBitmapFromMemCache(imageKey)?.also {
        mImageView.setImageBitmap(it)
    } ?: run {
        mImageView.setImageResource(R.drawable.image_placeholder)
        val task = BitmapWorkerTask()
        task.execute(resId)
        null
    }
}

Java

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를 업데이트해야 합니다.

Kotlin

private inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
    ...
    // Decode image in background.
    override fun doInBackground(vararg params: Int?): Bitmap? {
        return params[0]?.let { imageId ->
            decodeSampledBitmapFromResource(resources, imageId, 100, 100)?.also { bitmap ->
                addBitmapToMemoryCache(imageId.toString(), bitmap)
            }
        }
    }
    ...
}

Java

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 같은 구성요소는 메모리 캐시를 쉽게 채울 수 있습니다. 애플리케이션은 전화 통화와 같은 다른 작업에 의해 중단될 수 있으며 백그라운드에서 애플리케이션이 종료되고 메모리 캐시가 삭제될 수 있습니다. 사용자가 계속하면 애플리케이션에서 각 이미지를 다시 처리해야 합니다.

이러한 경우 디스크 캐시를 사용하여 처리된 비트맵을 유지하고 메모리 캐시에서 이미지가 더 이상 사용 가능하지 않을 때 로드 시간을 줄일 수 있습니다. 물론, 디스크에서 이미지를 가져오는 것은 메모리에서 로드하는 것보다 느리며 디스크 읽기 시간을 예측할 수 없기 때문에 백그라운드 스레드에서 실행되어야 합니다.

참고: 이미지가 이미지 갤러리 애플리케이션 같은 곳에서 자주 액세스된다면 캐시된 이미지를 저장하기에 ContentProvider가 더 적합한 장소가 될 수 있습니다.

이 클래스의 샘플 코드는 Android 소스에서 가져온 DiskLruCache 구현을 사용합니다. 다음은 기존 메모리 캐시 외에 디스크 캐시를 추가하는 업데이트된 코드의 예입니다.

Kotlin

private const val DISK_CACHE_SIZE = 1024 * 1024 * 10 // 10MB
private const val DISK_CACHE_SUBDIR = "thumbnails"
...
private var diskLruCache: DiskLruCache? = null
private val diskCacheLock = ReentrantLock()
private val diskCacheLockCondition: Condition = diskCacheLock.newCondition()
private var diskCacheStarting = true

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    // Initialize memory cache
    ...
    // Initialize disk cache on background thread
    val cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR)
    InitDiskCacheTask().execute(cacheDir)
    ...
}

internal inner class InitDiskCacheTask : AsyncTask<File, Void, Void>() {
    override fun doInBackground(vararg params: File): Void? {
        diskCacheLock.withLock {
            val cacheDir = params[0]
            diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE)
            diskCacheStarting = false // Finished initialization
            diskCacheLockCondition.signalAll() // Wake any waiting threads
        }
        return null
    }
}

internal inner class  BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
    ...

    // Decode image in background.
    override fun doInBackground(vararg params: Int?): Bitmap? {
        val imageKey = params[0].toString()

        // Check disk cache in background thread
        return getBitmapFromDiskCache(imageKey) ?:
                // Not found in disk cache
                decodeSampledBitmapFromResource(resources, params[0], 100, 100)
                        ?.also {
                            // Add final bitmap to caches
                            addBitmapToCache(imageKey, it)
                        }
    }
}

fun addBitmapToCache(key: String, bitmap: Bitmap) {
    // Add to memory cache as before
    if (getBitmapFromMemCache(key) == null) {
        memoryCache.put(key, bitmap)
    }

    // Also add to disk cache
    synchronized(diskCacheLock) {
        diskLruCache?.apply {
            if (!containsKey(key)) {
                put(key, bitmap)
            }
        }
    }
}

fun getBitmapFromDiskCache(key: String): Bitmap? =
        diskCacheLock.withLock {
            // Wait while disk cache is started from background thread
            while (diskCacheStarting) {
                try {
                    diskCacheLockCondition.await()
                } catch (e: InterruptedException) {
                }

            }
            return diskLruCache?.get(key)
        }

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
fun getDiskCacheDir(context: Context, uniqueName: String): File {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    val cachePath =
            if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
                    || !isExternalStorageRemovable()) {
                context.externalCacheDir.path
            } else {
                context.cacheDir.path
            }

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

Java

private DiskLruCache diskLruCache;
private final Object diskCacheLock = new Object();
private boolean diskCacheStarting = 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 (diskCacheLock) {
            File cacheDir = params[0];
            diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
            diskCacheStarting = false; // Finished initialization
            diskCacheLock.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) {
        memoryCache.put(key, bitmap);
    }

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

public Bitmap getBitmapFromDiskCache(String key) {
    synchronized (diskCacheLock) {
        // Wait while disk cache is started from background thread
        while (diskCacheStarting) {
            try {
                diskCacheLock.wait();
            } catch (InterruptedException e) {}
        }
        if (diskLruCache != null) {
            return diskLruCache.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);
}

참고: 디스크 캐시를 초기화하는 것도 디스크 작업이 필요하므로 기본 스레드에서 실행하면 안 됩니다. 하지만 이는 초기화 전에 캐시에 액세스할 기회가 있음을 의미합니다. 이 문제를 해결하기 위해 위의 구현에서 잠금 객체는 캐시가 초기화될 때까지 앱이 디스크 캐시에서 읽지 않도록 합니다.

UI 스레드에서 메모리 캐시를 확인하는 동안 백그라운드 스레드에서는 디스크 캐시를 확인합니다. 디스크 작업은 UI 스레드에서 발생해서는 안 됩니다. 이미지 처리가 완료되면 나중에 사용할 수 있도록 최종 비트맵을 메모리와 디스크 캐시에 모두 추가합니다.

구성 변경 처리

화면 방향 변경과 같은 런타임 구성 변경으로 인해 Android는 새 구성과 함께 실행 중인 활동을 삭제하고 다시 시작합니다(이 동작에 관한 자세한 정보는 런타임 변경사항 처리 참고). 구성이 변경될 때 사용자가 부드럽고 빠른 경험을 할 수 있도록 모든 이미지를 다시 처리하지 않도록 합니다.

다행히 메모리 캐시 사용 섹션에서 빌드한 좋은 비트맵 메모리 캐시가 있습니다. 이 캐시는 setRetainInstance(true) 호출을 통해 보존되는 Fragment를 사용하여 새 활동 인스턴스에 전달될 수 있습니다. 활동이 다시 생성되면 이 보관된 Fragment가 다시 연결되고 기존 캐시 객체에 액세스할 수 있게 됩니다. 그러면 이미지를 신속하게 가져와 ImageView 객체에 다시 채울 수 있습니다.

다음은 Fragment를 사용하여 구성 변경에서 LruCache 객체를 보관하는 방법을 보여주는 예입니다.

Kotlin

private const val TAG = "RetainFragment"
...
private lateinit var mMemoryCache: LruCache<String, Bitmap>

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    val retainFragment = RetainFragment.findOrCreateRetainFragment(supportFragmentManager)
    mMemoryCache = retainFragment.retainedCache ?: run {
        LruCache<String, Bitmap>(cacheSize).also { memoryCache ->
            ... // Initialize cache here as usual
            retainFragment.retainedCache = memoryCache
        }
    }
    ...
}

class RetainFragment : Fragment() {
    var retainedCache: LruCache<String, Bitmap>? = null

    companion object {
        fun findOrCreateRetainFragment(fm: FragmentManager): RetainFragment {
            return (fm.findFragmentByTag(TAG) as? RetainFragment) ?: run {
                RetainFragment().also {
                    fm.beginTransaction().add(it, TAG).commit()
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        retainInstance = true
    }
}

Java

private LruCache<String, Bitmap> memoryCache;

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

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

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

이를 테스트하려면 Fragment를 유지한 채, 그리고 유지하지 않은 채 기기를 회전해 보세요. 캐시를 유지하면 이미지가 메모리에서 거의 즉시 활동을 채우므로 지연이 거의 또는 전혀 없습니다. 메모리 캐시에서 발견되지 않은 모든 이미지는 대개 디스크 캐시에서 사용할 수 있으며 그렇지 않은 경우 평소와 같이 처리됩니다.