-
Android) 이미지 로딩 라이브러리 자세히 알아보자Android 2021. 7. 11. 20:32
안드로이드에서 ImageView에 이미지를 로딩할 때 자주 직면하는 문제
Out of Memory
Slow Loading of Image into the View
UI becomes unresponsive. Not Smoothing Scrolling
Out of Memory
- 종종 고퀄리티의 이미지 등을 로딩하거나 많은 이미지를 사용할 때 OOM 에러를 만날 수 있습니다.
- Google에서 권장하는 이미지 로딩 라이브러리인 Glide는 이를 해결하기 위해 다운 샘플링을 해줍니다.
- 다운 샘플링이란 분류에 속하는 데이터가 많은 쪽을 적게 추출하는 방법인데, Bitmap을 실제 View가 요구하는 사이즈로 줄여주는 것을 의미. 예를 들어 2000 x 2000 사이즈 이미지가 있고, View 사이즈는 400 x 400이라면 Glide는 다운 샘플링하여 400 x 400으로 만들고 View에 보여줍니다.
Glide.with(context).load(url).into(imageView);
- Glide는 ImageView를 파라미터로 받기 때문에 ImageView의 사이즈를 알 수 있습니다.
- 원본 이미지 전체를 메모리에 로딩하지 않고 다운샘플링을 합니다. 이를 통해 Bitmap은 메모리를 적게 차지하고, OOM 에러를 예방할 수 있습니다.
- Glide는 이미지를 최대한 빨리 로드해주는 데 최적화되어 있기 때문에 메모리 & 디스크에 이미지를 캐싱하는 정책을 기본적으로 하고 있습니다.
- 만약 Glide에서 원본 크기의 이미지를 캐싱하도록 하고 싶다면 아래와 같이 캐시 정책을 추가해 주면 됩니다.
Glide.with(context) .load(url) .diskCacheStrategy(DiskCacheStrategy.ALL) .into(imageView)
Slow Loading
- 느린 로딩은 Bitmap을 View에 로딩할 때 생기는 문제입니다.
- 주로 View가 Window를 벗어났음에도 불구하고 다운로드나 Bitmap을 디코딩하는 작업들을 취소하지 않기 때문입니다.
- Glide는 불필요한 동작을 하는 작업들을 취소하고 오직 사용자에게 보이는 Image만 로딩합니다. 그래서 빠르게 로딩이 가능합니다.
- Glide는 Activity와 Fragment의 LifeCycle을 알고 있습니다. 그래서 어떤 이미지들이 취소되어야 하는지을 알 수 있게 됩니다.
- Glide는 설정가능한 사이즈의 캐시를 만들어서 Bitmap을 캐싱합니다. 메모리 캐시를 만들어 매번 Bitmap을 디코딩할 필요가 없습니다.
캐싱에는 두 가지가 있습니다.
- 메모리 캐시
- 디스크 캐시
캐싱
- 캐시라고 하는 좀 더 빠른 메모리 영역으로 데이터를 가져와서 접근하는 방식.
- 즉 속도가 느린 디스크의 데이터를 속도가 빠른 메모리로 가져와서 읽고 쓰는 작업을 하는 것.
- 메모리 캐시 : 속도가 빠른 장치와 느린 장치 간의 속도차에 따른 병목현상을 줄이기 위한 범용 메모리
- 디스크 캐시 : 디스크로부터 읽은 내용을 일부 보존해두는 메모리 영역. 실제 디스크에서 읽는 게 아니라 디스크 캐시에서 읽어낼 수 있어 메모리에서 읽는 속도보다 빠르다.
LRU(Least Recently Used) 알고리즘
- 안드로이드에서는 LruCache를 제공합니다. LruCache는 LinkedHashMap을 사용하여 최근에 사용된 object의 strong reference를 보관하고 있다가 정해진 사이즈를 넘어가게 되면 가장 최근에 사용되지 않은 놈부터 쫓아내는 LRU 알고리즘을 사용하는 메모리 캐시입니다.
- 이를 위해 LRU 알고리즘은 메모리 상에서 가장 최근에 사용된 적이 없는 캐시의 메모리부터 대체하며 새로운 데이터로 갱신시켜줍니다.
- LRU 알고리즘의 구현은 Linked List를 이용한 Queue로 이루어지고, 접근의 성능 개선을 위한 Map을 같이 사용합니다.
@Test fun example1(){ val cache = LruCache<String,Int>(5) // maxSize = 5 cache.put("A",0) //[A] cache.put("B",0) //[A, B] cache.put("C",0) //[A, B, C] cache.put("D",0) //[A, B, C, D] cache.put("E",0) //[A, B, C, D, E] - A부터 E까지 캐싱 완료 cache.put("F",0) //[B, C, D, E, F] - F를 캐싱하면, A는 제거됨 cache.put("D",0) //[B, C, E, F, D] - D를 다시 캐싱하면 최근 참조된 상태로 변경 cache.get("C") //[B, E, F, D, C] - C를 통해 캐시된 데이터 접근시 최근 참조된 상태로 변경 } int cacheSize = 4 * 1024 * 1024; // 4MB LruCache<String, Bitmap> bitmapCache = new LruCache<String, Bitmap>(cacheSize) { protected int sizeOf(String key, Bitmap value) { return value.getByteCount(); } }
- LRU Cache를 사용하는 대표적인 예가 비트맵 캐싱입니다. 리사이클러뷰와 같은 대량의 이미지를 한 번에 로드하고 스크롤하여 재사용되는 경우 LRU Cache의 사용은 퍼포먼스 개선에 많은 도움이 될 수 있습니다.
- 그러므로 LRU Cache를 사용할 때는 자주 참조되는 객체일 수록 빠르게 캐시를 통해 객체에 접근할 수 있습니다.
- 또한, 많은 Bitmap을 메모리에 캐시하는 것은 부담될 수 있으므로(용량 한계가 작음), DiskLruCache와 같은 디스크 캐싱을 같이 사용하면 좀 더 메모리 부담을 줄일 수 있습니다.
- 디스크에서 이미지를 가져오는 것은 메모리에서 로드하는 것보다 느리며 디스크 읽기 시간을 예측할 수 없기 때문에 백그라운드 스레드에서 실행되어야 합니다.
LRU 예제
- removeEldestEntry()는 capacity를 상수로 두고 LinkedHashMap의 사이즈가 capacity보다 크면 자동으로 삭제해주는 메소드입니다.
val cacheSize = 3 val array = intArrayOf(1, 2, 3, 4, 5, 6) val map: LinkedHashMap<Int?, Int?> = object : LinkedHashMap<Int?, Int?>() { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int?, Int?>?): Boolean { return size > cacheSize } } for (arr in array) { map[arr] = 0 println(map.keys) } output: [1] [1, 2] [1, 2, 3] [2, 3, 4] [3, 4, 5] [4, 5, 6]
- 동일한 키가 등장한다면 결과는 어떻게 될까요?
val cacheSize = 3 val array = intArrayOf(3, 1, 2, 2, 1, 4) val map: LinkedHashMap<Int?, Int?> = object : LinkedHashMap<Int?, Int?>() { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int?, Int?>?): Boolean { return size > cacheSize } } for (arr in array) { map[arr] = 0 println(map.keys) } output: [3] [3, 1] [3, 1, 2] [3, 1, 2] [3, 1, 2] [1, 2, 4]
- 데이터의 순서와 상관없이 해당 키를 가지고 있는 위치의 키가 삭제되고 데이터가 삽입됩니다.
val cacheSize = 3 val array = intArrayOf(3, 1, 2, 2, 1, 4) val map: LinkedHashMap<Int?, Int?> = object : LinkedHashMap<Int?, Int?>() { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int?, Int?>?): Boolean { return size > cacheSize } } for (arr in array) { if (map.containsKey(arr)) { map.remove(arr) map[arr] = 0 } else { map[arr] = 0 } println(map.keys) } output: [3] [3, 1] [3, 1, 2] [3, 1, 2] [3, 2, 1] [2, 1, 4]
- 만약 동일한 키가 나왔을때 순서를 갱신하고 싶다면 위와 같이 키를 삭제하고 다시 넣어주는 방법이 있습니다.
Glide에 URL을 넘기면, 아래와 같이 동작합니다.
- 메모리에 URL Key에 해당하는 이미지가 있는지 확인
- 메모리에 캐시에 비트맵이 있다면 그대로 가져와서 사용
- 메모리 캐시에 비트맵이 없다면, 디스크 캐시에서 이미지를 가져온다
- 디스크 캐시에 이미지가 있다면, 디스크로부터 Bitmap을 로딩 후 메모리 캐시로 옮기고, Bitmap을 View에 로딩
- 디스크 캐시에 없다면, 네트워크에서 이미지를 다운로드하고 디스크 캐시에 옮긴 후 또 한 번 메모리 캐시에 옮긴다. 그리고 Bitmap을 View에 로드
- 이미지 전처리 : 이미지를 로딩하기 전에 섬네일이나 진행 상황을 보여주기 위한 단계
- 이미지 로딩 : 캐시나 네트워크에서 이미지를 가져오는 단계
- 디코딩 : BitmapFactory(여러가지 이미지 포맷을 디코드 해서 비트맵으로 변환하는 함수들을 가지고 있는 클래스)를 이용하여 비트맵 형식으로 변환하고 크기, 회전, 품질 등을 변환하는 단계
- 이미지 후처리 : 보여줄 이미지에 애니메이션이나 모서리를 둥글게 하는 효과를 적용하는 단계
Unresponsive UI
- UI가 반응하지 않는 가장 크고 중요한 문제는 메인 스레드에서 많은 작업이 일어나기 때문. UI 렌더링과 관련된 모든 작업은 메인 스레드에서 동작 해야하기 때문이 크다고 볼 수 있겠습니다.
- 안드로이드는 UI를 16ms마다 업데이트 합니다. 만약 어떤 작업이 16ms 보다 더 걸린다면 안드로이드는 그 Update를 Skip 하게 되고 결과적으로 frame이 Skip 됩니다.
- Bitmap을 심지어 백그라운드에서 로드하더라도 UI는 지연됩니다. Bitmap은 사이즈가 너무 크기 때문에 가비지 컬렉터(GC)가 매우 바쁘게 움직이기 때문입니다.
- 실제로 GC가 실행되는 동안, 애플리케이션은 동작하지 않습니다. GC는 동작할 시간이 필요하고, 동작하는 동안 시스템이 여러 frame을 Skip 하게끔 만듭니다.
- Glide는 이 또한 해결해 주는데, Bitmap Pool(재사용하기 위해 사용하지 않는 Bitmap을 담아 둘 Pool)을 사용하여 GC 호출을 최소화합니다. Bitmap Pool을 사용함으로써 어플리케이션에서 메모리의 지속적인 할당 및 할당 해제를 방지하고, GC 오버헤드를 줄이며 어플리케이션이 원활하게 실행되도록 합니다.
- 메모리의 지속적인 할당 및 해제는 Bitmap의 inBitmap (Bitmap 메모리를 재사용) 프로퍼티를 사용합니다. 여기서 재사용은 recycle이 아닌, 이미 존재하는 Bitmap 변수에 이미지를 가지고 오는 방법입니다.
- 그래서 Glide는 새로운 Bitmap을 로드해야 할 때면, 같은 메모리의 Bitmap Pool로부터 재사용 가능한 Bitmap을 찾아 가져 갑니다. GC가 호출되지 않고, Recycling도 없게 됩니다.
이미 메모리상에 존재하는 Bitmap에 이미지를 로드할 수 있으면
- 비트맵 메모리를 필요한 만큼 할당이 되어 있다면, 할당 / 해제 과정이 사라지게 되고, 이로 인해 GC에 의해 순간순간 멈추는 현상이 줄어들게 됩니다.
- 이미 존재하는 Bitmap을 이용하기 때문에 OOM이 현저히 줄어들게 됩니다.
- 사용되지 않는 비트맵을 Recycle 하지 않고, 계속 할당되어 있기 때문에 필요 없는 메모리 사용량이 존재하게 됩니다.
- 재사용할 비트맵을 유지관리하는 코드가 추가되어야 합니다.
Glide vs Picasso
- 안드로이드에는 여러 이미지 로딩 라이브러리가 존재하는데, 그들 중 이 두 가지를 가장 많이 사용할 것 같습니다.
- 두 가지 라이브러리의 사용방법은 비슷하지만, Bitmap 포맷이 다릅니다.
- 아래 표에서 확인할 수 있듯이, Picasso가 Glide보다 화질은 더 좋습니다.
- Glide는 Picasso보다 메모리 용량을 약 50% 적게 사용하여 메모리 효율이 좋습니다.
- 만약 Glide의 화질을 좋게 하기 위해 ARGB_8888로 비트맵 포맷을 바꾸더라도, Glide의 메모리 사용량은 여전히 Picasso보다 적습니다.
- Picasso는 원본 이미지를 메모리로 가져와서 실시간으로 작은 사이즈로 리사이즈하여 ImageView에 할당하고, Glide의 경우 작은 사이즈로 메모리에 가져와서 ImageView에 할당 시키기 때문에 메모리 사용량이 더 적습니다. 즉 Glide의 다운샘플링 때문입니다.
Preferences
How The Android Image Loading Library Glide And Fresco Works?
반응형'Android' 카테고리의 다른 글
Android) 안드로이드 네트워크 프로파일러 사용해보기 feat) 웹 파싱 방법 (0) 2021.08.11 Android) KAPT를 대체할 KSP(Kotlin Symbol Process) 소개 with Kotlin DSL (0) 2021.07.17 Android) WebView 정리 (0) 2021.07.08 Android) Coroutine Exception Handling 어떻게 처리 할까 (0) 2021.06.27 Android) ImageURL -> Bitmap 으로 변경하기 feat) HttpURLConnection, Coroutines (1) 2021.06.24