Skip to content
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

[REF/#198] ContentUriRequest Class의 이미지 압축 방식을 개선합니다. #200

Open
wants to merge 23 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d33a01a
[REF/#193] 개선된 압축 방식 적용 및 성능 테스트 로그 추가
angryPodo Feb 10, 2025
1ccfc5c
[REF/#193] 개선된 압축 방식 및 성능 최적화
angryPodo Feb 10, 2025
48028a9
[MOD/#193] 코드 정리
angryPodo Feb 10, 2025
ec66a0e
[MOD/#193] lint 오류 수정
angryPodo Feb 10, 2025
d2484cb
[MOD/#193] 스트링 한글로 변경 및 안정성 보완
angryPodo Feb 10, 2025
a0ee0dd
[REF/#198] 개선된 압축 방식 적용 및 성능 테스트 로그 추가
angryPodo Feb 10, 2025
f035fe3
[REF/#198] 개선된 압축 방식 및 성능 최적화
angryPodo Feb 10, 2025
59622bc
[MOD/#198] 코드 정리
angryPodo Feb 10, 2025
32f04f2
[MOD/#198] lint 오류 수정
angryPodo Feb 10, 2025
93ea73d
[MOD/#198] 스트링 한글로 변경 및 안정성 보완
angryPodo Feb 10, 2025
95c3275
Merge remote-tracking branch 'origin/refactor/#198-content-uri' into …
angryPodo Feb 10, 2025
1ea3d84
[MOD/#198] 테스트 코드 커밋
angryPodo Feb 10, 2025
7d8cc4a
Revert "[MOD/#198] 테스트 코드 커밋"
angryPodo Feb 10, 2025
5f42a87
[MOD/#198] 주석 변경
angryPodo Feb 10, 2025
59e3457
[MOD/#198] 비동기 처리 적용으로 전체 처리 시간 단축
angryPodo Feb 11, 2025
063f77d
[MOD/#198] 성능 향상, 예외 처리 추가
angryPodo Feb 11, 2025
121b411
[MOD/#198] 초기 크기 조절 및 불필요 배열 복사 방지
angryPodo Feb 11, 2025
4e908e9
[MOD/#198] calculateInSampleSize 함수 성능 최적화
angryPodo Feb 11, 2025
c2b3c98
[MOD/#198] compressBitmap 함수 메모리 할당 개선
angryPodo Feb 11, 2025
60f54cf
[MOD/#198] lint format
angryPodo Feb 11, 2025
77cd50d
[MOD/#198] lint format
angryPodo Feb 11, 2025
54fa0ea
[MOD/#198] lint 오류 수정
angryPodo Feb 11, 2025
e6a0538
[MOD/#198] lint 오류 수정
angryPodo Feb 11, 2025
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
3 changes: 1 addition & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ dependencies {
// Androidx
implementation(libs.bundles.androidx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.kotlinx.immutable)

// Network
implementation(platform(libs.okhttp.bom))
Expand All @@ -88,8 +89,6 @@ dependencies {
implementation(libs.lottie)
implementation(libs.advanced.bottom.sheet)

implementation(libs.kotlinx.immutable)

// Naver Map
implementation(libs.bundles.naverMap)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.spoony.spoony.core.database

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.spoony.spoony.core.database.entity.SearchEntity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DBModule {
object DatabaseModule {
@Singleton
@Provides
fun providesDataBase(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package com.spoony.spoony.core.network

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Size
import androidx.annotation.RequiresApi
import com.spoony.spoony.BuildConfig
import java.io.ByteArrayOutputStream
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okio.BufferedSink
import timber.log.Timber

class ContentUriRequestBody @Inject constructor(
context: Context,
private val uri: Uri?,
private val config: ImageConfig = ImageConfig.DEFAULT
) : RequestBody() {
private val contentResolver = context.contentResolver
private var compressedImage: ByteArray? = null
private var metadata: ImageMetadata? = null

private data class ImageMetadata(
val fileName: String,
val size: Long,
val mimeType: String?
) {
companion object {
val EMPTY = ImageMetadata("", 0L, null)
}
}

data class ImageConfig(
val maxWidth: Int,
val maxHeight: Int,
val maxFileSize: Int,
val initialQuality: Int,
val minQuality: Int,
val format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG
) {
companion object {
val DEFAULT = ImageConfig(
maxWidth = 1024,
maxHeight = 1024,
maxFileSize = 1024 * 1024,
initialQuality = 100,
minQuality = 20
)
}
}

init {
uri?.let {
metadata = extractMetadata(it)
}
}

private fun extractMetadata(uri: Uri): ImageMetadata =
runCatching {
contentResolver.query(
uri,
arrayOf(
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.DISPLAY_NAME
),
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
ImageMetadata(
fileName = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)),
size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)),
mimeType = contentResolver.getType(uri)
)
} else {
ImageMetadata.EMPTY
}
} ?: ImageMetadata.EMPTY
}.getOrDefault(ImageMetadata.EMPTY)
Comment on lines +91 to +94
Copy link
Member

Choose a reason for hiding this comment

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

p4) 위에 import하면 이렇게도 접근 가능하답니다~ 그런데 명시적으로 표시하기 위해 import하지 않는것도 좋은 방법이라 생각해서 참고만 하시길!

Suggested change
ImageMetadata.EMPTY
}
} ?: ImageMetadata.EMPTY
}.getOrDefault(ImageMetadata.EMPTY)
EMPTY
}
} ?: EMPTY
}.getOrDefault(EMPTY)


suspend fun prepareImage(): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
uri?.let { safeUri ->
compressImage(safeUri).onSuccess { bytes ->
compressedImage = bytes
}
}
}
}

private suspend fun compressImage(uri: Uri): Result<ByteArray> =
withContext(Dispatchers.IO) {
loadBitmap(uri).map { bitmap ->
compressBitmap(bitmap).also {
bitmap.recycle()
}
}
}

@RequiresApi(Build.VERSION_CODES.Q)
private suspend fun loadBitmapWithImageDecoder(uri: Uri): Result<Bitmap> =
withContext(Dispatchers.IO) {
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)
}
}
}
}
Comment on lines +115 to +128
Copy link
Member

Choose a reason for hiding this comment

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

신기하네여~

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

근데 지금 찾아보니 ImageDecoder 도입이 API 28이네요! 분기 제거해도 될듯..????


private suspend fun loadBitmap(uri: Uri): Result<Bitmap> =
withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
loadBitmapWithImageDecoder(uri)
} else {
loadBitmapLegacy(uri)
}
}
Comment on lines +130 to +137
Copy link
Member

Choose a reason for hiding this comment

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

p4) 사소하지만 함수의 선언 순서대로 사용하는 것을 저는 더 선호합니다.
그렇게 작성해야 위에서 아래로 코드를 읽으며 더욱 흐름을 이해하는데 도움이 되는 것 같더라구요

아래 순서대로 작성하는건 어떨까요~
loadBitmap
loadBitmapWithImageDecoder
loadBitmapLegacy

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

저도 더 좋은것 같습니다~ 가독성 챙기는 방향으로 수정하겠습니다!


private suspend fun loadBitmapLegacy(uri: Uri): Result<Bitmap> =
withContext(Dispatchers.IO) {
runCatching {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}

contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input, null, options)
}

options.apply {
inJustDecodeBounds = false
inSampleSize = calculateInSampleSize(outWidth, outHeight)
inPreferredConfig = Bitmap.Config.ARGB_8888
}

contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input, null, options)
}?.let { bitmap ->
getOrientation(uri).let { orientation ->
if (orientation != ORIENTATION_NORMAL) {
rotateBitmap(bitmap, orientation)
} else {
bitmap
}
}
} ?: error("비트맵 디코딩 실패")
}
}

private suspend fun compressBitmap(bitmap: Bitmap): ByteArray =
withContext(Dispatchers.IO) {
val estimatedSize = min(bitmap.byteCount / 4, config.maxFileSize)
ByteArrayOutputStream(estimatedSize).use { buffer ->
var lowerQuality = config.minQuality
var upperQuality = config.initialQuality
var bestQuality = lowerQuality

while (lowerQuality <= upperQuality) {
val midQuality = (lowerQuality + upperQuality) / 2
buffer.reset()

bitmap.compress(config.format, midQuality, buffer)

if (buffer.size() <= config.maxFileSize) {
bestQuality = midQuality
lowerQuality = midQuality + 1
} else {
upperQuality = midQuality - 1
}
}

if (BuildConfig.DEBUG) {
Timber.d("Compression completed - Quality: $bestQuality, Size: ${buffer.size()} bytes")
}
Comment on lines +192 to +194
Copy link
Member

Choose a reason for hiding this comment

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

p3) 저희 Timeber 셋팅에서 이미 DEBUG 모드에서만 뜨게 설정해뒀을껄용?
안해뒀으면 여기에서만 설정하는 것이 아니라 app 단위에서 해주시면 감사하겠습니다!

Copy link
Collaborator

Choose a reason for hiding this comment

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

이미 설정 해놨지롱!!ㅎㅎㅎ 그냥 바로 Timber 사용해도 됩니다😊

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

몰랐어잉~ 수정할게요~

return@use buffer.toByteArray()
}
}

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)

private fun calculateInSampleSize(width: Int, height: Int): Int {
val ratio = max(1, min(height / config.maxHeight, width / config.maxWidth))
return Integer.highestOneBit(ratio)
}

private fun calculateTargetSize(width: Int, height: Int): Size {
val ratio = width.toFloat() / height.toFloat()
return if (width > height) {
Size(config.maxWidth, (config.maxWidth / ratio).toInt())
} else {
Size((config.maxHeight * ratio).toInt(), config.maxHeight)
}
}

override fun contentLength(): Long = compressedImage?.size?.toLong() ?: -1L
override fun contentType(): MediaType? = metadata?.mimeType?.toMediaTypeOrNull()
override fun writeTo(sink: BufferedSink) {
compressedImage?.let(sink::write)
}

fun toFormData(name: String): MultipartBody.Part = MultipartBody.Part.createFormData(
name,
metadata?.fileName ?: DEFAULT_FILE_NAME,
this
)

companion object {
private const val ORIENTATION_NORMAL = 0
Copy link
Member

Choose a reason for hiding this comment

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

p4) 이건 어떨지? Normal이라는게 의미가 모호한거같아여

Suggested change
private const val ORIENTATION_NORMAL = 0
private const val ORIENTATION_DEFAULT = 0

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

음~ 확실히 Default가 더 친숙하네요! 수정하겠습니당

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"
}
}
Loading
Loading