peekaboo 이미지피커 라이브러리 개선하기

wonseok
Preat
Published in
12 min readDec 29, 2023

안녕하세요 :)

저번 포스팅에서는 Compose Multiplatform 이미지 피커 라이브러리, peekaboo를 배포하게 된 과정과 그 이야기를 공유해보았는데요, 이번 포스팅에서는 해당 라이브러리에서 발생한 문제점과 그 이슈를 해결한 과정에 대해서 작성해보려고 합니다.

먼저, 그동안 peekaboo 라이브러리를 좋게 봐주시는 전세계의 KMP, Android 개발자분들 덕분에 다음과 같은 소소한 성과가 있었습니다.

1. Android Weekly #601

Android Weekly #601 peekaboo

2. Kotlin Weekly #385

Kotlin Weekly #385 peekaboo

3. kmp-awesome

terrakok/kmp-awesome peekaboo

4. AAkira / Kotlin-Multiplatform-Libraries

AAkira/Kotlin-Multiplatform-Libraries

5. ChatGemini (peekaboo를 사용한 또 다른 오픈소스 프로젝트)

chouaibMo/ChatGemini peekaboo

6. Kotlin Slack 채널 DM

인도의 어느 한 안드로이드 개발자 분께서 고맙다는 메시지를 남겨주셨다.

이렇게 오픈소스를 처음 배포해본 경험으로써 매우 뿌듯한 일들이었지만, 그만큼 프로젝트 자체에 대한 책임감도 더 커져가는 것을 느꼈습니다.

문제 발견

어느날 문득, 이미지뷰에 표시되는 것보다 훨씬 더 고해상도의 이미지를 로드하면 어떻게 될까? 하는 생각이 들었습니다.

안드로이드에서 비트맵 이미지는 일반적으로 ARGB_8888 포맷을 사용하므로, 이는 픽셀 당 4바이트 (각각의 Red, Green, Blue, Alpha 채널이 8비트)를 사용하기 때문에, 예를 들어 4000 x 4000 픽셀 이미지를 로드하게 된다면, 어림잡아 하나의 이미지에 60MB가 넘는 큰 메모리 사용량을 요구하게 됩니다.

그래서 테스트를 해보았습니다.

고해상도 이미지 예시

구글에서 고해상도 이미지를 구해서 sample app에서 고해상도 이미지를 로드하는 작업을 해보았는데, 아니나 다를까 문제가 발생했습니다.

trying to draw too large bitmap RuntimeException

(안드로이드 기준) 기존 이미지 피커 로직은 다음과 같았습니다.

val singleImagePickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
uri?.let {
with(context.contentResolver) {
openInputStream(uri)?.use {
onResult(listOf(it.readBytes()))
}
}
}
}
)

contentResolver의 openInputStream() 함수를 사용하여 선택된 이미지의 URI로부터 입력 스트림을 열고, readBytes() 함수를 사용하여 스트림으로부터 모든 바이트를 읽은 다음, onResult 콜백을 통해 ByteArray 타입의 이미지 데이터를 전달하는 로직이었습니다.

하지만 이 경우, 별도의 이미지 리사이징 작업이 없기 때문에 고해상도 이미지의 경우 위와 같은 런타임 에러가 발생할 수 있었습니다.

그래서, 별도의 리사이징 작업을 하는 함수를 작성해보았습니다.

private fun resizeImage(
context: Context,
uri: Uri,
width: Int,
height: Int,
): ByteArray? {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val options =
BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(inputStream, null, options)

var inSampleSize = 1
while (options.outWidth / inSampleSize > width || options.outHeight / inSampleSize > height) {
inSampleSize *= 2
}

options.inJustDecodeBounds = false
options.inSampleSize = inSampleSize

context.contentResolver.openInputStream(uri)?.use { scaledInputStream ->
val scaledBitmap = BitmapFactory.decodeStream(scaledInputStream, null, options)
if (scaledBitmap != null) {
ByteArrayOutputStream().use { byteArrayOutputStream ->
scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream)
return byteArrayOutputStream.toByteArray()
}
}
}
}
return null
}

같은 방식으로 openInputStream()을 통해 URI에서 이미지 데이터를 읽을 수 있는 입력 스트림을 열고,BitmapFactory.decodeStream을 사용하여 이미지의 원본 크기를 확인 한 뒤, inJustDecodeBounds = true 을 통해 이미지를 실제 메모리에 로드하지 않고 크기 정보(메타 데이터)만 추출하도록 하였습니다.

이후, while 루프를 사용하여 이미지의 리사이징을 위한 적절한inSampleSize 값을 계산하였고, 다시 inJustDecodeBounds = false 설정을 하여 리사이징된 이미지를 메모리에 로드하였습니다.

참고로, 리사이징된 이미지 (scaledBitmap)는 ByteArrayOutputStream을 통해 JPEG 포맷으로 압축(압축률은 100%, 최대 품질 압축)되고 ByteArray 형태로 변환하는 방식으로 코드를 짜보았습니다.

고해상도 이미지가 적절한 리사이징을 통해 이제는 문제없이 로드가 된다.

iOS (Kotlin/Native) 코드도 비슷한 방식으로 이미지 리사이징하는 함수를 작성하였습니다.

@OptIn(ExperimentalForeignApi::class)
private fun UIImage.fitInto(
width: Int,
height: Int,
): UIImage {
val targetSize = CGSizeMake(width.toDouble(), height.toDouble())
return this.resize(targetSize)
}

@OptIn(ExperimentalForeignApi::class)
private fun UIImage.resize(targetSize: CValue<CGSize>): UIImage {
UIGraphicsBeginImageContextWithOptions(targetSize, false, 0.0)
this.drawInRect(CGRectMake(0.0, 0.0, targetSize.useContents { width }, targetSize.useContents { height }))
val newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

return newImage!!
}
해당 수정사항을 반영하여 0.3.1 버전으로 배포

또한, 라이브러리 사용자가 원하는 해상도로 리사이징 할 수 있도록 rememberImagePickerLauncher에 옵션을 추가하여 0.3.1 버전으로 배포할 수 있게 되었습니다.

val multipleImagePicker =
rememberImagePickerLauncher(
selectionMode = SelectionMode.Multiple(maxSelection = 5),
scope = scope,
// Resize options are customizable. Default is set to 800 x 800 pixels.
resizeOptions = ResizeOptions(width = 1200, height = 1200),
onResult = { byteArrays ->
images =
byteArrays.map { byteArray ->
byteArray.toImageBitmap()
}
},
)

이와 같은 방식으로 Compose Multiplatform 환경에서 고해상도 이미지를 로드할 때 생길 수 있는 메모리 관련 런타임 이슈를 적절한 리사이징 옵션을 추가하여 해결해보았습니다.

+ 이미지 메모리 캐시 (안드로이드)

peekaboo-image-picker의 rememberImagePickerLauncher를 통해 Composable에서 고해상도 이미지를 로드하는 작업은 다른 평균적인 이미지를 로드하는 작업에 비해 더 많은 작업 시간을 필요로 했습니다.
다행히 안드로이드에서는 android.util.LruCache에서 LruCache를 사용할 수 있었고, 이를 통해 사용 가능한 메모리의 일부에서 Bitmap을 캐싱하여 성능을 조금 더 개선할 수 있었습니다.

import android.graphics.Bitmap
import android.util.LruCache
import java.io.ByteArrayOutputStream

internal object PeekabooBitmapCache {
internal val instance: LruCache<String, Bitmap> by lazy {
LruCache<String, Bitmap>(calculateMemoryCacheSize())
}

private fun calculateMemoryCacheSize(): Int {
val maxMemory = Runtime.getRuntime().maxMemory() / 1024
val cacheSize = (maxMemory * 0.25).toInt()
return cacheSize.coerceAtLeast(1024 * 1024)
}

internal fun bitmapToByteArray(bitmap: Bitmap): ByteArray {
ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
return stream.toByteArray()
}
}
}

하지만 많은 Bitmap 객체들을 메모리에 캐시하는 것은 자칫하면 메모리 성능에 부담이 될 수 있으므로 DiskLruCache와 같은 디스크 캐싱을 같이 사용하면 좀 더 메모리 부담은 줄일 수 있겠지만, 메모리 I/O와 디스크 I/O의 속도차이를 생각해본다면 과연 어떤 방향이 더 좋을지는 추후 벤치마킹 테스트를 통해 두 방식 사이의 성능을 비교하고 살펴봐야겠다는 생각이 들었습니다.

마치며

이렇게 조금이나마 peekaboo의 문제점과 성능을 개선해나갈 수 있었습니다.

라이브러리에 대한 더 자세한 내용은 아래 깃허브 레포지토리에서 확인하실 수 있으며, 이슈 등록 및 컨트리뷰션은 언제든지 환영합니다 🤗

다음 포스팅에서 뵙겠습니다.

감사합니다 🙇🙇🙇

참고하면 더 좋을 포스팅

https://d2.naver.com/helloworld/429368

--

--