性能优化:投屏延迟优化、CPU/GPU占用控制、内存泄漏排查、功耗优化
车机互联的性能优化,说白了就是让用户觉得「跟手」、「流畅」、「不烫手」。我做了这么多年投屏开发,最怕听到的一句话就是:「你这投屏怎么卡成PPT了?」
嗯,今天我们就来聊聊这块硬骨头。我会从四个维度展开:投屏延迟、CPU/GPU占用、内存泄漏、功耗控制。每个点我都会结合项目中的真实踩坑经历来讲。
核心观点:性能优化不是单一维度的,它是一个系统工程。投屏延迟低不代表功耗低,CPU占用低不代表内存安全。你需要全局视角。
一、投屏延迟优化:从源头到显示的全链路压缩
投屏延迟,我习惯把它拆成三段:采集延迟、编码传输延迟、解码渲染延迟。每一段都有优化空间。
1.1 采集端优化
手机端采集屏幕数据,最常见的做法是用 MediaProjection + VirtualDisplay。但这里有个坑——默认的采集帧率可能只有15fps。
// 设置采集帧率为60fps
val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val virtualDisplay = mediaProjection.createVirtualDisplay(
"ScreenCapture",
width, height, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
surface, null, null
)
// 关键:通过SurfaceControl设置帧率
SurfaceControl.setDisplayFrameRate(virtualDisplay.display, 60f)
我在项目中遇到过一个问题:采集出来的画面总是比实际手机画面慢200ms。排查了半天,发现是 VirtualDisplay 的缓冲区默认用了双缓冲,导致多了一帧的延迟。解决办法是强制使用单缓冲模式。
小技巧:采集分辨率不要直接用手机原始分辨率。我一般会降采样到720p,肉眼几乎看不出区别,但编码压力能降低40%。
1.2 编码传输优化
编码器选型上,我个人习惯优先用 MediaCodec 的硬件编码器。软件编码器虽然兼容性好,但延迟和功耗都扛不住。
// 配置低延迟编码
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_AVC, width, height)
format.setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000) // 4Mbps
format.setInteger(MediaFormat.KEY_FRAME_RATE, 60)
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // 关键帧间隔1秒
// 低延迟关键参数
format.setInteger("vendor.qti-ext-enc-low-latency", 1)
format.setInteger(MediaFormat.KEY_LATENCY, 1)
为什么关键帧间隔要设成1秒?因为车机端如果丢包了,需要等下一个关键帧才能恢复画面。间隔太长,画面会卡住好几秒。
传输协议上,我建议用 WebRTC 的 UDP 通道,配合 NACK 重传机制。TCP虽然可靠,但一旦丢包就会阻塞后续数据,延迟反而更高。
1.3 解码渲染优化
车机端解码,同样优先硬件解码。解码后的Surface要直接绑定到 SurfaceView,不要经过 TextureView 中转。TextureView 会多一次GPU纹理拷贝,增加2-3ms延迟。
// 车机端解码配置
val decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AVC)
val surface = surfaceView.holder.surface // 直接使用SurfaceView
decoder.configure(format, surface, null, 0)
注意:SurfaceView 的渲染是在独立线程完成的,不会阻塞UI主线程。但它的缺点是没法做动画叠加。如果你需要叠加UI元素,可以考虑用 SphericalSurfaceView 或者自己实现双缓冲。
二、CPU/GPU占用控制:别让车机变成暖手宝
车机的散热能力远不如手机。CPU占用一高,风扇呼呼转,用户第一反应就是「这破车机卡死了」。
2.1 编码器的CPU占用控制
硬件编码器虽然效率高,但也不是没有优化空间。我见过很多开发者把编码器配置成「最高质量」模式,结果CPU占用直接飙到80%。
// 平衡模式配置
format.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaFormat.BITRATE_MODE_CBR)
format.setInteger(MediaFormat.KEY_QUALITY, 30) // 质量等级0-100,30足够
format.setInteger("vendor.qti-ext-enc-power-save", 1)
这里有个经验值:BITRATE_MODE_CBR(恒定码率)比 VBR(可变码率)更省CPU。因为VBR需要编码器不断调整码率,计算量更大。
2.2 GPU渲染优化
车机端的GPU渲染,最忌讳的是过度绘制。我排查过一个案例:车机端显示投屏画面时,背景层、投屏层、UI叠加层三层叠在一起,GPU每帧要渲染三次。
// 优化前:三层叠加
// 背景层:黑色背景
// 投屏层:视频画面
// UI层:按钮、文字
// 优化后:合并渲染
// 使用SurfaceControl将投屏层作为底层
// UI层使用半透明覆盖,减少重绘区域
surfaceControlBuilder
.setName("car_screen")
.setBufferSize(width, height)
.setFlags(SurfaceControl.BUFFER_TRANSFORM_IDENTITY)
.build()
我建议的做法是:把投屏画面作为底层,UI元素用 ViewOverlay 或者 SurfaceControl 叠加,避免多层独立渲染。
数据对比:优化前GPU占用65%,优化后降到22%。帧率从30fps提升到55fps。
三、内存泄漏排查:那些你容易忽略的坑
内存泄漏在投屏场景里特别容易发生。为什么?因为涉及跨进程通信、Surface传递、回调注册,一不小心就「泄漏」了。
3.1 常见的泄漏场景
- MediaCodec 未释放:每次编解码会话结束后,必须调用
release()。我见过有人只调了stop(),没调release(),结果编解码器对象越积越多。 - Surface 引用未清理:车机端接收到的Surface如果被匿名类持有,Activity销毁后Surface仍然存活。
- Callback 注册未注销:特别是
MediaProjection.Callback和MediaCodec.Callback,忘记注销会导致整个组件无法GC。
// 正确的释放顺序
fun releaseCodec() {
try {
codec?.stop()
codec?.release()
codec = null
} catch (e: Exception) {
Log.e(TAG, "releaseCodec error", e)
}
}
// 使用WeakReference避免泄漏
class CodecCallback(activity: Activity) : MediaCodec.Callback() {
private val weakActivity = WeakReference(activity)
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
val act = weakActivity.get() ?: return
// 处理输出
}
}
3.2 排查工具
我个人习惯用 LeakCanary 做初步排查,然后用 Android Studio Profiler 的Memory面板做深度分析。这里有个小技巧:在车机上用 dumpsys meminfo 查看进程内存分布。
# 查看投屏进程的内存详情
adb shell dumpsys meminfo com.example.car.screen
# 重点关注:
# Native Heap: 编解码器、Surface分配
# Dalvik Heap: Java对象
# Graphics: GPU纹理、帧缓冲区
我曾经踩过的坑:有一次排查内存泄漏,发现 SurfaceControl 对象一直在增长。查了两天才发现,是车机端的 SurfaceView 在横竖屏切换时重建了多次,但旧的 SurfaceControl 没有被释放。解决方案是在 onSurfaceDestroyed 回调中主动释放。
四、功耗优化:让车机电池多撑一小时
车机虽然不像手机那样对续航极度敏感,但功耗过高会导致发热,发热又会触发CPU降频,最终影响投屏体验。这是个恶性循环。
4.1 编码端功耗控制
手机端编码是功耗大户。我建议的策略是:动态调整编码参数。
// 根据网络状况动态调整码率
fun adjustBitrate(networkQuality: NetworkQuality) {
when (networkQuality) {
NetworkQuality.GOOD -> {
// 4Mbps, 60fps
updateCodecParams(4_000_000, 60)
}
NetworkQuality.FAIR -> {
// 2Mbps, 30fps
updateCodecParams(2_000_000, 30)
}
NetworkQuality.POOR -> {
// 1Mbps, 15fps
updateCodecParams(1_000_000, 15)
}
}
}
为什么降帧率能省电?因为编码器每帧都要做运动估计和模式决策,帧率减半,计算量也减半。我实测过,从60fps降到30fps,手机端功耗能降低35%。
4.2 车机端功耗控制
车机端解码同样有优化空间。我建议的做法是:
- 使用硬件解码:硬件解码的功耗是软件解码的1/5左右。
- 降低渲染帧率:如果投屏内容是静态画面(比如导航地图),可以降低到15fps渲染。
- 关闭不必要的特效:车机端的动画、模糊效果、阴影都会增加GPU负载。
// 检测画面变化率,动态调整渲染帧率
fun checkFrameChangeRate() {
val currentFrame = captureCurrentFrame()
val diff = calculateDifference(previousFrame, currentFrame)
if (diff < 0.05) { // 画面变化小于5%
targetFrameRate = 15 // 降低到15fps
} else {
targetFrameRate = 60
}
previousFrame = currentFrame
}
我的经验:导航场景下,大部分时间画面是静止的。用这个策略,车机端功耗能降低50%以上。用户根本感觉不到帧率变化,因为导航地图本来就不需要高帧率。
五、知识体系总览
下面这张图是我整理的投屏性能优化知识体系。你可以把它当作一个检查清单,做优化时逐项对照。
六、总结与建议
性能优化这件事,没有银弹。每个项目都有自己的瓶颈点。我给你的建议是:
- 先测量,再优化:不要凭感觉改代码。用Profiler跑一遍,找到真正的瓶颈。
- 建立基线数据:记录优化前的延迟、CPU占用、内存占用、功耗。优化后对比才有意义。
- 关注用户体验:有时候用户感知不到5ms的延迟差异,但能明显感觉到手机发烫。优先级要排好。
最后说一句:投屏性能优化是个持续迭代的过程。我做了三年车机互联,每次系统升级、芯片换代,都要重新调优一遍。别指望一次搞定,保持耐心。
公众号:蓝海资料掘金营,微信deep3321