从零构建Android Miracast接收端:原生API实现投屏

发布时间:2026/9/21 2:49:23
从零构建Android Miracast接收端:原生API实现投屏 1. 为什么我要自己动手写一个Miracast接收端先说说我做这件事的起因。家里有一台老款Android平板屏幕素质还不错但性能放到现在只能算勉强够用。我平时想把它当成一个副屏来用——比如把手机上的导航界面、会议投屏内容、或者视频直接甩到平板上显示。市面上能实现这个需求的第三方投屏软件我几乎试了个遍体验参差不齐有的强制看广告有的限制分辨率有的延迟高得离谱还有的干脆在Android高版本上直接闪退。最让我受不了的是这些应用往往要求一堆莫名其妙的权限后台还常驻进程耗电感人。后来我琢磨了一下Miracast这个协议本身是Android从4.2开始就原生支持的系统底层已经带了完整的Source端发送端和Sink端接收端实现。也就是说我完全可以用Android原生的API去搭建一个投屏接收器不依赖任何第三方应用。这条路走通之后延迟、画质、稳定性都掌握在自己手里而且整个应用体积可以做到非常小。这篇文章就是把我从零搭建这个接收器的完整过程整理出来。涉及的核心技术点包括MediaCodec硬解码、Presentation双屏显示、WifiP2pManager设备发现、以及MediaProjection相关的权限处理。适合有一定Android开发基础、想深入理解投屏底层原理的开发者也适合像我这样喜欢折腾、想摆脱第三方应用束缚的玩家。读完你至少能搞清楚一件事Miracast接收端到底是怎么把一路H.264码流变成屏幕上实时画面的。2. Miracast接收端的整体架构与方案选型2.1 先搞清楚Miracast在Android里到底是怎么跑的很多人以为Miracast是一个应用层协议其实不是。它底层依赖的是Wi-Fi Direct也就是Wi-Fi P2P来做设备发现和连接建立然后通过RTSP做会话协商最后用RTP承载H.264视频流和AAC/LPCM音频流。Android系统从Framework层就内置了这套协议的实现具体来说WifiP2pManager负责P2P设备发现、组网、连接管理MediaCodec负责H.264视频流的硬解码AudioTrack负责音频流的播放SurfaceView或TextureView负责最终画面的渲染关键点在于Android并没有直接暴露一个“Miracast Sink API”给第三方应用调用。系统设置里的“无线显示”功能是系统级应用才有的权限。那第三方应用怎么实现接收端答案是走MediaProjectionMediaCodec的组合路线自己实现一个软件层面的Sink。这里要区分两个概念系统级Sink和应用级Sink。系统级Sink是Android原生设置里那个“无线显示”接收功能需要系统签名权限。应用级Sink则是我们通过MediaProjection拿到屏幕采集权限后自己编码再传输或者反过来接收远端流再解码。我这次做的是后者——接收端。2.2 为什么不用第三方库而是死磕原生API市面上有一些开源库比如LibStreaming、Spydroid之类的但它们大多停留在比较老的Android版本上对新系统的适配很差。而且这些库往往把编码、传输、解码揉在一起出了问题很难定位。我选择原生API的理由很直接第一可控性。从P2P连接到解码渲染每一层我都能插手调参。比如解码器选软解还是硬解、缓冲区设多大、渲染用SurfaceView还是TextureView这些在第三方库里往往被封装死了。第二体积和依赖。原生API不需要引入任何额外的so库APK可以做到几百KB级别。第三方库动辄几MB甚至几十MB还经常带着一堆用不上的编解码器。第三学习价值。把这条链路自己走一遍对Android多媒体框架的理解会上一个台阶。后面再遇到音视频同步、花屏、延迟这些问题排查起来心里有底。当然原生API的坑也不少。最大的坑就是兼容性——不同厂商的ROM对MediaCodec的实现差异很大尤其是硬解码器的支持情况。这个后面会专门讲。2.3 整体数据流设计我的接收端整体数据流是这样的远端Source设备 ↓ (Wi-Fi Direct P2P连接) RTSP会话协商 (SETUP/PLAY) ↓ (RTP over UDP) H.264 NAL单元拆包重组 ↓ MediaCodec 硬解码 ↓ SurfaceView 渲染音频链路类似只是把MediaCodec换成AudioTrack把视频流换成AAC或LPCM。为了先跑通主流程我第一版只做了视频音频后面再补。这里有个设计决策需要说明RTSP和RTP的处理我是自己写的没有用现成的库。原因是Android自带的MediaPlayer虽然支持RTSP但它不支持Miracast特有的WFDWi-Fi Display扩展参数比如wfd_video_formats、wfd_audio_codecs这些。自己解析RTSP消息虽然麻烦但灵活度最高。3. 核心模块拆解与关键API实操3.1 WifiP2pManager设备发现与组网Miracast的第一步是让接收端和发送端互相发现。Android的WifiP2pManager提供了完整的P2P操作接口。核心流程分四步初始化WifiP2pManager和Channel注册WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION等广播调用discoverPeers()开始搜索周边设备收到onPeersAvailable()回调后选择目标设备调用connect()代码骨架大概长这样WifiP2pManager manager (WifiP2pManager) getSystemService(Context.WIFI_P2P_SERVICE); WifiP2pManager.Channel channel manager.initialize(this, getMainLooper(), null); manager.discoverPeers(channel, new WifiP2pManager.ActionListener() { Override public void onSuccess() { // 搜索已启动 } Override public void onFailure(int reason) { // 处理失败常见原因Wi-Fi未开启、权限不足 } });这里有个非常关键的坑从Android 10开始WifiP2pManager的很多操作需要ACCESS_FINE_LOCATION权限而且必须动态申请。Android 13之后又引入了NEARBY_WIFI_DEVICES权限。如果你在Manifest里只写了ACCESS_WIFI_STATE在真机上大概率会静默失败——onFailure回调都不一定触发。我的做法是在onCreate里就把权限检查做掉用ActivityCompat.requestPermissions一次性申请ACCESS_FINE_LOCATION和NEARBY_WIFI_DEVICES。申请完再初始化P2P管理器。注意部分厂商ROM尤其是国内定制系统会限制后台应用使用P2P功能。如果你的应用切到后台后P2P连接断开需要在onPause里做保活处理或者引导用户把应用加入电池优化白名单。3.2 RTSP会话协商WFD扩展参数是重点P2P连接建立后发送端会主动向接收端的RTSP端口默认7236发起OPTIONS请求。接收端需要依次响应OPTIONS、GET_PARAMETER、SET_PARAMETER、SETUP、PLAY这几个方法。其中GET_PARAMETER和SET_PARAMETER是Miracast特有的用来交换WFD能力参数。发送端会发来类似这样的bodywfd_video_formats: 00 00 02 04 00000010 00000000 00000000 00 0000 0000 00 none none wfd_audio_codecs: LPCM 00000002 00 wfd_client_rtp_ports: RTP/AVP/UDP;unicast 19000 0 modeplay接收端需要解析这些参数然后返回自己支持的能力集。这里最容易出错的是wfd_video_formats的解析——它是一串十六进制编码每一位代表一种分辨率、帧率、CEA/VESA格式的组合。我第一版就是这里解析错了导致协商出来的分辨率是640x480画面糊得没法看。我的处理方式是写一个专门的WfdVideoFormats解析类把十六进制字符串按位拆开映射到具体的MediaFormat参数。核心逻辑是// 解析native分辨率位图 long nativeBitmap Long.parseLong(hexPart, 16); for (int i 0; i 32; i) { if ((nativeBitmap (1L i)) ! 0) { // 第i位对应一种分辨率 int width CEA_RESOLUTIONS[i][0]; int height CEA_RESOLUTIONS[i][1]; // 加入候选列表 } }解析完之后接收端要选一个自己解码器支持的分辨率返回。这里建议优先选720p30fps因为绝大多数中低端设备的硬解码器对1080p60fps的支持都不稳定选高了容易花屏或者掉帧。3.3 MediaCodec硬解码配置参数决定成败协商完成后发送端开始通过RTP推送H.264码流。接收端要做的是把RTP包拆开取出NAL单元然后喂给MediaCodec解码。MediaCodec的配置有几个关键参数参数推荐值说明KEY_MIMEvideo/avcH.264KEY_WIDTH/KEY_HEIGHT协商结果必须和SPS/PPS一致KEY_COLOR_FORMAT不设置用Surface模式让系统自动选KEY_FRAME_RATE30和协商帧率一致KEY_BIT_RATE不设置解码端不需要这里有个大坑如果你用configure()的时候传了Surface那么MediaCodec会走Surface模式解码后的帧直接渲染到Surface上不需要你手动处理YUV数据。这是性能最好的方式。但如果你传了null作为Surface那就得自己处理输出Buffer性能会差很多而且容易出花屏。我的代码是这样的MediaFormat format MediaFormat.createVideoFormat(video/avc, width, height); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); codec MediaCodec.createDecoderByType(video/avc); codec.configure(format, surfaceView.getHolder().getSurface(), null, 0); codec.start();解码线程的主循环while (isRunning) { int inIndex codec.dequeueInputBuffer(10000); if (inIndex 0) { ByteBuffer buffer codec.getInputBuffer(inIndex); buffer.clear(); buffer.put(nalData); codec.queueInputBuffer(inIndex, 0, nalData.length, pts, 0); } int outIndex codec.dequeueOutputBuffer(info, 10000); if (outIndex 0) { codec.releaseOutputBuffer(outIndex, true); // true表示渲染到Surface } }releaseOutputBuffer的第二个参数传true是关键它告诉解码器把这一帧直接渲染到Surface上。传false的话帧就被丢弃了屏幕上什么都不会有。3.4 SurfaceView vs TextureView渲染容器的选择渲染容器我选的是SurfaceView不是TextureView。原因很简单SurfaceView有独立的绘图表面解码器可以直接把帧渲染上去不经过应用的主线程UI合成延迟更低。TextureView虽然支持变换动画但它的渲染路径要经过GPU合成延迟会高个十几毫秒。对于投屏这种对延迟敏感的场景十几毫秒的差距是能感觉出来的。而且SurfaceView在播放视频时功耗更低对老设备更友好。不过SurfaceView有个缺点它不支持View的透明度、旋转等属性。如果你需要做画中画或者悬浮窗效果那就得用TextureView。我的场景是全屏显示所以SurfaceView完全够用。4. 完整实操流程与关键环节实现4.1 环境准备与权限声明先列一下我用的开发环境Android Studio Hedgehog2023.1.1编译SDKAndroid 14API 34最低支持Android 6.0API 23测试设备一台Android 10的旧手机作为接收端一台Android 13的手机作为发送端Manifest里需要声明的权限uses-permission android:nameandroid.permission.ACCESS_WIFI_STATE / uses-permission android:nameandroid.permission.CHANGE_WIFI_STATE / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.NEARBY_WIFI_DEVICES / uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE /FOREGROUND_SERVICE是为了让接收端在后台也能保持连接。从Android 14开始前台服务还需要声明具体的服务类型比如mediaProjection。4.2 启动P2P搜索并建立连接在MainActivity的onCreate里我先做权限检查然后初始化P2P管理器。搜索到设备后弹出一个列表让用户选择。选中后调用connect()WifiP2pConfig config new WifiP2pConfig(); config.deviceAddress device.deviceAddress; config.wps.setup WpsInfo.PBC; // Push Button Config最简单 manager.connect(channel, config, new ActionListener() { Override public void onSuccess() { // 连接请求已发送等待WIFI_P2P_CONNECTION_CHANGED_ACTION广播 } Override public void onFailure(int reason) { // 常见原因设备忙、P2P不支持 } });连接成功后系统会分配一个P2P Group接收端通常是Group OwnerGO。这时候接收端的IP地址一般是192.168.49.1发送端是192.168.49.x。RTSP服务就监听在192.168.49.1:7236。4.3 启动RTSP服务器并处理会话我用ServerSocket起了一个简单的RTSP服务器ServerSocket serverSocket new ServerSocket(7236); while (isRunning) { Socket client serverSocket.accept(); // 每个客户端起一个线程处理 new RtspSession(client).start(); }RtspSession里解析请求行和方法然后分发处理。以OPTIONS为例if (method.equals(OPTIONS)) { String response RTSP/1.0 200 OK\r\n CSeq: cseq \r\n Public: org.wfa.wfd1.0, GET_PARAMETER, SET_PARAMETER\r\n\r\n; outputStream.write(response.getBytes()); }GET_PARAMETER请求里会带WFD能力查询接收端需要返回自己的能力集。这一步是整个协商的核心返回的参数直接决定了后续的码流规格。4.4 RTP收包与NAL重组发送端开始推流后接收端会收到RTP包。每个RTP包有一个12字节的头部后面跟着H.264的NAL单元。但一个NAL单元可能被拆成多个RTP包FU-A分片也可能多个NAL单元打包在一个RTP包里STAP-A聚合。我的处理逻辑是解析RTP头取出sequence number和timestamp检查marker bit判断是否是帧的最后一个包根据NAL类型判断是单包、分片还是聚合分片的话要等所有分片到齐后重组这里有个性能优化点RTP收包我用的是DatagramSocket缓冲区设成了64KB。默认的8KB在高码率下会丢包。另外收包线程和解码线程要分开中间用一个ArrayBlockingQueue做缓冲避免解码卡顿导致RTP丢包。DatagramSocket socket new DatagramSocket(19000); socket.setReceiveBufferSize(65536); byte[] buffer new byte[65536]; while (isRunning) { DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); // 解析并放入队列 nalQueue.offer(parseRtpPacket(packet)); }4.5 解码渲染与延迟优化解码线程从队列里取NAL数据喂给MediaCodec。这里有几个延迟优化的技巧第一设置解码器的低延迟模式。部分设备支持KEY_LOW_LATENCY设成1可以显著降低解码延迟if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1); }第二控制队列长度。如果队列里积压超过5帧说明解码跟不上这时候要主动丢帧丢最老的帧保证画面实时性。投屏场景下画面实时比画面完整更重要。第三SurfaceView的setZOrderOnTop。如果你的SurfaceView被其他View遮挡渲染会走合成路径延迟增加。设成true可以让它直接显示在最上层。实测下来从发送端点击播放到接收端出画面延迟大概在120ms到180ms之间。这个水平看视频、做演示完全够用但玩游戏的体验还是差一点。5. 常见问题排查与避坑经验5.1 协商成功但黑屏怎么办这是最常见的问题。协商日志显示SETUP和PLAY都返回200但屏幕上就是黑的。排查思路按顺序来第一步确认SPS/PPS有没有收到。H.264解码必须要有SPS和PPS参数集。如果发送端没有通过SET_PARAMETER或者码流里的SPS/PPS NAL单元传过来解码器就不知道分辨率、帧率等信息直接不输出。你可以在解码前打印一下NAL类型看看有没有type 7SPS和type 8PPS。第二步确认MediaCodec有没有报错。在dequeueOutputBuffer返回负值的时候打印一下具体的错误码。INFO_TRY_AGAIN_LATER是正常的INFO_OUTPUT_FORMAT_CHANGED说明格式变了要重新配置其他负值就是真出错了。第三步确认Surface有没有准备好。SurfaceView的Surface是异步创建的如果你在surfaceCreated回调之前就调用了codec.configure()Surface是无效的解码器会静默失败。我的做法是在surfaceCreated回调里才启动解码线程。5.2 画面花屏、绿屏、马赛克花屏的原因通常有三个RTP丢包Wi-Fi信号不好或者缓冲区太小。解决办法是加大DatagramSocket的接收缓冲区同时在应用层做重传请求RTCP NACK。NAL重组错误分片包的顺序搞错了或者时间戳没对齐。检查一下FU-A分片的start bit和end bit处理逻辑。解码器不支持该分辨率有些设备的硬解码器只支持到1080p你协商了4K就会花屏。解决办法是在协商阶段就限制最大分辨率。绿屏通常是色彩空间不匹配。如果你用的是Surface模式一般不会出现这个问题。如果是自己处理YUV数据要确认COLOR_FormatYUV420Flexible的排列方式。5.3 延迟越来越高最后卡死这是典型的生产者-消费者失衡。发送端推流速度大于接收端解码速度队列越积越长延迟越来越大最后内存爆掉。解决办法是主动丢帧。在解码线程里维护一个队列长度计数超过阈值就丢弃最老的帧if (nalQueue.size() MAX_QUEUE_SIZE) { nalQueue.poll(); // 丢弃最老的 }阈值设多少合适我的经验是3到5帧。按30fps算5帧就是166ms的缓冲。超过这个数延迟就明显能感觉到了。另外MediaCodec的dequeueInputBuffer超时时间不要设太长设成10ms就够了。设太长会导致解码线程响应不及时。5.4 不同厂商ROM的兼容性差异这是最让人头疼的部分。我测试了五六台设备发现厂商问题解决办法某米P2P连接后RTSP端口不通关闭系统自带的“无线显示”功能某为硬解码器不支持720p以下分辨率强制协商720p某星SurfaceView渲染有撕裂改用TextureView某O后台P2P被杀死加前台服务保活这些差异没有统一的解决方案只能一台一台测。我的建议是在应用里加一个兼容性开关让用户手动选择解码器类型硬解/软解和渲染容器SurfaceView/TextureView。软解虽然功耗高但兼容性最好作为兜底方案。5.5 常见问题速查表现象可能原因排查方向搜索不到设备权限不足检查定位权限和附近设备权限连接失败设备忙重启双方Wi-Fi协商失败WFD参数不匹配打印RTSP消息体对比黑屏SPS/PPS缺失检查NAL类型7和8花屏RTP丢包加大Socket缓冲区延迟高队列积压主动丢帧卡死内存溢出限制队列长度无声音频链路未实现检查AudioTrack配置6. 后续可以继续折腾的方向第一版跑通之后我又陆续加了一些东西。音频链路用AudioTrack补上了AAC解码用MediaCodec的audio/mp4a-latm类型延迟控制在200ms以内。音视频同步用RTCP的SR包做时间戳对齐效果还可以。再往后可以考虑加录制功能。既然解码后的帧已经渲染到Surface上了那用MediaRecorder或者MediaMuxer把码流存成MP4文件是很自然的事。我试过用MediaMuxer直接封装H.264裸流不需要重新编码CPU占用很低。还有一个方向是多接收端。一个发送端同时推给多个接收端这在会议场景下很有用。实现上就是把RTP包复制多份分别发给不同的接收端。但要注意带宽1080p30fps的码流大概4Mbps三个接收端就是12MbpsWi-Fi Direct的带宽不一定扛得住。最后说一个我在实际调试中总结的小技巧用Wireshark抓包分析RTSP和RTP交互。Android设备上可以用tcpdump抓包导出pcap文件后在电脑上用Wireshark打开。RTSP的协商过程、RTP的丢包情况一目了然。这个手段帮我定位了好几个靠日志看不出来的问题比如发送端实际推的分辨率和协商的不一致、RTP时间戳跳变等等。如果你也在这条路上折腾强烈建议把抓包工具用起来。