Skip to content

[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

Merged
merged 11 commits into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,6 +43,15 @@ class ContentUriRequestBody @Inject constructor(
}
}

/**
* 이미지 압축을 위한 설정값을 담는 데이터 클래스입니다.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kdoc👍

* @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,
Expand All @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와 진짜 잘 모르는건데 공부하겠습니다...

Copy link
Member Author

Choose a reason for hiding this comment

The 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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버 내부에서 어떻게 처리를 하는지 모르겠지만, 클라이언트에 저장하는 경우에 이름이 같으면 덮어쓰기를 해버려요. 물론 서버에서 이를 처리한다면 괜찮겠지만, 애초에 예방하는 방법으로 timestemp를 사용하는 것도 있답니다

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 동일 이미지로 여러번 테스트 했을때 덮어쓰기가 발생하지 않는 것을 확인했습니다.
따라서 지금은 timestamp를 추가할 필요가 없지만, 혹시 추후 파일명 충돌 이슈가 생긴다면 적용하겠습니다요

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미 Dispatchers.IO에서 실행되고 있는 것 같아요. withContext로 나타낸 이유가 있을까요?

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ class RegisterRepositoryImpl @Inject constructor(
Json.encodeToString(RegisterPostRequestDto.serializer(), requestDto)
.toRequestBody(MEDIA_TYPE_JSON.toMediaType())
}
// 각 사진에 대해 ContentUriRequestBody 인스턴스를 생성하고 비동기로 압축 준비 후 form-data 변환
val asyncPhotoParts = photos.map { uri ->
async {
ContentUriRequestBody(context, uri)
.apply { prepareImage() }
.toFormData(FORM_DATA_NAME_PHOTOS)
val photoBody = ContentUriRequestBody.getOrCreate(context, uri)
photoBody.prepareImage()
photoBody.toFormData(FORM_DATA_NAME_PHOTOS)
}
}
postService.registerPost(
Expand Down