Kotlin实现拳击对练回合视频自动录制的时长与频率控制问题(2024年11月22日更新)
你好!我仔细看了你的拳击回合录制APP的需求、代码和当前遇到的问题,咱们一步步来拆解和解决这些问题:
一、核心需求回顾
你的APP需要根据用户输入的三个参数自动完成循环录制流程:
numberOfRounds:总录制回合数lengthOfRound:每回合录制时长(分钟)breakRounds:回合间休息时长(分钟)
核心逻辑是:录制指定时长 → 休息指定时长 → 重复直到所有回合完成,目前你已经通过Handler.postDelayed实现了基础的循环逻辑,但卡在了视频文件生成和权限请求两个关键问题上。
二、当前问题的具体分析与修复方案
1. 视频不生成文件的核心原因与修复
这是Camera2 API录制视频的典型问题:你没有把MediaRecorder的Surface和相机的CaptureSession绑定,相机的视频数据根本没有输出到MediaRecorder,自然不会生成视频文件。
现在你的代码中,相机预览的画面输出到了TextureView的Surface,但录制时MediaRecorder的Surface没有被添加到CameraCaptureSession的目标列表里,相机只会把画面输出到预览Surface,不会给MediaRecorder传递数据。
修复步骤:
(1)重构startRecording方法,绑定MediaRecorder Surface到相机会话
private fun startRecording() { try { hideInputs() if (!::mediaRecorder.isInitialized) { mediaRecorder = MediaRecorder() } val outputFile = getOutputFile() val cameraManager = getSystemService(CAMERA_SERVICE) as CameraManager val cameraId = cameraManager.cameraIdList.firstOrNull { cameraManager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK } ?: throw IllegalStateException("No back-facing camera found") // 1. 初始化MediaRecorder mediaRecorder.apply { setAudioSource(MediaRecorder.AudioSource.CAMCORDER) setVideoSource(MediaRecorder.VideoSource.SURFACE) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFile(outputFile.absolutePath) setVideoEncoder(MediaRecorder.VideoEncoder.H264) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setVideoEncodingBitRate(10000000) setVideoFrameRate(30) // 建议根据相机支持的尺寸设置,避免硬编码导致不兼容 val characteristics = cameraManager.getCameraCharacteristics(cameraId) val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) val optimalSize = map?.getOutputSizes(MediaRecorder::class.java)?.maxByOrNull { it.width * it.height } optimalSize?.let { setVideoSize(it.width, it.height) } ?: setVideoSize(1920, 1080) prepare() } // 2. 获取MediaRecorder的Surface和预览Surface val recorderSurface = mediaRecorder.surface val previewSurface = Surface(textureView.surfaceTexture) // 3. 重新创建相机CaptureSession,同时包含预览和录制的Surface cameraDevice.createCaptureSession(listOf(previewSurface, recorderSurface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { cameraCaptureSession = session try { // 4. 创建录制用的CaptureRequest(使用TEMPLATE_RECORD模板) val captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD) captureRequestBuilder.addTarget(previewSurface) captureRequestBuilder.addTarget(recorderSurface) // 启动MediaRecorder mediaRecorder.start() isRecording = true Toast.makeText(this@MainActivity, "Recording started", Toast.LENGTH_SHORT).show() // 开始重复请求,输出数据到两个Surface session.setRepeatingRequest(captureRequestBuilder.build(), null, null) } catch (e: Exception) { Log.e("MainActivity", "Failed to start recording session: ${e.message}") Toast.makeText(this@MainActivity, "录制启动失败", Toast.LENGTH_SHORT).show() } } override fun onConfigureFailed(session: CameraCaptureSession) { Log.e("MainActivity", "Failed to configure recording session") Toast.makeText(this@MainActivity, "相机会话配置失败", Toast.LENGTH_SHORT).show() } }, null) } catch (e: Exception) { Log.e("MainActivity", "Error starting recording: ${e.message}") Toast.makeText(this, "录制初始化失败: ${e.message}", Toast.LENGTH_SHORT).show() } }
(2)修复stopRecording方法,切换回预览模式
录制停止后,需要把相机会话切回预览模式,释放录制Surface:
private fun stopRecording() { try { if (isRecording) { mediaRecorder.stop() mediaRecorder.reset() isRecording = false Toast.makeText(this, "Recording stopped", Toast.LENGTH_SHORT).show() // 切换回预览会话 val previewSurface = Surface(textureView.surfaceTexture) cameraDevice.createCaptureSession(listOf(previewSurface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { cameraCaptureSession = session val previewRequest = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { addTarget(previewSurface) } session.setRepeatingRequest(previewRequest.build(), null, null) } override fun onConfigureFailed(session: CameraCaptureSession) { Log.e("MainActivity", "Failed to configure preview session") } }, null) } showInputs() } catch (e: Exception) { Log.e("MainActivity", "Error stopping recording: ${e.message}") Toast.makeText(this, "停止录制失败: ${e.message}", Toast.LENGTH_SHORT).show() } }
2. 权限请求循环问题的修复
现在的权限请求逻辑中,如果用户拒绝权限并勾选“不再询问”,代码会继续重复请求权限,导致无限循环。我们需要判断用户是否拒绝了权限且不再提示,此时应该引导用户去设置页开启权限。
修复onRequestPermissionsResult方法:
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == 102) { val deniedPermissions = mutableListOf<String>() val hasPermanentDenied = permissions.any { perm -> grantResults[permissions.indexOf(perm)] != PackageManager.PERMISSION_GRANTED && !shouldShowRequestPermissionRationale(perm) } for (i in permissions.indices) { if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { deniedPermissions.add(permissions[i]) } } if (deniedPermissions.isEmpty()) { Log.d("MainActivity", "All permissions granted!") initializeApp() } else { if (hasPermanentDenied) { // 有用户勾选了不再询问的权限,直接引导到设置 showPermissionExplanation(deniedPermissions) } else { permissionAttempts++ if (permissionAttempts < 2) { // 最多请求2次,避免循环 Toast.makeText( this, "需要以下权限才能使用功能: $deniedPermissions,请允许", Toast.LENGTH_LONG ).show() requestPermissions() } else { showPermissionExplanation(deniedPermissions) } } } } }
另外,注意Android 10+的Scoped Storage规则:你用的getExternalFilesDir(Environment.DIRECTORY_MOVIES)是APP私有目录,不需要WRITE_EXTERNAL_STORAGE和READ_EXTERNAL_STORAGE权限,可以把这两个权限从REQUIRED_PERMISSIONS中移除,减少权限请求的数量,也避免不必要的权限问题。
3. 其他优化建议
- 用Coroutines替代Handler实现循环:Handler的嵌套回调容易导致内存泄漏,用协程的
delay函数更简洁易维护,比如:
private fun startRecordingLoop() = lifecycleScope.launch { hideInputs() for (round in 1..numberOfRounds) { Toast.makeText(this@MainActivity, "Recording round $round", Toast.LENGTH_SHORT).show() startRecording() delay(lengthOfRound * 60 * 1000L) stopRecording() if (round < numberOfRounds) { Toast.makeText(this@MainActivity, "Break round $round", Toast.LENGTH_SHORT).show() delay(breakRounds * 60 * 1000L) } } Toast.makeText(this@MainActivity, "All rounds completed", Toast.LENGTH_SHORT).show() showInputs() }
(需要在build.gradle中添加androidx.lifecycle:lifecycle-runtime-ktx依赖)
- 相机资源的释放:在Activity的
onDestroy方法中,确保关闭相机设备和释放MediaRecorder:
override fun onDestroy() { super.onDestroy() if (::cameraDevice.isInitialized) { cameraDevice.close() } if (::mediaRecorder.isInitialized) { mediaRecorder.release() } }
三、总结
- 先修复权限请求循环问题,简化权限列表,优化权限请求逻辑;
- 重点修复MediaRecorder与Camera2 Session的绑定问题,这是视频不生成的核心原因;
- 可以用协程替代Handler让循环逻辑更清晰;
- 增加更明确的错误提示,方便调试和用户感知。
按照这些修改后,你的APP应该能正常生成录制的视频文件,权限请求也不会陷入循环了!
备注:内容来源于stack exchange,提问作者FranciscoRincon

