-
Notifications
You must be signed in to change notification settings - Fork 0
[MOD/#207] 이미지 압축 로직을 다시한번 개선합니다 :( #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0bbe9b7
1207380
fa24573
c29c110
f9a5d70
bbeaf2f
4a0172e
7d627d5
03ae4e3
6e0c197
894494b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,15 +3,18 @@ package com.spoony.spoony.core.network | |
import android.content.Context | ||
import android.graphics.Bitmap | ||
import android.graphics.ImageDecoder | ||
import android.graphics.Matrix | ||
import android.media.ExifInterface | ||
import android.net.Uri | ||
import android.provider.MediaStore | ||
import android.util.Size | ||
import java.io.ByteArrayOutputStream | ||
import java.util.concurrent.ConcurrentHashMap | ||
import javax.inject.Inject | ||
import kotlin.math.max | ||
import kotlin.math.min | ||
import kotlinx.coroutines.Deferred | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.async | ||
import kotlinx.coroutines.coroutineScope | ||
import kotlinx.coroutines.withContext | ||
import okhttp3.MediaType | ||
import okhttp3.MediaType.Companion.toMediaTypeOrNull | ||
|
@@ -40,6 +43,15 @@ class ContentUriRequestBody @Inject constructor( | |
} | ||
} | ||
|
||
/** | ||
* 이미지 압축을 위한 설정값을 담는 데이터 클래스입니다. | ||
* @param maxWidth 리사이징 시 제한할 최대 너비 | ||
* @param maxHeight 리사이징 시 제한할 최대 높이 | ||
* @param maxFileSize 압축 후 목표하는 최대 파일 크기 (바이트) | ||
* @param initialQuality 압축 품질의 초기값 (0~100) | ||
* @param minQuality 압축 품질의 최소값 (0~100) | ||
* @param format 이미지 포맷 (기본은 JPEG) | ||
*/ | ||
data class ImageConfig( | ||
val maxWidth: Int, | ||
val maxHeight: Int, | ||
|
@@ -65,56 +77,90 @@ class ContentUriRequestBody @Inject constructor( | |
} | ||
} | ||
|
||
suspend fun prepareImage(): Result<Unit> = runCatching { | ||
withContext(Dispatchers.IO) { | ||
uri?.let { safeUri -> | ||
compressImage(safeUri).onSuccess { bytes -> | ||
compressedImage = bytes | ||
@Volatile | ||
private var prepareImageDeferred: Deferred<Result<Unit>>? = null | ||
Comment on lines
+80
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 와 진짜 잘 모르는건데 공부하겠습니다... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 어려워잉.. |
||
|
||
/** | ||
* prepareImage(): 이미지 압축 작업을 비동기로 준비합니다. | ||
* 동일 인스턴스 내에서 여러 호출이 동시에 들어오면, 첫 번째 작업의 Deferred를 공유합니다. | ||
*/ | ||
suspend fun prepareImage(): Result<Unit> { | ||
if (compressedImage != null) return Result.success(Unit) | ||
|
||
prepareImageDeferred?.let { return it.await() } | ||
|
||
return coroutineScope { | ||
val deferred = async(Dispatchers.IO) { | ||
if (compressedImage == null && uri != null) { | ||
compressImage(uri).onSuccess { bytes -> | ||
compressedImage = bytes | ||
} | ||
} | ||
Result.success(Unit) | ||
} | ||
prepareImageDeferred = deferred | ||
deferred.await() | ||
} | ||
} | ||
|
||
fun toFormData(name: String): MultipartBody.Part = MultipartBody.Part.createFormData( | ||
name, | ||
metadata?.fileName ?: DEFAULT_FILE_NAME, | ||
this | ||
) | ||
/** | ||
* toFormData(): OkHttp Multipart 전송을 위해 RequestBody를 생성합니다. | ||
* 메타데이터에서 파일명을 가져오며, 없으면 기본 이름("image.jpg")을 사용합니다. | ||
*/ | ||
fun toFormData(name: String): MultipartBody.Part = | ||
MultipartBody.Part.createFormData( | ||
name = name, | ||
filename = metadata?.fileName ?: DEFAULT_FILE_NAME, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버 내부에서 어떻게 처리를 하는지 모르겠지만, 클라이언트에 저장하는 경우에 이름이 같으면 덮어쓰기를 해버려요. 물론 서버에서 이를 처리한다면 괜찮겠지만, 애초에 예방하는 방법으로 timestemp를 사용하는 것도 있답니다 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금 동일 이미지로 여러번 테스트 했을때 덮어쓰기가 발생하지 않는 것을 확인했습니다. |
||
body = this | ||
) | ||
|
||
/** | ||
* compressImage(): 주어진 URI로부터 비트맵을 로드하고 압축 작업을 수행합니다. | ||
* 파일 크기가 1MB 이하이면 압축을 건너뛰고 100% 품질로 처리합니다. | ||
*/ | ||
private suspend fun compressImage(uri: Uri): Result<ByteArray> = | ||
withContext(Dispatchers.IO) { | ||
loadBitmap(uri).map { bitmap -> | ||
compressBitmap(bitmap).also { | ||
bitmap.recycle() | ||
if ((metadata?.size ?: 0) <= config.maxFileSize) { | ||
ByteArrayOutputStream().use { buffer -> | ||
bitmap.compress(config.format, 100, buffer) | ||
bitmap.recycle() | ||
buffer.toByteArray() | ||
} | ||
} else { | ||
compressBitmap(bitmap).apply { | ||
bitmap.recycle() | ||
} | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* loadBitmap(): ImageDecoder를 사용하여 URI에서 비트맵을 로드합니다. | ||
* 이미지 크기는 설정된 최대 크기(maxWidth, maxHeight) 이하로 리사이즈하며, | ||
* EXIF 정보는 ImageDecoder가 자동으로 처리합니다. | ||
*/ | ||
private suspend fun loadBitmap(uri: Uri): Result<Bitmap> = | ||
withContext(Dispatchers.IO) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이미 Dispatchers.IO에서 실행되고 있는 것 같아요. withContext로 나타낸 이유가 있을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이미 Dispatchers.IO에서 실행하지만 withContext 사용해 I/O 스레드에서의 실행을 보장할수있어서 따로 명시했습니다! |
||
runCatching { | ||
val source = ImageDecoder.createSource(contentResolver, uri) | ||
ImageDecoder.decodeBitmap(source) { decoder, info, _ -> | ||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE | ||
decoder.isMutableRequired = true | ||
calculateTargetSize(info.size.width, info.size.height).let { size -> | ||
decoder.setTargetSize(size.width, size.height) | ||
} | ||
} | ||
}.map { bitmap -> | ||
getOrientation(uri).let { orientation -> | ||
if (orientation != ORIENTATION_NORMAL) { | ||
rotateBitmap(bitmap, orientation) | ||
} else { | ||
bitmap | ||
} | ||
val size = calculateTargetSize(info.size.width, info.size.height) | ||
decoder.setTargetSize(size.width, size.height) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* compressBitmap(): CPU연산 부담이 큰 이미지 압축 작업은 Dispatchers.Default에서 처리합니다. | ||
* 이진 탐색을 통해 압축 품질을 결정하며, 메모리 기반 측정을 활용하여 압축 후 파일 크기를 측정합니다. | ||
* 임시파일 기반에서 바꾼 이유는 요즘 기기들 메모리 생각했을때 이게 맞는거 같음...속도도 빠르고... | ||
*/ | ||
private suspend fun compressBitmap(bitmap: Bitmap): ByteArray = | ||
withContext(Dispatchers.IO) { | ||
val estimatedSize = min(bitmap.byteCount / 4, config.maxFileSize) | ||
withContext(Dispatchers.Default) { | ||
val estimatedSize = max(32 * 1024, min(bitmap.byteCount / 4, config.maxFileSize)) | ||
ByteArrayOutputStream(estimatedSize).use { buffer -> | ||
var lowerQuality = config.minQuality | ||
var upperQuality = config.initialQuality | ||
|
@@ -124,7 +170,11 @@ class ContentUriRequestBody @Inject constructor( | |
val midQuality = (lowerQuality + upperQuality) / 2 | ||
buffer.reset() | ||
|
||
bitmap.compress(config.format, midQuality, buffer) | ||
if (config.format == Bitmap.CompressFormat.PNG) { | ||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, buffer) | ||
} else { | ||
bitmap.compress(config.format, midQuality, buffer) | ||
} | ||
|
||
if (buffer.size() <= config.maxFileSize) { | ||
bestQuality = midQuality | ||
|
@@ -145,7 +195,8 @@ class ContentUriRequestBody @Inject constructor( | |
uri, | ||
arrayOf( | ||
MediaStore.Images.Media.SIZE, | ||
MediaStore.Images.Media.DISPLAY_NAME | ||
MediaStore.Images.Media.DISPLAY_NAME, | ||
MediaStore.Images.Media.MIME_TYPE | ||
), | ||
null, | ||
null, | ||
|
@@ -155,65 +206,18 @@ class ContentUriRequestBody @Inject constructor( | |
ImageMetadata( | ||
fileName = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)), | ||
size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)), | ||
mimeType = contentResolver.getType(uri) | ||
mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)) | ||
) | ||
} else { | ||
ImageMetadata.EMPTY | ||
} | ||
} ?: ImageMetadata.EMPTY | ||
}.getOrDefault(ImageMetadata.EMPTY) | ||
|
||
private fun getOrientation(uri: Uri): Int { | ||
contentResolver.query( | ||
uri, | ||
arrayOf(MediaStore.Images.Media.ORIENTATION), | ||
null, | ||
null, | ||
null | ||
)?.use { cursor -> | ||
if (cursor.moveToFirst()) { | ||
return cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) | ||
} | ||
} | ||
return getExifOrientation(uri) | ||
} | ||
|
||
private fun getExifOrientation(uri: Uri): Int = | ||
contentResolver.openInputStream(uri)?.use { input -> | ||
ExifInterface(input).getAttributeInt( | ||
ExifInterface.TAG_ORIENTATION, | ||
ExifInterface.ORIENTATION_NORMAL | ||
) | ||
}?.let { orientation -> | ||
when (orientation) { | ||
ExifInterface.ORIENTATION_ROTATE_90 -> ORIENTATION_ROTATE_90 | ||
ExifInterface.ORIENTATION_ROTATE_180 -> ORIENTATION_ROTATE_180 | ||
ExifInterface.ORIENTATION_ROTATE_270 -> ORIENTATION_ROTATE_270 | ||
else -> ORIENTATION_NORMAL | ||
} | ||
} ?: ORIENTATION_NORMAL | ||
|
||
private fun rotateBitmap(bitmap: Bitmap, angle: Int): Bitmap = | ||
runCatching { | ||
Matrix().apply { | ||
postRotate(angle.toFloat()) | ||
}.let { matrix -> | ||
Bitmap.createBitmap( | ||
bitmap, | ||
0, | ||
0, | ||
bitmap.width, | ||
bitmap.height, | ||
matrix, | ||
true | ||
) | ||
} | ||
}.onSuccess { rotatedBitmap -> | ||
if (rotatedBitmap != bitmap) { | ||
bitmap.recycle() | ||
} | ||
}.getOrDefault(bitmap) | ||
|
||
/** | ||
* calculateTargetSize(): 이미지의 가로/세로 크기가 | ||
* 설정된 최대 크기를 넘지 않도록 리사이징할 크기를 계산합니다. | ||
*/ | ||
private fun calculateTargetSize(width: Int, height: Int): Size { | ||
if (width <= config.maxWidth && height <= config.maxHeight) { | ||
return Size(width, height) | ||
|
@@ -228,15 +232,31 @@ class ContentUriRequestBody @Inject constructor( | |
|
||
override fun contentType(): MediaType? = metadata?.mimeType?.toMediaTypeOrNull() | ||
|
||
/** | ||
* writeTo(): 압축된 이미지가 있을 경우, 해당 바이트 배열을 Sink에 씁니다. | ||
*/ | ||
override fun writeTo(sink: BufferedSink) { | ||
compressedImage?.let(sink::write) | ||
} | ||
|
||
companion object { | ||
private const val ORIENTATION_NORMAL = 0 | ||
private const val ORIENTATION_ROTATE_90 = 90 | ||
private const val ORIENTATION_ROTATE_180 = 180 | ||
private const val ORIENTATION_ROTATE_270 = 270 | ||
private const val DEFAULT_FILE_NAME = "image.jpg" | ||
const val DEFAULT_FILE_NAME = "image.jpg" | ||
|
||
/** | ||
* Uri를 키로 사용하여 ContentUriRequestBody 인스턴스를 저장하는 캐시입니다. | ||
* 이 캐시는 동일한 Uri에 대한 중복된 이미지 처리 작업을 방지합니다. | ||
* ConcurrentHashMap을 사용하여 멀티스레드 환경에서의 동시 접근을 안전하게 처리합니다. | ||
*/ | ||
private val cache = ConcurrentHashMap<Uri, ContentUriRequestBody>() | ||
|
||
fun getOrCreate( | ||
context: Context, | ||
uri: Uri, | ||
config: ImageConfig = ImageConfig.DEFAULT | ||
): ContentUriRequestBody { | ||
return cache.computeIfAbsent(uri) { | ||
ContentUriRequestBody(context, uri, config) | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
kdoc👍