안드로이드 Bitmap 최적화(Resize)한 다중 이미지 서버에 업로드하기 1 - 비트맵 다이어트 시키기

임현주·2022년 5월 3일
5
post-thumbnail

시작

혼자 공부하면서 정리하고 싶은 부분을 작성한 글입니다 👀
함께 공부하는 사람에게는 도움이 되었으면 좋겠고,
혹시 제가 잘못 이해한 부분이 있다면 알려주시면 감사하겠습니다 💌


비트맵 다이어트 시키기

사진 한 장 올릴 때는 원본을 올려도 문제가 없었지만(사실 이것 또한 정말 큰 사이즈의 사진을 만나면 언젠가는 터질 문제였던 것..) 백엔드 서버에 리뷰 사진을 다량으로 업로드하면서 413(Request Entity Too Large) 에러를 계속 만나게 되었다. 직접 촬영한 원본 이미지를 10장씩이나 보냈기 때문에 당연히 파일 크기 허용량을 초과할 수밖에 없었다. 사진을 업로드하는 데에 있어 Bitmap 최적화는 필수라는 것을 몸소 느꼈다 😖

파일 관련 주요 개념이 많아서 필요한 부분을 조각으로 공부하다보니 꼭 한번 정리해보고싶어서 작성해 보았다. 같은 문제를 겪고 있는 분들께 도움이 되기를..✨


👉🏻 이 글은 안드로이드 도큐먼트 기준으로 작성했으며, 코틀린을 사용했습니다 :)

FileUtil 클래스 생성

FileUtil 클래스에서 아래 과정으로 최적화를 진행해 볼 것이다.

임시 파일 생성 ➡ Bitmap 리사이징 ➡ Bitmap을 JPEG로 압축하여 저장 ➡ 최적화된 임시 파일의 저장 경로 리턴

안드로이드에서 서버로 이미지를 보낼 때 Multipart를 사용하여 FormData를 담아 보내게 된다.

MultipartBody.Part.createFormData(name: String, filename: String?, body: RequestBody)
  • name : 서버에서 받는 키 값
  • filename : 파일 이름
  • body : 파일 경로(pathname)를 가지는 RequestBody 객체

FileUtil은 위의 body 객체를 만들기 위해 파일 경로를 반환해주기 위한 클래스라고 보면 된다.



🎈 캐시 파일 생성 및 경로 반환

fun optimizeBitmap(context: Context, uri: Uri): String? {
    try {
        val storage = context.cacheDir // 임시 파일 경로
        val fileName = String.format("%s.%s", UUID.randomUUID(), "jpg") // 임시 파일 이름
        
        val tempFile = File(storage, fileName)
        tempFile.createNewFile() // 임시 파일 생성
        
        // 지정된 이름을 가진 파일에 쓸 파일 출력 스트림을 만든다.
        val fos = FileOutputStream(tempFile) 
        
        decodeBitmapFromUri(uri)?.apply {
        	compress(Bitmap.CompressFormat.JPEG, 100, fos)
            recycle()
        } ?: throw NullPointerException()

        fos.flush()
        fos.close()

        return tempFile.absolutePath // 임시파일 저장경로 리턴
        
    } catch (e:Exception) {
        Log.e(TAG, "FileUtil - ${e.message}")
    }

    return null
}

먼저 내부 저장소에 캐시 파일을 생성할 것이다. 여러 장의 이미지를 저장할 것이기 때문에 파일 이름 중복 방지를 위해 UUID.randomUUID() 를 통해 이름을 생성해준다.

내부 저장소

구동하는 어플리케이션에서만 접근이 가능한 저장소. 앱 삭제 시 내부 저장소 데이터도 모두 삭제됨.
👉🏻 압축된 이미지를 굳이 우리 폰에 저장할 필요가 없기 때문에 내부 저장소를 이용하는 것!

외부 저장소

어떤 어플리케이션에서든 접근 가능한 저장 공간. 앱 삭제시에도 데이터 유지.

파일 관련 내용을 다루면서 try-catch문은 다들 필수인거 아시쥬 ผ(•̀_•́ผ) ? Exception 발생 시 e.printStackTrace()로 찍으면 호출한 부분부터 에러 발생 A-Z까지 보여주는 느낌으로 출력되는데 요런식으로 외부에 노출되면 큰일이기 때문에 현업에서는 사용하지 않는다고 한다.


FileOutputStream

데이터를 파일에 바이트 스트림으로 저장하기 위해 사용.

  • flush()
    더 이상 출력 될 데이터가 없을 때, 마지막 부분에 호출하여 출력 스트림 내부에 작은 버퍼에 남아있는 데이터를 모두 출력 시키고 버퍼를 비운다.

  • close()
    OutputStream을 더 이상 사용하지 않을 때 호출하여 사용했던 시스템 자원을 풀어준다.

👩‍💻 스트림이란?

음성, 영상, 데이터 등의 작은 조각들이 하나의 줄기를 이루며 전송되는 열.
데이터, 패킷, 비트 등의 일련의 연속성을 갖는 흐름을 의미한다.
물리 디스크 상의 파일, 장치를 통일된 방식으로 다루기 위한 가상적인 개념이다.


bitmap compress

형식을 지정해서 Bitmap을 압축하는 메소드이다.

public boolean compress(Bitmap.CompressFormat format, int quality, OutputStream stream) {...}
  • CompressFormat
    압축할 파일 타입. 보통 JPEG, PNG를 사용한다.
    우리는 투명 값을 가진 이미지를 저장하는 것이 아니므로 압축 속도가 더 빠르고, 압축 시 파일 용량이 더 작은 JPEG 타입을 사용할 것이다.

  • quality
    압축 정도. 0~100의 숫자를 넣으면 (quality)%로 압축된다.

  • OutputStream
    Bitmap 이미지를 저장하기 위한 output stream 객체를 받는다.

Bitmap은 OS에서 제대로 관리를 해주지 않기 때문에 메모리 누수가 나지 않기 위해서는 별도의 관리를 해주어야 한다. 따라서, 사용 후 recycle() 호출은 필수이다 !



🎈 Bitmap 최적화 (Resize)

위에 구현한 optimizeBitmap() 메소드에서 bitmap compress를 하기 전에 최적화된 Bitmap을 반환하는 메소드이다.

// 최적화 bitmap 반환
private fun decodeBitmapFromUri(uri: Uri, context: Context): Bitmap? {
    
    // 인자값으로 넘어온 입력 스트림을 나중에 사용하기 위해 저장하는 BufferedInputStream 사용
    val input = BufferedInputStream(context.contentResolver.openInputStream(uri))
    
    input.mark(input.available()) // 입력 스트림의 특정 위치를 기억
    
    var bitmap: Bitmap?
    
    BitmapFactory.Options().run {
    	// inJustDecodeBounds를 true로 설정한 상태에서 디코딩한 다음 옵션을 전달
    	inJustDecodeBounds = true
		bitmap = BitmapFactory.decodeStream(input, null, this)
        
        input.reset() // 입력 스트림의 마지막 mark 된 위치로 재설정
        
        // inSampleSize 값과 false로 설정한 inJustDecodeBounds를 사용하여 다시 디코딩
        inSampleSize = calculateInSampleSize(this)
        inJustDecodeBounds = false
        
        bitmap = BitmapFactory.decodeStream(input, null, this)?.apply {
        	// 회전된 이미지 되돌리기에서 다시 언급할게용 :)
        	rotateImageIfRequired(this, uri)
        }
    }
    
    input.close()
    
    return bitmap
    
}

// 리샘플링 값 계산 : 타겟 너비와 높이를 기준으로 2의 거듭제곱인 샘플 크기 값을 계산
private fun calculateInSampleSize(options: BitmapFactory.Options): Int {
    val (height: Int, width: Int) = options.run { outHeight to outWidth }
    var inSampleSize = 1

    if (height > MAX_HEIGHT || width > MAX_WIDTH) {

        val halfHeight: Int = height / 2
        val halfWidth: Int = width / 2

        while (halfHeight / inSampleSize >= MAX_HEIGHT && halfWidth / inSampleSize >= MAX_WIDTH) {
            inSampleSize *= 2
        }
    }

    return inSampleSize
}

Content Provider, Content Resolver

  • Content Provider
    앱과 앱 저장소 사이에서 데이터 접근을 쉽게 하도록 관리해주는 클래스

  • Content Resolver
    Content Provider에 접근해 결과를 반환하는 브릿지 역할

Content Resolver를 통해 content://스키마를 가진 Uri를 전달해서 Content Provider가 제공하는 데이터에 접근 가능해지는 것이다.

BitmapFactory

파일, 스트림 및 바이트 배열을 포함한 다양한 소스에서 Bitmap 객체를 만든다. Bitmap을 만들 수 있는 여러 가지 디코딩 메소드들을 제공하며, BitmapFactory.Options 객체로 디코딩 옵션을 지정할 수 있다.

  • inJustDecodeBounds = true
    메모리 할당을 방지한다. 디코딩할 때 이미지(데이터의 크기, 유형을 읽을 수 있음) 크기만 먼저 불러오기 때문에 OutOfMemory를 일으킬만한 큰 이미지를 불러와도 선처리를 가능하게 해준다.

  • inSampleSize
    얼만큼 줄여서 디코딩할지 지정하면 이미지를 서브 샘플링하여 더 작은 버전을 메모리에 로드하도록 지시한다.

decodeStream

@Nullable
public static Bitmap decodeStream(@Nullable InputStream is, @Nullable Rect outPadding, @Nullable BitmapFactory.Options opts) {...}
  • InputStream
    비트맵으로 디코딩 할 원시 데이터를 보유하는 입력 스트림.

  • outPadding
    null이 아닌 경우, 비트맵에 대한 Padding 사각형 반환.
    null인 경우, Padding을 [-1, -1, -1, -1]로 설정.

  • opts
    다운 샘플링을 제어하는 옵션과 이미지가 완전히 디코딩 되어야 하는지, 크기만 반환되어야 하는지.



여기까지 진행하면 이미지 최적화 완료!인 줄 알았는데 띠용... 몇 몇 이미지가 회전된 채로 서버에 날아갔다..^^..?ㅎㅎㅎ

이런 말 없었자나요..(ノ`Д)ノ 찾아보니 원본 이미지를 가로로 찍었을 경우 이런 현상이 발생할 수 있다고... 결론은 얼마나 회전 됐는지 정보를 받아온 뒤, 그만큼 또 반대로 회전시켜서 저장해야한다...ㅋㅅㅋ



🎈 회전된 이미지 되돌리기

private fun rotateImageIfRequired(context: Context, bitmap: Bitmap, uri: Uri): Bitmap? {
    val input = context.contentResolver.openInputStream(uri) ?: return null

    val exif = if (Build.VERSION.SDK_INT > 23) {
        ExifInterface(input)
    } else {
        ExifInterface(uri.path!!)
    }

    val orientation =
        exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)

    return when (orientation) {
        ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90)
        ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180)
        ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270)
        else -> bitmap
    }
}

private fun rotateImage(bitmap: Bitmap, degree: Int): Bitmap? {
    val matrix = Matrix()
    matrix.postRotate(degree.toFloat())
    return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}

이미지가 가지고 있는 정보의 집합 클래스인 ExifInterface에서 얼마나 회전했는지 정보를 받아와 rotateImage 메소드를 통해 재회전을 시켜 리턴해준다. rotateImageIfRequired 메소드는 optimizeBitmap에서 decodeStream을 진행한 후에 호출해주면 된다.



FileUtil 전체 코드

object FileUtil {

	private const val MAX_WIDTH = 1280
	private const val MAX_HEIGHT = 960

    ...
    
    fun optimizeBitmap(context: Context, uri: Uri): String? {
        try {
            val storage = context.cacheDir
            val fileName = String.format("%s.%s", UUID.randomUUID(), "jpg")

            val tempFile = File(storage, fileName)
            tempFile.createNewFile()

            val fos = FileOutputStream(tempFile)

            decodeBitmapFromUri(uri)?.apply {
                compress(Bitmap.CompressFormat.JPEG, 100, fos)
                recycle()
            } ?: throw NullPointerException()

            fos.flush()
            fos.close()

            return tempFile.absolutePath

        } catch (e:Exception) {
            Log.e(TAG, "FileUtil - ${e.message}")
        }

        return null
    }
    
    private fun decodeBitmapFromUri(uri: Uri, context: Context): Bitmap? {

        val input = BufferedInputStream(context.contentResolver.openInputStream(uri))

        input.mark(input.available())

        var bitmap: Bitmap?

        BitmapFactory.Options().run {
            inJustDecodeBounds = true
            bitmap = BitmapFactory.decodeStream(input, null, this)

            input.reset()

            inSampleSize = calculateInSampleSize(this)
            inJustDecodeBounds = false

            bitmap = BitmapFactory.decodeStream(input, null, this)?.apply {
                rotateImageIfRequired(this, uri)
            }
        }

        input.close()

        return bitmap

    }

    private fun calculateInSampleSize(options: BitmapFactory.Options): Int {
        val (height: Int, width: Int) = options.run { outHeight to outWidth }
        var inSampleSize = 1

        if (height > MAX_HEIGHT || width > MAX_WIDTH) {

            val halfHeight: Int = height / 2
            val halfWidth: Int = width / 2

            while (halfHeight / inSampleSize >= MAX_HEIGHT && halfWidth / inSampleSize >= MAX_WIDTH) {
                inSampleSize *= 2
            }
        }

        return inSampleSize
    }

    private fun rotateImageIfRequired(context: Context, bitmap: Bitmap, uri: Uri): Bitmap? {
        val input = context.contentResolver.openInputStream(uri) ?: return null

        val exif = if (Build.VERSION.SDK_INT > 23) {
            ExifInterface(input)
        } else {
            ExifInterface(uri.path!!)
        }

        val orientation =
            exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)

        return when (orientation) {
            ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90)
            ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180)
            ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270)
            else -> bitmap
        }
    }

    private fun rotateImage(bitmap: Bitmap, degree: Int): Bitmap? {
        val matrix = Matrix()
        matrix.postRotate(degree.toFloat())
        return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    }
    
}

로그를 찍어서 얼마나 압축됐는지 확인해보면 더 좋겠쥬 ( •̀ ω •́ )y

아직 최적화만 진행한거라 앞으로 더 담을 내용이 많아서 시리즈로 나눌 예정이다 ^_ㅠ
다음 포스팅은 FormData에 담아 retrfit을 통해 서버로 보내는 내용을 담을 것이다 뿅 💨

profile
🐰 피드백은 언제나 환영합니다

1개의 댓글

comment-user-thumbnail
2022년 10월 4일

좋은 정보 감사합니다~!

답글 달기