Skip to main content

Composable을 Bitmap으로 변환하는 방법

· 11 min read
Donghyeon Kim
Android Developer @Cashwalk

Composable을 Bitmap으로 변환하는 과정에서 발생한 이슈와 해결 과정에 대해 소개합니다.

아래 글은 Jetpack Compose로 작성한 명함(또는 카드) UI를 이미지로 저장하고 공유하는 기능을 구현하면서 겪은 문제와 해결 과정을 정리한 글입니다.

Composable → Bitmap 변환 과정과, 하드웨어 가속 / isLaidOut / 블러 등의 이슈에 대해 정리하였습니다.

기능 소개

Loading

현재 사용자의 음악 취향이 드러나는 명함을 생성할 수 있는 기능을 개발하고 있습니다. 앱에서 생성한 명함은 이미지로 변환 되어 갤러리에 저장하거나, SNS로 공유할 수 있도록 할 예정이었습니다.

기존 View 시절에는 drawToBitmap() 등으로 쉽게 처리했지만, Jetpack Compose로 UI를 작성하면 동일한 방식이 바로 적용되지 않아, 여러 문제를 해결해야 했습니다.


drawToBitmap()을 활용한 방식

초기 아이디어 💭

처음엔 Compose 내에 있는 Composable을 별도의 ComposeView에 그린 뒤, drawToBitmap()을 호출해서 Bitmap을 얻는 방식이었습니다.

이 방법으로 간단하게 Composable을 Bitmap으로 변환할 수 있었으나 여러가지 이슈가 존재했습니다.

코드 예시

@Composable
fun convertToBitmap(
targetContent: @Composable () -> Unit
): () -> Bitmap? {
val context = LocalContext.current
val composeView = remember { ComposeView(context) }

fun captureBitmap(): Bitmap? {
// composeView가 아직 레이아웃되지 않았다면 null을 반환
return if (composeView.isLaidOut) {
composeView.drawToBitmap()
} else {
null
}
}

AndroidView(
factory = {
composeView.apply {
// 여기에서 targetContent를 실제로 그려주기
setContent { targetContent() }
}
}
)

// 필요한 시점에 captureBitmap()을 호출해 Bitmap을 얻음
return ::captureBitmap
}
  • convertToBitmap 함수는 별도 ComposeView를 생성해, 원하는 Composable(targetContent)를 그립니다.
  • 실제 뷰가 레이아웃을 마쳐야 drawToBitmap()을 안전하게 호출할 수 있으므로, isLaidOut 체크를 추가했습니다.

info

isLaidOut 은 View(또는 ComposeView)가 레이아웃 과정을 모두 마쳐서 실제 화면 크기가 확정되었는지를 알려주는 플래그입니다.

이 값이 true여야 drawToBitmap() 등을 통해 안전하게 뷰를 Bitmap으로 변환할 수 있으며, 아직 레이아웃이 결정되지 않은 상태에서 캡처를 시도하면 IllegalStateException이 발생할 수 있습니다.


이는 아래와 같이 호출할 수 있습니다.

val nameCardBitmapGenerator = convertToBitmap {
// Bitmap으로 변환할 Composable
NameCardComponent(nameCardInfo = state.nameCardInfo)
}

// 필요한 시점(예: 버튼 클릭 등)에 호출
val bitmap: Bitmap? = nameCardBitmapGenerator.invoke()

Bitmap 변환은 잘 되는 듯 했으나 하드웨어 가속 사용시 변환 불가한 이슈, Blur 미적용 이슈 등 여러 문제가 뒤따랐습니다.


1️⃣ 하드웨어 가속 사용시 변환 불가한 이슈

Coil을 사용해서 이미지를 로드하면 기본적으로 하드웨어 가속 옵션을 사용하게 됩니다.

info
class DefaultRequestOptions(
/*...*/
val allowHardware: Boolean = true,
)

Coil의 DefaultRequestOptions 클래스를 보면 allowHardware 옵션의 기본 값이 true인 것을 알 수 있습니다.

하드웨어 가속을 사용하는 상태에서 drawToBitmap()을 호출하면, 소프트웨어 Bitmap으로 직접 복사하기가 불가능해서 예외가 발생합니다.

해결 :
명함 생성 기능에서는 이미지를 대량으로 로드할 필요가 없었기에, 소프트웨어 가속을 사용하도록 설정을 변경했습니다.
성능 저하가 있을 수 있지만, 실제 명함 UI에서 이미지를 1~2장 정도만 다뤘으므로 영향이 크지 않았습니다.



2️⃣ Blur가 Bitmap으로 변환이 되지 않는 이슈

두 번째 이슈는 Blur가 적용된 Composable을 캡쳐하면, Blur가 적용되지 않은 상태로 Bitmap이 추출된다는 점이었습니다.

Loading

아래와 같이 여러 명함 디자인 중 Blur를 사용하는 케이스가 많았는데, 캡쳐 결과물에는 그 블러 효과가 전혀 나타나지 않아 난감했습니다.

Loading

이를 해결하기 위해 Modifier.blur() 대신 다른 Blur 라이브러리(cloudy, haze) 등을 사용해봤는데도 문제는 동일했습니다. 결국 Blur 자체의 문제라기보다는, 캡쳐 방법의 문제임을 알게 되었습니다.


graphicsLayer를 이용한 새로운 로직

위 문제를 해결하기 위해, 공식 문서에서 안내한 graphicsLayer 기반의 캡쳐 방식을 도입했습니다.

graphicsLayer.toImageBitmap()를 사용하면, Blur가 GPU에서 처리되는 과정을 소프트웨어 버퍼로 기록할 수 있어 Blur가 적용된 상태를 보다 안정적으로 캡쳐할 수 있었습니다.

val graphicsLayer = rememberGraphicsLayer()

NameCardComponent3(
nameCardInfo = state.nameCardInfo,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
}
drawLayer(graphicsLayer)
}
)

graphicsLayer.toImageBitmap()

Bitmap 변환

graphicsLayer.toImageBitmap()는 ImageBitmap을 반환하는데, 이를 곧바로 파일로 저장하거나 공유할 수는 없습니다.

따라서 아래와 같은 확장 함수를 만들어, ImageBitmap → Bitmap 변환 과정을 거쳤습니다.

fun ImageBitmap.toAndroidBitmap(): Bitmap {
// 1. Compose의 PixelMap 가져오기
val pixelMap = this.toPixelMap()

// 2. PixelMap 크기에 맞는 빈 Bitmap 생성
val bitmap = Bitmap.createBitmap(pixelMap.width, pixelMap.height, Bitmap.Config.ARGB_8888)

// 3. PixelMap을 순회하며 Bitmap에 픽셀 쓰기
for (y in 0 until pixelMap.height) {
for (x in 0 until pixelMap.width) {
val color: Color = pixelMap[x, y]
bitmap.setPixel(x, y, color.toArgb())
}
}
return bitmap
}

이렇게 최종적으로 Blur가 적용된 Bitmap을 얻을 수 있었습니다.

info

graphicsLayer.toImageBitmap()는 suspend 함수이므로, Coroutine 내에서 호출해야 합니다.
또한 UI가 완전히 렌더링된 뒤에 호출해야 올바른 결과물을 얻을 수 있습니다.



BottomSheet에서 Bitmap 보여주기

마지막으로, 생성된 Bitmap을 BottomSheet에 표시할 수 있게 구현하였습니다.

문제점

  • graphicsLayer.toImageBitmap()은 비동기(suspend) 로직입니다.
  • 따라서, 곧바로 BottomSheet를 열어버리면, Bitmap이 준비되지 않은 상태일 수 있습니다.

해결 방법

  1. 공유하기 버튼 클릭 시 -> Bitmap 생성 (graphicsLayer.toImageBitmap())
  2. Bitmap 생성이 완료되면 state로 저장
  3. BottomSheet에서는 미리 준비된 Bitmap을 안전하게 사용할 수 있음
var nameCardBitmap by remember { mutableStateOf<ImageBitmap?>(null) }

WepliBasicButton(
/*...*/
onClick = {
coroutineScope.launch {
// 사용자가 클릭한 시점에 Bitmap을 캡쳐
nameCardBitmap = captureNameCard(graphicsLayer)
sendAction(NameCardResultIntent.ShowShareBottomSheet(true))
}
},
)

if (state.isShownShareBottomSheet) {
nameCardBitmap?.let {
NameCardShareBottomSheet(nameCardBitmap = it, sendAction = sendAction)
}
}

동작 결과

Loading


마무리

이번 글에서는 Composable을 Bitamp으로 변환하고, 이를 저장하거나 공유하는 기능을 구현하면서 마주한 다양한 이슈들을 정리했습니다.

  • 하드웨어 가속이 켜진 이미지를 소프트웨어 비트맵으로 변환할 수 없는 문제
  • isLaidOut 플래그를 이용해 레이아웃이 완료된 뒤에만 캡처해야 하는 이슈
  • Blur가 전혀 반영되지 않고 원본 상태로 캡처되는 문제
  • graphicsLayer.toImageBitmap()가 suspend 함수이므로, UI 흐름(예: BottomSheet)과의 타이밍을 잘 맞춰야 하는 점

이러한 문제들을 해결하는 과정에서,

  1. 소프트웨어 가속 사용
  2. isLaidOut 체크 후 안전한 시점에 drawToBitmap() 호출
  3. Blur 효과를 살리기 위한 graphicsLayer 활용
  4. 코루틴을 통한 비동기 처리와 UI 제어(예: BottomSheet 열기 시점 조정)

등을 적용했습니다.

Compose가 반복 렌더링(Recomposition)을 통해 UI를 효율적으로 그려주는 장점이 있지만, 이미지 캡쳐처럼 정확한 시점렌더링 타입을 제어해야 하는 기능은 기존 View 대비 고려해야 할 요소들이 많은 것 같습니다.

이번 경험을 통해 쌓은 노하우가, 앞으로 다양한 UI 개발에 있어 큰 도움이 될 것이라 기대합니다.

끝까지 읽어주셔서 감사하며, 앞으로도 Compose 기반 앱 개발에서 발생하는 이슈들을 공유하여, 같은 문제로 고민하는 분들께 도움이 되길 바랍니다.