7 minute read

본 글은 이전 글과 이어지는 내용으로 이전 글을 먼저 읽고 오시는 것을 추천드립니다.

AndroidMatrix에 대한 개념이 등장하기 때문에 해당 글을 참고하시면 좋습니다.


해당 포스팅에서는 AndroidBitmap에 대한 개념적 이해를 바탕으로, 실질적으로 생성하고, 조작하며, 최적화하는 다양한 기능적 부분에 대해서 다루고자 합니다. 가장 먼저 Bitmap의 생성 메커니즘부터 Bitmap의 압축과 Bitmap 변환 그리고 다양한 활용법까지 Bitmap을 제어하기 위한 핵심적인 기능들을 알아보겠습니다.

생성

Bitmap을 생성하는 방식은 여러 가지가 존재하며, 일반적으로 Bitmap.createBitmap()을 사용합니다. cretateBitmap()은 다양한 오버로드 메서드로 조합되어 있으며, 대표적인 생성 메서드는 아래와 같습니다.

@NonNull
public static Bitmap createBitmap(int width, int height,
@NonNull Config config, boolean hasAlpha) {
    return createBitmap(null, width, height, config, hasAlpha);
}

이때 Config는 픽셀 포맷을 의미합니다. 픽셀 포맷은 ARGB_8888, RGB_565 등이 있으며, 포맷에 따라 메모리 구조가 달라집니다. width와 height는 Bitmap의 넓이와 높이를 의미하며, hasAlpha 파라미터는 false일 경우 투명도가 없다고 설정되어서 Bitmap이 투명도 채널을 가지고 있어도 불투명하게 처리됩니다. 주의할 점으로는 Config 파라미터에 Config.HARDWARE속성을 주입할 경우 IllegalArgumentException 예외를 발생시킵니다.

HARDWARE Bitmap은 주로 복사나, 파일에서 이미지를 로드할 때 시스템을 통해 자동으로 설정되기 때문에 직접적으로 빈 HARDWARE Bitmap을 생성할 수는 없습니다.

직접 색상 배열을 만들어 비트맵을 생성할 수도 있습니다. colors는 픽셀 데이터의 집합인 int 배열을 의미하며, 이때 색상 배열의 길이가 픽셀 수보다 작거나 너비 또는 높이가 0일 경우 에러를 발생시킵니다. 픽셀은 왼쪽에서 오른쪽, 위에서 아래 방향으로 채워집니다.

@NonNull
public static Bitmap createBitmap(@NonNull @ColorInt int[] colors,
int width, int height, Config config) {
    return createBitmap(null, colors, 0, width, width, height, config);
}

특정 Bitmap을 대상으로 일부 영역을 크롭하여 새로운 Bitmap을 생성하는 메서드 또한 존재합니다. 이는 x, y만큼의 오프셋을 지정하여 width height 크기의 새로운 비트맵을 만들 수 있습니다. 해당 자르기 연산은 해당 width height만큼의 부분집합 범위만 추출하여 새로운 Bitmap을 생성 후 메모리를 할당하는 방식입니다.

@NonNull
public static Bitmap createBitmap(@NonNull Bitmap source, int x, int y, int width, int height) {
    return createBitmap(source, x, y, width, height, null, false);
}

앞서 소개한 메서드 외에도 Bitmap을 생성하는 다양하게 오버로드된 메서드들이 존재하므로 주어진 요구사항에 맞춰 생성하는 것이 중요합니다. 그 외 메서드들은 [해당 소스]를 참고하시길 바랍니다.


createBitmap()외에도 특수하게 Bitmap을 생성하는 연산들이 존재합니다. 아래에서부터는 특수하게 생성할 수 있는 방식들에 대해 추가로 알아보겠습니다.

복사

copy()Bitmap의 기존 이미지를 그대로 복사하거나, 포맷(Config)을 바꾸어 복사할 수 있게 도와주는 메서드입니다. 복사 연산은 픽셀 메모리를 새로 할당하고 포맷에 맞게 변환하여 복사합니다. 이때 isMutable 플래그를 통해 읽기 전용 비트맵을 편집 가능한 상태로 만들 수 있습니다. 단, 변환이 지원되지 않거나 할당에 실패하면 null을 반환합니다.

 public Bitmap copy(@NonNull Config config, boolean isMutable) {
    
    ...
    
 }

크기 조절

비트맵을 특정 크기로 리사이징 하고자 할 경우에는 createScaledBitmap()을 사용합니다. 이때 내부적으로는 scaleMatrix와 createBitmap()을 조합하여 연산을 수행합니다. 가장 마지막 파라미터인 filter가 true일 경우 이중 선형 필터링 기법(Bilinear Filtering)을 적용하여 품질이 향상된 이미지를 생성합니다.

@NonNull
public static Bitmap createScaledBitmap(@NonNull Bitmap src, int dstWidth, int dstHeight,
boolean filter) {
    Matrix m = new Matrix();

    final int width = src.getWidth();
    final int height = src.getHeight();
    if (width != dstWidth || height != dstHeight) {
        final float sx = dstWidth / (float) width;
        final float sy = dstHeight / (float) height;
        m.setScale(sx, sy);
    }
    return Bitmap.createBitmap(src, 0, 0, width, height, m, filter);
}

BitmapFactory

이전까지는 Bitmap 객체를 직접 생성하거나 다른 Bitmap의 일부를 변환하여 복제하는 방식으로 Bitmap을 생성하였습니다. 하지만 실제 Android 프로젝트 환경에서는 대부분 이미지를 파일, 네트워크 스트림, 또는 앱의 리소스 등과 같은 외부 소스를 불러와서 처리합니다. 이러한 소스들을 또한 Bitmap 객체로 만들기 위해서는 BitmapFactory라는 객체의 도움이 필요하며, BitmapFactory를 통해 이미지 파일 또는 네트워크로 받아오는 이미지 스트림 데이터를 Bitmap으로 변환할 수 있게 됩니다.

BitmapFactory는 다양한 이미지 포맷의 데이터를 파싱하여 Bitmap 객체로 디코드하는 기능을 제공합니다. 해당 과정은 내부적으로 JNI를 통해 네이티브 영역으로 전달되어 디코드과 메모리 할당이 이루어집니다. 즉, BitmapFactory 또한 Bitmap과 같이 직접적으로 Java/Kotlin 영역에서 처리되는 것이 아닌, 네이티브 메서드에서 처리가 진행됩니다. 실제로 BitmapFactory 내부 코드 하단에는 JNI 들이 존재하는 것을 확인하실 수 있습니다.

...

@UnsupportedAppUsage
private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
Rect padding, Options opts, long inBitmapHandle, long colorSpaceHandle);
@UnsupportedAppUsage
private static native Bitmap nativeDecodeFileDescriptor(FileDescriptor fd,
Rect padding, Options opts, long inBitmapHandle, long colorSpaceHandle);
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
private static native Bitmap nativeDecodeAsset(long nativeAsset, Rect padding, Options opts,
long inBitmapHandle, long colorSpaceHandle);
@UnsupportedAppUsage
private static native Bitmap nativeDecodeByteArray(byte[] data, int offset,
int length, Options opts, long inBitmapHandle, long colorSpaceHandle);
private static native boolean nativeIsSeekable(FileDescriptor fd);

...

앞서 BitmapFactory는 파일 소스 유형에 따라 다양한 팩터리 메서드를 제공하고 있다고 언급하였습니다. 아래부터는 간략하게 소스들을 디코드하는 메서드들에 대해 간략하게 소개하겠습니다. 더욱 자세한 정보를 원할 경우 [해당 소스]를 참고하시길 바랍니다.

decodeFile

decodeFile() 메서드는 특정 파일 경로에 저장된 이미지 데이터를 Bitmap 객체로 디코드하는 연산을 수행합니다. 이때 파라미터로 들어가는 파일의 경로는 절대경로여야만 하며, 파일 로드에 실패할 경우 null을 반환합니다.

public static Bitmap decodeFile(String pathName) {
   return decodeFile(pathName, null);
}

decodeResource

decodeResource() 메서드는 안드로이드 패키지 내부에 포함된 리소스를 Bitmap 객체로 디코드하는 데 사용됩니다. 여기에서의 리소스는 주로 res/drawable 등 리소스 폴더에 저장된 이미지 파일들을 불러올 때 사용됩니다.

public static Bitmap decodeResource(Resources res, int id, Options opts) {
    
    ...
    
}

decodeResourceStream

decodeResourceStream() 메서드는 InputStream을 Bitmap으로 디코드하는 메서드입니다. 스트림이 null이거나 디코드할 수 없는 경우에는 null을 반환합니다.

@Nullable
public static Bitmap decodeStream(@Nullable InputStream is, @Nullable Rect outPadding,
@Nullable Options opts) {
  
  ...  
   
}

decodeByteArray

BitmapFactory.decodeByteArray() 메서드는 메모리에 바이트 배열로 존재하는 이미지 데이터를 Bitmap 객체로 디코드 하는 메서드입니다. 해당 메서드도 마찬가지로 디코드에 실패할 경우 null을 반환합니다.

public static Bitmap decodeByteArray(byte[] data, int offset, int length, Options opts) {
	
	...
	
}

BitmapFactory.Option

BitmapFactory는 이미지 소소를 로드하는데 사용되는 팩터리지만, 한편으로 너무 큰 이미지 리소스를 로드하였을 때 발생할 수 있는 문제나 Bitmap의 속성들을 생성 과정에서 제어할 수 없다는 사항들이 존재합니다. BitmapFactory.Option은 이미지를 디코드할 때에 대한 옵션을 설정할 수 있도록 도와줘 메모리 효율성과 성능을 개선할 때 큰 도움을 줍니다. 실제로 흔히 사용되는 이미지 라이브러리들은 해당 속성들을 전략적으로 활용하여 메모리 효율성과 성능을 개선하였습니다.

inSampleSize

해당 필드는 이미지를 디코드할 때 픽셀 단위로 얼마나 축소하여 메모리에 로드할 지 결정하는 값입니다. 예를 들어 값이 1일 경우에는 원본 크기가 되며 4일 경우 넓이와 높이를 각각 1/4로 줄여 총 픽셀 수를 1/16로 줄여 로드하는 방식입니다. 해당 필드를 전략적으로 사용할 때 OOM(이하 OutOfMemory)을 방지하고 이미지 로딩 속도를 향상시키는데 도움을 줍니다.

inJustDecodeBounds

해당 플래그를 true로 설정한 상태로 디코드 할 경우 BitmapFactory는 실제 픽셀 데이터를 메모리에 로드하지 않고 이미지의 크기 및 타입 정보만 읽어옵니다. 해당 속성은 앞서 소개한 inSampleSize를 개선하기 전 이미지의 원본 크기를 알아내어 적절한 축소비율로 결정할 수 있도록 도와줄 수 있습니다.

inPreferredConfig

디코드할 이미지 소스가 Bitmap으로 변환되었을 때의 픽셀 포맷을 지정합니다. 이미지는 기본적으로 ARGB_8888을 사용하여 로드됩니다.

inMutable

디코드 된 Bitmap의 isMutable 속성을 결정하는 플래그로 기본값은 false입니다. true로 설정할 경우 Bitmap으로 변환 후 픽셀 데이터를 수정하거나 Canvas에 연결하여 그림을 그리는 작업을 수행할 수 있게 됩니다.

inBitmap

이미 사용이 끝난 Bitmap 객체를 새롭게 디코드할 소스가 메모리를 재활용할 수 있도록 처리하는 메서드입니다. 일반적으로 새로운 Bitmap객체가 생성될 때마다 새로운 메모리를 할당하지만, 해당 방식을 채택할 경우 재활용 가능한 메모리를 그대로 사용하여 GC의 발생 빈도를 낮춰 성능을 향상시킬 수 있습니다. (비트맵과 같은 데이터들은 GC로 처리할 때, 상대적으로 높은 비용이 부담될 수 있습니다.) 단 inBitmap을 사용할 경우 isMutable이 허용되어야 하고, 사이즈가 크거나 같아야 한다는 등의 여러 가지 제약조건이 존재합니다. 이러한 제약조건을 극복하고 효율적으로 활용하기 위해서 이미지 라이브러리들은 Bitmap Pool을 설계하고 해당 Pool 기반하에 어떤 Bitmap을 재사용하고 해제할지에 대한 전략들을 구성해 두었습니다.

압축

지금까지는 어떠한 색상 집합이나 소스, 때로는 비어 있는 Bitmap 객체를 생성하였지만, 반대로 Bitmap을 특정 이미지 포맷으로 압축하여 저장할 수 있는 기능이 존재합니다. 압축하기 위해서는 Bitmap.compress() 메서드를 사용하여야 하며, 압축 포맷은 JPEG, PNG, WEBP 등이 존재합니다. compress 메서드는 아래와 같은 형태로 구성되어 있습니다.

public boolean compress(@NonNull CompressFormat format, int quality,
                        @NonNull OutputStream stream)

알아두어야 할 점은 반환 값은 압축 성공 여부를 판단하는 플래그 값이며, 실제 압축 데이터는 파라미터에 정의한 OutputStream에 저장됩니다. quality는 압축의 품질을 의미하며, 0 - 100까지의 값으로 지정할 수 있습니다. 이때 quality 설정에 따른 결과 데이터는 압축 포맷에 따라 달라질 수 있습니다. 대표적으로 PNG 포맷의 경우 quality를 설정해도 무시됩니다.

변환

Bitmap을 변환하기 위해서는 android.graphics.Matrix라는 클래스가 요구됩니다. Matrix는 2D변환을 지원하는 클래스로 이미지의 픽셀 좌표를 수학적으로 변경하여 이미지를 시각적으로 회전, 크기 조절, 이동 그리고 기울이기 같은 변화를 만들어 낼 수 있습니다. Matrix에 대한 자세한 내용은 [Matrix 관련 포스트]를 통해 확인할 수 있습니다.

아래 예시는 Bitmap.createBitmap() 메서드에 Matrix와 변환하고자 하는 Bitmap을 인자로 넣어 새로운 이미지를 생성합니다.

val transformedBitmap = Bitmap.createBitmap(
    bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)

sameAs

두 비트맵이 완전히 동일한지 비교하는 연산입니다. 이때 비교하는 대상은 크기, Config, 픽셀데이터로 화면에서 보이는 모습이 같더라도 픽셀이 하나라도 다르거나 Config가 다를 경우 false를 출력하게 됩니다.

val bitmap1 = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).apply {
    eraseColor(Color.RED)
}

val bitmap2 = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).apply {
    eraseColor(Color.RED)
}

val bitmap3 = Bitmap.createBitmap(50, 50, Bitmap.Config.RGB_565).apply {
    eraseColor(Color.RED)
}

println(bitmap1.sameAs(bitmap2)) // true
println(bitmap1.sameAs(bitmap3)) // false (config가 다르기 떄문이다.)

앞서 소개한 기능 외에도 Bitmap에는 값을 획득하거나 제어할 수 있는 다양한 기능들이 존재합니다. 이와 관련한 자세한 내용들은 해당 소스나 문서를 통해 참고하시길 바랍니다.