ABOUT ME

-

  • 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);
    • GlideImageView를 파라미터로 받기 때문에 ImageView의 사이즈를 알 수 있습니다.
    • 원본 이미지 전체를 메모리에 로딩하지 않고 다운샘플링을 합니다. 이를 통해 Bitmap은 메모리를 적게 차지하고, OOM 에러를 예방할 수 있습니다.
    • Glide는 이미지를 최대한 빨리 로드해주는 데 최적화되어 있기 때문에 메모리 & 디스크에 이미지를 캐싱하는 정책을 기본적으로 하고 있습니다.
    • 만약 Glide에서 원본 크기의 이미지를 캐싱하도록 하고 싶다면 아래와 같이 캐시 정책을 추가해 주면 됩니다.
       Glide.with(context)
                        .load(url)
                        .diskCacheStrategy(DiskCacheStrategy.ALL)
                        .into(imageView)

     

    Slow Loading

    • 느린 로딩 BitmapView에 로딩할 때 생기는 문제입니다. 
    • 주로 ViewWindow를 벗어났음에도 불구하고 다운로드나 Bitmap을 디코딩하는 작업들을 취소하지 않기 때문입니다.
    • Glide는 불필요한 동작을 하는 작업들을 취소하고 오직 사용자에게 보이는 Image만 로딩합니다. 그래서 빠르게 로딩이 가능합니다.
    • GlideActivityFragment LifeCycle을 알고 있습니다. 그래서 어떤 이미지들이 취소되어야 하는지을 알 수 있게 됩니다.
    • Glide는 설정가능한 사이즈의 캐시를 만들어서 Bitmap캐싱합니다. 메모리 캐시를 만들어 매번 Bitmap을 디코딩할 필요가 없습니다.

     

    캐싱에는 두 가지가 있습니다.

     

    1. 메모리 캐시
    2. 디스크 캐시 

     

    캐싱

    • 캐시라고 하는 좀 더 빠른 메모리 영역으로 데이터를 가져와서 접근하는 방식.
    • 즉 속도가 느린 디스크의 데이터를 속도가 빠른 메모리로 가져와서 읽고 쓰는 작업을 하는 것.
    • 메모리 캐시 : 속도가 빠른 장치와 느린 장치 간의 속도차에 따른 병목현상을 줄이기 위한 범용 메모리
    • 디스크 캐시 : 디스크로부터 읽은 내용을 일부 보존해두는 메모리 영역. 실제 디스크에서 읽는 게 아니라 디스크 캐시에서 읽어낼 수 있어 메모리에서 읽는 속도보다 빠르다.

     

    LRU(Least Recently Used) 알고리즘

    • 안드로이드에서는 LruCache를 제공합니다. LruCacheLinkedHashMap을 사용하여 최근에 사용된 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을 넘기면, 아래와 같이 동작합니다.

    1. 메모리에 URL Key에 해당하는 이미지가 있는지 확인
    2. 메모리에 캐시에 비트맵이 있다면 그대로 가져와서 사용
    3. 메모리 캐시에 비트맵이 없다면, 디스크 캐시에서 이미지를 가져온다
    4. 디스크 캐시에 이미지가 있다면, 디스크로부터 Bitmap을 로딩 후 메모리 캐시로 옮기고, Bitmap을 View에 로딩
    5. 디스크 캐시에 없다면, 네트워크에서 이미지를 다운로드하고 디스크 캐시에 옮긴 후 또 한 번 메모리 캐시에 옮긴다. 그리고 Bitmap을 View에 로드
    • 이미지 전처리 : 이미지를 로딩하기 전에 섬네일이나 진행 상황을 보여주기 위한 단계
    • 이미지 로딩 : 캐시나 네트워크에서 이미지를 가져오는 단계
    • 디코딩 : BitmapFactory(여러가지 이미지 포맷을 디코드 해서 비트맵으로 변환하는 함수들을 가지고 있는 클래스)를 이용하여 비트맵 형식으로 변환하고 크기, 회전, 품질 등을 변환하는 단계
    • 이미지 후처리 : 보여줄 이미지에 애니메이션이나 모서리를 둥글게 하는 효과를 적용하는 단계

     

    Unresponsive UI

    • UI가 반응하지 않는 가장 크고 중요한 문제는 메인 스레드에서 많은 작업이 일어나기 때문. UI 렌더링과 관련된 모든 작업은 메인 스레드에서 동작 해야하기 때문이 크다고 볼 수 있겠습니다.
    • 안드로이드는 UI를 16ms마다 업데이트 합니다. 만약 어떤 작업이 16ms 보다 더 걸린다면 안드로이드는 그 UpdateSkip 하게 되고 결과적으로 frame이 Skip 됩니다.
    • Bitmap을 심지어 백그라운드에서 로드하더라도 UI지연됩니다. Bitmap은 사이즈가 너무 크기 때문에 가비지 컬렉터(GC)가 매우 바쁘게 움직이기 때문입니다.
    • 실제로 GC가 실행되는 동안, 애플리케이션은 동작하지 않습니다. GC는 동작할 시간이 필요하고, 동작하는 동안 시스템이 여러 frame을 Skip 하게끔 만듭니다.
    • Glide는 이 또한 해결해 주는데, Bitmap Pool(재사용하기 위해 사용하지 않는 Bitmap을 담아 둘 Pool)을 사용하여 GC 호출을 최소화합니다. Bitmap Pool을 사용함으로써 어플리케이션에서 메모리의 지속적인 할당 및 할당 해제를 방지하고, GC 오버헤드를 줄이며 어플리케이션이 원활하게 실행되도록 합니다.
    • 메모리의 지속적인 할당 및 해제BitmapinBitmap (Bitmap 메모리를 재사용) 프로퍼티를 사용합니다. 여기서 재사용recycle이 아닌, 이미 존재하는 Bitmap 변수에 이미지를 가지고 오는 방법입니다.
    • 그래서 Glide는 새로운 Bitmap을 로드해야 할 때면, 같은 메모리의 Bitmap Pool로부터 재사용 가능한 Bitmap을 찾아 가져 갑니다. GC가 호출되지 않고, Recycling도 없게 됩니다.

     

    이미 메모리상에 존재하는 Bitmap에 이미지를 로드할 수 있으면

    • 비트맵 메모리를 필요한 만큼 할당이 되어 있다면, 할당 / 해제 과정이 사라지게 되고, 이로 인해 GC에 의해 순간순간 멈추는 현상이 줄어들게 됩니다.
    • 이미 존재하는 Bitmap을 이용하기 때문에 OOM이 현저히 줄어들게 됩니다.
    • 사용되지 않는 비트맵을 Recycle 하지 않고, 계속 할당되어 있기 때문에 필요 없는 메모리 사용량이 존재하게 됩니다.
    • 재사용할 비트맵을 유지관리하는 코드가 추가되어야 합니다.

     

    Glide vs Picasso

    • 안드로이드에는 여러 이미지 로딩 라이브러리가 존재하는데, 그들 중 이 두 가지를 가장 많이 사용할 것 같습니다.
    • 두 가지 라이브러리의 사용방법은 비슷하지만, Bitmap 포맷이 다릅니다. 
    • 아래 표에서 확인할 수 있듯이, PicassoGlide보다 화질은 더 좋습니다.
    • GlidePicasso보다 메모리 용량을 약 50% 적게 사용하여 메모리 효율이 좋습니다.
    • 만약 Glide의 화질을 좋게 하기 위해 ARGB_8888로 비트맵 포맷을 바꾸더라도, Glide의 메모리 사용량은 여전히 Picasso보다 적습니다.
    • Picasso는 원본 이미지를 메모리로 가져와서 실시간으로 작은 사이즈로 리사이즈하여 ImageView에 할당하고, Glide의 경우 작은 사이즈로 메모리에 가져와서 ImageView에 할당 시키기 때문에 메모리 사용량이 더 적습니다. 즉 Glide의 다운샘플링 때문입니다.

     

     


    Preferences

    How The Android Image Loading Library Glide And Fresco Works?

    반응형

    댓글

Designed by Me.