260424 카메라레코더 fastfail 예제
24 Apr 2026
좋습니다.
아래는 당신이 마지막으로 요청한 스타일을 반영한 전체 예제입니다.
반영 기준:
Event sealed class제거statusFlow,recordingFlow,elapsedFlow,outputFlow,errorFlow분리setRecording(Boolean)제거startRecording(),stopRecording()사용@MainThread만 사용withContext(Dispatchers.Main)제거suspendCancellableCoroutine<Unit> { cont -> ... }로 TextureView 대기하는 코드 제거_waitUntilTextureViewReady,_openCameraAndStartPreview인라인화_chooseBackCamera,_choosePreviewSize인라인화- timeout 제거
if (textureView.isAvailable) return제거- 예외처리 최소화, fast-fail 스타일
- 상세 주석 포함
주의:
이 버전은 의도적으로 짧고 직선적인 코드를 우선한 예제입니다.
즉, 실서비스용 방어코드보다는 구조 이해와 간결성을 우선합니다.
1) RecorderViewModel.kt
package com.example.camerarecorder
import android.content.Context
import android.view.TextureView
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
// 화면 상태를 용도별 StateFlow로 분리한 ViewModel.
// 장점:
// - 상태 타입이 즉시 보인다.
// - 이벤트 클래스/분기 없이 바로 갱신할 수 있다.
// - Compose에서 필요한 값만 선택적으로 구독할 수 있다.
class RecorderViewModel : ViewModel() {
private lateinit var _recorder: CameraRecorder
// 현재 화면 상태 문자열.
// 예: "IDLE", "PREVIEW_STARTED", "RECORDING_IN_PROGRESS"
private val _statusFlow = MutableStateFlow("IDLE")
val statusFlow: StateFlow<String> = _statusFlow
// 현재 녹화 중 여부.
private val _recordingFlow = MutableStateFlow(false)
val recordingFlow: StateFlow<Boolean> = _recordingFlow
// 현재 녹화 경과 시간(초).
private val _elapsedFlow = MutableStateFlow(0)
val elapsedFlow: StateFlow<Int> = _elapsedFlow
// 마지막 저장 파일 경로.
// 아직 저장된 파일이 없으면 null.
private val _outputFlow = MutableStateFlow<String?>(null)
val outputFlow: StateFlow<String?> = _outputFlow
// UI 표시용 에러 메시지.
private val _errorFlow = MutableStateFlow<String?>(null)
val errorFlow: StateFlow<String?> = _errorFlow
fun setStatus(value: String) {
_statusFlow.value = value
}
// Boolean setter 대신 start/stop 메서드로 상태를 명시 갱신한다.
fun startRecording() {
_recordingFlow.value = true
}
fun stopRecording() {
_recordingFlow.value = false
}
fun setElapsed(sec: Int) {
_elapsedFlow.value = sec
}
fun setOutput(path: String?) {
_outputFlow.value = path
}
fun setError(message: String?) {
_errorFlow.value = message
}
fun clearError() {
_errorFlow.value = null
}
fun initRecorder(context: Context) {
_recorder = CameraRecorder(context = context, vm = this)
}
suspend fun initPreview(textureView: TextureView) {
_recorder.initPreview(textureView)
}
suspend fun startRecorder() {
clearError()
_recorder.startRecording()
}
suspend fun stopRecorder() {
_recorder.stopRecording()
}
fun releaseRecorder() {
if (::_recorder.isInitialized) {
_recorder.release()
}
}
}
2) CameraRecorder.kt
package com.example.camerarecorder
import android.content.Context
import android.graphics.SurfaceTexture
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.os.Environment
import android.util.Range
import android.view.Surface
import android.view.TextureView
import androidx.annotation.MainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// Camera2 + MediaCodec + MediaMuxer를 묶은 단순 Recorder.
// 역할:
// 1) TextureView preview 연결
// 2) H.264 인코딩
// 3) mp4 저장
// 4) 상태를 ViewModel에 반영
// 전제:
// - @MainThread 함수는 호출자가 Main 스레드에서 호출한다.
// - 방어코드보다 fast-fail을 우선한다.
class CameraRecorder {
private val _context: Context
private val _vm: RecorderViewModel
constructor(context: Context, vm: RecorderViewModel) {
_context = context
_vm = vm
}
companion object {
private const val _VIDEO_WIDTH = 1280
private const val _VIDEO_HEIGHT = 720
private const val _VIDEO_BITRATE = 4_000_000
private const val _VIDEO_FRAME_RATE = 30
private const val _VIDEO_I_FRAME_INTERVAL = 2
}
// 현재 열린 카메라 디바이스.
private var _camera: CameraDevice? = null
// 현재 사용 중인 CameraCaptureSession.
// preview: preview surface 1개
// recording: preview + encoder surface 2개
private var _camCapSession: CameraCaptureSession? = null
// TextureView preview용 Surface.
private var _previewSurface: Surface? = null
// H.264 비디오 인코더.
private var _encoder: MediaCodec? = null
// MediaCodec 입력 Surface.
// camera 프레임이 이 Surface로 들어와 인코딩된다.
private var _encoderInputSurface: Surface? = null
// mp4 파일 writer.
private var _muxer: MediaMuxer? = null
// muxer.start() 호출 여부.
// start 이전에는 sample write를 할 수 없다.
private var _muxerStarted = false
// muxer에 등록된 video track index.
private var _videoTrackIndex = -1
// codec 출력 버퍼를 muxer에 쓰는 drain 코루틴 Job.
private var _drainJob: Job? = null
// 1초 단위 elapsed 갱신 타이머 Job.
private var _timerJob: Job? = null
// Recorder 내부 작업용 scope.
// lifecycleScope와 분리해 내부 job 생명주기를 독립적으로 관리한다.
private val _scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// 최종 저장될 mp4 파일 경로.
private var _outputPath: String? = null
// TextureView를 받아 preview를 초기화한다.
// 흐름:
// 1) TextureView 준비 대기
// 2) 후면 카메라 선택
// 3) preview size 계산
// 4) surface 준비
// 5) camera open
// 6) preview session 구성
// 7) repeating request 시작
@MainThread
suspend fun initPreview(textureView: TextureView) {
val cameraManager = _context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
// TextureView surface 준비 완료까지 polling 대기.
while (!textureView.isAvailable) {
delay(50)
}
// 후면 카메라를 선택한다. 없으면 first에서 즉시 실패한다.
val cameraId = cameraManager.cameraIdList.first { id ->
cameraManager.getCameraCharacteristics(id)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
}
// 선택된 카메라 특성 조회.
val chars = cameraManager.getCameraCharacteristics(cameraId)
// 출력 가능 포맷/해상도 맵.
val map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
// preview size 선택 규칙:
// - 1280x720 이상 중 최소 면적
// - 없으면 첫 번째 size
val previewSize = map.getOutputSizes(SurfaceTexture::class.java)
.filter { it.width >= 1280 && it.height >= 720 }
.minByOrNull { it.width * it.height }
?: map.getOutputSizes(SurfaceTexture::class.java).first()
// isAvailable 대기 이후이므로 !!로 surfaceTexture 사용.
val surfaceTexture = textureView.surfaceTexture!!
// preview 버퍼 크기 지정.
surfaceTexture.setDefaultBufferSize(previewSize.width, previewSize.height)
// SurfaceTexture를 Camera2용 Surface로 래핑.
_previewSurface = Surface(surfaceTexture)
var isFinish = false
cameraManager.openCamera(
cameraId,
object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
isFinish = true
_camera = camera
}
override fun onDisconnected(camera: CameraDevice) {
camera.close()
isFinish = true
throw IllegalStateException("CAMERA_DISCONNECTED")
}
override fun onError(camera: CameraDevice, error: Int) {
camera.close()
isFinish = true
throw IllegalStateException("CAMERA_OPEN_ERROR_${error}")
}
},
null
)
while (!isFinish) {
delay(10)
}
_camCapSession?.close()
var isFinish = false
_camera!!.createCaptureSession(
listOf(_previewSurface!!),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
isFinish = true
_camCapSession = session
}
override fun onConfigureFailed(session: CameraCaptureSession) {
isFinish = true
throw IllegalStateException("PREVIEW_SESSION_CONFIGURE_FAILED")
}
},
null
)
while (!isFinish) {
delay(10)
}
// preview request 생성.
val capReqBuilder = _camera!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
capReqBuilder.addTarget(_previewSurface!!)
capReqBuilder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO)
val captureRequest = capReqBuilder.build()
// repeating request 시작.
_camCapSession!!.setRepeatingRequest(captureRequest, null, null)
_vm.setStatus("PREVIEW_STARTED")
}
// 녹화 시작.
// 흐름:
// 1) encoder/muxer 준비
// 2) recording session 구성
// 3) drain loop 시작
// 4) elapsed 타이머 시작
@MainThread
suspend fun startRecording() {
// 이미 녹화 중이면 중복 시작을 막는다.
if (_encoder != null) return
val outputFile = File(_context.getExternalFilesDir(Environment.DIRECTORY_MOVIES) ?: _context.filesDir, "VID_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".mp4")
_outputPath = outputFile.absolutePath
// H.264 encoder 포맷 설정.
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, _VIDEO_WIDTH, _VIDEO_HEIGHT)
// 입력을 ByteBuffer 대신 Surface로 받는다.
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
format.setInteger(MediaFormat.KEY_BIT_RATE, _VIDEO_BITRATE)
format.setInteger(MediaFormat.KEY_FRAME_RATE, _VIDEO_FRAME_RATE)
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, _VIDEO_I_FRAME_INTERVAL)
// encoder 생성 및 시작.
_encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
_encoder!!.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
// camera 프레임 입력 Surface 생성.
_encoderInputSurface = _encoder!!.createInputSurface()
_encoder!!.start()
// mp4 muxer 생성.
_muxer = MediaMuxer(_outputPath!!, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
_muxerStarted = false
_videoTrackIndex = -1
// recording session 생성.
// preview와 encoder surface를 동시에 타깃으로 사용한다.
_camCapSession?.close()
var isFinish = false
_camera!!.createCaptureSession(
listOf(_previewSurface!!, _encoderInputSurface!!),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
_camCapSession = session
isFinish = true
}
override fun onConfigureFailed(session: CameraCaptureSession) {
isFinish = true
throw IllegalStateException("RECORDING_SESSION_CONFIGURE_FAILED")
}
},
null
)
while (!isFinish) {
delay(10)
}
// 녹화용 capture request 생성.
val camCapReqBuilder = _camera!!.createCaptureRequest(CameraDevice.TEMPLATE_RECORD)
camCapReqBuilder.addTarget(_previewSurface!!)
camCapReqBuilder.addTarget(_encoderInputSurface!!)
camCapReqBuilder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO)
camCapReqBuilder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(30, 30))
val camCapReq = camCapReqBuilder.build()
_camCapSession!!.setRepeatingRequest(camCapReq, null, null)
// drain loop:
// encoder 출력 버퍼를 읽어 muxer로 기록한다.
_drainJob = _scope.launch {
val info = MediaCodec.BufferInfo()
while (isActive) {
val outIndex = _encoder!!.dequeueOutputBuffer(info, 10_000)
when {
// 아직 꺼낼 버퍼가 없으면 짧게 대기.
outIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
delay(10)
}
// 출력 포맷 확정 시점.
// addTrack 후 muxer.start()를 한 번만 호출한다.
outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val newFormat = _encoder!!.outputFormat
_videoTrackIndex = _muxer!!.addTrack(newFormat)
_muxer!!.start()
_muxerStarted = true
}
// 실제 인코딩 버퍼를 받은 경우.
outIndex >= 0 -> {
val encodedData = _encoder!!.getOutputBuffer(outIndex)!!
// 유효 데이터이며 muxer 시작 후에만 기록.
if (info.size > 0 && _muxerStarted) {
encodedData.position(info.offset)
encodedData.limit(info.offset + info.size)
_muxer!!.writeSampleData(_videoTrackIndex, encodedData, info)
}
// codec에 버퍼 반환.
_encoder!!.releaseOutputBuffer(outIndex, false)
// EOS면 drain loop 종료.
val isEos = (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
if (isEos) break
}
}
}
}
// 경과 시간 표시용 타이머.
_timerJob = _scope.launch {
var sec = 0
while (isActive) {
_vm.setElapsed(sec)
delay(1000)
sec++
}
}
_vm.startRecording()
_vm.setStatus("RECORDING_IN_PROGRESS")
}
// 녹화 종료.
// 흐름:
// 1) timer 중지
// 2) repeating 중지
// 3) encoder EOS 전달
// 4) drain 종료 대기
// 5) encoder/muxer 정리
// 6) preview 세션 복구
@MainThread
suspend fun stopRecording() {
_timerJob!!.cancel()
_timerJob = null
// 현재 반복 캡처 중지.
_camCapSession!!.stopRepeating()
_camCapSession!!.abortCaptures()
// Surface 입력 종료(EOS) 알림.
_encoder!!.signalEndOfInputStream()
// drain loop 가 끝날 때까지 대기.
// 즉 마지막 버퍼가 muxer 로 다 기록될 때까지 기다림.
_drainJob!!.join()
// encoder 종료 및 해제.
_encoder!!.stop()
_encoder!!.release()
_encoder = null
// encoder input surface 해제.
_encoderInputSurface!!.release()
_encoderInputSurface = null
// muxer 종료 및 해제.
if (_muxerStarted) {
_muxer!!.stop()
}
_muxer!!.release()
_muxer = null
_muxerStarted = false
_videoTrackIndex = -1
// 다시 preview-only session 으로 복귀.
_camCapSession!!.close()
var isFinish = false
_camera!!.createCaptureSession(
listOf(_previewSurface!!),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
_camCapSession = session
isFinish = true
}
override fun onConfigureFailed(session: CameraCaptureSession) {
isFinish = true
throw IllegalStateException("PREVIEW_SESSION_RECONFIGURE_FAILED")
}
},
null
)
while (!isFinish) {
delay(10)
}
val requestBuilder = _camera!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
requestBuilder.addTarget(_previewSurface!!)
requestBuilder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO)
val request = requestBuilder.build()
_camCapSession!!.setRepeatingRequest(request, null, null)
_vm.setOutput(_outputPath)
_vm.stopRecording()
_vm.setStatus("PREVIEW")
}
@MainThread
fun release() {
_timerJob?.cancel()
_drainJob?.cancel()
_camCapSession?.close()
_camera?.close()
if (_encoder != null) {
_encoder!!.release()
_encoder = null
}
if (_encoderInputSurface != null) {
_encoderInputSurface!!.release()
_encoderInputSurface = null
}
if (_muxer != null) {
_muxer!!.release()
_muxer = null
}
_scope.cancel()
}
}
3) MainActivity.kt
package com.example.camerarecorder
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.TextureView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.ViewModelProvider
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.weight
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
// Activity 역할:
// 1) 권한 요청
// 2) Recorder 생성
// 3) Compose UI 표시
// 4) 버튼 이벤트에서 start/stop 실행
class MainActivity : ComponentActivity() {
// 화면 상태 ViewModel.
private lateinit var _vm: RecorderViewModel
// Compose에서 생성한 TextureView 참조.
// 권한 허용 이후 preview 초기화에 사용한다.
private var _textureView: TextureView? = null
// 명시적 권한 요청 코드.
private val _PERM_REQ_CODE = 1001
private val _PERM_LIST = arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val vmProvider = ViewModelProvider(this)
_vm = vmProvider[RecorderViewModel::class.java]
// Recorder는 ViewModel이 소유한다.
_vm.initRecorder(this)
setContent(::_setContent)
}
@Composable
private fun _setContent() {
MaterialTheme {
// Flow를 Compose 상태로 수집.
val status = _vm.statusFlow.collectAsStateWithLifecycle().value
val isRecording = _vm.recordingFlow.collectAsStateWithLifecycle().value
val elapsed = _vm.elapsedFlow.collectAsStateWithLifecycle().value
val outputPath = _vm.outputFlow.collectAsStateWithLifecycle().value
val error = _vm.errorFlow.collectAsStateWithLifecycle().value
Surface(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Camera preview용 TextureView.
// Compose에서는 AndroidView로 감싸서 사용한다.
AndroidView(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
factory = { context ->
val textureView = TextureView(context)
_textureView = textureView
_initPreviewOrRequestPermission(textureView)
textureView
}
)
Column(
modifier = Modifier.padding(16.dp)
) {
Text("상태: $status")
Text("녹화시간: ${elapsed}초")
if (outputPath != null) {
Spacer(modifier = Modifier.height(8.dp))
Text("저장: $outputPath")
}
if (error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(text = "에러: $error", color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 녹화 시작 버튼.
Button(
onClick = ::_onClickStartRecording,
enabled = !isRecording
) {
Text("녹화 시작")
}
// 녹화 종료 버튼.
Button(
onClick = ::_onClickStopRecording,
enabled = isRecording
) {
Text("녹화 종료")
}
}
}
}
}
}
}
private fun _initPreviewOrRequestPermission(textureView: TextureView) {
if (_hasCameraPermission()) {
lifecycleScope.launch {
_vm.clearError()
_vm.initPreview(textureView)
}
} else {
requestPermissions(_PERM_LIST, _PERM_REQ_CODE)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != _PERM_REQ_CODE) {
return
}
var cameraGranted = false
var i = 0
while (i < permissions.size) {
if (permissions[i] == Manifest.permission.CAMERA) {
cameraGranted = grantResults[i] == PackageManager.PERMISSION_GRANTED
break
}
i++
}
if (!cameraGranted) {
_vm.setError("CAMERA_PERMISSION_DENIED")
return
}
lifecycleScope.launch {
_vm.clearError()
_vm.initPreview(_textureView!!)
}
}
private fun _onClickStartRecording() {
lifecycleScope.launch {
_vm.startRecorder()
}
}
private fun _onClickStopRecording() {
lifecycleScope.launch {
_vm.stopRecorder()
}
}
override fun onDestroy() {
super.onDestroy()
_vm.releaseRecorder()
}
// 카메라 권한 보유 여부 확인.
private fun _hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
}
}
4) 이 버전의 핵심 해설
상태 전달 방식
이전의 sealed class Event 구조 대신:
val statusFlow: StateFlow<String>
val recordingFlow: StateFlow<Boolean>
val elapsedFlow: StateFlow<Int>
val outputFlow: StateFlow<String?>
val errorFlow: StateFlow<String?>
이런 식으로 의미별로 쪼갠 상태 모델을 사용했습니다.
이 방식의 장점은:
- 타입이 바로 보임
when(event)분기 필요 없음- Compose 바인딩이 단순함
@MainThread만 사용한 이유
이번 버전은 의도적으로:
@MainThread
suspend fun initPreview(...)
처럼 쓰고, 내부에서:
withContext(Dispatchers.Main) { ... }
를 넣지 않았습니다.
즉 철학은:
- 함수는 Main thread 에서만 부른다
- 실제 thread 전환 책임은 호출자에게 둔다
입니다.
현재 예제에서는 lifecycleScope.launch { ... } 에서 호출하므로 Main에서 실행됩니다.
callback 대기 스타일
현재 예제는 suspendCancellableCoroutine를 사용하지 않습니다.
대신 요청하신 스타일대로:
isFinish플래그- 결과 변수 / 에러 변수
while (!isFinish) { delay(10) }
패턴으로 callback 완료를 순차 코드처럼 처리합니다.
fast-fail 스타일
질문하신 취향대로:
!!error(...)first { ... }
등을 적극 사용했습니다.
즉:
문제가 있으면 조용히 회복하려 하지 않고
바로 터져서 원인을 드러내는 스타일입니다.
5) 이 코드에서 가장 중요하게 볼 지점
preview 초기화
initPreview() 안에서:
- TextureView 준비 대기
- cameraId 선택
- preview size 선택
- camera open
- preview session 생성
- setRepeatingRequest
이 한 번에 이어집니다.
녹화 시작
startRecording() 안에서:
- encoder 생성
- muxer 생성
- recording session 생성
- drain loop 시작
- timer 시작
이 한 번에 이어집니다.
녹화 종료
stopRecording() 안에서:
- timer 중단
- repeating 중지
signalEndOfInputStream()- drain 종료 대기
- encoder/muxer 해제
- preview session 복구
이 흐름이 핵심입니다.
6) 이 버전의 한계
이 코드는 의도적으로 단순화된 버전이라서 아래는 아직 없습니다.
- 오디오 녹음
- 회전 처리
- 전면 카메라 선택
- pause / resume
- HandlerThread 기반 Camera callback thread 분리
- 기기별 스트림 조합 최적화
- 상세 예외 복구
즉 구조 이해용 / fast-fail 학습용 예제로 보는 것이 맞습니다.
원하시면 다음에는 이 코드에서 이어서
오디오(AAC)까지 포함한 mp4 저장 버전으로 확장해드리겠습니다.
7) 풀어서 구현하는 방식 정리
핵심 원칙
- 임시 범위를 만들지 않고, 순서대로 변수 선언 후
if/대입/호출을 진행 - 객체 초기화는 생성 후 각 속성을 한 줄씩 대입
- null 처리도 체이닝 대신
if (x != null)블록으로 명시
예시
val name: String? = "hhd"
var upper: String? = null
if (name != null) {
upper = name.uppercase()
}
val list = mutableListOf("a")
list.add("b")
val intent = Intent()
intent.action = "ACTION_VIEW"
val person = Person("Kim", "Hhd")
val text = person.firstName + " " + person.lastName
8) Kotlin 생성자 스타일 비교
소개
constructor방식: 클래스 본문 안에 보조 생성자를 명시하는 방식init방식: 주 생성자 파라미터를 받고init블록에서 초기화 로직을 수행하는 방식class AAA(private val a, private val b)방식: 주 생성자에서 프로퍼티를 바로 선언/초기화하는 방식
차이점
| 방식 | 선언 위치 | 초기화 위치 | 코드 길이 | Kotlin 관용성 |
|---|---|---|---|---|
constructor |
클래스 본문 | 생성자 본문 | 김 | 낮음~중간 |
init |
클래스 헤더 + init |
init 블록 |
중간 | 중간 |
| 주 생성자 프로퍼티 선언 | 클래스 헤더 | 헤더에서 즉시 | 짧음 | 높음 |
장단점
1) constructor 방식
장점
- C# 스타일과 매우 유사해 익숙함
- 초기화 순서를 명시적으로 쓰기 쉬움
단점
- 코드가 장황해지기 쉬움
- 단순 DI(의존성 주입)에는 과한 경우가 많음
예제
class CameraRecorder {
private val _context: Context
private val _vm: RecorderViewModel
constructor(context: Context, vm: RecorderViewModel) {
_context = context
_vm = vm
}
}
2) init 방식
장점
- 검증/가공 로직을 초기화 단계에 모으기 좋음
- 주 생성자와 초기화 책임이 분리되어 읽기 쉬움
단점
- 단순 대입만 할 때는 코드가 늘어남
init블록이 많아지면 흐름이 분산될 수 있음
예제
class CameraRecorder(context: Context, vm: RecorderViewModel) {
private val _context: Context
private val _vm: RecorderViewModel
init {
_context = context
_vm = vm
}
}
3) class AAA(private val a, private val b) 방식
장점
- 가장 간결함
- Kotlin에서 가장 일반적으로 쓰는 형태
- 불변 의존성 주입에 적합
단점
- 호출부에 파라미터명이 그대로 드러남
- C# 스타일을 선호하면 덜 익숙할 수 있음
예제
class CameraRecorder(
private val _context: Context,
private val _vm: RecorderViewModel,
)
선택 가이드
- C# 스타일 일관성이 중요하면:
constructor - 초기화 시 검증/변환 로직이 있으면:
init - 단순 의존성 주입과 간결성이 우선이면: 주 생성자 프로퍼티 선언