
去年年底我把一台退役的骁龙660旧手机翻了出来刷干净系统、装上自己写的APP固定在中控台侧面当行车记录仪用。核心需求就一句话安卓后台录像息屏状态下持续录存视频片段随时可以翻看回放。做出来的东西虽然比不上大牌行车记录仪的工业设计但胜在完全可控、无广告、不上传任何画面最关键的是——这套思路完全可以复现任何用过Android Studio的人都能跟着做一遍。这篇东西不是产品说明书是我自己从需求拆解、技术选型、代码落地到实车测试的完整记录。适合两类人看一是想把手头旧手机改成车载记录仪的折腾党二是正在做Android后台录制类应用、被息屏断录和系统杀进程折磨的开发者。我把踩过的坑、查过的原理、最后跑通的方案全部摊开讲包括代码片段和参数设置思路你照着改就能用。1. 需求梳理与整体方案设计1.1 不要急着写代码先把“息屏录存”拆透项目标题看着简单“后台录像”“息屏”“录存片段”“行车用”四个词背后其实藏了四个完全不同的技术问题后台录像要求应用退到后台甚至锁屏后录制服务仍然存活。Android从6.0开始对后台限制越来越狠Doze模式、App Standby、厂商定制ROM的墓碑机制都会杀后台。所以这不是“写个Service就行”的事必须用前台服务唤醒锁的组合拳。息屏意味着不能依赖常规的预览界面。屏幕灭掉之后Camera数据流能不能继续答案是能但前提是CPU不休眠、Camera对象不被系统回收。你需要在息屏瞬间让App拿到控制权而不是傻等着屏幕亮了再录。录存片段说明不能录成一个巨型文件。行车场景最怕的就是文件损坏——碰撞断电、电瓶亏电、SD卡突然抽风任何一个环节出问题都可能导致整段视频报废。所以必须分段写入比如3分钟一段、5分钟一段单文件损坏只损失当前片段。行车用带来了附加需求循环覆盖、时间水印、碰撞锁定、异常断电自恢复。这些不是锦上添花是行车记录仪能用的基本门槛。我把需求拆成五层录制链路、电源管理、生命周期保活、文件管理、行车增强功能。每一层独立设计再串成完整方案。1.2 为什么不用现成的行车记录仪APP市面上现成的“行车记录仪”类APP不少但我在选型时直接排除了它们原因很实际第一大多数免费APP靠广告和用户数据变现。行车记录仪拍的是你每天的通勤路线、停车位置、家人上下车画面这些东西交给一个不透明的云服务我不放心。自己做APP所有数据留在手机本地SD卡关网也能用隐私边界完全自己掌控。第二现成APP很难满足“息屏录存”的细节要求。有的APP息屏后录制断断续续有的强制亮屏保活有的不支持分片时长设置还有的在低电量时直接罢工。商业产品要考虑兼容绝大多数机型往往只能做最保守的策略自己的项目可以针对你这台手机的功耗和发热情况精确调参。第三可扩展性。我想要碰撞锁定片段不覆盖、GPS轨迹关联视频、按日期归档这些个性化功能在通用APP里实现成本极高而自己写代码只需要多几百行逻辑。如果你的需求只是“能录就行”那确实没必要折腾但如果你跟我一样想要一个干净、可控、可改的录制系统自己写是更优解。1.3 技术栈选型MediaRecorder还是MediaCodec这是项目里第一个关键决策点。Android上有两条视频录制路线MediaRecorder是封装好的高层API几行代码就能把相机画面麦克风声音编码成MP4文件内部整合了Camera和AudioRecord的采集逻辑。优点是简单、稳定、兼容性好缺点是无法直接操作视频帧做水印和自定义处理很麻烦。MediaCodec MediaMuxer是底层硬编码方案你从Camera拿到的每帧数据都可以过一道自定义处理再送进编码器灵活度极高水印、滤镜、行驶数据叠加都能实现。缺点是代码量暴增要自己管理缓冲区、时间戳、编码格式坑也更多。我的选择是分阶段走第一版用MediaRecorder快速跑通整个流程把息屏录制的电源管理、服务保活、文件分片这些核心难点解决掉后面如果需要水印和GPS信息叠加再升级到MediaCodec方案重写录制内核。实际上做完碰撞锁定之后我发现MediaRecorder已经能满足九成需求水印我用的是“边录边记轨道文件”的旁路方案不必死磕MediaCodec。所以对于大多数想复现这个项目的人我建议直接用MediaRecorder。别一上来就整MediaCodec先把后台录制链路跑稳这是最省时间的路径。2. 息屏录制的底层原理与关键细节2.1 息屏后系统在做什么为什么录像会断很多人遇到过这个问题App开着预览时录像正常一锁屏几秒后录制就停了。要理解为什么得先看息屏后Android系统的一系列动作按下电源键锁屏系统发出ACTION_SCREEN_OFF广播屏幕背光关闭。接下来系统进入IDLE流程如果设备静止不动一段时间后就会进入Doze休眠状态CPU被挂起网络、定位、后台任务全部受限。你在Service里跑的录制线程本质依赖CPU持续执行编码指令CPU一睡一切归零。同时还有一个隐蔽问题Camera设备是共享资源系统在锁屏后会做一些资源整理如果应用不是前台状态Camera对象可能被系统强制释放表现为CameraAccessException或者回调直接停止。解决办法是三层防御第一层注册ACTION_SCREEN_OFF和ACTION_SCREEN_ON广播接收器在息屏瞬间立刻确保服务处于前台状态、确保录制线程正在运行。第二层持有PARTIAL_WAKE_LOCK唤醒锁。这种唤醒锁只保证CPU运行不点亮屏幕是息屏录制的核心。注意不要用SCREEN_DIM_WAKE_LOCK或FULL_WAKE_LOCK那会强制亮屏违背息屏录存的初衷。第三层申请电池优化白名单让应用在Doze模式下仍然获得CPU时间片。这个后面细说。2.2 四个必须拿下的控制权WakeLock、前台服务、电池策略、相机资源我用一张表把四个关键控制权列出来每一项都是息屏录存的必要条件控制权作用获取方式使用要点WakeLockPARTIAL保持CPU运行防止休眠断录PowerManager.newWakeLock分段持有录完一段释放别一直挂着耗电前台服务提升进程优先级防止被杀startForeground() 通知Android 8.0必须带通知渠道12还可能要声明类型电池优化白名单避免Doze模式深度休眠ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS自己用直接弹窗申请面向用户就引导去设置页Camera资源息屏后仍然持有相机不在onPause/锁屏时释放Camera结合Wakelock和前台服务避免系统回收这里有一个容易踩的坑有些人在锁屏广播里重新acquire()WakeLock但忘了先判断isHeld()结果重复持有导致解锁后唤醒锁一直不释放整机功耗飙升。我的做法是统一封装一个acquireSafer()方法先检查再获取释放时也先检查再释放。另外前台服务通知是“门面”不能敷衍。系统收到通知后才知道有个服务在卖力干活通知越清晰被系统误杀的概率越低。我把通知内容做成实时显示当前录制状态和已录时间既方便排查也告诉系统“我在录着”。2.3 预览与编码解耦没有画面也能录很多初学者以为“录像预览保存”所以用setPreviewDisplay()把画面画到SurfaceView上再启动MediaRecorder。这套思路在亮屏场景没问题但息屏后SurfaceView没有渲染目标录制就可能异常。实际上MediaRecorder根本不需要预览。它的工作方式是setVideoSource(SURFACE)指定使用Surface作为视频输入源调用MediaRecorder.createInputSurface()拿到一个输入Surface把这个Surface交给Camera让相机直接把采集到的帧送给编码器也就是说你完全可以在不创建任何预览View的情况下把相机和编码器串起来。这样息屏与否不影响数据流因为压根没有可“看不见”的UI控件。具体到实现上我建议用Camera2的CameraDevice.createCaptureSession()把MediaRecorder的InputSurface作为唯一的OutputTarget加入会话。这样Camera启动后每一帧数据直奔编码器不经过屏幕绘制功耗还更低。我踩过的一个坑直接复用网上示例代码在Activity里先弄了个TextureView做预览再把它跟MediaRecorder绑定。结果一锁屏TextureView的Surface被销毁录像立刻停。后来改成纯Surface输入模式彻底解耦锁屏后录制稳如老狗。3. 核心代码实现从0到1跑通录制链路3.1 权限与工程准备工程建议用Kotlin最低API 26Android 8.0因为前台服务和通知渠道在8.0之后是强制要求。你手头的旧手机基本都满足。Manifest里需要的权限如下uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.WAKE_LOCK / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / uses-feature android:nameandroid.hardware.camera android:requiredtrue /我的经验是权限不是在Manifest里声明了就万事大吉Android 6.0运行时权限必须动态申请尤其是Camera和麦克风用户拒绝任何一个整个项目跑不起来。建议在进入录制主界面时一次性申请三个权限相机、麦克风、位置如果不做轨迹可以不申请位置。申请完后把结果存到SharedPreferences后续启动服务前不再弹窗。存储方面Android 10开始分区存储直接往/sdcard/DCIM/写会有权限问题。最简单的方式是用getExternalFilesDir()val recordRoot File( context.getExternalFilesDir(null), DashCam )虽然这个目录在App被卸载时会一起清掉但自己用的工具无伤大雅省去一堆存储权限适配的麻烦。3.2 前台服务与唤醒锁的落地先看核心Service骨架class RecordService : Service() { companion object { const val NOTIFICATION_ID 1001 const val WAKELOCK_TIMEOUT_MS 3 * 60 * 60 * 1000L } private lateinit var wakeLock: PowerManager.WakeLock private var mediaRecorder: MediaRecorder? null override fun onCreate() { super.onCreate() val powerManager getSystemService(POWER_SERVICE) as PowerManager wakeLock powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, DashCam::RecordWakeLock ) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForeground(NOTIFICATION_ID, buildNotification()) if (!wakeLock.isHeld) { wakeLock.acquire(WAKELOCK_TIMEOUT_MS) } startRecording() return START_STICKY } override fun onDestroy() { if (wakeLock.isHeld) { wakeLock.release() } stopRecording() super.onDestroy() } }这里解释几个关键设计WAKELOCK_TIMEOUT_MS我设成了3小时。理论上可以一直持有但为了防止异常情况下唤醒锁泄漏导致整夜耗电加一个超时兜底。正常情况下分段录制逻辑会在每段结束时重新规划持有时间不会真出现锁被系统超时释放后没人管的情况。START_STICKY的作用是系统因为资源紧张杀掉Service后会在内存恢复时重新调用onStartCommand()并且传入的Intent是null。所以我在onStartCommand里不能假设intent一定非空要兼容被系统重建的场景。buildNotification()必须返回一个前台服务通知Android 12以上如果服务类型是camera还需要在Manifest里声明FOREGROUND_SERVICE_CAMERA权限并在启动时给通知加上类型。3.3 分段录制的实现思路分段录制的本质是每个分段都新建一个MediaRecorder实例录完当前段后停止、释放再立刻创建下一个分段的实例。由于MediaRecorder.stop()在数据量不足时会抛RuntimeException必须做好异常兜底private fun rotateSegment() { try { mediaRecorder?.stop() } catch (e: RuntimeException) { Log.e(TAG, stop failed, file may be corrupted, e) } finally { mediaRecorder?.release() mediaRecorder null } startNewSegment() } private fun startNewSegment() { val recorder MediaRecorder().apply { setAudioSource(MediaRecorder.AudioSource.MIC) setVideoSource(MediaRecorder.VideoSource.SURFACE) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFile(nextSegmentPath()) setVideoEncodingBitRate(12_000_000) setVideoFrameRate(30) setVideoSize(1920, 1080) setVideoEncoder(MediaRecorder.VideoEncoder.H264) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioSamplingRate(44100) setAudioEncodingBitRate(96_000) setOrientationHint(90) prepare() } mediaRecorder recorder // 用Surface给相机设置输出 cameraManager.openCamera(cameraId, stateCallback, cameraHandler) recorder.start() handler.postDelayed({ rotateSegment() }, SEGMENT_DURATION_MS) }几个参数要特别说清楚setOrientationHint(90)手机横装在中控台上CMOS的物理方向会让画面横过来这个参数告诉编码器在封装时把视频旋转90度最终播放时画面就是正的。不同安装方向要调整竖装就填0或270。setVideoEncodingBitRate(12_000_000)1080P30帧用12Mbps比较合适。10Mbps以下画面会有压缩噪点尤其高速行驶时路牌文字糊成一团15Mbps以上文件暴涨一部手机存储扛不住。我的实车测试里12Mbps在清晰度和体积之间平衡得很好。setVideoFrameRate(30)行车记录仪30帧完全够用60帧会带来发热和数据量翻倍旧手机扛不住。分段时长我推荐3分钟。太短则文件碎片多频繁启停MediaRecorder也更容易触发异常太长则碰撞断电时损失太大。3分钟是行车记录仪行业的成熟选择。启动分段的时机也有讲究。不要在startNewSegment()里立即handler.postDelayed那样第一段会比设定时长少。更稳的做法是每段开始前先记录SystemClock.elapsedRealtime()在旋转前算出实际剩余时间避免因stop耗时累积导致漂移。3.4 存储与循环覆盖策略车载场景的存储空间是有限的旧手机一般64G或128G按我的码率算1080P3分钟大约250MB64G大概能存4个多小时。这显然不够必须有循环覆盖。循环覆盖的逻辑很简单启动时扫描录制目录计算总大小超过配额就按文件名时间戳从旧到新删除。但有两个坑第一个坑是正在写的当前文件不能删。假设当前分段已经足够老但还在写入你删了会导致录制崩溃。解决办法是跳过文件名时间和当前时间差小于分段时长的文件。第二个坑是锁定文件不能删。碰撞锁定的片段要优先保留所以删除时要先检查文件名里是否带有_lock标记。我的文件命名规则是这样的20240205_183000_001.mp4 20240205_183000_002_lock.mp4解析文件名前14位作为时间戳按升序排列从最旧的开始删。设定配额可以写到SharedPreferences里我一般推荐预留总存储的70%给录像剩下的留给其他数据。4. 行车场景功能增强4.1 时间水印怎么叠加这个问题我在第一版用MediaRecorder时纠结了很久。MediaRecorder拿不到帧不能直接在画面上叠加文字。我试过两种方案第一种先录后转码。录制完的MP4文件用MediaCodec解码再编码往画面上写时间戳。这个方案对旧手机来说要命——转码一小时的视频差不多也要一小时存储还翻倍完全不可接受。第二种旁路记录轨道文件。我把GPS坐标、时间戳、车速按时间戳关联写入一个同名的.csv或.nmea文件。看回放时不直接看到水印但在播放器里能调出关联数据。这个方案实现简单、实时性高缺点是回放时不能像商业记录仪那样“所见即所得”。如果你一定要叠加画面水印正确的做法是升级到MediaCodec方案在编码前对每一帧做Canvas绘制把时间文本渲染到Bitmap上再混帧。这个方案对CPU有一定压力我实测骁龙660跑1080P会偶尔掉帧但骁龙7/8系没问题。我的最终选择是轨道文件方案理由很简单行车记录的真实需求是“事故后还原时间线”而不是“播放时画面里有个时间”。旁路方案数据更完整还能记录加速度、速度、经纬度。注意做旁路数据时要保证写入间隔稳定。我用的方案是每秒写一行用SimpleDateFormat格式化时间写入前先FileWriter.flush()避免App异常退出时数据丢失。4.2 碰撞锁定与急刹车保护这是行车记录仪最有价值的特性之一发生碰撞时当前正在录制的片段和前后几个片段要标记为“锁定”不被循环覆盖删除。我用加速度计实现核心逻辑如下class CollisionDetector(private val context: Context) { private var lastAccel 0F private val threshold 15F // 单位 m/s^2 private val sensorListener object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { val x event.values[0] val y event.values[1] val z event.values[2] val magnitude sqrt(x * x y * y z * z) // 静止时重力加速度约 9.8碰撞瞬间会超过 15 if (magnitude threshold lastAccel threshold) { onCollisionDetected() } lastAccel magnitude } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} } private fun onCollisionDetected() { // 将当前片段和前后各1个片段重命名为 _lock 结尾 RecordingManager.lockRecentSegments() } fun start() { val sensorManager context.getSystemService(Context.SENSOR_SERVICE) as SensorManager sensorManager.registerListener( sensorListener, sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER), SensorManager.SENSOR_DELAY_UI ) } }阈值15这个数字怎么来的我实际测试时发现正常城市道路的颠簸加速度峰值大概在11到13之间急刹车时能到16到18真正碰撞瞬间轻松超过20。所以取15能过滤掉大部分颠簸又能在碰撞时及时触发。但不同车型、不同悬挂软硬会有差异最好做成可调参数自己在实车里测两圈。碰撞触发后要“锁定前后几个片段”。原因是碰撞发生时刻和当前正在写的片段不一定完全对齐事故可能发生在当前片段的前半段或者上一个片段末尾。所以我的逻辑是把当前片段、上一个片段、当前正在写的片段都加上_lock标记。锁定操作是重命名文件需要在文件写入完成后执行所以我会在分段轮转时检查锁定队列而不是直接改正在写入的文件。4.3 速度轨迹与GPS信息如果装了GPS模块或者手机自带北斗/GPS定位可以顺便记录轨迹。做法很简单在录制服务里注册LocationManager每5秒取一次定位写入轨道文件。locationManager.requestLocationUpdates( LocationManager.GPS_PROVIDER, 5000L, 0F, locationListener )这样每条录像都能关联上速度、经纬度、时间。出事故后如果交警要看行驶轨迹直接导入到地图软件里就能还原整条路线。GPS天线靠近手机时隔热的金属中控台和金属贴膜会衰减信号导致定位漂移。我的实测经验是把手机平放在挡风玻璃下方、不要贴金属膜卫星定位稳定在10颗以上。冬天戴毛绒方向盘套、中控台上放碳纤维饰品都会影响信号尽量避开。5. 常见问题排查与避坑记录5.1 高频问题速查表整个开发周期里我遇到的需求之外的问题大概分成四类整理成速查表供你直接对照排查症状可能原因检查点解决方法锁屏后几秒停止录像没有持有WakeLock 或 被Doze休眠看日志里是否出现WakeLock释放加上PARTIAL_WAKE_LOCK并申请电池优化白名单Camera打开失败/被回收前后台切换时相机资源被占检查是否有其他App占用相机锁屏广播里不释放Camera保持前台服务状态MediaRecorder.stop()崩溃录制时长太短或数据不足检查异常日志是否是RuntimeExceptiontry-catch捕获后release重建录像有声音没画面Surface没有正确设置给Camera检查InputSurface是否创建成功确认createInputSurface()被调用并传给Camera文件都是0字节stop逻辑异常没等数据落盘检查是否在stop后立即释放保留stop-catch-release的完整顺序不要提前释放白天画面过曝/夜间太黑固定曝光参数不适用观察画面亮度变化接入Camera2的AE/AWB控制或者开头拍一帧做测光第4个坑我印象很深。网上很多教程是setPreviewDisplay()配合MediaRecorder但SURFACE输入模式下Camera的createCaptureSession()传的必须是MediaRecorder的InputSurface如果传成预览Surface结果就是“正在录”但文件里只有音轨、没有视频轨。5.2 厂商ROM后台限制的应对这是所有做Android后台任务的开发者绕不过去的坎。小米MIUI、华为EMUI、OPPO ColorOS、vivo OriginOS都有各自的“杀后台”策略哪怕你用了前台服务厂商ROM照样能在用户锁屏后把进程冻结。我的应对思路分三步第一步引导用户做基础设置。App内做一个“一键设置”引导页带着用户去允许自启动、允许后台运行、关闭省电策略、锁屏后不清理进程。虽然现在Android 11限制了部分后台操作但厂商设置页的入口仍在。第二步进程自愈。我的Service使用START_STICKY并且单独写了一个AlarmManager定时任务每分钟检查一次MediaRecorder是否还在运行如果挂了就重新拉起。AlarmManager可以被Doze延迟但定时唤醒频率不低于15分钟足够兜底。第三步降低对系统“可见度”的刺激。比如避免在息屏后台频繁打印日志、避免高频率访问定位、避免后台唤醒屏幕。系统对CPU占用异常高的后台应用非常敏感越“安静”越不容易被杀。5.3 存储与格式的坑录制格式我选了MP4容器H.264视频AAC音频这是兼容性最好的组合Windows、Mac、手机自带播放器都能直接打开。如果你想要更“原汁原味”的画质可以选H.265但很多老手机硬解不了播放会卡成PPT还要装第三方播放器不建议。还有SD卡的问题。旧手机如果带TF卡扩展我强烈建议把录像目录放到TF卡上。但要注意TF卡的写入速度直接决定能不能录1080P。市面上的卡片级C10/A1基本够用但有些杂牌卡标称C10实际持续写入只有6MB/s录制大动态画面时会丢帧、花屏。我自己实测过一张32G的杂牌卡录5分钟后文件损坏换了闪迪A1卡后一切正常。如果发现录出来的视频文件播放时最后几秒“卡死”大概率是stop()时没有给编码器足够的flush时间。我的做法是在stop()之前延迟200毫秒让编码器把缓冲区的数据全部写入文件。6. 实车测试与个人心得6.1 我的实车测试结果我用的测试机型是骁龙660、6G内存、64G存储屏幕在息屏录制时全程关闭。环境是冬天气温5到10摄氏度车停户外白天跑城市快速路晚上跑一段无路灯的县道。实车测试持续一周每天通勤加短停累计录制约80小时结果如下续航方面手机插着车充基本无感。熄火后如果不拔电息屏录制能扛约6小时所以停车监控功能也能用但要保证电瓶电量充足。长期停车监控建议用移动电源或降压线供电别把电瓶耗干。发热方面息屏录制比亮屏预览低很多因为屏幕是最大耗电和发热来源。骁龙660连续录制2小时后机身温度实测38度左右放在中控台出风口附近甚至摸不到温热。如果是夏天暴晒再加录制温度会明显上升建议在中控台加个遮阳板或者选阴凉处停车。文件稳定性方面80小时录制中没有出现整段文件损坏的情况。一次急刹车触发碰撞锁定锁定的片段完整可放回放时能看到前车尾灯在我车头前迅速变大说明加速度触发时机是准的。6.2 踩坑复盘与可复用经验如果我重新再做一遍会有几个地方在一开始就选对第一不要迷信“高画质”。1080P30帧12Mbps已经非常够用夜间拍摄噪点主要是CMOS底子决定的方案选择影响有限。把码率拉到18Mbps并不能让夜间画面更清楚只会让存储和发热爆炸。第二不要期望一个“万能App”适配所有手机。不同机型Camera2对Surface输入的支持、厂商ROM的后台策略差异巨大。每换一台新手机做测试都要重新过一遍上面5.2的引导设置。第三把日志系统做好。我一开始只在Logcat里打印状态后来发现崩溃和异常信息在Logcat里经常被系统清掉尤其是长时间息屏后。后来我把每次录制启动、每段轮转、每次stop异常都写入一个recorder.log文件排查效率提升了一个量级。回到最初的目的这台旧手机现在每天跟我上下班息屏录存稳定工作。如果你也想做类似的事我建议你直接照着我这个方案搭第一版跑通整条链路后再根据实际需求去改想要画面水印就上MediaCodec想要长途油耗统计就把GPS和OBD数据串起来想要更长的停车监控就换个大容量移动电源。工具是死的需求是活的把底层机制理解透了怎么改都有方向。