황현동 블로그 개발, 인생, 유우머

260424 카메라레코더 fastfail 예제

Tags:

좋습니다.
아래는 당신이 마지막으로 요청한 스타일을 반영한 전체 예제입니다.

반영 기준:

  • 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() 안에서:

  1. TextureView 준비 대기
  2. cameraId 선택
  3. preview size 선택
  4. camera open
  5. preview session 생성
  6. setRepeatingRequest

이 한 번에 이어집니다.


녹화 시작

startRecording() 안에서:

  1. encoder 생성
  2. muxer 생성
  3. recording session 생성
  4. drain loop 시작
  5. timer 시작

이 한 번에 이어집니다.


녹화 종료

stopRecording() 안에서:

  1. timer 중단
  2. repeating 중지
  3. signalEndOfInputStream()
  4. drain 종료 대기
  5. encoder/muxer 해제
  6. 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
  • 단순 의존성 주입과 간결성이 우선이면: 주 생성자 프로퍼티 선언