Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 57 additions & 14 deletions core/ui/src/main/java/com/twix/ui/image/ImageGenerator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ class ImageGenerator(
* 주어진 [Uri]로부터 이미지를 읽어 JPEG 형식의 [ByteArray]로 변환한다.
*
* 내부 동작 과정:
* 1. [android.content.ContentResolver.openInputStream]으로 InputStream을 연다.
* 2. [android.graphics.BitmapFactory.decodeStream]으로 Bitmap 디코딩
* 3. JPEG(품질 90) 압축 후 ByteArray 반환
* 1. EXIF 메타데이터로 회전 방향 확인
* 2. [Uri]로부터 Bitmap 디코딩 (메모리 최적화를 위해 샘플링 적용)
* 3. 필요 시 회전 처리 후 JPEG(품질 90) 압축하여 [ByteArray] 반환
*
* 실패 케이스:
* - InputStream 열기 실패
* - 디코딩 실패 (손상 이미지 등)
* - 디코딩 실패 (손상된 이미지 등)
* - 압축 실패
*
* @param imageUri 변환할 이미지 Uri (content:// 또는 file://)
Expand All @@ -40,9 +40,7 @@ class ImageGenerator(
else -> bitmap
}

/**
* 회전된 새로운 비트맵이 생성되었다면 원본은 즉시 해제
* */
// 회전된 새 비트맵이 생성된 경우 원본 즉시 해제
if (rotatedBitmap !== bitmap) bitmap.recycle()
bitmapToByteArray(rotatedBitmap)
} catch (e: Exception) {
Expand All @@ -51,23 +49,68 @@ class ImageGenerator(
}

/**
* [Uri] 로부터 실제 [Bitmap] 을 디코딩한다.
* [Uri]로부터 [Bitmap]을 디코딩한다.
*
* 새로운 InputStream을 열어 [BitmapFactory.decodeStream] 으로 변환한다.
* 메모리 사용량을 줄이기 위해 두 단계로 디코딩한다.
* 1. [BitmapFactory.Options.inJustDecodeBounds]로 이미지 크기만 먼저 읽기
* 2. [calculateInSampleSize]로 샘플 크기를 계산한 뒤 실제 디코딩
*
* @throws ImageProcessException.DecodeFailedException 디코딩 실패 시
*/
private fun uriToBitmap(imageUri: Uri): Bitmap =
private fun uriToBitmap(imageUri: Uri): Bitmap {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
contentResolver.openInputStream(imageUri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream)
BitmapFactory.decodeStream(inputStream, null, bounds)
}

val options =
BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(bounds, 1920, 1080)
}

return contentResolver.openInputStream(imageUri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, options)
} ?: throw ImageProcessException.DecodeFailedException(imageUri)
}

/**
* 목표 해상도([reqWidth] x [reqHeight])에 맞는 최적의 [BitmapFactory.Options.inSampleSize]를 계산한다.
*
* 반환값은 2의 거듭제곱이며, 디코딩된 이미지가 목표 해상도보다 작아지지 않는 최대값을 반환한다.
*
* @param options outWidth, outHeight가 채워진 [BitmapFactory.Options]
* @param reqWidth 목표 너비 (px)
* @param reqHeight 목표 높이 (px)
* @return 계산된 inSampleSize (최솟값 1)
*/
fun calculateInSampleSize(
options: BitmapFactory.Options,
reqWidth: Int,
reqHeight: Int,
): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1

if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2

while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}

return inSampleSize
}

/**
* [Bitmap] 을 JPEG 형식(품질 90)으로 압축하여 [ByteArray] 로 변환한다.
* [Bitmap]을 JPEG 형식(품질 90)으로 압축하여 [ByteArray]로 변환한다.
*
* 압축 완료 후 메모리 절약을 위해 내부에서 [Bitmap.recycle] 을 호출한다.
* 따라서 호출 이후 전달한 Bitmap은 재사용하면 안 된다.
* 압축 완료 후 [Bitmap.recycle]을 호출하므로, 이후 해당 [Bitmap]을 재사용해선 안 된다.
*
* @param bitmap 압축 대상 Bitmap
* @return JPEG 바이트 배열
* @throws ImageProcessException.CompressionFailedException 압축 실패 시
*/
private fun bitmapToByteArray(bitmap: Bitmap): ByteArray {
val outputStream = ByteArrayOutputStream()
Expand Down