14、投屏功能:Miracast/WiFi Display协议、屏幕镜像实现
投屏功能,现在几乎是车载系统的标配了。你想想看,手机上的导航、视频、音乐,直接甩到车机大屏上,多方便。但实现起来,坑不少。
今天我们就聊聊 Miracast,也就是 WiFi Display 协议。我最早接触它是在 2015 年做平板项目的时候,那时候投屏卡顿、掉线是家常便饭。后来做车载,发现车机环境更复杂——电磁干扰、散热、系统负载,都会影响体验。
说白了,Miracast 就是让手机把屏幕内容,通过 WiFi 直接投射到车机上。不需要连网线,不需要 HDMI 线,甚至不需要路由器。手机和车机建立点对点连接,然后传输音视频流。
14.1 Miracast 协议栈概览
先看协议栈长什么样。我画了一张图,帮你理清层次关系。
从下往上看,物理层就是 WiFi 硬件。网络层走 WiFi P2P(点对点),不经过路由器。传输层用 RTP 或 MPEG2-TS 打包音视频。会话层用 RTSP 来协商参数。应用层就是最终的屏幕镜像和触控回传。
嗯,这里要注意:RTSP 只负责控制,不传数据。真正的音视频流走的是 RTP 或 MPEG2-TS。我见过不少新手把 RTSP 和 RTP 搞混,调试半天发现没画面,其实是流根本没发出去。
14.2 WiFi P2P 连接建立
投屏的第一步,是让手机和车机连上。WiFi P2P(也叫 WiFi Direct)就是干这个的。
流程大致如下:
- 设备发现:车机作为 Group Owner(GO),广播自己的存在。手机扫描到后发起连接请求。
- 连接协商:双方交换能力信息,比如支持的 WiFi 频段、信道宽度。
- IP 分配:GO 给 Client 分配 IP 地址。通常车机是 192.168.49.1,手机是 192.168.49.x。
- 连接建立: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。流程如下:
- 创建
VirtualDisplay,把屏幕内容渲染到Surface上。 - 用
MediaCodec编码Surface上的数据,输出 H.264 流。 - 把 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 流,然后渲染到 SurfaceView 或 TextureView 上。
解码流程:
// 配置解码器
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 干扰多,系统资源紧张。我建议你在开发时,先保证基础功能稳定,再优化画质和延迟。毕竟用户最不能忍的是卡顿和掉线,而不是画质不够清晰。
好了,这一章就到这里。代码示例都是我在项目中实际用过的,你可以直接参考。如果有问题,欢迎交流。