마스터Q&A 안드로이드는 안드로이드 개발자들의 질문과 답변을 위한 지식 커뮤니티 사이트입니다. 안드로이드펍에서 운영하고 있습니다. [사용법, 운영진]

base64 string 값을 image로 전환하는법 (Android, Kotlin, Jetpack Compose)

0 추천

프로필 수정기능을 구현하는 중입니다. 

이 기능을 통해 서버는 폼 데이터 방식으로 개조를 위해 멀티파트를 사용하여 이미지 변경 및 닉네임 변경을 위한 값을 요청할 수 있으며, 수정된 닉네임 값과 인코딩된 이미지 값을 응답 값으로 전달합니다. 인코딩된 이미지 응답 값을 디코딩하여 Image Composable의 인자 값으로 입력합니다.

클라이언트는 서버로 form-data 방식으로 Multipart를 이용하여 이미지 변경 및 닉네임 변경을 위한 값을 보낼수 있으며, 응답값으로 수정된 닉네임 값과 인코딩된 string 형태의 이미지 값을 받습니다. 인코딩된 string 형태의 이미지 값을 디코딩 하여 Composable에서 Image의 painter에 전달할려고 합니다.

Android 앱의 이미지 합성 가능에서 사용하려면 String 응답 값을 BitMap 이미지로 다시 변환 하는 작업을 하고 있는데 Composable에서 Image의 painter에 전달하는 방법을 잘 모르겠어서 질문합니다.

API: 

스크린 내 변수값: 

val imageUri = rememberSaveable {
    mutableStateOf<Uri?>(null)
}
val painter = rememberImagePainter(
    data = imageUri.value,
    builder = {
        if (imageUri.value != null) {
            placeholder(R.drawable.defaultprofile)
        }
    }
)

val launcher =
    rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? ->
        uri?.let {
            imageUri.value = it
            Log.v("image", "image: ${uri}")
        }
    }

val file = imageUri.value?.let { uri ->
    val contentResolver = LocalContext.current.contentResolver
    val inputStream = contentResolver.openInputStream(uri)
    val tempFile = File.createTempFile("image", null, LocalContext.current.cacheDir)
    tempFile.outputStream().use { outputStream ->
        inputStream?.copyTo(outputStream)
    }
    tempFile
}

val requestFile = file?.asRequestBody("image/jpeg".toMediaTypeOrNull())
val body = requestFile?.let {
    MultipartBody.Part.createFormData("image", file.name, requestFile)
}

이벤트 컴포저블: 

TopAppBar(
            title = {
                Text(text = "프로필 수정")
            },
            actions = {
                Text(
                    text = "완료",
                    modifier = Modifier
                        .padding(30.dp)
                        .clickable {
                            val currenNickname = MyApplication.prefs.getData("nickname", nickname)
                            if ((body != null) || !(nickname.equals(currenNickname))) {
                                changeNicknameAndProfile(token,
                                    nickname,
                                    body!!,
                                    routeAction
                                ) {
                                    for (i in it!!.data.image) {
                                        val base64String = i.toString()
                                        val decodedBytes  =
                                            Base64.decode(base64String, Base64.DEFAULT)
                                        val decodedImage = BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
                                    }
                                }
                            }
                        })
            })

        Spacer(modifier = Modifier.height(50.dp))

        Image(
            painter = painter,
            contentDescription = "profileImage",
            modifier = Modifier
                .size(150.dp)
                .padding(8.dp),
            contentScale = ContentScale.Crop
        ) 
jongjoon (340 포인트) 님이 2023년 2월 19일 질문
jongjoon님이 2023년 2월 19일 수정

2개의 답변

0 추천
 
채택된 답변

ViewModel를 사용해서 처리하는 것이 일반적인 접근방법이지만, 님의 경우는 최소한 Retrofit를 처리하는 부분은 suspend function을 사용해서 좀더 다루기 쉬운 형태로 만들 수 있습니다.

class MyBusinessException(message: String) : Throwable(message)

fun changeNicknameAndProfile(
    token: String,
    nickname: String,
    file: File
): Result<UserProfile> {
    val multipartBody = file.asMultiPartBody("image")

    val result = requestChangeNicknameAndProfile(
        token = token, 
        nickname = nickname, 
        multipartBody = multipartBody
    )

    if (result.isFailure) {
        return result
    }

    val response = result.getOrNull()!!
    val userProfile = convertChangeNicknameAndProfileResonseToUserProfile(response)
    return Result.success(userProfile)
}

fun File.asMultiPartBody(name: String): MultipartBody.Part {
  val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
  return MultipartBody.Part.createFormData(name, file.name, requestFile)
}

suspend fun requestChangeNicknameAndProfile(
    token: String,
    nickname: String,
    multipartBody: MultipartBody.Part,
): Result<ChangeNicknameAndProfileResonse>: ChangeNicknameAndProfileResonse = suspendCancellableCoroutine { continuation ->
    val callback = object : Callback<ChangeNicknameAndProfileResonse> { 
        override fun onResponse(
               call: Call<ChangeNicknameAndProfileResonse>,
               response: Response<ChangeNicknameAndProfileResonse>     
            ) {
                if (response.isSuccessful()) {
                    Result.success(response.body())
                } else {
                    Result.failure(MyBusinessException(reponse.errorBody().string()))
                }
            }
  
            override fun onFailure(call: Call<ChangeNicknameAndProfileResonse>, t: Throwable) {
                Result.failure(t)
            }
    }
    
    val request = ...

    request.requestChangeNicknameAndProfile(token, nickname, multipartBody)
        .enqueue(callback)
    
    // Remove callback on cancellation
    continuation.invokeOnCancellation { 
        // Do somethig if you need
    }
} 

 

spark (227,830 포인트) 님이 2023년 2월 19일 답변
jongjoon님이 2023년 2월 21일 채택됨
suspendCancellableCoroutine 보다는 아래처럼 Retroift 서비스의 인터페이스에서 suspend 함수를 사용하는 것이 훨씬 더 쉽습니다. Retrofit 2.6부터 아래처럼 함수에 suspend 키워드만 붙여주면 자동으로 suspend함수로 변환을 해줍니다.

// ChangeNicknameAndProfileRequest 보다는 UserProfileService 정도가 더 명확해 보임
interface UserProfileService {
    suspend fun updateProfile(
        token: String,
        nickname: String,
        multipartBody: MultipartBody.Part
    ) : ChangeNameAndProfileResponse
}

fun changeNicknameAndProfile(
    token: String,
    nickname: String,
    file: File
): Result<UserProfile> {
    val multipartBody = file.asMultiPartBody("image")

    val userProfileService = retrofit.create(...)

    val result = userProfileService.updateProfile(
        token = token,
        nickname = nickname,
        multipartBody = multipartBody
    )

    if (result.isFailure) {
        return result
    }

    val response = result.getOrNull()!!
    val userProfile = convertUserProfileResonseToUserProfile(response)
    return Result.success(userProfile)
}
var decodedImagePainter = remember {
        mutableStateOf<Bitmap?>(null)
    }


TopAppBar(
            title = {
                Text(text = "프로필 수정")
            },
            navigationIcon = {
                IconButton(onClick = {
                    routeAction.goBack()
                }) {
                    Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "back")
                }
            },
            actions = {
                Text(
                    text = "완료",
                    modifier = Modifier
                        .padding(30.dp)
                        .clickable {
                            val currenNickname = MyApplication.prefs.getData("nickname", nickname)
                            if ((body != null) || !(nickname.equals(currenNickname))) {
                                changeNicknameAndProfile(token,
                                    nickname,
                                    body!!,
                                    routeAction,
                                    response = {
                                        for (i in it!!.data.image) {
                                            val base64String = i.toString()
                                            val decodedBytes =
                                                Base64.decode(base64String, Base64.DEFAULT)
                                            val decodedImage = BitmapFactory.decodeByteArray(
                                                decodedBytes,
                                                0,
                                                decodedBytes.size)
//                                            painter = decodedImage
//                                                bitmapString(i.toString())
                                            decodedImagePainter.value = decodedImage
                                        }
                                    }
                                )
                            }
                        })
            })

이런식으로 API 호출하는 함수의 인자값을 변형하고 실행해봤는데, 갤러리에서 이미지 가져오고 버튼 클릭시 앱이 팅겨 버립니다 ㅠㅠ
버튼 클릭시 왜 튕기는지 로그캣 등을 통해 원인을 파악하세요. 보통 이미지 사이즈, 데이터 포맷의 문제 같은 걸 의심해 볼 수 있으나, 에러 로그를 보는 것이 정확합니다. 에러에 따라 해결방법도 달라져야 겠죠.
Text(
                    text = "완료",
                    modifier = Modifier
                        .padding(30.dp)
                        .clickable {
                            val currenNickname = MyApplication.prefs.getData("nickname", nickname)
                            if ((body != null) || !(nickname.equals(currenNickname))) {
                                changeNicknameAndProfile(token,
                                    nickname,
                                    body!!,
                                    routeAction,
                                    response = {
                                        for (i in it!!.data.image) {
                                            val base64String = i.toString()
                                            val decodedBytes =
                                                Base64.decode(base64String, Base64.DEFAULT)
                                            val decodedImage = BitmapFactory.decodeByteArray(
                                                decodedBytes,
                                                0,
                                                decodedBytes.size)
                                            imageUri.value =
                                        }
                                    }
                                )
                            }
                        })

 Image(
            painter = painter,
            contentDescription = "profileImage",
            modifier = Modifier
                .size(150.dp)
                .padding(8.dp)
                .clickable {
                    openDialog = !openDialog
                },
            contentScale = ContentScale.Crop
        )


응답값으로 받은 값들을 반복문을 통해 데이터를 추출하고 디코딩하고, bitmap으로 만들었는데 이걸 uri로 어떻게 전환하는지 방법을 알수 있을까요?
0 추천

제가 Compose를 사용하지는 않아서 정확하지는 않지만, 님처럼 Composable함수 안에서 Base64 처리를 하시면 계산이 발생할 때마다 recomposition이 일어나는 걸로 압니다. 이럴 때는 변환된 bitmap만 넘겨주거나 아니면 derivedStateOf를 사용해서 recomposition을 방지해주어야 하는지 체크해 보세요.

제가 아는 범위에서는 Comose에서는 각 UI 요소에 해당하는 부분은 Composiable로 만들고 이벤트는 하위의 Composable에서 상위의 Composable로, 데이터를 상위에서 하위로 전달하는 구조가 되어야 성능이나 코드관리에 유리한 것으로 압니다.
 
따라서, 아래처럼, 프로파일 변경 이벤트를 함수인자로 받아서 처리하는 구조가 되는 것이 낫지 않을까 생각합니다.
@Composable
fun MyTopAppBar(
   modifier: Modifier = Modifier,
   profile: UserProfile? = null,
   onUpdateUserProfile: (String) -> Unit
) {
    TopAppBar(
            modifier = modifier,
            title = {
                Text(text = "프로필 수정")
            },
            actions = {
             
                Text(
                    text = "완료",
                    modifier = Modifier
                        .padding(30.dp)
                        .clickable {
                           // 이벤트의 처리를 상위 Composable에서 처리하도록 함.
                            onUpdateUserProfile(nickname)

                        })             
            })

        Spacer(modifier = Modifier.height(50.dp))

       if (profile != null) {
          Image(
            bitmap = profile.bitmap,
            contentDescription = "profileImage",
            modifier = Modifier
                .size(150.dp)
                .padding(8.dp),
            contentScale = ContentScale.Crop
        ) 
    }
}

 

이제 changeNicknameAndProfile은 MyTopAppBar를 사용하는 쪽에서 제공하도록 만들어 주어야 겠죠.

// 상위에 Composable이 있다면 onUpdateProfile을 함수인자로 추가
@Composable
fun AnotherComposable() {

    val profile = remember {.... }

    TopMyAppBar(
        profile = profile.value,
    ) { nickname ->
        changeNicknameAndProfile(...)
    }
}

 

그리고 ViewModel 같은 State관리를 해주는 추가 클래스없이 Retrofit을 콜백형태로 사용하는 건 Compose 에서는 다루기가 힘들므로  Coroutine을 사용하시는게 훨씬 나을 것 같습니다.

 
spark (227,830 포인트) 님이 2023년 2월 19일 답변
...