일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 안드로이드 라이선스 종류
- 안드로이드 os 구조
- 서비스 쓰레드 차이
- ANR이란
- 플러터 설치 2022
- 객체
- rxjava hot observable
- 클래스
- 2022 플러터 안드로이드 스튜디오
- 안드로이드 레트로핏 사용법
- 안드로이드 유닛 테스트 예시
- 안드로이드 라이선스
- Rxjava Observable
- 큐 자바 코드
- 스택 자바 코드
- jvm 작동 원리
- android ar 개발
- 자바 다형성
- rxjava cold observable
- 안드로이드 유닛 테스트
- 스택 큐 차이
- rxjava disposable
- 안드로이드 유닛테스트란
- android retrofit login
- 서비스 vs 쓰레드
- ar vr 차이
- 안드로이드 레트로핏 crud
- 멤버변수
- jvm이란
- 2022 플러터 설치
- Today
- Total
나만을 위한 블로그
[Android Compose] 이미지 압축 구현하기 본문
※ 모든 코드는 예시 코드기 때문에 실제로 사용하려면 반드시 리팩토링하고 프로젝트에 맞는 예외처리를 추가해야 한다
이 포스팅은 아래의 영상을 바탕으로 작성했다.
https://www.youtube.com/watch?v=Q0Njj-rfEXE
이미지 압축은 구글링해 보면 다양한 방법으로 구현할 수 있다. 아래 코드도 그 예시 중 하나일 뿐이니 이렇게도 구현할 수 있다 치고 넘어가면 좋을 듯하다.
먼저 이미지 압축을 담당하는 ImageCompressor의 구현은 아래와 같다.
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
class ImageCompressor(
private val context: Context
) {
suspend fun compressImage(
contentUri: Uri,
compressionThreshold: Long,
): ByteArray? {
return withContext(Dispatchers.IO) {
val mimeType = context.contentResolver.getType(contentUri)
val inputBytes =
context.contentResolver.openInputStream(contentUri)?.use { inputStream ->
inputStream.readBytes()
} ?: return@withContext null
ensureActive()
withContext(Dispatchers.Default) {
val bitmap = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)
ensureActive()
val compressFormat = when (mimeType) {
"image/png" -> Bitmap.CompressFormat.PNG
"image/jpeg" -> Bitmap.CompressFormat.JPEG
"image/webp" -> if (Build.VERSION.SDK_INT >= 30) {
Bitmap.CompressFormat.WEBP_LOSSLESS
} else {
Bitmap.CompressFormat.WEBP
}
else -> Bitmap.CompressFormat.JPEG
}
var outputBytes: ByteArray
var quality = 100
do {
ByteArrayOutputStream().use { outputStream ->
bitmap.compress(compressFormat, quality, outputStream)
outputBytes = outputStream.toByteArray()
quality -= (quality * 0.1).roundToInt()
}
} while (isActive &&
outputBytes.size > compressionThreshold &&
quality > 5 &&
compressFormat != Bitmap.CompressFormat.PNG
)
outputBytes
}
}
}
}
먼저 컨텐트 리졸버에 접근하기 위해 매개변수로 context를 요구하는 게 보인다. 그리고 이미지 압축 함수는 압축할 사진의 uri와 임계값(압축된 이미지 크기를 얼마나 크게 할 것인지에 대한 값)을 매개변수로 받아서 압축된 이미지를 바이트 배열로 리턴한다.
compressImage()의 로직 흐름은 아래와 같다.
- 이미지 타입(mimeType) 확인 : 이미지 파일의 확장자가 jpg, png, webp인지 또는 어느것도 아닌지 파악한다
- 이미지 읽기 : openInputStream을 써서 contentUri의 데이터를 스트림으로 열어서 readBytes()로 모든 바이트를 읽어 inputBytes 배열을 만든다. close 처리를 자동으로 하기 위해 use 확장 함수를 사용했다. 이 때 만약 openInputStream이 실패하면 null을 리턴해서 함수를 종료한다
- 코루틴이 취소됐는지 확인 : ensureActive()를 써서 현재 코루틴이 취소됐는지 확인한다. 코루틴이 취소된 상태라면 CancellationException을 던져 작업을 중단한다. decodeByteArray()와 이미지 압축은 CPU를 많이 굴리는 작업이기 때문에 취소 여부를 수시로 확인해서 적절한 시점에 작업을 멈추기 위해 넣은 것이라 생각된다. 여기선 데이터 읽기가 완료된 시점에 코루틴이 아직 유효한지 확인해서 취소됐다면 이후 작업들인 이미지 디코딩, 압축을 건너뛰고 함수를 바로 종료한다
- ByteArray에서 비트맵으로 이미지 디코딩 : decodeByteArray()로 바이트 배열을 비트맵 객체로 바꾼다. 이 작업이 끝난 후 다시 ensureActive()를 호출해서 다음 작업을 실행하기 전에 코루틴 활성 상태를 다시 확인한다.
- 압축 포맷(compressFormat) 설정 : 디코딩이 끝났다면 jpg인지 png인지 결정해야 한다. 앞서 구한 mimeType에 따라 결정하는데 mimeType은 image/jpeg, image/png 형태로 얻게 된다. 그래서 when 절과 같이 조건을 정해서 CompressFormat을 정한다. webp의 경우 API 30부터 WEBP 필드가 deprecated됐기 때문에 저렇게 if를 걸어서 처리한 것으로 보인다.
- 이미지 압축 반복 : 매개변수로 받은 입력 이미지를 반복 압축해서 목표 크기인 compressionThreshold 이하로 크기를 줄이기 위한 처리다. 그냥 한 번 압축해서 내가 원하는 결과를 얻기 어려울 수 있어서 반복문을 쓴 것으로 보이고, 압축 조건인 '압축된 이미지 크기가 임계값보다 큰 경우, 압축 품질이 5 이상인 경우, png가 아닌 경우, 코루틴이 활성 상태인 경우'에 맞는 동안 계속 압축한다. 반복해서 압축을 시도하는 이유는 아래와 같다
- 이미지 크기, 품질 간 비례 관계 : 초기 quality 설정이 100일 때 이미지 크기가 엄청 클 수 있다. 그래서 품질을 단계적으로 낮추면서 크기를 줄인다
- 동적으로 목표 크기에 도달 : 앞서 말했듯 한 번의 압축으로 목표하는 크기를 정확히 맞추기는 어렵다. 그래서 반복 압축을 통해 과한 압축으로 품질이 너무 낮아지는 걸 방지하면서 크기 조건을 만족하게 한다
- 손실 압축 : jpeg, webp는 손실 압축을 사용하는 포맷으로 품질을 낮출수록 크기가 줄어든다
- 압축 완료, 반복문 종료 : 압축된 이미지가 임계값보다 작아지거나 품질이 5 이하거나, 코루틴이 취소됐거나 png 포맷이라는 조건이 일치하면 압축이 완료되어 압축된 이미지 데이터를 ByteArray 타입으로 리턴한다. 실패했다면 null을 리턴한다
이미지를 압축했으면 어딘가에 저장해야 한다. 이를 담당하는 FileManager의 구현은 아래와 같다.
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class FileManager(
private val context: Context
) {
suspend fun saveImage(
contentUri: Uri,
fileName: String,
) {
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(contentUri)?.use { inputStream ->
context.openFileOutput(fileName, Context.MODE_PRIVATE)
.use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
}
suspend fun saveImage(
bytes: ByteArray,
fileName: String,
) {
withContext(Dispatchers.IO) {
context.openFileOutput(fileName, Context.MODE_PRIVATE)
.use { outputStream ->
outputStream.write(bytes)
}
}
}
}
함수 오버로딩으로 읽는 데이터가 uri, byteArray인지의 차이만 있고 그 외에는 대동소이하다.
Context.MODE_PRIVATE는 앱의 내부 저장소에 비공개 파일을 저장하도록 설정한다. 그 외에는 파일 입출력 작업이기 때문에 withContext를 써서 IO 디스패처에서 실행하고, 입력 스트림을 열어서 입력 스트림의 데이터를 출력 스트림으로 복사하거나 직접 쓰는 처리를 진행한다.
아래는 버튼을 누르면 photo picker를 통해 사진을 가져와서 앞서 구현한 클래스들을 사용해 압축, 저장하는 UI 코드다.
import android.webkit.MimeTypeMap
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
@Composable
fun PhotoPickerScreen(
imageCompressor: ImageCompressor,
fileManager: FileManager,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { contentUri ->
if (contentUri == null) return@rememberLauncherForActivityResult
val mimeType = context.contentResolver.getType(contentUri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
scope.launch {
fileManager.saveImage(
contentUri = contentUri,
fileName = "uncompressed.$extension"
)
}
scope.launch {
val compressedImage = imageCompressor.compressImage(
contentUri = contentUri,
compressionThreshold = 200 * 1024L
)
fileManager.saveImage(
bytes = compressedImage ?: return@launch,
fileName = "compressed.$extension"
)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Button(
onClick = {
photoPicker.launch(
PickVisualMediaRequest(
mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly,
)
)
}
) {
Text(text = "이미지 선택")
}
}
}
이후 에뮬레이터에서 테스트했다. 테스트한 에뮬레이터는 Pixel 9 XL API 35고 아래의 무료 jpg 이미지로 확인했다.
버튼을 눌러 사진을 선택한 후 잠시 기다리면 아래와 같이 에뮬레이터에 compressed, uncompressed란 이름의 jpg 파일이 생긴 걸 볼 수 있다.
6.3MB의 jpg 이미지가 314KB 정도의 사진으로 압축됐고, 압축 결과물은 아래와 같다.
서두에서도 말했듯 이 코드는 예시 코드기 때문에 실제로 사용하려면 많은 예외처리와 리팩토링이 필요하다. quality의 경우 앱에서 자체적으로 진행하지 않고 서버나 웹뷰에서 받아서 사용할 수 있는데 그렇게 되면 compressImage()의 로직이 바뀔 수도 있다.
또한 이미지 압축 시 별다른 로딩 표시가 없어서 유저에겐 앱이 오작동하는 것으로 보일 수도 있기 때문에 상태에 따라 토스트, 스낵바를 띄워야 할 수도 있다. 뷰모델로 이미지 압축, 저장 로직을 옮겨서 Flow와 같이 사용하고 싶을 수도 있다. 적절하게 수정해서 요구사항에 맞는 기능을 구현하자.
'Android > Compose' 카테고리의 다른 글
[Android Compose] Shared Element Transition 구현 요소 알아보기 (0) | 2024.12.08 |
---|---|
[Android Compose] Shared Element Transition(공유 요소 전환) 구현하는 법 (0) | 2024.12.03 |
[Android Compose] MutableState vs MutableStateFlow (0) | 2024.11.25 |
[Android Compose] margin 설정 방법 정리 (0) | 2024.10.09 |
[Android Compose] 바텀 시트와 시스템 네비게이션 바가 겹치는 현상 해결 방법 (0) | 2024.10.08 |