Skip to content

refactor: CameraX AndroidView + PreviewView -> Composable CameraXViewfinder로 전환#265

Open
easyhooon wants to merge 4 commits intodevelopfrom
BOOK-493-refactor/#256
Open

refactor: CameraX AndroidView + PreviewView -> Composable CameraXViewfinder로 전환#265
easyhooon wants to merge 4 commits intodevelopfrom
BOOK-493-refactor/#256

Conversation

@easyhooon
Copy link
Contributor

@easyhooon easyhooon commented Feb 3, 2026

🔗 관련 이슈

📙 작업 설명

  • CameraX PreviewView(AndroidView) → CameraXViewfinder(Compose) 마이그레이션
  • LifecycleCameraController → ProcessCameraProvider + Preview + ImageCapture로 전환
  • androidx-camera-view 의존성 제거, androidx-camera-compose 의존성 추가

🧪 테스트 내역 (선택)

  • 주요 기능 정상 동작 확인
  • 브라우저/기기에서 동작 확인
  • 엣지 케이스 테스트 완료
  • 기존 기능 영향 없음

💬 추가 설명 or 리뷰 포인트 (선택)

  • ProcessCameraProvider → 카메라 use case(Preview, ImageCapture)를
    lifecycle에 바인딩하는 역할
  • SurfaceRequest → Preview가 만들어낸 카메라 프레임을 CameraXViewfinder
    Composable에 전달하는 역할
  • CameraXViewfinder는 SurfaceRequest를 통해 카메라 프레임을 전달받는데, 기존 LifecycleCameraController는 SurfaceRequest를 외부로 제공하지 않으므로 ProcessCameraProvider로 전환했습니다.

reference)
Compose-Native CameraX Is Now Stable: End-to-End Guide for Jetpack Compose
[Android/Compose] CameraX 사용해서 QR 코드 스캔하기

Summary by CodeRabbit

  • 리팩토링
    • 카메라 촬영/미리보기 기반을 최신 Compose 기반 카메라로 전환했습니다.
    • 미리보기와 촬영 흐름이 Compose에 맞게 재구성되어 UI 응답성과 일관성이 개선되었습니다.
    • 촬영된 이미지는 미리보기 영역(고정 높이) 기준으로 중앙 크롭되어 예상한 프레임을 제공합니다.
    • 전반적인 안정성 및 유지보수성이 향상되었습니다.

@coderabbitai
Copy link

coderabbitai bot commented Feb 3, 2026

Walkthrough

AndroidView 기반 CameraController/PreviewView를 제거하고 CameraX Compose 구성으로 마이그레이션했습니다. ProcessCameraProvider, Preview, ImageCapture, CameraXViewfinder 및 surfaceRequest/ViewPort 기반 바인딩으로 프리뷰와 촬영 흐름을 재구성했습니다. 라이브러리 의존성은 camera-view에서 camera-compose로 변경되었습니다.

Changes

Cohort / File(s) Summary
CameraX Compose 마이그레이션
feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt
LifecycleCameraController/PreviewView 및 AndroidView 사용 제거. ProcessCameraProvider + Preview + ImageCapture 기반 바인딩으로 전환, surfaceRequest 흐름과 CameraXViewfinder로 프리뷰 렌더링, ViewPort로 캡처 영역 제약 및 imageCapture.takePicture 호출로 캡처 로직 변경.
모듈 의존성 변경
feature/record/build.gradle.kts
의존성에서 libs.androidx.camera.viewlibs.androidx.camera.compose로 교체.
버전 관리 파일 업데이트
gradle/libs.versions.toml
androidx-camera-view 항목 제거 및 androidx-camera-compose 항목 추가로 라이브러리 선언 변경.

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 처리 및 이벤트 전송
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 빛 따라 폴짝, 렌즈 속으로,
안드로이드뷰 떠나 컴포즈 숲으로,
surface가 손짓하면 뷰파인더 춤추고,
찰칵 소리에 토끼도 깡충—
새 스냅, 새 리듬, 함께 껑충!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning 코드 변경사항이 연결된 이슈 #256의 주요 요구사항(AndroidView 제거, Composable 카메라 프리뷰 구현)을 충족하나, OCR 성능 문제로 인한 미해결된 회귀가 있습니다. OCR 분석 영역을 미리보기 영역으로 제한하여 성능 저하 문제를 완전히 해결해야 합니다. 현재 구현은 센서 전체 해상도로 분석하고 있어 정확도가 감소합니다.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경 사항(CameraX AndroidView + PreviewView에서 Composable CameraXViewfinder로의 전환)을 명확하게 설명합니다.
Out of Scope Changes check ✅ Passed 모든 코드 변경사항이 PR 목표(CameraX 마이그레이션)와 직접 관련이 있으며 범위 내에 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch BOOK-493-refactor/#256

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

feature 모듈에 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))
     }
 }

@easyhooon
Copy link
Contributor Author

@seoyoon513 근데 혹시 요거 어떤 케이스에서 문제 발생하는지 공유가능할까요? 트러블 슈팅으로 문서화해두기 좋을거같아 질문드림다~

@seoyoon513
Copy link
Contributor

@easyhooon 요거 테스트해보니 현재 구조에서는 OCR 분석 속도가 이전보다 더 오래 걸리는 것으로 느껴졌습니다. 이전 Preview를 사용할 때는 실질적으로 눈에 보이는 영역(height 200.dp)만 분석이 진행됐는데 마이그레이션 한 뒤로 센서 전체 해상도 기준으로 OCR 분석이 수행되고 있는 걸로 확인됩니다.

  • 불필요한 영역까지 함께 분석 -> 처리 시간 증가
  • 실제로 인식하고자 하는 영역 외의 정보까지 분석 -> 정확도 떨어짐

전체가 아닌 Preview에 표시되는 영역 기준으로 분석 범위를 제한해야할 것 같습니다...!

센서 전체 해상도로 OCR 분석하던 문제를 수정하여, CameraXViewfinder에 표시되는 높이 200dp Preview 영역만 crop 후 분석하도록 변경
@easyhooon
Copy link
Contributor Author

@easyhooon 요거 테스트해보니 현재 구조에서는 OCR 분석 속도가 이전보다 더 오래 걸리는 것으로 느껴졌습니다. 이전 Preview를 사용할 때는 실질적으로 눈에 보이는 영역(height 200.dp)만 분석이 진행됐는데 마이그레이션 한 뒤로 센서 전체 해상도 기준으로 OCR 분석이 수행되고 있는 걸로 확인됩니다.

  • 불필요한 영역까지 함께 분석 -> 처리 시간 증가
  • 실제로 인식하고자 하는 영역 외의 정보까지 분석 -> 정확도 떨어짐

전체가 아닌 Preview에 표시되는 영역 기준으로 분석 범위를 제한해야할 것 같습니다...!

662deb6
반영 완료

@seoyoon513
Copy link
Contributor

@easyhooon 크롭된 이미지가 아닌 전체 영역이 적용되고 있는 것 같아요! fdd14cd 이후 촬영된 이미지를 단말에 저장하도록 코드를 약간 수정했는데요. 마이그레이션 전후 비교했을 때의 사진은 아래와 같습니다.

마이그레이션 후
Screenshot_20260226_215209

마이그레이션 전
Screenshot_20260226_215238

@easyhooon
Copy link
Contributor Author

쉽지않군요

CameraX 마이그레이션 시 LifecycleCameraController의 자동 ViewPort 설정이 누락되어 센서 전체 해상도로 OCR이 수행되던 문제를 수정
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 비율이 갱신되지 않음

screenWidthPxpreviewHeightPx는 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

📥 Commits

Reviewing files that changed from the base of the PR and between fdd14cd and 669b65a.

📒 Files selected for processing (1)
  • feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt

Comment on lines +141 to 165
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,
)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ProcessCameraProvider unbind 누락으로 인한 리소스 누수 가능성

Composable이 composition을 벗어날 때 카메라 바인딩을 해제하는 cleanup 로직이 없습니다. LaunchedEffect는 key가 변경되거나 composition을 벗어날 때 코루틴을 취소하지만, ProcessCameraProviderunbindAll()은 자동으로 호출되지 않습니다.

🐛 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BOOK-493/refactor] CameraX AndroidView -> Compose 마이그레이션

2 participants