텃텃
에선 일지 작성
과 프로필 변경
기능에서 이미지를 업로드한다.
100% Compose Android 앱이기 때문에 Compose에서 이미지를 가져오는 방법
이 필요했다.
또 Firebase의 Storage에 이미지를 저장하기 때문에 적절한 크기의 이미지로 변환하는 방법
이 필요했다. Spark 요금제.. ㅠㅠ
휴대폰 카메라 성능은 점점 좋아지기에 이미지 용량도 커진다.
내 휴대폰(갤럭시S23) 기준 이미지 한 장은 2~4MB의 용량을 가지는데, 이런 이미지를 그대로 업로드한다면 Storage 한도 5GB를 금새 넘길 것이기 때문에 이미지를 적절한 크기로 변환하는 것에 대한 고민이 있었다.
그래서 내가 적용한 방법을 다음과 같이 단계적으로 소개하려 한다.
Compose PhotoPicker
사용하기
이미지 Resizing
Firebase Storage에 업로드
하고 Download Url
가져오기
텃텃에서 Compose PhotoPicker
를 사용한 모습이다.
ChangeProfileScreen.kt
@Composable
fun ChangeProfileRoute(
viewModel: ChangeProfileViewModel = hiltViewModel()
) {
..
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = viewModel::handleImage
)
ChangeProfileScreen(
..
onChangeImage = { viewModel.onChangeImage(launcher) },
)
}
Android Activity에서 활동의 결과를 가져오기 위해 registerForActivityResult
콜백을 등록한 것처럼, Compose에서는 rememberLauncherForActivityResult
로 활동 결과 계약을 사용한다.
ChangeProfileViewModel.kt
fun onChangeImage(launcher: ManagedActivityResultLauncher<PickVisualMediaRequest, Uri?>) {
launcher.launch(
PickVisualMediaRequest(
mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}
fun handleImage(uri: Uri?) {
...
}
난 해당 launcher
를 ViewModel로 넘겨 실행시켰다.
launcher의 결과(Uri)는 onResult를 통해 반환하고,
handleImage
메서드를 통해 반환된 이미지 Uri를 처리해주기로 했다.
장점
구현이 간단함
사용성이 편함
READ_EXTERNAL_STORAGE
권한이 필요 없음
단점
여러 이미지 선택한 경우, 선택한 순서가 지켜지지 않음
KitKat
버전 이상으로 제한 (이전 버전은 이전 PhotoPicker를 띄워줌)
2번의 이전 PhotoPicker를 사용한 경우, 이미지 회전 정보가 전달되지 않는 이슈
(공식 문서) 사진 선택 도구는 사용자가 앱에 미디어 라이브러리 전체가 아닌 선택한 이미지 및 동영상에 대해서만 액세스 권한을 부여하도록 내장된 안전한 방법을 제공합니다.
Compose PhotoPicker의 제일 큰 장점은 READ_EXTERNAL_STORAGE
권한이 필요 없다는 점이다.
권한 요청이 없으므로 앱 사용성도 좋아지는 효과를 볼 수 있다.
하지만 개발하며 발견한 이슈로, 여러 이미지를 선택한 경우 선택한 순서가 지켜지지 않는다
란 문제점이 있다.
실제로 github issue에서 이것에 대해 문의한 내용을 봤었는데, 아직 해결되지 않았다.
그래서 장단점을 판단해보고 프로젝트에 적용하면 좋을 것 같다.
이미지 처리에 대한 공부가 더 필요해서 '이런 흐름이구나' 정도만 봐주면 좋겠다.
(Android 문서) 큰 비트맵을 효율적으로 로드를 참고했다.
@Singleton
class ImageUtil @Inject constructor(
@ApplicationContext private val context: Context
) {
..
Context
가 필요하기 때문에 ApplicationContext를 생성자에 주입해 사용하기로 했다.
fun getOptimizedFile(uri: Uri, maxWidth: Int, maxHeight: Int): File? {
try {
val tempFile = createTempImageFile()
val fos = FileOutputStream(tempFile)
decodeBitmapFromUri(uri, maxWidth, maxHeight)?.apply {
compress(Bitmap.CompressFormat.JPEG, 100, fos)
recycle()
} ?: return null
fos.flush()
fos.close()
return tempFile
} catch (e: Exception) {
Log.e(javaClass.name, "ImageUtil - ${e.message}")
}
return null
}
private fun createTempImageFile(): File {
val fileName = "${UUID.randomUUID()}.jpg"
return File(context.cacheDir, fileName)
}
앱 임시파일 경로에 randomUUID
를 이름으로 하는 임시 파일을 생성한다.
uri를 maxWidth
, maxHeight
로 최적화한 Bitmap
을 임시 파일에 압축해 저장하고 메모리 누수를 방지하기 위해 recycle()
을 호출한다.
FileOutputStream에 사용한 시스템 자원을 풀어주고 임시 파일을 반환한다.
private fun decodeBitmapFromUri(uri: Uri, maxWidth: Int, maxHeight: Int): Bitmap? {
var input = context.contentResolver.openInputStream(uri) ?: return null
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)
input.close()
input = context.contentResolver.openInputStream(uri) ?: return null
options.run {
inSampleSize = calculateInSampleSize(maxWidth, maxHeight)
inJustDecodeBounds = false
}
val bitmap = BitmapFactory.decodeStream(input, null, options) ?: return null
input.close()
val rotatedBitmap = rotateImageIfNeeded(bitmap, uri)
return resizeBitmapIfNeeded(rotatedBitmap, maxWidth, maxHeight)
}
private fun BitmapFactory.Options.calculateInSampleSize(maxWidth: Int, maxHeight: Int): Int {
val (width, height) = this.run { outWidth to outHeight }
var inSampleSize = 1
if (width > maxWidth || height > maxHeight) {
val halfWidth = width / 2
val halfHeight = height / 2
while (halfWidth / inSampleSize >= maxWidth && halfHeight / inSampleSize >= maxHeight) {
inSampleSize *= 2
}
}
return inSampleSize
}
inJustDecodeBounds = false
로 설정된 inSampleSize
로 디코딩하도록 설정한다.
inSampleSize
를 통해 이미지를 서브샘플링하여 더 작은 버전을 메모리에 로드시키도록 한다. 만약 inSampleSize = 4라면, 가로 해상도와 세로 해상도 모두 1/4
이 된다. 따라서 기존 메모리의 1/16
만 사용하게 된다.
inSampleSize
를 maxWidth, maxHeight를 기준으로 2의 거듭제곱의 값으로 계산되게 작성했다.
특정 이미지는 회전되어 업로드되는 경우가 발생했다. 원래 방향으로 되돌려야한다..
private fun rotateImageIfNeeded(bitmap: Bitmap, uri: Uri): Bitmap {
val filePath = getRealPath(uri) ?: return bitmap
val exif = ExifInterface(filePath)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
return when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> rotate(bitmap, 90F)
ExifInterface.ORIENTATION_ROTATE_180 -> rotate(bitmap, 180F)
ExifInterface.ORIENTATION_ROTATE_270 -> rotate(bitmap, 270F)
else -> return bitmap
}
}
private fun getRealPath(uri: Uri): String? {
var realPath: String? = null
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor = context.contentResolver.query(uri, projection, null, null, null)
try {
if (cursor != null && cursor.moveToFirst()) {
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
realPath = cursor.getString(columnIndex)
}
} finally {
cursor?.close()
}
return realPath
}
private fun rotate(bitmap: Bitmap, degree: Float): Bitmap {
val matrix = Matrix()
matrix.postRotate(degree)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
uri를 통해 파일의 절대경로
를 가져온다.
해당 경로를 바탕으로 ExifInterface
를 이용해 이미지에 대한 상세 정보 중 회전 정보
를 가져온다.
회전 정보를 바탕으로 이미지를 원래 방향으로 회전
시킨다.
주의사항
ExifInterface를 import할 때,
import androidx.exifinterface.media.ExifInterface
AndroidX에서 import하자. 다른 버전에서는 이슈가 있다고 한다.
private fun resizeBitmapIfNeeded(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
val (width, height) = bitmap.run { width to height }
return if (width > maxWidth || height > maxHeight) {
var resizedWidth = width
var resizedHeight = height
if (width == height) {
resizedWidth = maxWidth
resizedHeight = maxHeight
}
if (width > height && width > maxWidth) {
resizedWidth = maxWidth
resizedHeight = (maxWidth.toDouble() / width * height).toInt()
}
if (height > width && height > maxHeight) {
resizedHeight = maxHeight
resizedWidth = (maxHeight.toDouble() / height * width).toInt()
}
Bitmap.createScaledBitmap(bitmap, resizedWidth, resizedHeight, true)
}
else bitmap
}
이미지의 가로, 세로 중 maxWidth, maxHeight을 넘어갈 경우, 이미지의 비율에 맞춰 가로, 세로 크기를 조정한다.
createScaledBitmap()
을 통해 Resized된 Bitmap을 반환한다.
이미지를 띄울 때마다 Storage의 이미지를 조회할 순 없다.
그래서 업로드 후 이미지의 Download Url
을 가져와 FireStore에 저장하기로 했다.
Storage에 이미지를 업로드하는 방법은
메모리 데이터에서 업로드
, 스트림에서 업로드
, 로컬 파일에서 업로드
총 3가지인데, 로컬이미지를 업로드할 것이기 때문에 파일에서 업로드하는 putFile()
을 사용하기로 했다.
interface StorageRepository {
suspend fun uploadProfileImage(name: String, uri: Uri): Flow<String?>
}
class StorageRepositoryImpl @Inject constructor(
@Named("profileImageRef") val profileImageRef: StorageReference
) : StorageRepository {
override suspend fun uploadProfileImage(name: String, uri: Uri): Flow<String?>
= profileImageRef.child(name).uploadAndGetUrl(uri)
@Module
@InstallIn(SingletonComponent::class)
class FireBaseModule {
@Provides
@Singleton
@Named("profileImageRef")
fun provideProfileImageRef() = Firebase.storage.getReference(FireBaseKey.USER_IMAGE_KEY)
}
uploadProfileImage()
의 name
은 Storage에 저장될 이미지 파일의 이름이다.
uri
는 putFile()
매개변수로 Uri를 받기 때문에 넘겨줄 것이다.
결론적으로, name
이란 이름의 이미지를 업로드하고 download url을 반환하는 Flow를 만들었다.
Storage 관련된 작업을 편하게 하도록 StorageExtension
을 만들었다.
suspend fun StorageReference.uploadAndGetUrl(uri: Uri): Flow<String?> = callbackFlow {
val uploadTask = putFile(uri)
val listener = uploadTask
.addOnFailureListener { trySend(null) }
.addOnSuccessListener {
uploadTask.continueWithTask {
downloadUrl
}.addOnCompleteListener { task ->
if (task.isSuccessful) trySend(task.result.toString())
else trySend(null)
}
}
awaitClose { listener.cancel() }
}
업로드 결과를 listener 콜백을 통해 반환하기 때문에 callbackFlow
블록을 만든다.
uploadTask에 대한 listener를 만들고, 성공했을 때 continueWithTask
를 통해 downloadUrl을 방출하도록 한다.
메모리 누수를 방지하기 위해 awiatClose
블록에서 listener을 해제한다.
// ViewModel에서
val downloadUrl = storageRepo.uploadProfileImage(
name = inputImage.name,
uri = imageUtil.getUriFromPath(inputImage.url)
).firstOrNull()
// ImageUtil.kt에서
fun getUriFromPath(path: String): Uri {
return Uri.fromFile(File(path))
}
Flow의 firstOrNull()
을 이용해 처음 방출된 값을 받고, Flow 수집을 해제한다.
만약, null
이 반환됐다면 에러가 발생한 상황이라 처리할 수 있고
정상적으로 downloadUrl
이 반환됐다면, 이것을 FireStore에 저장해 사용할 수 있다.
민망하긴 하지만 텃밭을 바라보는 내 사진이다..
원본 이미지가 5.43MB
로 용량이 꽤 컸는데 이미지 Resizing을 통해 31.7KB
로 대폭 줄었다. 두 크기는 약 1700배
차이가 난다.
프로필 이미지는 앱에서 작은 이미지로 사용되서 MaxWidth, MaxHight 모두 200으로 작아 크기가 상당히 줄었지만, 앱에서 보여지는 화질엔 문제가 없다.
Storage 한도가 5GB인것을 생각하면, 원본 이미지를 업로드하면 약 940장까지 가능한 반면 Resized된 이미지를 업로드하면 약 165만장
까지 가능하다.
이번 포스팅을 통해 Firebase를 사용하지 않더라도 이미지 Resizing은 필수적임을 느꼈다.
아쉬운 점은 이미지 처리 로직을 100% 이해하지 못한 채 구현한 것이다.
Compose PhotoPicker가 여러 장의 이미지를 선택할 때, 순서를 유지하지 못하는 이슈 때문에 다음 업데이트에선 텃텃 자체 PhotoPicker를 만들어볼 계획이다. 이미지 처리 공부를 같이 하면서 다음 포스팅에선 개선된 이미지 처리 로직과 Custom PhotoPicker에 대해 공유해보겠다.
Android에서 이미지 Resizing이 필요한 사람에게 도움이 됐으면 좋겠다!