프로필 수정 및 게시글 올리는 기능을 할 때 사진과 정보를 한번에 보내는 것에 굉장이 애먹었던 경험이 있습니다
진행했던 과정중 문제에 부딪혔을 때와 현재 사용하는 방법에 대해서 글을 작성했습니다.
처음엔 안드로이드에서 직접 aws에 이미지를 올리고 받아서 처리 후
url을 서버에 올리는 형식으로 진행하려 했습니다
사용 방법을 자세히 몰라서 진행 방법을 교체했습니다.
갤러리에서 이미지를 선택 후 임시 파일로 만들어서 서버에 보내는 형식입니다
요즘 핸드폰 기능이 좋다보니까 사진의 용량이 많이 커졌습니다.
사진 압축은 사정에 맞게 수정하시면 될 것 같습니다.
// 사진 1장과 여러 정보를 보낼 경우
@Multipart
@PUT("members/profile")
suspend fun modifyProfile(
@Part("request") ProfileRequestDTO: RequestBody,
@Part image: MultipartBody.Part?
): Response<ProfileResponse>
사진 1장과 정보만 서버에 보내는 형식으로 진행되기 때문에
사진 여러 장과 정보를 보내는 형식은 이 글에 담고있지 않습니다.
또한 이 글엔 data source 없기에 자세한 내용은
veganLife에 들어가신 후 mypage 폴더에서 봐주시길 바랍니다.
// 버튼 클릭 시 갤러리 열기
private fun replaceProfilePhoto() {
binding.ivMypagePhoto.setOnClickListener {
openGallery()
}
}
// 갤러리 여는 함수
private fun openGallery() {
val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
startActivityForResult(intent, PICK_IMAGE_REQUEST)
}
// 갤러리에서 아이템 선택 후 변환 및 프로필 사진 변경
// 아직 서버엔 안보낸 상태
@RequiresApi(Build.VERSION_CODES.R)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == PICK_IMAGE_REQUEST && resultCode == Activity.RESULT_OK && data != null) {
val imageUri: Uri? = data.data
if (imageUri != null) {
Glide.with(this)
.load(imageUri)
.apply(requestOptions)
.into(binding.ivMypageProfile)
lifecycleScope.launch {
if (context != null) {
val imageMultipart = withContext(Dispatchers.IO) {
// 1. 최적화된 비트맵을 임시 파일로 저장
val imagePath = PhotoUtils.optimizeBitmap(requireContext(), imageUri)
// 2. 임시 파일 경로를 사용해 MultipartBody.Part로 변환
createImageMultipart(imagePath)
}
if (imageMultipart != null) {
// viewModel에 넣기
mypageViewmodel.putProfilePhotoMultipart(imageMultipart)
}
}
}
}
}
}
// - - - mypageViewmodel - - -
// 프로필 사진 MultiPart
private val _profilePhotoMultipart = MutableLiveData<MultipartBody.Part>()
val profilePhotoMultipart: LiveData<MultipartBody.Part> get() = _profilePhotoMultipart
// 프로필 수정 정보 RequestBody
private val _profileModifyRequestBody = MutableLiveData<RequestBody>()
val profileModifyRequestBody: LiveData<RequestBody> get() = _profileModifyRequestBody
---
// set photo
fun putProfilePhotoMultipart(photo: MultipartBody.Part) {
_profilePhotoMultipart.value = photo
}
// set 프로필 정보
fun putProfileRequestBody(profile: RequestBody) {
_profileModifyRequestBody.value = profile
}
---
// api에 사진 및 정보 보낼 함수
fun getProfileModifyInfo() {
val profileModifyDTO = profileModifyRequestBody.value ?: return
val profilePhoto = profilePhotoMultipart.value ?: return
viewModelScope.launch {
// api 보내는 usecase
val response = mypageUsecase.modifyProfile(profileModifyDTO, profilePhoto)
when (response) {
is ApiResult.Error -> {
val responseDescription = response.description
Log.d("get User Info Error", responseDescription)
when (response.errorCode) {
// 중복 닉네임
"409" -> {
getModifyResponse(response.errorCode)
}
// 토큰 사용시간 초과
"404" -> {
getModifyResponse(response.errorCode)
}
}
}
is ApiResult.Exception -> {
Log.d(
"mypage modify Info Error",
response.e.message ?: "No message available"
)
}
is ApiResult.Success -> {
getModifyResponse("200")
}
}
}
}
class PhotoUtils {
companion object {
private const val MAX_WIDTH = 800
private const val MAX_HEIGHT = 600
/**
* 최적화된 Bitmap을 생성하고 임시 파일로 저장 후 해당 파일의 경로를 반환합니다.
* @param context 컨텍스트
* @param uri 이미지의 URI
* @return 최적화된 이미지의 임시 파일 경로
*/
@RequiresApi(Build.VERSION_CODES.R)
fun optimizeBitmap(context: Context, uri: Uri): String? {
return try {
// 파일 확장자를 확인
val extension = getFileExtension(uri, context) ?: "jpg"
val format = when (extension.lowercase()) {
"png" -> Bitmap.CompressFormat.PNG
"webp" -> Bitmap.CompressFormat.WEBP
"webp_lossless" -> Bitmap.CompressFormat.WEBP_LOSSLESS
"webp_lossy" -> Bitmap.CompressFormat.WEBP_LOSSY
"jpg", "jpeg" -> Bitmap.CompressFormat.JPEG
else -> Bitmap.CompressFormat.JPEG
}
// 임시 파일을 위한 디렉토리와 파일명 설정
val storage = context.cacheDir
val fileName = String.format("%s.%s", UUID.randomUUID(), extension)
val tempFile = File(storage, fileName)
tempFile.createNewFile()
// 파일 출력 스트림 생성
val fos = FileOutputStream(tempFile)
// Bitmap을 디코딩 및 최적화 후 임시 파일로 저장
decodeBitmapFromUri(uri, context)?.apply {
compress(format, 80, fos)
recycle()
} ?: throw NullPointerException("Bitmap decoding failed")
fos.flush()
fos.close()
return tempFile.absolutePath
} catch (e: Exception) {
Log.e("PhotoUtils", "FileUtil - ${e.message}")
null
}
}
/**
* URI로부터 Bitmap을 디코딩하고 최적화된 Bitmap을 반환합니다.
* @param uri 이미지의 URI
* @param context 컨텍스트
* @return 최적화된 Bitmap
*/
private fun decodeBitmapFromUri(uri: Uri, context: Context): Bitmap? {
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
val bufferedInputStream = BufferedInputStream(inputStream)
bufferedInputStream.mark(1024 * 1024) // 1MB로 마크 설정
var bitmap: Bitmap?
BitmapFactory.Options().run {
inJustDecodeBounds = true // 이미지 실제 로딩 없이 크기만 가져오기
bitmap = BitmapFactory.decodeStream(bufferedInputStream, null, this)
bufferedInputStream.reset() // 스트림을 초기 위치로 되돌리기
// 이미지 리샘플링을 위해 inSampleSize 계산
inSampleSize = calculateInSampleSize(this)
inJustDecodeBounds = false
bitmap = BitmapFactory.decodeStream(bufferedInputStream, null, this)?.apply {
rotateImageIfRequired(context, this, uri)
}
}
bufferedInputStream.close()
return bitmap
}
/**
* 최적화된 Bitmap을 위한 inSampleSize를 계산합니다.
* @param options BitmapFactory.Options
* @return inSampleSize 값
*/
private fun calculateInSampleSize(options: BitmapFactory.Options): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > MAX_HEIGHT || width > MAX_WIDTH) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while (halfHeight / inSampleSize >= MAX_HEIGHT && halfWidth / inSampleSize >= MAX_WIDTH) {
inSampleSize *= 2
}
}
return inSampleSize
}
/**
* 이미지가 회전된 경우 이를 원래 방향으로 되돌립니다.
* @param context 컨텍스트
* @param bitmap 회전된 Bitmap
* @param uri 이미지의 URI
* @return 원래 방향으로 회전된 Bitmap
*/
private fun rotateImageIfRequired(context: Context, bitmap: Bitmap, uri: Uri): Bitmap? {
val input = context.contentResolver.openInputStream(uri) ?: return null
val exif = if (Build.VERSION.SDK_INT > 23) {
ExifInterface(input)
} else {
ExifInterface(uri.path!!)
}
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
return when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90)
ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180)
ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270)
else -> bitmap
}
}
/**
* Bitmap을 주어진 각도만큼 회전시킵니다.
* @param bitmap 회전할 Bitmap
* @param degree 회전 각도
* @return 회전된 Bitmap
*/
private fun rotateImage(bitmap: Bitmap, degree: Int): Bitmap? {
val matrix = Matrix()
matrix.postRotate(degree.toFloat())
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
// URI에서 파일 확장자 추출
fun getFileExtension(uri: Uri, context: Context): String? {
val contentResolver = context.contentResolver
val mimeType = contentResolver.getType(uri)
return mimeType?.substringAfterLast('/')
}
/**
* 이미지 파일을 MultipartBody.Part로 변환합니다.
* @param imagePath 최적화된 이미지 파일 경로
* @return MultipartBody.Part로 변환된 이미지 파일
*/
fun createImageMultipart(imagePath: String?): MultipartBody.Part? {
if (imagePath == null) return null
val file = File(imagePath)
val mimeType = file.extension.let {
when (it.lowercase()) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"webp" -> "image/webp"
else -> "image/jpeg"
}
}
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
return MultipartBody.Part.createFormData("image", file.name, requestFile)
}
/**
* ProfileModifyInfo 객체를 JSON으로 변환하여 RequestBody로 반환합니다.
* @param profileModifyInfo 서버에 보낼 프로필 정보
* @return RequestBody로 변환된 프로필 정보
*/
fun createProfileRequestBody(contentDTO: Any): RequestBody {
val gson = Gson()
val json = gson.toJson(contentDTO)
return json.toRequestBody("application/json".toMediaTypeOrNull())
}
}
}
decodeBitmapFromUri(uri, context)?.apply { compress(format, 80, fos) recycle() } ?: throw NullPointerException("Bitmap decoding failed")
- 숫자를 낮출수록 압축이 되지만, 이미지 품질이 떨어지는점 유의하여
목적에 맞게 압축하시면 될 것 같습니다
private fun modifyUserInfo() {
mypageViewmodel.apply {
binding.apply {
btnMypageModify.setOnClickListener {
val profileDTO = ProfileRequestDTO(
nickname = tietMypageNickname.text.toString(),
vegetarianType = profileInfoResponse.value!!.vegetarianType,
gender = profileInfoResponse.value!!.gender,
birthYear = tietMypageAge.text.toString().toInt(),
height = tietMypageHeight.text.toString().toInt(),
weight = tietMypageWeight.text.toString().toInt()
)
lifecycleScope.launch {
if (isUserInfoStateCheck(profileDTO)) {
val profileRequestBody = withContext(Dispatchers.IO) {
PhotoUtils.createProfileRequestBody(profileDTO)
}
putProfileRequestBody(profileRequestBody)
getProfileModifyInfo()
} else {
makeToast("모든 정보를 올바르게 입력해주세요")
}
responseCode.observe(viewLifecycleOwner) { response ->
if (response != null) handleSignupResponse(response)
}
}
}
}
}
}
이렇게 함으로써 사진과 본인이 원하는 정보를 담아 서버에 보낼 수 있게 됐다 !
굳굳