14、投屏功能:Miracast/WiFi Display协议、屏幕镜像实现

投屏功能,现在几乎是车载系统的标配了。你想想看,手机上的导航、视频、音乐,直接甩到车机大屏上,多方便。但实现起来,坑不少。

今天我们就聊聊 Miracast,也就是 WiFi Display 协议。我最早接触它是在 2015 年做平板项目的时候,那时候投屏卡顿、掉线是家常便饭。后来做车载,发现车机环境更复杂——电磁干扰、散热、系统负载,都会影响体验。

说白了,Miracast 就是让手机把屏幕内容,通过 WiFi 直接投射到车机上。不需要连网线,不需要 HDMI 线,甚至不需要路由器。手机和车机建立点对点连接,然后传输音视频流。

14.1 Miracast 协议栈概览

先看协议栈长什么样。我画了一张图,帮你理清层次关系。

Miracast / WiFi Display 协议栈 应用层(屏幕镜像、触控回传、音频播放) 会话层(RTSP 控制协议、M4 消息交换) 传输层(RTP / MPEG2-TS 音视频流) 网络层(TCP/IP、UDP、WiFi P2P) 物理层(WiFi 802.11 a/b/g/n/ac)

从下往上看,物理层就是 WiFi 硬件。网络层走 WiFi P2P(点对点),不经过路由器。传输层用 RTP 或 MPEG2-TS 打包音视频。会话层用 RTSP 来协商参数。应用层就是最终的屏幕镜像和触控回传。

嗯,这里要注意:RTSP 只负责控制,不传数据。真正的音视频流走的是 RTP 或 MPEG2-TS。我见过不少新手把 RTSP 和 RTP 搞混,调试半天发现没画面,其实是流根本没发出去。

14.2 WiFi P2P 连接建立

投屏的第一步,是让手机和车机连上。WiFi P2P(也叫 WiFi Direct)就是干这个的。

流程大致如下:

  1. 设备发现:车机作为 Group Owner(GO),广播自己的存在。手机扫描到后发起连接请求。
  2. 连接协商:双方交换能力信息,比如支持的 WiFi 频段、信道宽度。
  3. IP 分配:GO 给 Client 分配 IP 地址。通常车机是 192.168.49.1,手机是 192.168.49.x。
  4. 连接建立:P2P 组建立完成,可以开始传数据了。

关键点:车机必须作为 Group Owner。为什么?因为车机需要同时处理多个连接(比如蓝牙、4G),作为 GO 可以更好地控制信道和带宽。我曾经在项目中让手机做 GO,结果车机频繁掉线,后来改成车机做 GO,问题就解决了。

Android 上启用 WiFi P2P 的代码片段:

// 注册广播接收器
IntentFilter filter = new IntentFilter();
filter.addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION);
filter.addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION);
filter.addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION);
filter.addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION);

// 初始化 P2P 管理器
mManager = (WifiP2pManager) getSystemService(Context.WIFI_P2P_SERVICE);
mChannel = mManager.initialize(this, getMainLooper(), null);

// 创建组(车机作为 GO)
mManager.createGroup(mChannel, new WifiP2pManager.ActionListener() {
    @Override
    public void onSuccess() {
        // 组创建成功,可以接受手机连接
    }

    @Override
    public void onFailure(int reason) {
        // 创建失败,检查 WiFi 是否开启
    }
});

注意:createGroup 需要系统权限。在 Android 10 以上,非系统应用可能无法直接调用。车载系统通常有系统签名,所以问题不大。如果是普通应用开发,可以考虑用 WifiP2pManager 的 discoverPeers 和 connect 方法,让手机主动连接车机。

14.3 RTSP 会话协商

WiFi P2P 连接建立后,接下来就是 RTSP 握手。手机和车机通过 RTSP 交换 M1-M4 消息,协商音视频参数。

典型的 M4 请求(手机发给车机)长这样:

M4 Request:
wfd_presentation_url=rtsp://192.168.49.1/wfd1.0
wfd_client_rtp_ports=RTP/AVP/UDP;unicast;mode=play;source=192.168.49.2+1
wfd_video_formats=00 01 01 01 00000020 00000000 00000000 00 0000 0000 00 none none
wfd_audio_codecs=LPCM 00000002 00

这里 wfd_video_formats 是一串编码后的参数,表示手机支持的视频格式。比如 00 01 01 01 表示 H.264,分辨率 1920x1080,帧率 30fps。

车机收到后,需要解析并回复自己的能力。如果双方能力匹配,就进入流传输阶段。

避坑指南:我曾经遇到一个 case,手机和车机都支持 1080p,但投屏画面只有 720p。查了半天,发现是车机回复的 M4 消息里,wfd_video_formats 的优先级写错了。手机按照优先级选择了 720p。所以,一定要检查参数顺序

14.4 屏幕镜像实现

屏幕镜像,说白了就是把手机的屏幕内容,一帧一帧地编码成视频流,通过 RTP 发送给车机。车机解码后显示出来。

Android 上实现屏幕采集,主要用 MediaCodec + VirtualDisplay。流程如下:

  1. 创建 VirtualDisplay,把屏幕内容渲染到 Surface 上。
  2. MediaCodec 编码 Surface 上的数据,输出 H.264 流。
  3. 把 H.264 流封装成 RTP 包,通过 UDP 发送给车机。

核心代码:

// 创建 VirtualDisplay
mMediaProjection.createVirtualDisplay(
    "ScreenMirror",
    width, height, dpi,
    DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,
    mSurface,  // MediaCodec 的输入 Surface
    null, null
);

// 配置 MediaCodec 编码器
MediaFormat format = MediaFormat.createVideoFormat(
    MediaFormat.MIMETYPE_VIDEO_AVC, width, height);
format.setInteger(MediaFormat.KEY_BIT_RATE, 5000000);  // 5 Mbps
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);  // 关键帧间隔 1 秒

mEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
mEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
mEncoder.start();

// 编码循环
while (isRunning) {
    int index = mEncoder.dequeueOutputBuffer(bufferInfo, 10000);
    if (index >= 0) {
        ByteBuffer outputData = mEncoder.getOutputBuffer(index);
        // 封装成 RTP 包发送
        sendRtpPacket(outputData, bufferInfo);
        mEncoder.releaseOutputBuffer(index, false);
    }
}

性能优化建议

  • 码率不要设太高。车载环境 5-8 Mbps 就够 1080p 了。设太高会卡顿。
  • 关键帧间隔设 1 秒。这样手机画面变化时,车机能快速刷新。
  • 如果车机解码能力弱,可以降分辨率到 720p。

14.5 车机端解码与显示

车机端收到 RTP 包后,需要解包、解码、显示。Android 上可以用 MediaCodec 解码 H.264 流,然后渲染到 SurfaceViewTextureView 上。

解码流程:

// 配置解码器
MediaFormat format = MediaFormat.createVideoFormat(
    MediaFormat.MIMETYPE_VIDEO_AVC, width, height);
mDecoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
mDecoder.configure(format, mSurface, null, 0);  // 直接输出到 Surface
mDecoder.start();

// 解码循环
while (isRunning) {
    int index = mDecoder.dequeueInputBuffer(10000);
    if (index >= 0) {
        ByteBuffer inputBuffer = mDecoder.getInputBuffer(index);
        // 从 RTP 包中取出 H.264 数据,填入 inputBuffer
        int bytesRead = fillInputBuffer(inputBuffer);
        mDecoder.queueInputBuffer(index, 0, bytesRead, pts, 0);
    }

    int outputIndex = mDecoder.dequeueOutputBuffer(bufferInfo, 10000);
    if (outputIndex >= 0) {
        mDecoder.releaseOutputBuffer(outputIndex, true);  // 渲染到 Surface
    }
}

这里有个细节:时间戳(PTS)必须连续。如果 PTS 跳变太大,解码器会报错。我遇到过手机端发送的 PTS 不连续,导致车机画面卡住。后来在发送端加了 PTS 校正逻辑才解决。

14.6 触控回传

屏幕镜像不只是看,还要能操作。触控回传就是把车机上的触摸事件,发送给手机,手机模拟点击。

实现方式:

  • 车机捕获触摸坐标(x, y)。
  • 通过 UDP 或 TCP 发送给手机。
  • 手机端用 InputManager 注入触摸事件。

触控回传的难点在于坐标映射。车机屏幕和手机屏幕分辨率不同,需要做缩放。比如车机是 1920x720,手机是 1080x2400,触摸坐标要按比例映射。

注意:Android 10 以上,非系统应用无法使用 InputManager.injectInputEvent。车载系统通常有系统权限,所以没问题。如果是普通应用,可以考虑用 AccessibilityService 模拟点击,但延迟会高一些。

14.7 常见问题与优化

问题 原因 解决方案
画面卡顿 码率过高或 WiFi 干扰 降低码率到 5 Mbps,切换 5G 频段
连接失败 WiFi P2P 组未正确创建 检查车机是否已启用 P2P,重启 WiFi
音画不同步 音频和视频时间戳不一致 统一使用系统时钟作为 PTS 基准
触控不灵敏 坐标映射错误或延迟高 检查映射比例,优化网络传输

嗯,最后说一句。投屏功能看起来简单,但要做好不容易。尤其是车载环境,WiFi 干扰多,系统资源紧张。我建议你在开发时,先保证基础功能稳定,再优化画质和延迟。毕竟用户最不能忍的是卡顿和掉线,而不是画质不够清晰。

好了,这一章就到这里。代码示例都是我在项目中实际用过的,你可以直接参考。如果有问题,欢迎交流。

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