1. 先把"后台"这两个字掰开:Android 上后台录像的真实边界
做了几年相机相关的需求,"后台录像"这个说法我听过太多次,有人要行车记录,有人要做安防值守,有人要做取证留痕。但真正动手之后,十个人里有八个会在第一天就被系统拦住:Service 起来了,通知也挂了,MediaRecorder一start()就抛RuntimeException或者拿到一个 0 字节的 mp4。原因并不在代码写得烂,而在于很多人对"后台"两个字的理解,和 Android 系统的定义完全是两回事。
在 Android 的语境里,"后台录像"从来不是"让进程躲在后台偷偷开相机",而是"用户退出界面或锁屏之后,由前台服务继续持有相机的使用会话"。这个区别决定了整个方案的骨架:你必须是一个前台服务(Foreground Service),必须有常驻通知,必须声明foregroundServiceType="camera",必须从前台的 Activity 里发起启动。这四条里少任何一条,都会在不同 Android 版本上以不同姿势失败。
所以这篇内容适合谁?适合已经会用 Camera2 拍单张照片、但想把录制能力搬到 Service 里的开发者;适合正在做运动相机、车载记录、长时间留痕类工具的同学;也适合那些被"锁屏后录像三分钟就断"折磨过、想搞清楚背后机制的人。下面我会把权限、服务类型、Camera2 与 MediaRecorder 的装配顺序、参数计算、断流自愈、文件落盘这些一条条拆开,代码给到能直接抄的程度,同时讲清楚每个选择背后的理由,避免你只是复制粘贴却不知道哪天会炸。
先说结论性的边界:Android 11(API 30)起,处于后台状态的应用无法访问相机;而运行着前台服务的应用被视为前台状态,所以相机可以用。Android 12(API 31)起,禁止从后台启动前台服务;Android 14(API 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,一条是MediaCodec+MediaMuxer自建管线。
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:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.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:exported="false" android:foregroundServiceType="camera|microphone" />foregroundServiceType这个属性决定了系统在startForeground时校验哪一套权限。只写camera却调用了startForeground(id, notification, FOREGROUND_SERVICE_TYPE_CAMERA or FOREGROUND_SERVICE_TYPE_MICROPHONE),会在部分版本上抛IllegalArgumentException。清单里声明的类型和代码里传入的类型必须是子集关系,最稳的做法是保持完全一致。
还有一个容易忽略的点:android:exported="false"。如果你需要通过通知栏的"停止"按钮控制服务,用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: Array<Size>): 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×720 | 30 | 4 Mbps | 约 30 MB | 长时间值守、存储受限 |
| 1920×1080 | 30 | 8 Mbps | 约 60 MB | 通用取证、日常记录 |
| 1920×1080 | 60 | 16 Mbps | 约 120 MB | 运动、需要看清快速动作 |
| 2560×1440 | 30 | 16 Mbps | 约 120 MB | 需要裁剪细节的后处理 |
码率的估算我用这个公式:bitRate ≈ width × height × fps × k,H.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)完全不需要存储权限,卸载时自动清掉,适合内部使用;要让用户在相册里看到,就得走MediaStore:
val 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。后来改成在停止录制的第一时间就释放相机和 recorder,onDestroy里只做兜底,问题才消失。资源释放要尽早,不要指望生命周期回调一定被调用。
第三坑是"多实例并存"。用户连点两下开始按钮,会拉起两个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,那么上线后收到的第一条用户反馈大概率是"录了几分钟就停了",而那时候再回头重构,成本要高得多。