refactor: CameraX AndroidView + PreviewView -> Composable CameraXViewfinder로 전환#265
refactor: CameraX AndroidView + PreviewView -> Composable CameraXViewfinder로 전환#265
Conversation
…CameraXViewfinder로 전환
WalkthroughAndroidView 기반 CameraController/PreviewView를 제거하고 CameraX Compose 구성으로 마이그레이션했습니다. ProcessCameraProvider, Preview, ImageCapture, CameraXViewfinder 및 surfaceRequest/ViewPort 기반 바인딩으로 프리뷰와 촬영 흐름을 재구성했습니다. 라이브러리 의존성은 camera-view에서 camera-compose로 변경되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant App as 앱
participant Provider as ProcessCameraProvider
participant Preview as Preview
participant Capture as ImageCapture
participant Viewfinder as CameraXViewfinder
App->>Provider: awaitInstance()
Provider-->>App: Provider 반환
App->>Preview: Preview 생성/설정 (surfaceRequest 발급)
App->>Capture: ImageCapture 생성/설정
App->>App: surfaceRequest 상태 업데이트
Viewfinder->>App: surfaceRequest 수신 및 렌더 준비
App->>Provider: Preview + ImageCapture 바인딩 (lifecycle, CameraSelector, ViewPort)
Provider-->>Preview: 카메라 프레임 제공
Preview-->>Viewfinder: 프레임 렌더링
App->>Capture: takePicture()
Capture-->>App: 캡처 콜백 (파일/바이트)
App->>App: 캡처 이미지 cropToPreviewArea 처리 및 이벤트 전송
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
feature/record/build.gradle.kts (1)
13-23:⚠️ Potential issue | 🟡 Minorfeature 모듈에 camera-compose 직접 선언은 타당합니다.
카메라 Compose 전환에 맞춘 적절한 의존성 추가이며, camera-compose는 1.5.1부터 안정화되었습니다. 다만 버전을 현재 최신 안정화 버전인 1.5.3(2026년 1월 28일 릴리스)으로 업데이트하는 것을 권장합니다. 1.5.3에는 ExifInterface 관련 JPEG 파싱 문제 수정이 포함되어 있습니다.
🧹 Nitpick comments (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt (1)
133-144: 카메라 바인딩 실패 시 에러 처리가 누락되었습니다.
bindToLifecycle이 실패할 경우(예: 카메라가 다른 앱에서 사용 중이거나 하드웨어 오류) 예외가 발생할 수 있습니다. try-catch로 감싸서 사용자에게 적절한 피드백을 제공하는 것을 권장합니다.♻️ 에러 처리 추가 제안
LaunchedEffect(isGranted) { if (!isGranted) return@LaunchedEffect - ProcessCameraProvider.awaitInstance(context).apply { - unbindAll() - bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageCapture, - ) + try { + ProcessCameraProvider.awaitInstance(context).apply { + unbindAll() + bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageCapture, + ) + } + } catch (e: Exception) { + // 카메라 바인딩 실패 처리 (예: 에러 이벤트 전송) + // state.eventSink(OcrUiEvent.OnCameraBindingFailed(e)) } }
|
@seoyoon513 근데 혹시 요거 어떤 케이스에서 문제 발생하는지 공유가능할까요? 트러블 슈팅으로 문서화해두기 좋을거같아 질문드림다~ |
|
@easyhooon 요거 테스트해보니 현재 구조에서는 OCR 분석 속도가 이전보다 더 오래 걸리는 것으로 느껴졌습니다. 이전 Preview를 사용할 때는 실질적으로 눈에 보이는 영역(height 200.dp)만 분석이 진행됐는데 마이그레이션 한 뒤로 센서 전체 해상도 기준으로 OCR 분석이 수행되고 있는 걸로 확인됩니다.
전체가 아닌 Preview에 표시되는 영역 기준으로 분석 범위를 제한해야할 것 같습니다...! |
센서 전체 해상도로 OCR 분석하던 문제를 수정하여, CameraXViewfinder에 표시되는 높이 200dp Preview 영역만 crop 후 분석하도록 변경
662deb6 |
|
@easyhooon 크롭된 이미지가 아닌 전체 영역이 적용되고 있는 것 같아요! fdd14cd 이후 촬영된 이미지를 단말에 저장하도록 코드를 약간 수정했는데요. 마이그레이션 전후 비교했을 때의 사진은 아래와 같습니다. |
|
쉽지않군요 |
CameraX 마이그레이션 시 LifecycleCameraController의 자동 ViewPort 설정이 누락되어 센서 전체 해상도로 OCR이 수행되던 문제를 수정
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt (1)
137-139: Configuration 변경 시 ViewPort 비율이 갱신되지 않음
screenWidthPx와previewHeightPx는 composition 시점에 한 번만 계산됩니다. 화면 회전 시LocalConfiguration.current.screenWidthDp가 변경되지만,remember블록 외부에서 계산되어LaunchedEffect의 ViewPort가 이전 값을 사용하게 됩니다.현재 화면이 회전을 지원하지 않는다면 문제가 없지만, 향후 지원 시 고려가 필요합니다.
♻️ derivedStateOf 또는 LaunchedEffect 내부로 이동 제안
- val density = LocalDensity.current - val screenWidthPx = with(density) { LocalConfiguration.current.screenWidthDp.dp.roundToPx() } - val previewHeightPx = with(density) { 200.dp.roundToPx() } + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val screenWidthPx = remember(configuration.screenWidthDp, density) { + with(density) { configuration.screenWidthDp.dp.roundToPx() } + } + val previewHeightPx = remember(density) { + with(density) { 200.dp.roundToPx() } + } - LaunchedEffect(isGranted) { + LaunchedEffect(isGranted, screenWidthPx) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt` around lines 137 - 139, screenWidthPx and previewHeightPx are computed only once at composition, so when LocalConfiguration (e.g., screen rotation) changes the LaunchedEffect that builds the ViewPort still uses stale values; move their computation into a recomposing/observable scope such as a remember(key1 = LocalConfiguration.current) or a derivedStateOf that reads LocalConfiguration.current and LocalDensity.current (or compute them inside the same LaunchedEffect before creating the ViewPort) so screenWidthPx and previewHeightPx update on configuration changes and the ViewPort is recreated with current values (refer to variables screenWidthPx, previewHeightPx, LocalConfiguration.current, LocalDensity.current, and the LaunchedEffect that constructs the ViewPort).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt`:
- Around line 141-165: The LaunchedEffect block binds camera use cases via
ProcessCameraProvider.awaitInstance(...) but lacks cleanup, causing potential
resource leaks; replace or augment this with a DisposableEffect that acquires
the ProcessCameraProvider (via ProcessCameraProvider.awaitInstance(context) or
the provider returned inside LaunchedEffect), calls unbindAll() in
DisposableEffect's onDispose, and performs the bindToLifecycle(...) inside
DisposableEffect's effect body (using lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, viewPort/useCaseGroup, preview, and
imageCapture) so that unbindAll() is always invoked when the composable leaves
composition.
---
Nitpick comments:
In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt`:
- Around line 137-139: screenWidthPx and previewHeightPx are computed only once
at composition, so when LocalConfiguration (e.g., screen rotation) changes the
LaunchedEffect that builds the ViewPort still uses stale values; move their
computation into a recomposing/observable scope such as a remember(key1 =
LocalConfiguration.current) or a derivedStateOf that reads
LocalConfiguration.current and LocalDensity.current (or compute them inside the
same LaunchedEffect before creating the ViewPort) so screenWidthPx and
previewHeightPx update on configuration changes and the ViewPort is recreated
with current values (refer to variables screenWidthPx, previewHeightPx,
LocalConfiguration.current, LocalDensity.current, and the LaunchedEffect that
constructs the ViewPort).
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt
| LaunchedEffect(isGranted) { | ||
| if (!isGranted) return@LaunchedEffect | ||
| ProcessCameraProvider.awaitInstance(context).apply { | ||
| unbindAll() | ||
|
|
||
| // Preview 영역(fillMaxWidth x 200dp) 비율로 ViewPort를 설정하여 | ||
| // ImageCapture 출력을 해당 영역으로 제한 | ||
| val viewPort = ViewPort.Builder( | ||
| Rational(screenWidthPx, previewHeightPx), | ||
| preview.targetRotation, | ||
| ).build() | ||
|
|
||
| val useCaseGroup = UseCaseGroup.Builder() | ||
| .setViewPort(viewPort) | ||
| .addUseCase(preview) | ||
| .addUseCase(imageCapture) | ||
| .build() | ||
|
|
||
| bindToLifecycle( | ||
| lifecycleOwner, | ||
| CameraSelector.DEFAULT_BACK_CAMERA, | ||
| useCaseGroup, | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
ProcessCameraProvider unbind 누락으로 인한 리소스 누수 가능성
Composable이 composition을 벗어날 때 카메라 바인딩을 해제하는 cleanup 로직이 없습니다. LaunchedEffect는 key가 변경되거나 composition을 벗어날 때 코루틴을 취소하지만, ProcessCameraProvider의 unbindAll()은 자동으로 호출되지 않습니다.
🐛 DisposableEffect를 사용한 cleanup 로직 추가 제안
LaunchedEffect(isGranted) {
if (!isGranted) return@LaunchedEffect
- ProcessCameraProvider.awaitInstance(context).apply {
+ val cameraProvider = ProcessCameraProvider.awaitInstance(context)
+ cameraProvider.apply {
unbindAll()
// Preview 영역(fillMaxWidth x 200dp) 비율로 ViewPort를 설정하여
// ImageCapture 출력을 해당 영역으로 제한
val viewPort = ViewPort.Builder(
Rational(screenWidthPx, previewHeightPx),
preview.targetRotation,
).build()
val useCaseGroup = UseCaseGroup.Builder()
.setViewPort(viewPort)
.addUseCase(preview)
.addUseCase(imageCapture)
.build()
bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
useCaseGroup,
)
}
}
+
+ DisposableEffect(Unit) {
+ onDispose {
+ // Composable 종료 시 카메라 리소스 해제
+ // ProcessCameraProvider는 singleton이므로 getInstance 사용
+ ProcessCameraProvider.getInstance(context).get().unbindAll()
+ }
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt`
around lines 141 - 165, The LaunchedEffect block binds camera use cases via
ProcessCameraProvider.awaitInstance(...) but lacks cleanup, causing potential
resource leaks; replace or augment this with a DisposableEffect that acquires
the ProcessCameraProvider (via ProcessCameraProvider.awaitInstance(context) or
the provider returned inside LaunchedEffect), calls unbindAll() in
DisposableEffect's onDispose, and performs the bindToLifecycle(...) inside
DisposableEffect's effect body (using lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, viewPort/useCaseGroup, preview, and
imageCapture) so that unbindAll() is always invoked when the composable leaves
composition.


🔗 관련 이슈
📙 작업 설명
🧪 테스트 내역 (선택)
💬 추가 설명 or 리뷰 포인트 (선택)
lifecycle에 바인딩하는 역할
Composable에 전달하는 역할
reference)
Compose-Native CameraX Is Now Stable: End-to-End Guide for Jetpack Compose
[Android/Compose] CameraX 사용해서 QR 코드 스캔하기
Summary by CodeRabbit