[Android] Multipart로 서버에 이미지와 다른 정보 전송하기

Sdoubleu·2024년 12월 15일
0

Android

목록 보기
15/16
post-thumbnail

프로필 수정 및 게시글 올리는 기능을 할 때 사진과 정보를 한번에 보내는 것에 굉장이 애먹었던 경험이 있습니다

진행했던 과정중 문제에 부딪혔을 때와 현재 사용하는 방법에 대해서 글을 작성했습니다.


초기 문제점

처음엔 안드로이드에서 직접 aws에 이미지를 올리고 받아서 처리 후
url을 서버에 올리는 형식으로 진행하려 했습니다

사용 방법을 자세히 몰라서 진행 방법을 교체했습니다.


해결 방안

갤러리에서 이미지를 선택 후 임시 파일로 만들어서 서버에 보내는 형식입니다

요즘 핸드폰 기능이 좋다보니까 사진의 용량이 많이 커졌습니다.
사진 압축은 사정에 맞게 수정하시면 될 것 같습니다.


1. API

// 사진 1장과 여러 정보를 보낼 경우
    @Multipart
    @PUT("members/profile")
    suspend fun modifyProfile(
        @Part("request") ProfileRequestDTO: RequestBody,
        @Part image: MultipartBody.Part?
    ): Response<ProfileResponse>

사진 1장과 정보만 서버에 보내는 형식으로 진행되기 때문에
사진 여러 장과 정보를 보내는 형식은 이 글에 담고있지 않습니다.
또한 이 글엔 data source 없기에 자세한 내용은
veganLife에 들어가신 후 mypage 폴더에서 봐주시길 바랍니다.


2. 버튼 클릭 시 앨범 열기

Mypagemodifyfragment

// 버튼 클릭 시 갤러리 열기
    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

// - - - 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")
            }
        }
    }
}

3. PhotoUtils

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())
        }
    }
}
  • 현재 이 코드는 gif와 동영상을 제외한 원래 확장자를 유지하여 보냅니다
decodeBitmapFromUri(uri, context)?.apply {
                    compress(format, 80, fos)
                    recycle()
                } ?: throw NullPointerException("Bitmap decoding failed")
  • 숫자를 낮출수록 압축이 되지만, 이미지 품질이 떨어지는점 유의하여
    목적에 맞게 압축하시면 될 것 같습니다

4. 사진 및 정보 보내기

MypageModifyFragment

    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)
                        }
                    }
                }
            }
        }
    }

끝 👍

이렇게 함으로써 사진과 본인이 원하는 정보를 담아 서버에 보낼 수 있게 됐다 !
굳굳


🔗참고 자료

나의 전체 코드 🔥

Multipart를 통해 서버로 이미지 전송하기

안드로이드 Bitmap 최적화(Resize)한 다중 이미지 서버에 업로드하기 1 - 비트맵 다이어트 시키기

profile
개발자희망자

0개의 댓글

관련 채용 정보