性能优化:投屏延迟优化、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秒?因为车机端如果丢包了,需要等下一个关键帧才能恢复画面。间隔太长,画面会卡住好几秒。

传输协议上,我建议用 WebRTCUDP 通道,配合 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.CallbackMediaCodec.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%以上。用户根本感觉不到帧率变化,因为导航地图本来就不需要高帧率。

五、知识体系总览

下面这张图是我整理的投屏性能优化知识体系。你可以把它当作一个检查清单,做优化时逐项对照。

投屏性能优化知识体系 投屏延迟优化 • 采集端:单缓冲模式 • 编码:硬件编码+低延迟 • 传输:UDP+NACK重传 • 解码:SurfaceView直连 CPU/GPU占用控制 • CBR模式省CPU • 质量等级30足够 • 减少过度绘制 • SurfaceControl合并层 内存泄漏排查 • MediaCodec释放 • Surface引用清理 • Callback注销 • WeakReference使用 功耗优化 • 动态调整码率 • 降帧率省电35% • 硬件解码省电80% • 画面变化检测 核心原则:全局视角,平衡延迟、功耗、内存三者关系 单一指标最优 ≠ 整体体验最优 推荐工具 LeakCanary(内存泄漏) Android Studio Profiler(CPU/GPU) dumpsys meminfo(内存详情) Systrace(全链路追踪)

六、总结与建议

性能优化这件事,没有银弹。每个项目都有自己的瓶颈点。我给你的建议是:

  1. 先测量,再优化:不要凭感觉改代码。用Profiler跑一遍,找到真正的瓶颈。
  2. 建立基线数据:记录优化前的延迟、CPU占用、内存占用、功耗。优化后对比才有意义。
  3. 关注用户体验:有时候用户感知不到5ms的延迟差异,但能明显感觉到手机发烫。优先级要排好。

最后说一句:投屏性能优化是个持续迭代的过程。我做了三年车机互联,每次系统升级、芯片换代,都要重新调优一遍。别指望一次搞定,保持耐心。


公众号:蓝海资料掘金营,微信deep3321