@@ -16,13 +16,13 @@ class ImageGenerator(
1616 * 주어진 [Uri]로부터 이미지를 읽어 JPEG 형식의 [ByteArray]로 변환한다.
1717 *
1818 * 내부 동작 과정:
19- * 1. [android.content.ContentResolver.openInputStream]으로 InputStream을 연다.
20- * 2. [android.graphics.BitmapFactory.decodeStream]으로 Bitmap 디코딩
21- * 3. JPEG(품질 90) 압축 후 ByteArray 반환
19+ * 1. EXIF 메타데이터로 회전 방향 확인
20+ * 2. [Uri]로부터 Bitmap 디코딩 (메모리 최적화를 위해 샘플링 적용)
21+ * 3. 필요 시 회전 처리 후 JPEG(품질 90) 압축하여 [ ByteArray] 반환
2222 *
2323 * 실패 케이스:
2424 * - InputStream 열기 실패
25- * - 디코딩 실패 (손상 이미지 등)
25+ * - 디코딩 실패 (손상된 이미지 등)
2626 * - 압축 실패
2727 *
2828 * @param imageUri 변환할 이미지 Uri (content:// 또는 file://)
@@ -40,9 +40,7 @@ class ImageGenerator(
4040 else -> bitmap
4141 }
4242
43- /* *
44- * 회전된 새로운 비트맵이 생성되었다면 원본은 즉시 해제
45- * */
43+ // 회전된 새 비트맵이 생성된 경우 원본 즉시 해제
4644 if (rotatedBitmap != = bitmap) bitmap.recycle()
4745 bitmapToByteArray(rotatedBitmap)
4846 } catch (e: Exception ) {
@@ -51,23 +49,68 @@ class ImageGenerator(
5149 }
5250
5351 /* *
54- * [Uri] 로부터 실제 [Bitmap] 을 디코딩한다.
52+ * [Uri]로부터 [Bitmap]을 디코딩한다.
5553 *
56- * 새로운 InputStream을 열어 [BitmapFactory.decodeStream] 으로 변환한다.
54+ * 메모리 사용량을 줄이기 위해 두 단계로 디코딩한다.
55+ * 1. [BitmapFactory.Options.inJustDecodeBounds]로 이미지 크기만 먼저 읽기
56+ * 2. [calculateInSampleSize]로 샘플 크기를 계산한 뒤 실제 디코딩
57+ *
58+ * @throws ImageProcessException.DecodeFailedException 디코딩 실패 시
5759 */
58- private fun uriToBitmap (imageUri : Uri ): Bitmap =
60+ private fun uriToBitmap (imageUri : Uri ): Bitmap {
61+ val bounds = BitmapFactory .Options ().apply { inJustDecodeBounds = true }
5962 contentResolver.openInputStream(imageUri)?.use { inputStream ->
60- BitmapFactory .decodeStream(inputStream)
63+ BitmapFactory .decodeStream(inputStream, null , bounds)
64+ }
65+
66+ val options =
67+ BitmapFactory .Options ().apply {
68+ inSampleSize = calculateInSampleSize(bounds, 1920 , 1080 )
69+ }
70+
71+ return contentResolver.openInputStream(imageUri)?.use { inputStream ->
72+ BitmapFactory .decodeStream(inputStream, null , options)
6173 } ? : throw ImageProcessException .DecodeFailedException (imageUri)
74+ }
75+
76+ /* *
77+ * 목표 해상도([reqWidth] x [reqHeight])에 맞는 최적의 [BitmapFactory.Options.inSampleSize]를 계산한다.
78+ *
79+ * 반환값은 2의 거듭제곱이며, 디코딩된 이미지가 목표 해상도보다 작아지지 않는 최대값을 반환한다.
80+ *
81+ * @param options outWidth, outHeight가 채워진 [BitmapFactory.Options]
82+ * @param reqWidth 목표 너비 (px)
83+ * @param reqHeight 목표 높이 (px)
84+ * @return 계산된 inSampleSize (최솟값 1)
85+ */
86+ fun calculateInSampleSize (
87+ options : BitmapFactory .Options ,
88+ reqWidth : Int ,
89+ reqHeight : Int ,
90+ ): Int {
91+ val (height: Int , width: Int ) = options.run { outHeight to outWidth }
92+ var inSampleSize = 1
93+
94+ if (height > reqHeight || width > reqWidth) {
95+ val halfHeight: Int = height / 2
96+ val halfWidth: Int = width / 2
97+
98+ while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
99+ inSampleSize * = 2
100+ }
101+ }
102+
103+ return inSampleSize
104+ }
62105
63106 /* *
64- * [Bitmap] 을 JPEG 형식(품질 90)으로 압축하여 [ByteArray] 로 변환한다.
107+ * [Bitmap]을 JPEG 형식(품질 90)으로 압축하여 [ByteArray]로 변환한다.
65108 *
66- * 압축 완료 후 메모리 절약을 위해 내부에서 [Bitmap.recycle] 을 호출한다.
67- * 따라서 호출 이후 전달한 Bitmap은 재사용하면 안 된다.
109+ * 압축 완료 후 [Bitmap.recycle]을 호출하므로, 이후 해당 [Bitmap]을 재사용해선 안 된다.
68110 *
69111 * @param bitmap 압축 대상 Bitmap
70112 * @return JPEG 바이트 배열
113+ * @throws ImageProcessException.CompressionFailedException 압축 실패 시
71114 */
72115 private fun bitmapToByteArray (bitmap : Bitmap ): ByteArray {
73116 val outputStream = ByteArrayOutputStream ()
0 commit comments