Tistory View

Android Develop/image

안드로이드 비트맵 재사용 BitmapPool만들기

God Dangchy What should I do? 2020. 2. 26. 04:37

이전편에서 언급한 Bitmap를 재사용하려면 사용하지 않는 비트맵을 담아 둘 Pool이 필요하게 된다. 이번편에서는 이 풀(pool)을 만들어 보기로 하겠다.

원리는 간단하다. 사용하지 않는 Bitmap을 리스트에 넣어두고 이미지를 로드할 때, 크기가 맞는 녀석을 골라서 다시 내어주면 된다. 약간의 효율을 위해 리스트는 Bitmap의 ByteSize를 기준으로 정렬을 해두도록 한다.

 

 

멤버변수들

다음은 만들 클래스의 멤버변수들과 컨스트럭터이다.

public class SimpleLruBitmapPool {

    private static final String TAG = "SimpleLruBitmapPool";

    // Pool에 들어올수 있는 최대 크기
    private int mMaxByteSize;
    // 현재 비트맵들이 잡아먹고 있는 바이트 크기
    private int mUseByteSize;

    // LRU : 가장 최근에 들어온 것이 맨앞에 배치되며, 가장 먼저들어온 것이 맨뒤에 배치된다.  
    private LinkedList<Bitmap> mLruList = new LinkedList<Bitmap>();

    // 바이트사이즈로 정렬된 목록 크기가 가장 큰 것이 맨뒤에 위치한다.
    private LinkedList<Bitmap> mSizeList = new LinkedList<Bitmap>();


    public SimpleLruBitmapPool(int maxByteSize) {
        if (maxByteSize <= 0) {
            throw new IllegalArgumentException("Invalid maxByteSize");
        }
        mMaxByteSize = maxByteSize;
    }
    .
    .
    .
}

mSizeList와 mLruList는 들어있는 비트맵들의 순서만 다를 뿐, 같은 비트맵들이 담기게 되고, 크기 또한 늘 갖게 된다. 처음부터 LRU를 다루게 되면 머리기 복잡해지니 나중에 설명하기로 한다.

 

 

 

이 Class로 인스턴스를 만들 경우는 주로 다음과 같은 코드를 사용한다.

final int totalByteSize = (int)( Runtime.getRuntime().maxMemory() / 8 );
mBitmapPool = new SimpleLruBitmapPool( totalByteSize );

현재 실행중인 런타임에서 사용할 수 있는 최대 메모리크기의 1/8을 사용하도록 하는 코드이다. 이 코드는 그냥 예시일뿐, 실제로 사용을 할 때는 사용할 이미지들의 크기와 총 몇 개가 필요한지를 계산해서 적당한 값을 넣어야 한다. 

 

 

 

비트맵풀에 넣기

다음의 코드는 리스트에 사용하지 않는 비트맵을 넣는 코드이다.

public synchronized void add( Bitmap bmp ) {
   // 넘어온 값이 올바른지 체크 
   if( bmp == null ) {
       throw new IllegalArgumentException( "Bitmap is null, null can't be add to pool" );
   }

   if( bmp.isRecycled() ) {
       throw new IllegalArgumentException( "Bitmap is already recycled" );
   }

   if( !bmp.isMutable() ) {
       throw new IllegalArgumentException( "Bitmap is not mutable" );
   }

   // mSizeList는 크기별로 정렬이 되어야 하기 때문에, 넣어야 하는 위치를 판단한다.  
   int idx = 0;
   Iterator<Bitmap> it = mSizeList.iterator();
   while( it.hasNext() ) {
       Bitmap b = it.next();

       if( GetByteSize( b ) >= GetByteSize( bmp ) ) {
           break;
       }

       idx++;
   }
   
   // 최적위치를 찾았으니, 넣으면 된다. 
   // LinkedList의 add는 insert before로 동작한다.
   mSizeList.add( idx, bmp );
   
   // Lru에 넣는다. 현재 넘어온 Bitmap이 최근의 Bitmap이므로 맨앞에 넣는다.
   mLruList.addFirst( bmp );
   mUseByteSize += GetByteSize( bmp );

   // 최대사용량을 넘기면 크기를 줄여준다.
   if( mUseByteSize > mMaxByteSize ) {
       trimToSize( mMaxByteSize );
   }
}

넘어온 비트맵의 byte크기를 보고 올바른 위치에 배치시키는 코드이다. LinkedList의 add의 동작이 insertBefore와 같기 때문에 넣는 위치는 idx에 들어가게되고 원래 idx에 있던 놈부터 뒤엣놈들은 한칸씩 밀려나게 된다.

마지막으로 총사용량이 최대치를 넘어갈 경우 크기를 줄이는 코드를 실행하게 된다.

 

 

필요한 녀석을 골라내는 코드

다음은 이미지를 로드하기위해 이 Pool에서 필요한 녀석을 골라내는 코드이다.

따로 설명이 필요없으니 주석을 보면 이해할 수 있을 것이다.

public synchronized Bitmap getBitmap( int w, int h, Bitmap.Config config )
{
   Bitmap bmp = null;


   // 리스트를 돌면서 알맞은 항목을 찾는다.
   // 찾았을 경우 bmp변수에 값이 들어가게 되며, 없을 경우 null이 된다.
   // 이 후의 코드에서 null이면 새로 만들어 리턴한다.

   Iterator<Bitmap> it = mSizeList.iterator();
   while( it.hasNext() ) {
       Bitmap b = it.next();

       if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT )
       {

           if( b.getAllocationByteCount() >= w * h * GetBytePerPixel( config ) ) {
               b.reconfigure( w, h, config );
               bmp = b;
               break;
           }

       } else {

           if( GetByteSize( b ) > w * h * GetBytePerPixel( config ) ) {
               // 현재 항목이후의 모든 항목은 크기가 찾고자하는 것보다 크기 때문에
               // 모두 사용할 수가 없다. 더이상 찾을 필요가 없다.
               break;
           }

           // kitkat 미만은 폭, 높이, 픽셀정보 이 세가지가 다 같아야 재사용이 가능하다.
           if( b.getWidth() == w && b.getHeight() == h && b.getConfig() == config ) {
               bmp = b;
               break;
           }
       }
   }

   if( bmp != null ) {
       // 목록내에서 알맞은 비트맵을 찾았다. 이 Pool에서 제거한다.
       mSizeList.remove( bmp );
       mLruList.remove( bmp );
       mUseByteSize -= GetByteSize( bmp );
   }
   else {
       // 목록내에 쓸만한 녀석이 없다. 그냥 새로 만들어 준다.
       bmp =  Bitmap.createBitmap( w, h, config );
   }

   return bmp;
}

 

Pool의 크기를 줄이는 코드

Pool이 최대 사용량을 넘어서거나 다른 이유로(특히 안드로이드 운영체제가 메모리반환을 요청하는 경우:onTrimMemory로 검색) 크기를 줄이는 코드이다. 이 클래스뿐아니라 다른 이유로 줄여야하는 경우를 위해 public으로 설정해 둔다.

 

public synchronized void trimToSize( int trimToSize ) {

   while( mLruList.size() > 0 && mUseByteSize > trimToSize )
   {
       Bitmap bmp =  mLruList.removeLast();  // LRU에서 삭제
       mSizeList.remove( bmp );              // SizeList에서 삭제
       mUseByteSize -= GetByteSize( bmp );
       bmp.recycle();
   }
}

 목록(LRU)에서 가장뒤에 것부터 하나씩 지워가며 원하는 사이즈에 도달하면 끝나게 되어있다.

 

사용예제

inBitmap에 사용하려고 만들었으니, 다음의 코드가 inBitmap에 할당하게 된다. getBitmap코드가 새로 만들어서라도 늘 올바른 비트맵을 돌려주니 null체크없이 그냥 쓰면된다.(뭐.. 특별한 경우는 제외하고.. 아.. null체크 하세용..)

BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inMutable = true;
opts.inBitmap  = [Pool객체].getBitmap( 폭, 높이, config );




안쓰는 것은
[Pool객체].add( 안쓰는비트맵 );

 

ImageView에 사용하기

이미지를 로드한 이후 ImageView에 SetImageBitmap함수를 통해서 그리게 될 것이다. 사용후에 이제 다시 회수하여 위에서 여태까지 만든 Pool에 다시 넣어줘야하는 데, 다음의 코드를 통해 회수를 할 수 있다.

Bitmap bitmap = ((BitmapDrawable)[ImageView 객체].getDrawable()).getBitmap();
[ImageView 객체].setImageBitmap( null );

[BitmapPool].add( bitmap );

하지만 이 코드는 뭔가 찜찜함이 있다. 이 찜찜함은 첫 줄에서 발생하는 데, 

((BitmapDrawable)[ImageView 객체].getDrawable()).getBitmap();

돌려 주는 값이 지금까지 만든 BitmapPool에서 할당된 것인지도 알 수 없고(우리는 우리가 열심히 만든 Pool에서 생성된 녀석만 쓰기로하고) [ImageView 객체].setBitmap으로 지정한 것인지도 명확하지 않다.

왜 그런지는 https://stackoverflow.com/questions/8306623/get-bitmap-attached-to-imageview 여기의 댓글들을 자세히 보면 알 수 있다.

 

 

그래서 필자는 다음의 방식을 쓴다.

final int key = R.string.bitmap_tag_key; // 어딘가에 글로벌하게 넣어두는 것이 좋다.

Bitmap old = (Bitmap)imageView.getTag( key );
imageView.setImageBitmap( bmp );
imageView.setTag( key, bmp );

if( old != null ) { // 회수가 되면, 설정된 비트맵이 있었다면 다시 Pool에 넣는다.
    [BitmapPool].add( old );
}

 비트맵을 ImageView에 지정할 때, setTag를 이용해서 "이 ImageView가 BitmapPool에서 만든 Bitmap을 사용하고 있다" 라는 표시를 해두는 것이다. 

 

 

setTag는 키값으로 Resource이 아이디값만 사용할 수 있으므로  res/values/strings.xml에 다음의 코드를 추가해서 사용하면 된다.

<resources>
    ...
    <string name="bitmap_tag_key">bitmap_tag_key지롱</string>
</resources>

 

이 코드는 주의사항이 있는 데, ImageView당 한 개씩 이미지를 따로 따로 load해서 지정해두어야 한다. 하나의 Bitmap을 여러 ImageView에 사용하게 되면, 문제가 발생하게된다.(Pool에 같은 Bitmap이 여러개 들어가 버리게 된다.)

이 문제를 해결하려면 Reference Count방식을 이용해서 코드를 사용하기 바란다.

 

ImageView가 그냥 없어져 버리면 회수를 할 수가 없게 되는 데, 이 때는 onDetachedFromWindow를 이용하면 회수할 수 있다.

 

 

굳이 LRU방식을 쓰는 이유

어려운 내용이 될 수 있으니, 그냥 "그런가부다~"라는 식으로 이해하면 된다. 꼼꼼히 읽어볼 독자는 잘 읽어 보시고..

 

LRU기능을 넣지않고 이 코드를 실전에 투입해보면 잼있는 일이 발생한다. 처음에 이 풀이 가득찰 때까지는 문제없이 동작을 하지만, 재사용을 하는 부분에서 쓰는 놈만 쓰고 나머지는 그냥 App이 끝날 때까지 메모리만 쳐 먹고 있게 된다.

심지어는 Pool상에 사용하지 않는 비트맵들이 잔뜩 있는 데도, 지속적으로 새로운 비트맵만을 생성하여 리턴하는 경우도 많다. 이 문제를 해결하려고 LRU를 쓰는 것이다.

 

현재 목록에 작은 크기의 이미지들이 잔뜩 있고, 전체크기를 사용하고 있다면

큰 크기의 이미지가 회수가 되어 목록의 맨 마지막에 들어가게될 것이다. 그럼 사이즈를 줄이기 위해 어떤 것을 삭제해야될지 판단을 해야한다.

 

  1. 가장 큰이미지를 삭제할 경우, 이 경우 가장큰이미지가 바로 최근에 회수된 이미지이다. 이 것이 삭제가 되고, 이후에 계속 큰이미지만을 요청하고 다시회수되고 하면서 결국 이 작은 것들은 그냥 영원히 남아있게 된다.

 

  2. 작은 것들을 삭제하는 경우, 1번의 반대되는 경우를 생각하면 되는 데, 이 경우는 큰 것들은 그냥 영원히 남아있게 된다.

 

1,2 동작모두 새로운 bitmap을 지속적으로 만들어서 사용되게되고, 이 만들어진 것들이 회수가 되도 결국 바로 삭제 되기 때문에 이 Pool은 무용지물이 되어버린다.

 

따라서, 가장 안 쓰는 놈을 삭제하기위해 LRU를 사용하게 된다. LRU는 가장 안쓰는 놈이 맨뒤로 밀리게 된다.

 

 

 

전체코드

package com.tistory.jamssoft.myblogbitmap; <<- 바꿔서 쓰셈

import android.os.Build;
import android.graphics.Bitmap;
import java.util.Iterator;
import java.util.LinkedList;

public class SimpleLruBitmapPool {

    private static final String TAG = "SimpleLruBitmapPool";

    // Pool에 들어올수 있는 최대 크기
    private int mMaxByteSize;
    // 현재 비트맵들이 잡아먹고 있는 바이트 크기
    private int mUseByteSize;

    // LRU : 가장 최근에 들어온 것이 맨앞에 배치되며, 가장 먼저들어온 것이 맨뒤에 배치된다.
    private LinkedList<Bitmap> mLruList = new LinkedList<Bitmap>();

    // 바이트사이즈로 정렬된 목록 크기가 가장 큰 것이 맨뒤에 위치한다.
    private LinkedList<Bitmap> mSizeList = new LinkedList<Bitmap>();


    public SimpleLruBitmapPool(int maxByteSize) {
        if (maxByteSize <= 0) {
            throw new IllegalArgumentException("Invalid maxByteSize");
        }
        mMaxByteSize = maxByteSize;
    }



    public synchronized void add( Bitmap bmp ) {
        // 넘어온 값이 올바른지 체크
        if( bmp == null ) {
            throw new IllegalArgumentException( "Bitmap is null, null can't be add to pool" );
        }

        if( bmp.isRecycled() ) {
            throw new IllegalArgumentException( "Bitmap is already recycled" );
        }

        if( !bmp.isMutable() ) {
            throw new IllegalArgumentException( "Bitmap is not mutable" );
        }

        // mSizeList는 크기별로 정렬이 되어야 하기 때문에, 넣어야 하는 위치를 판단한다.
        int idx = 0;
        Iterator<Bitmap> it = mSizeList.iterator();
        while( it.hasNext() ) {
            Bitmap b = it.next();

            if( GetByteSize( b ) >= GetByteSize( bmp ) ) {
                break;
            }

            idx++;
        }

        mSizeList.add( idx, bmp );

        // Lru에 넣는다. 현재 넘어온 Bitmap이 최근의 Bitmap이므로 맨앞에 넣는다.
        mLruList.addFirst( bmp );
        mUseByteSize += GetByteSize( bmp );

        // 최대사용량을 넘기면 크기를 줄여준다.
        if( mUseByteSize > mMaxByteSize ) {
            trimToSize( mMaxByteSize );
        }
    }

    public synchronized Bitmap getBitmap( int w, int h, Bitmap.Config config )
    {
        Bitmap bmp = null;


        // 리스트를 돌면서 알맞은 항목을 찾는다.
        // 찾았을 경우 bmp변수에 값이 들어가게 되며, 없을 경우 null이 된다.
        // 이 후의 코드에서 null이면 새로 만들어 리턴한다.

        Iterator<Bitmap> it = mSizeList.iterator();
        while( it.hasNext() ) {
            Bitmap b = it.next();

            if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT )
            {

                if( b.getAllocationByteCount() >= w * h * GetBytePerPixel( config ) ) {
                    b.reconfigure( w, h, config );
                    bmp = b;
                    break;
                }

            } else {

                if( GetByteSize( b ) > w * h * GetBytePerPixel( config ) ) {
                    // 현재 항목이후의 모든 항목은 크기가 찾고자하는 것보다 크기 때문에
                    // 모두 사용할 수가 없다. 더이상 찾을 필요가 없다.
                    break;
                }

                // kitkat 미만은 폭, 높이, 픽셀정보 이 세가지가 다 같아야 재사용이 가능하다.
                if( b.getWidth() == w && b.getHeight() == h && b.getConfig() == config ) {
                    bmp = b;
                    break;
                }
            }
        }

        if( bmp != null ) {
            // 목록내에서 알맞은 비트맵을 찾았다. 이 Pool에서 제거한다.
            mSizeList.remove( bmp );
            mLruList.remove( bmp );
            mUseByteSize -= GetByteSize( bmp );
        }
        else {
            // 목록내에 쓸만한 녀석이 없다. 그냥 새로 만들어 준다.
            bmp =  Bitmap.createBitmap( w, h, config );
        }

        return bmp;
    }

    public synchronized void trimToSize( int trimToSize ) {

        while( mLruList.size() > 0 && mUseByteSize > trimToSize )
        {
            Bitmap bmp =  mLruList.removeLast();
            mSizeList.remove( bmp );
            mUseByteSize -= GetByteSize( bmp );
            bmp.recycle();
        }
    }

    public void evictAll() {
        trimToSize( -1 );
    }

    public static int GetBytePerPixel( Bitmap.Config config ) {
        switch( config ) {
            case RGBA_F16:
                return 8;
            case ARGB_8888:
                return 4;
            case RGB_565:
            case ARGB_4444:
                return 2;
            case ALPHA_8:
                return 1;

        }
        return -1;
    }

    private static int GetSizeOrder( Bitmap.Config config ) {
        switch( config ) {
            case RGBA_F16:
                return 8;
            case ARGB_8888:
                return 4;
            case RGB_565:
                return 3;
            case ARGB_4444:
                return 2;
            case ALPHA_8:
                return 1;

        }
        return 0;
    }

    public static int GetByteSize( Bitmap bmp ) {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            return bmp.getAllocationByteCount();
        }
        return bmp.getByteCount();
    }
}

참고로 .. 필자는 이 것 테스트 안해 봤다....... 도망가야 되나..

Replies
Reply Write