资讯详情 Android后台录像实战:前台服务+Camera2+MediaRecorder
📅 2026/10/29 21:35:38
1. 先把后台这两个字掰开Android 上后台录像的真实边界做了几年相机相关的需求后台录像这个说法我听过太多次有人要行车记录有人要做安防值守有人要做取证留痕。但真正动手之后十个人里有八个会在第一天就被系统拦住Service 起来了通知也挂了MediaRecorder一start()就抛RuntimeException或者拿到一个 0 字节的 mp4。原因并不在代码写得烂而在于很多人对后台两个字的理解和 Android 系统的定义完全是两回事。在 Android 的语境里后台录像从来不是让进程躲在后台偷偷开相机而是用户退出界面或锁屏之后由前台服务继续持有相机的使用会话。这个区别决定了整个方案的骨架你必须是一个前台服务Foreground Service必须有常驻通知必须声明foregroundServiceTypecamera必须从前台的 Activity 里发起启动。这四条里少任何一条都会在不同 Android 版本上以不同姿势失败。所以这篇内容适合谁适合已经会用 Camera2 拍单张照片、但想把录制能力搬到 Service 里的开发者适合正在做运动相机、车载记录、长时间留痕类工具的同学也适合那些被锁屏后录像三分钟就断折磨过、想搞清楚背后机制的人。下面我会把权限、服务类型、Camera2 与 MediaRecorder 的装配顺序、参数计算、断流自愈、文件落盘这些一条条拆开代码给到能直接抄的程度同时讲清楚每个选择背后的理由避免你只是复制粘贴却不知道哪天会炸。先说结论性的边界Android 11API 30起处于后台状态的应用无法访问相机而运行着前台服务的应用被视为前台状态所以相机可以用。Android 12API 31起禁止从后台启动前台服务Android 14API 34起camera 与 microphone 类型的前台服务受到更严格的校验必须声明FOREGROUND_SERVICE_CAMERA/FOREGROUND_SERVICE_MICROPHONE权限并且启动时机通常要求应用当时对用户可见。这三条限制叠加起来就形成了今天唯一可行的形态用户在界面上点一下开始 → 立刻startForegroundService→ 服务内部startForeground并带上 camera 类型 → 之后用户可以把界面切走甚至锁屏录制继续。1.1 三个绕不过去的系统约束第一个约束是相机访问权限的状态门槛。系统判断的不是你有没有CAMERA权限而是你这个进程当前处于什么 importance。进程退到 cached 状态之后openCamera会直接抛CameraAccessException错误码是CAMERA_DISABLED或者干脆onDisconnected。我最初的做法是用一个普通 Service结果在 Android 12 的机器上必定失败日志里能看到相机服务侧拒绝连接的记录。改成前台服务之后同一份代码就正常了。这个改动看起来只是加了一个通知实质是把进程的 importance 从 cached 拉到 foreground是所有后续操作的前提。第二个约束是前台服务的启动时机。startForegroundService本身在任何时候都能调用但 Android 12 之后如果调用发生时应用不处于前台没有可见窗口、没有正在运行的前台服务系统会抛ForegroundServiceStartNotAllowedException。Android 14 上更严格即使你声明了 camera 类型如果是从后台比如广播接收器、定时任务拉起仍然会被拒绝。这意味着定时自动开始录像这种需求在新版本上必须换实现方式——要么用户在场时先启动服务要么用AlarmManager唤醒一个短暂可见的 Activity 由用户确认。我一般会在产品层面直接把这功能砍掉改成开机后首次打开应用即恢复省得和系统策略对抗。第三个约束是厂商的省电策略。这一块文档上完全不写但实际项目里它造成的投诉最多。不同 ROM 对后台进程的清理阈值差异巨大有些会在锁屏 5 分钟后冻结应用有些会在电量低于 20% 时直接杀掉前台服务。表现出来就是录像文件时长忽长忽短。应对办法有三层申请PARTIAL_WAKE_LOCK保持 CPU 不睡引导用户把应用加入电池优化白名单用ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS跳转注意这个权限在应用商店审核时有政策约束需要说明用途以及在服务被杀死后能通过落盘的临时文件恢复出可用片段而不是留下一个损坏的 mp4。提示不要试图用隐藏通知绕过前台服务的可见性要求。Android 8 之后系统对隐藏前台服务通知有明确限制强行设置会导致通知被系统接管显示体验更差还可能触发审核问题。1.2 录制管线选型MediaRecorder 与 MediaCodec 的分水岭确定骨架之后第二个决策是录制管线。摆在我面前的两条路一条是MediaRecorder一条是MediaCodecMediaMuxer自建管线。MediaRecorder的优势极其明显两百行以内能跑通封装格式、编码器、音视频同步全帮你处理掉输出标准 mp4兼容性经过十几年验证。缺点是控制粒度粗——录制中无法切换输出文件无法动态调整码率无法做无缝分段。想分段只能stop()再start()中间会丢 0.3 到 0.8 秒具体丢多少取决于机型低端机上我见过丢将近 1.5 秒的。MediaCodec路线则是另一个极端从ImageReader拿YUV_420_888处理 stride 和 slice height 对齐喂给 H.264 编码器再自己写MediaMuxer封装。分段可以做到几乎无感——编码器不重建只是每段新建一个 muxer 写新的文件头。但坑也多不同设备的 YUV 排列不一致颜色格式要试COLOR_FormatYUV420Flexible码率控制模式要挑BITRATE_MODE_VBR还是CBR音视频时间戳对不齐会导致播放器拖动进度条花屏。我的实践结论是分场景选录制时长在 30 分钟以内、对文件连续性要求不高的直接上MediaRecorder需要 7×24 小时循环录制、要求文件按固定时长切片且尽量不掉帧的必须走MediaCodec。本文以MediaRecorder为主线给出完整可跑代码因为它覆盖了 80% 的需求同时在分段章节里说明怎么把损失压到最小以及什么情况下必须换方案。2. 工程骨架权限、Manifest 与前台服务声明在写第一行业务代码之前先把清单文件理清楚。这一步做错后面所有调试都是在错误的地基上打转。我见过最常见的错误是只声明了FOREGROUND_SERVICE却忘了FOREGROUND_SERVICE_CAMERA结果在 Android 14 上startForeground直接抛SecurityException而且堆栈信息很不直观只告诉你权限不足不告诉你缺的是哪一个。2.1 权限清单与运行时申请顺序清单里需要这几条uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_CAMERA / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / uses-permission android:nameandroid.permission.WAKE_LOCK /FOREGROUND_SERVICE_CAMERA和FOREGROUND_SERVICE_MICROPHONE是 API 34 才引入的声明在低版本设备上不会有副作用可以放心一起写上。POST_NOTIFICATIONS是 API 33 的运行时权限很多人会漏——漏了之后前台服务通知不会展示用户体验上就变成看不见的后台任务同时在某些 ROM 上会被判定为异常进程而更容易被清理。运行时申请的时机也有讲究。我的做法是在 Activity 里一次性申请CAMERA、RECORD_AUDIO、POST_NOTIFICATIONS三个全部授予后再允许点击开始录制按钮按钮置灰并给出明确文案说明缺哪个权限。不要在 Service 里申请权限Service 没有 Activity 上下文申请流程会走不通或者弹窗被系统吞掉最后表现为点了没反应排查起来非常浪费时间。RECORD_AUDIO有一个细节如果你只需要纯视频比如某些监控场景完全可以不加音频轨这样能省掉一个敏感权限用户的接受度明显更高也降低审核风险。关掉音频的方式是不调用setAudioSource同时不调用setAudioEncoder其余不变。2.2 Service 声明与 foregroundServiceType 的坑Service 的声明如下service android:name.record.RecordService android:exportedfalse android:foregroundServiceTypecamera|microphone /foregroundServiceType这个属性决定了系统在startForeground时校验哪一套权限。只写camera却调用了startForeground(id, notification, FOREGROUND_SERVICE_TYPE_CAMERA or FOREGROUND_SERVICE_TYPE_MICROPHONE)会在部分版本上抛IllegalArgumentException。清单里声明的类型和代码里传入的类型必须是子集关系最稳的做法是保持完全一致。还有一个容易忽略的点android:exportedfalse。如果你需要通过通知栏的停止按钮控制服务用PendingIntent就够了PendingIntent不受exported限制。设成true会让任何应用都能拉起你的录像服务这既是安全问题也会在应用商店审核时被问询。关于onStartCommand的返回值我建议返回START_NOT_STICKY。很多人习惯写START_STICKY觉得被杀之后能自动重启更稳。但实际效果是服务被系统杀掉后重启此时应用往往处于后台重启的瞬间去openCamera会失败然后进入一个反复重启、反复失败的循环日志刷屏、耗电飙升。不如老老实实返回START_NOT_STICKY让进程安静地死掉由用户手动恢复。如果确实需要断点续录用文件层面的续写或者前台界面里的恢复按钮来实现比依赖系统重启可控得多。3. Service 里的相机管线从 openCamera 到 start这一章是核心我会把 Service 的完整骨架和关键代码贴出来同时解释每一步为什么这么排。3.1 通知渠道与 startForeground 的正确姿势前台服务的第一件事是建通知渠道并调用startForeground而且必须在onStartCommand里立刻做不能等相机打开、会话建立之后再调。系统的宽限时间是 5 秒左右不同版本有差异超时会抛RemoteServiceException: Context.startForegroundService() did not then call Service.startForeground()。我踩过这个坑早期版本我把startForeground写在onOpened回调里想着相机打开成功再显示通知结果在相机打开慢的设备上偶尔崩溃。private fun buildNotification(): Notification { val manager getSystemService(NotificationManager::class.java) if (Build.VERSION.SDK_INT Build.VERSION_CODES.O manager.getNotificationChannel(CHANNEL_ID) null) { val channel NotificationChannel( CHANNEL_ID, 录像服务, NotificationManager.IMPORTANCE_LOW ).apply { setShowBadge(false) description 录制过程中保持相机可用 } manager.createNotificationChannel(channel) } val stopIntent PendingIntent.getService( this, 1, Intent(this, RecordService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(正在录制) .setContentText(点击停止可结束本次录制) .setSmallIcon(R.drawable.ic_record) .setOngoing(true) .setOnlyAlertOnce(true) .addAction(0, 停止, stopIntent) .build() }IMPORTANCE_LOW是关键用IMPORTANCE_DEFAULT会发出提示音每次开始录制都响一声体验很差。setOnlyAlertOnce(true)保证更新通知内容时不会反复打扰。FLAG_IMMUTABLE从 API 23 起必须加Android 12 之后不加会直接崩溃。然后是最容易被写错的一行if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { startForeground( NOTI_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE ) } else { startForeground(NOTI_ID, buildNotification()) }带类型的这个重载是 API 29 引入的。低于 29 的版本没有类型概念用两参数版本即可。这里有个陷阱如果你在 API 29 以下传了三参数版本通过反射或者高 compileSdk 直接调用运行时会崩溃所以版本判断不能省。3.2 Camera2 MediaRecorder 的配置顺序与代码下面是核心的打开与启动流程。我把整个 Service 拆成几个方法方便你对照。class RecordService : Service() { private lateinit var cameraManager: CameraManager private lateinit var cameraHandler: Handler private var handlerThread: HandlerThread? null private var cameraDevice: CameraDevice? null private var session: CameraCaptureSession? null private var recorder: MediaRecorder? null private var outputFile: File? null private var cameraId 0 private var videoSize Size(1920, 1080) private val fps 30 override fun onCreate() { super.onCreate() cameraManager getSystemService(Context.CAMERA_SERVICE) as CameraManager handlerThread HandlerThread(camera-worker).also { it.start() } cameraHandler Handler(handlerThread!!.looper) } private fun beginRecording() { cameraHandler.post { openCamera() } } private fun openCamera() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ! PackageManager.PERMISSION_GRANTED) { stopSelf(); return } cameraId pickBackCameraId() val chars cameraManager.getCameraCharacteristics(cameraId) val map chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: run { stopSelf(); return } videoSize chooseVideoSize(map.getOutputSizes(MediaRecorder::class.java)) cameraManager.openCamera(cameraId, deviceCallback, cameraHandler) } }pickBackCameraId遍历cameraIdList用LENS_FACING LENS_FACING_BACK找后置。如果产品允许前置加个参数切换即可但要注意前置摄像头录出来的画面是镜像的是否水平翻转要在产品层面确认。chooseVideoSize的选法我一般这样写目标 1080p在候选列表里找长宽比接近 16:9 且不超过 1920×1080 的最大项找不到就退到 1280×720。不要直接用getOutputSizes的第一个某些设备第一个是 3840×2160码率和发热都会失控。private fun chooseVideoSize(sizes: ArraySize): Size { val target 1920 * 1080 return sizes.filter { it.width * 9 it.height * 16 it.width 1920 } .minByOrNull { abs(it.width * it.height - target) } ?: sizes.minByOrNull { abs(it.width * it.height - target) } ?: Size(1280, 720) }接下来是设备回调与会话建立private val deviceCallback object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { cameraDevice device startSession(device) } override fun onDisconnected(device: CameraDevice) { device.close() cameraDevice null safeStopRecorder() // 交给上层状态机决定是否重建 } override fun onError(device: CameraDevice, error: Int) { device.close() cameraDevice null safeStopRecorder() stopSelf() } }startSession里集中了整段逻辑中最容易出错的部分private fun startSession(device: CameraDevice) { val file newOutputFile() outputFile file val rec if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) MediaRecorder(this) else Suppress(DEPRECATION) MediaRecorder() rec.apply { if (withAudio) { setAudioSource(MediaRecorder.AudioSource.CAMCORDER) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioEncodingBitRate(128_000) setAudioSamplingRate(44_100) } setVideoSource(MediaRecorder.VideoSource.SURFACE) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFile(file.absolutePath) setVideoEncoder(MediaRecorder.VideoEncoder.H264) setVideoSize(videoSize.width, videoSize.height) setVideoFrameRate(fps) setVideoEncodingBitRate(estimateBitRate(videoSize, fps)) setOrientationHint(computeOrientationHint()) prepare() } recorder rec val surfaces listOf(rec.surface) device.createCaptureSession(surfaces, object : CameraCaptureSession.StateCallback() { override fun onConfigured(s: CameraCaptureSession) { session s try { rec.start() } catch (t: Throwable) { safeStopRecorder(); stopSelf(); return } val req device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply { addTarget(rec.surface) set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO) set(CaptureRequest.CONTROL_AF_MODE, CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO) }.build() s.setRepeatingRequest(req, null, cameraHandler) } override fun onConfigureFailed(s: CameraCaptureSession) { safeStopRecorder(); stopSelf() } }, cameraHandler) }这段代码里有四个细节值得单独说。第一MediaRecorder 的调用顺序是强约束。官方文档列的是一张顺序表实际执行时顺序错了不一定立刻抛异常而是prepare()之后生成的文件播放器打不开。我遇到过一种情况先调setOutputFile再调setOutputFormat代码不崩start()也成功但录出来的 mp4 里没有 moov box所有播放器都报文件损坏。所以务必按音视频源 → 输出格式 → 输出文件 → 编码器 → 编码参数 → prepare来。第二rec.surface必须在prepare()之后才能取。顺序反了会抛IllegalStateException。这就是为什么代码里是prepare()之后再listOf(rec.surface)。第三会话配置成功后先rec.start()再setRepeatingRequest。顺序反过来的话前几帧数据会写到一个还没开始的 recorder 上表现为录制开头一两秒绿屏或者花屏。这个现象在某些机型上概率很低但确实存在。第四TEMPLATE_RECORD与CONTROL_AF_MODE_CONTINUOUS_VIDEO的搭配。用TEMPLATE_PREVIEW也能录但曝光和帧率策略偏预览长时间录制的功耗和发热会更差。CONTINUOUS_VIDEO的对焦策略是为录像优化的不会在录制中来回拉风箱。如果想更稳可以再加CONTROL_VIDEO_STABILIZATION_MODE_ON但要注意这个特性不是所有设备都支持设置前先用get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES)查一下不支持时强行设置会被忽略不报错但也不会生效。3.3 分辨率、码率、帧率、方向这四个参数怎么定参数不是拍脑袋定的每一个都有代价。分辨率决定单帧像素量直接影响编码器负载和发热。我给出一个经验表分辨率建议帧率建议码率每分钟体积适用场景1280×720304 Mbps约 30 MB长时间值守、存储受限1920×1080308 Mbps约 60 MB通用取证、日常记录1920×10806016 Mbps约 120 MB运动、需要看清快速动作2560×14403016 Mbps约 120 MB需要裁剪细节的后处理码率的估算我用这个公式bitRate ≈ width × height × fps × kH.264 的k取 0.1 到 0.15 之间。1080p30 算出 6.2 到 9.3 Mbps取 8 Mbps 是中间值实测画质和体积平衡得不错。要注意这个值不是越高越好超过 20 Mbps 之后很多设备的编码器会降帧甚至直接拒绝配置表现为录制出来有卡顿。private fun estimateBitRate(size: Size, fps: Int): Int { val raw size.width.toLong() * size.height * fps * 0.12 return raw.coerceIn(2_000_000, 20_000_000).toInt() }帧率的选择上如果不需要高帧率永远用 30。60 帧在多数中低端设备上会让编码器和相机同时吃紧机身温度十分钟内明显上升然后系统开始降频最终录出来的视频反而不如 30 帧稳定。真正需要 60 帧的场景比如运动相机建议同时把分辨率降到 1080p 以下。方向这个参数最容易被忽略也最容易出问题。setOrientationHint只是在 mp4 里写一个旋转元数据播放器读这个值来旋转画面它不改变实际像素数据。问题在于后台录制时没有预览界面你没法靠预览看着是正的来判断方向对不对。计算方法要把传感器方向和当前屏幕旋转结合起来private fun computeOrientationHint(): Int { val chars cameraManager.getCameraCharacteristics(cameraId) val sensor chars.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 90 val rotationDeg when (displayRotation()) { Surface.ROTATION_90 - 90 Surface.ROTATION_180 - 180 Surface.ROTATION_270 - 270 else - 0 } val facing chars.get(CameraCharacteristics.LENS_FACING) return if (facing CameraCharacteristics.LENS_FACING_FRONT) (sensor rotationDeg) % 360 else (sensor - rotationDeg 360) % 360 }后置是减、前置是加这是相机坐标系和显示坐标系的关系决定的记不住就记住这个符号差异实测下来一致。还有个现实问题后台录制时用户可能随时旋转设备尤其是放在支架上的场景而setOrientationHint是录制前定死的中途改不了。这时候只能接受画面方向固定或者用MediaCodec路线在编码前做旋转。我一般选择在 UI 上提示用户录制期间请勿旋转设备。4. 长时间稳定运行锁屏、省电与断流自愈能在 Service 里录起来只是第一步真正难的是录两个小时还在录。4.1 WakeLock 与 Doze 的取舍屏幕熄灭后CPU 会逐渐进入低功耗状态。相机和编码器虽然有自己的硬件通路但驱动层的回调、编码器的时间戳推进都依赖 CPU 参与进入深度休眠后表现为录制卡住或者时间戳跳跃。private var wakeLock: PowerManager.WakeLock? null private fun acquireWakeLock() { val pm getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, record:camera ).apply { setReferenceCounted(false) acquire(6 * 60 * 60 * 1000L) } } private fun releaseWakeLock() { wakeLock?.let { if (it.isHeld) it.release() } wakeLock null }用PARTIAL_WAKE_LOCK而不是SCREEN_DIM_WAKE_LOCK前者只保持 CPU 运行不点亮屏幕功耗增加有限实测中端机每小时多耗 2% 到 4%。超时时间一定要给别用无参的acquire()那种写法在异常退出时会让 CPU 一直被占住用户抱怨手机放一晚上烫得离谱。Doze 模式下即使持有 WakeLock网络和应用唤醒也会被限制但相机采集和本地编码不受影响因为这条链路不依赖网络。所以纯本地录制的场景WakeLock 加上电池优化白名单基本就够了如果还涉及上传那就得用系统提供的延迟容忍队列别指望在 Doze 里保持长连接。申请电池优化白名单的跳转代码val intent Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse(package:$packageName)) startActivity(intent)注意这条 Intent 在部分应用商店的审核政策里有额外要求需要向用户说明为什么需要后台持续运行。不要静默跳转最好配一段文案解释清楚。4.2 分段落盘与文件命名MediaRecorder 不支持录制中切换输出文件分段只能用停—起的方式。我的做法是用setMaxDuration让 recorder 自己在到达时长时触发回调rec.setMaxDuration(segmentMillis) rec.setOnInfoListener { mr, what, extra - if (what MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { cameraHandler.post { restartSegment() } } }restartSegment的实现要小心先停重复请求session.stopRepeating()再recorder.stop()然后release()接着重新走一遍startSession。这里有个顺序问题——如果先cameraDevice.close()再重建会有 0.5 秒以上黑屏和额外耗电保持cameraDevice不关只重建会话和 recorder损失更小。即便如此实测 1080p30 下的丢帧在 0.3 到 0.5 秒之间也就是每段之间会有半秒空档。如果这半秒不能接受只有一条路换MediaCodec。思路是编码器常驻MediaMuxer每段新建在关键帧BUFFER_FLAG_KEY_FRAME处切段这样切片点对齐 I 帧播放器不会花屏且几乎没有时间空档。代价是要自己处理 YUV 转换和音视频时间戳代码量大概三倍。文件命名我习惯带时间戳和序号REC_20240612_143015_001.mp4。序号不能省因为同一秒内多次重启会导致文件名冲突覆盖掉前一段。存储位置分两种写应用私有目录getExternalFilesDir(Environment.DIRECTORY_MOVIES)完全不需要存储权限卸载时自动清掉适合内部使用要让用户在相册里看到就得走MediaStoreval values ContentValues().apply { put(MediaStore.Video.Media.DISPLAY_NAME, REC_${System.currentTimeMillis()}.mp4) put(MediaStore.Video.Media.MIME_TYPE, video/mp4) if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES /RecordApp) put(MediaStore.Video.Media.IS_PENDING, 1) } } val uri contentResolver.insert( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!!注意IS_PENDING这个标记录制过程中设为 1录完后置 0否则相册里会出现一个时长显示为 0 的坏条目用户体验很差。这个细节在官方示例里往往被省略但实际项目中必须处理。4.3 状态机与异常重建长时间运行的服务必须有明确的状态机否则各种回调交错会让代码变成一团乱麻。我用的状态只有五个IDLE、OPENING、RECORDING、SWITCHING、ERROR。所有外部事件相机回调、recorder 回调、用户指令、文件写完都先转成状态迁移再由状态驱动具体动作。异常重建的关键在于区分可恢复和不可恢复。onDisconnected是可恢复的通常是因为别的应用抢占了相机比如来电、扫码。处理方式是延迟 800 毫秒后重试openCamera重试上限 3 次超过就走ERROR并发出通知告诉用户录制中断。onError里携带ERROR_CAMERA_IN_USE、ERROR_MAX_CAMERAS_IN_USE这类码基本不可恢复直接停服务。还有一个隐蔽的异常源MediaRecorder.stop()。如果录制时长太短小于 1 秒或者根本没有写入任何帧stop()会抛RuntimeException此时文件是损坏的必须删掉。所以我的safeStopRecorder长这样private fun safeStopRecorder() { val rec recorder ?: return recorder null try { rec.stop() } catch (t: Throwable) { outputFile?.delete() } finally { try { rec.reset(); rec.release() } catch (_: Throwable) {} } }stop()抛异常后文件删掉这一步不能省否则用户的存储空间里会慢慢堆积一堆 0 字节或几百字节的垃圾文件而且相册里全是打不开的缩略图。5. 问题排查实录那些文档里不会写的东西5.1 高频故障速查表现象大概率原因处理方式startForeground抛 SecurityException缺FOREGROUND_SERVICE_CAMERA或类型不匹配补齐权限清单与代码类型保持一致ForegroundServiceStartNotAllowedException应用不在前台时启动了服务改为从可见 Activity 触发相机打开失败CAMERA_DISABLED进程处于 cached 状态确认已startForeground录制文件 0 字节MediaRecorder 配置顺序错或未prepare就取 surface按标准顺序重排视频能播放但无画面没有setRepeatingRequest或 target 未添加检查请求里是否addTarget(rec.surface)录到一半断开文件损坏进程被杀moov 未写入用分段录制降低损失配合文件校验画面方向错误setOrientationHint计算错按传感器方向 ± 屏幕旋转计算锁屏后 3 到 5 分钟停止未持有 WakeLock 或被 ROM 清理加 PARTIAL_WAKE_LOCK引导加白名单音频有但视频卡顿码率过高导致编码器降帧下调码率或分辨率5.2 几个我自己踩过的坑第一坑是预览与录制共用 surface 时的资源冲突。早期版本我为了在界面上显示预览同时给会话加了SurfaceView的 surface 和 recorder 的 surface。在部分设备上两个 surface 的分辨率不一致会直接导致onConfigureFailed。解决办法是把预览 surface 的分辨率设置成和录制一致或者后台录制时干脆不要预览——反正用户已经把界面切走了。第二坑是MediaRecorder的释放时机。我在onDestroy里统一释放结果发现某些情况下onDestroy压根不会被调用进程被直接杀掉导致相机资源没释放下次打开时报CAMERA_IN_USE。后来改成在停止录制的第一时间就释放相机和 recorderonDestroy里只做兜底问题才消失。资源释放要尽早不要指望生命周期回调一定被调用。第三坑是多实例并存。用户连点两下开始按钮会拉起两个startForegroundService虽然onStartCommand会走两次但如果状态判断不严谨会打开两次相机第二次必然失败。我的处理是在onStartCommand里对ACTION_START做幂等判断如果已经在RECORDING状态就直接 return同时把按钮在发出指令后立即置灰 500 毫秒。第四坑是文件系统满。长时间录制最容易撞上这个。不加处理的后果是MediaRecorder写入失败但start()和回调都正常最后得到一个截断的文件。解决办法是录制前检查可用空间按码率估算能录多久低于阈值就提示用户同时监听每段写完的事件写完后检查剩余空间低于 500MB 就自动停止并发出通知。5.3 调试手段日志、相机状态与文件校验相机相关的崩溃堆栈往往没什么信息量得靠日志。我会在关键节点打上统一前缀的日志比如[REC]覆盖 openCamera 开始、onOpened、会话 onConfigured、recorder.start、每段文件关闭、异常捕获这六个位置。出问题时用日志时间戳对齐能很快判断是卡在打开阶段还是写入阶段。dumpsys media.camera是个好东西能看到当前哪些进程持有相机、相机的连接状态、会话配置。排查相机被占用类问题时比看代码快得多。另外在录完之后做一次文件校验也很有必要用MediaMetadataRetriever读一下时长的元数据如果拿不到或者为 0说明文件损坏直接删掉并记录一条错误日志这样问题不会静默积累。还有个偏门但有效的验证方式把录出来的文件放到不同播放器里各播一遍。某些机型录出来的文件系统自带播放器能放第三方播放器打不开原因通常是编码 profile 或 level 设置超出范围。遇到这种情况把setVideoEncoder的 profile 显式降级用MediaRecorder时不好直接设可改用MediaCodec指定 Baseline能解决大部分兼容问题。6. 隐私边界与后续扩展6.1 合法合规使用的几条底线后台录像这个能力本身是中性的但它天然带着隐私敏感属性做产品的必须提前想清楚。我的原则是三条录制必须有明确的、用户主动触发的开始动作不做静默启动录制状态必须始终可见通知栏常驻不可隐藏这是系统要求也是用户知情权麦克风开启时要有额外的提示因为音频涉及的内容往往比画面更敏感。如果应用场景涉及记录他人需要在产品文案里明确提示使用者遵守当地关于肖像与录音的相关规定把责任边界交代清楚。存储侧建议默认加密或者至少放在应用私有目录并提供一键清除全部录制文件的入口。这些不是技术要求但决定了这个功能能不能长期存在。6.2 还能往哪些方向扩展基础版本跑通之后能加的东西不少。第一是循环覆盖录制像行车记录仪那样边录边删最旧的片段实现上就是维护一个文件队列每写完一段检查总容量超了就删队首。第二是运动检测触发用CameraCaptureSession的setRepeatingRequest拿低分辨率帧做差分检出变化才开始正式录制能大幅省电省空间。第三是录制中截图用ImageReader挂一路 JPEG 输出和录像共用同一个会话不影响录制。第四是断网续传或者局域网推流那就得把管线换成MediaCodec加上自己写的封装层了。我个人在实际操作中的体会是这类需求的难点从来不在怎么调用 API而在怎么让它在各种机型和各种系统策略下持续活下去。把状态机、异常重建、分段落盘这三件事做扎实代码量会翻倍但线上故障率会降到可以接受的水平。反过来如果只追求最短路径跑通 Demo那么上线后收到的第一条用户反馈大概率是录了几分钟就停了而那时候再回头重构成本要高得多。