완전 커스텀 UI
CameraController와 직접 만든 SurfaceView로 100% 커스텀 카메라 UI를 구현하는 방법.
개요
기본 사용법의 CameraView는 프리뷰와 기본 트래킹 UI를 함께 그려줍니다. 반면 트래킹 오버레이까지 포함해 100% 직접 그리고 싶다면, CameraView 없이 CameraController만 사용하고 프리뷰 Surface를 직접 제공하면 됩니다.
이 방식은 다음과 같은 경우에 적합합니다:
- Jetpack Compose 기반의 완전 커스텀 화면
- React Native / Flutter 등 브릿지 (실제로 Petnow React Native 패키지가 이 방식을 사용합니다)
- SDK 기본 트래킹 UI를 쓰지 않고 자체 오버레이를 그려야 하는 경우
CameraView 방식 vs 완전 커스텀 방식
| 기능 | CameraView (기본) | 완전 커스텀 |
|---|---|---|
| 프리뷰 Surface | CameraView가 내부 관리 | SurfaceView를 직접 만들고 attachPreviewSurface() |
| 기본 트래킹 UI | 내장 | 없음 — 직접 구현 |
| 카메라 권한 | 호스트가 처리 | 호스트가 처리 |
| 상태 수신 | V2 리스너 / state 프로퍼티 | V2 리스너 / state 프로퍼티 |
| UI 자유도 | 오버레이 추가 | 100% 커스텀 |
완전 커스텀 방식에서는 SDK가 어떤 UI도 그리지 않습니다. 진행률·상태·트래킹 박스를 모두 state 프로퍼티 또는 V2 리스너 값으로 직접 렌더링해야 합니다.
아키텍처
CameraController는 카메라·탐지의 전체 라이프사이클을 관리하되 UI 렌더링에는 관여하지 않습니다. 프리뷰는 직접 만든 SurfaceView로 그리고, 상태는 StateFlow(state 프로퍼티)와 PetnowCameraDetectionListenerV2 콜백으로 받습니다.
기본 통합
Step 1: 컨트롤러 생성 + 리스너 등록
import io.petnow.ui.CameraController
import io.petnow.ui.CameraResult
import io.petnow.ui.config.LicenseInfo
import io.petnow.ui.status.DetectionStatus
import io.petnow.callback.PetnowCameraDetectionListenerV2
val controller = CameraController(
context.applicationContext,
LicenseInfo(apiKey = "YOUR_API_KEY"),
scope,
).apply {
setDetectionListenerV2(object : PetnowCameraDetectionListenerV2 {
override fun onDetectionStatus(primaryDetectionStatus: DetectionStatus) { /* 직접 렌더링 */ }
override fun onDetectionProgress(progress: Int) { /* 0~100 */ }
override fun onDetectionFinished(result: CameraResult) { /* 결과 처리 */ }
})
}Step 2: SurfaceView 제공 + 세션 시작
직접 만든 SurfaceView의 Surface를 attachPreviewSurface()로 전달합니다. Surface가 준비되면 카메라가 열립니다. initializeCamera()는 카메라가 열릴 때까지 대기하므로, 그 뒤에 startDetection()을 호출하면 됩니다.
import android.view.SurfaceHolder
import android.view.SurfaceView
import io.petnow.ui.config.DetectionConfiguration
import io.petnow.ui.config.DetectionPurpose
import io.petnow.ui.config.PetSpecies
import java.util.UUID
val config = DetectionConfiguration(
species = PetSpecies.DOG,
purpose = DetectionPurpose.PET_PROFILE_REGISTRATION,
enableFakeDetection = false,
)
val captureSessionId: UUID = /* 서버로부터 받은 captureSessionId */
val surfaceView = SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
controller.attachPreviewSurface(holder.surface)
scope.launch {
controller.initializeCamera(config, captureSessionId)
controller.startDetection()
}
}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
controller.detachPreviewSurface() // 카메라 닫힘
}
})
}권한은 호스트가 처리합니다
CameraController는 카메라 권한을 요청하지 않습니다. AndroidManifest.xml에 CAMERA 권한을 선언하고, 세션을 시작하기 전에 런타임 권한을 직접 요청하세요.
Step 3: 상태 관찰 (StateFlow)
scope.launch {
controller.state.collect { state ->
// state.progressPercent: 진행률 (0~100)
// state.detectionStatusList: 현재 프레임의 탐지 상태 목록 (PetnowDetectionStatus)
// state.currentDetectionResult: 코/얼굴 BoundingBox (DetectionResult?) — 트래킹 박스 렌더링용
// state.isDetectionFinished: 탐지 완료 여부
}
}Step 4: 리소스 해제
controller.release() // 네이티브 디텍터·사운드 풀까지 완전히 해제 (종료)finalizeCamera()는 카메라 세션만 멈추고 컨트롤러를 재사용할 수 있게 합니다. 화면을 완전히 떠날 때는 release()로 네이티브 자원까지 정리하세요.
Jetpack Compose 예제
@Composable
fun PetnowCameraScreen(captureSessionId: UUID) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val controller = remember {
CameraController(
context.applicationContext,
LicenseInfo(apiKey = "YOUR_API_KEY"),
scope,
)
}
val state by controller.state.collectAsState()
val config = remember {
DetectionConfiguration(
species = PetSpecies.DOG,
purpose = DetectionPurpose.PET_PROFILE_REGISTRATION,
enableFakeDetection = false,
)
}
LaunchedEffect(Unit) {
controller.setDetectionListenerV2(object : PetnowCameraDetectionListenerV2 {
override fun onDetectionStatus(primaryDetectionStatus: DetectionStatus) { /* ... */ }
override fun onDetectionProgress(progress: Int) { /* ... */ }
override fun onDetectionFinished(result: CameraResult) { /* ... */ }
})
}
// 화면이 컨트롤러를 소유하므로, dispose = 완전 해제(release).
DisposableEffect(Unit) {
onDispose { controller.release() }
}
Column(modifier = Modifier.fillMaxSize()) {
// 프리뷰: 직접 만든 SurfaceView를 AndroidView로 감쌉니다.
AndroidView(
factory = { ctx ->
SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
controller.attachPreviewSurface(holder.surface)
scope.launch {
controller.initializeCamera(config, captureSessionId)
controller.startDetection()
}
}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
controller.detachPreviewSurface()
}
})
}
},
modifier = Modifier.weight(1f),
)
// 진행률 바 (state로 직접 렌더링)
LinearProgressIndicator(
progress = { state.progressPercent / 100f },
modifier = Modifier.fillMaxWidth(),
)
// 탐지 상태 텍스트
Text(
text = state.detectionStatusList.firstOrNull()?.name ?: "",
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center,
)
}
}카메라 전환 / 일시정지 / 재개
CameraView 방식과 동일한 컨트롤러 API를 사용합니다.
controller.switchCamera() // Result 반환 (전면 ↔ 후면)
controller.pauseDetection() // 일시정지 (반환값 없음)
controller.resumeDetection() // 재개 (Result 반환)
controller.startDetection() // 재촬영: 진행률 리셋 후 새 세션 시작 (Result 반환)사운드 재생
import io.petnow.ui.sound.SoundType
val streamId = controller.playSound(SoundType.RANDOM)
controller.stopSound(streamId)자세한 사운드 종류는 사운드 가이드를 참고하세요.
API 요약
| 메서드 | 설명 |
|---|---|
CameraController(context, license, scope) | 컨트롤러 생성 |
setDetectionListenerV2(listener) | 탐지 상태·진행률·결과 콜백 등록 |
attachPreviewSurface(surface) | 직접 만든 Surface 연결 (카메라 열림) |
detachPreviewSurface() | Surface 분리 (카메라 닫힘) |
initializeCamera(config, captureSessionId) | 세션 준비 (suspend) |
startDetection() | 탐지 시작/재시작 (Result) |
pauseDetection() / resumeDetection() | 탐지 일시정지 / 재개 |
switchCamera() | 전/후면 전환 (Result) |
state 프로퍼티 | StateFlow<DetectionState> 관찰 |
setBracketingMode(enabled) | 브래킷팅 모드 설정 |
playSound(type) / stopSound(id) | 사운드 재생/중지 |
finalizeCamera() | 카메라 세션 정지 (재사용 가능) |
release() | 네이티브 자원 완전 해제 (종료) |