简介安卓设备投屏功能演示工程面向需要实现屏幕实时共享的移动端开发者。它使用媒体投影接口捕获屏幕画面经媒体编解码器完成H.264格式的硬编码再通过套接字将视频流传送到接收设备完整覆盖投屏链路中的关键环节。压缩包共1536个文件大小仅4.87MB包含近千个flat资源、两百余个xml配置、两百余个json数据、三十余个java源码以及png图片、gradle构建脚本、密钥库文件等类型丰富、目录清晰。该项目已有3262人学习可帮助理解安卓多媒体开发与网络传输原理。示例从权限申请、屏幕帧捕获到编码分包与远端解码给出了可运行的全套路线并提供H.264码流中NAL单元解析、网络延迟与丢包处理的思路。读者可直接运行观察实际投屏效果也可在此基础上扩展音视频同步、优化传输效率。1. Android设备投屏功能Demo别被 H264 和 MediaCodec 两座山吓住做模拟项目X的时候我第一次正经接 Android设备投屏功能Demo心里想的无非是「录屏加转发能有多难」。结果被 MediaProjection、VirtualDisplay、MediaCodec、H264 裸流这几个词轮番教育了一个星期。屏幕采集只是第一关真正决定一个投屏 Demo 能不能跑通的是编码参数设置和解码端的配合。这篇文章把投屏链路拆成采集、编码、传输、解码四段每段都给出能直接落地的代码片段和参数起点再把最容易翻车的五个坑单独拉出来讲透。适合手里只有一台 Android 真机、想快速跑通画面的开发者熟手可以直接跳到第 5 章看避坑记录或者到第 6 章拿量化验证技巧。2. 投屏链路拆解与H264选型为什么硬编码是默认答案2.1 投屏全链路从屏幕像素到远端画面要过四道管道投屏是一个典型的多段管道应用。从 Android 设备的屏幕像素到接收端那块 SurfaceView中间要穿过四段采集、编码、传输、解码显示。每一段都有自己的坑但整体链路并不神秘把它拆成四段之后定位问题时会清晰很多。第一段是采集。Android 官方从 5.0 开始把屏幕采集能力收敛到 MediaProjection API 上。应用先向用户弹窗请求采集授权拿到授权后通过 createVirtualDisplay 创建一个虚拟显示把屏幕内容镜像到一个 Surface 上。这里的 Surface 是一个生产消费模型中的生产者端口屏幕合成器在后台把每一帧画面推到这个 Surface 上由消费方决定是直接渲染还是交给编码器。第二段是编码。Surface 本身不产生网络可用的字节流我们要把每一帧图像压缩成 H264AVC码流。Android 里压缩编码的标准接口是 MediaCodec底层由硬件厂商的编解码单元实现所以通常叫硬编码。编码器的输入有两种方式一种是普通 ByteBuffer每次往里面填 YUV 帧另一种是直接传 Surface省掉一次 YUV 数据拷贝这是投屏 Demo 里最省事的做法。我一般都用 Surface 输入理由很简单省掉一帧 1080p 的 YUV 拷贝大约能省 3 到 5 毫秒的 CPU 时间这在延迟敏感的场景里非常有价值。第三段是传输。编码器输出的是 H264 Annex-B 裸流由一个一个 NAL unit 组成。Annex-B 格式的特点是每个 NAL 前面都跟着起始码 0x00000001 或 0x000001解码器靠起始码切分 NAL。Demo 阶段最朴素的传输方案是拿一个 TCP Socket 把这些字节按顺序发给接收端。更完整的方案是包装成 RTSP 流或者走 WebSocket但那会引入额外的协议解析成本后面会单独对比。第四段是解码显示。接收端拿到 H264 裸流之后用另一个 MediaCodec解码模式把数据喂进去解码器输出的帧渲染到 SurfaceView 或 TextureView 上。发送端和接收端其实共用同一套 MediaCodec 知识只要把编码端的接口理解透解码端只是把方向反过来。这条链路里有一个经常被忽略的关键认知瓶颈往往不在某一段内部而在段与段之间的衔接。采集端掉帧编码器就会空闲编码器码率设置不合理传输带宽就爆掉传输丢包解码端就花屏解码端缓冲区堆积延迟就一路飙升。所以下面每一章都不只讲代码还会把参数边界和判断依据讲清楚这一点比贴代码更重要。2.2 采集端方案选型MediaProjection 是 Demo 阶段唯一合理的入口提到采集屏幕很多人第一反应是直接用 MediaRecorder 录屏。确实MediaRecorder 是最省事的录制方案但它有两个硬伤第一它把采集和编码封装成一个黑盒你拿不到中间的 H264 裸流只能拿到一个本地文件没法做实时传输第二它的输出目标被设计成 mp4 文件或一个固定的文件描述符不适合做低延迟流式传输。如果你只是想录一段屏幕视频MediaRecorder 没问题但投屏 Demo 需要的是可传输的裸流所以 MediaRecorder 直接排除。另一个看起来有吸引力的做法是直接读 SurfaceFlinger 的帧缓冲或者通过 root 权限抓帧。底层确实能拿到原始画面但普通应用没有这个权限而且即使拿到了帧缓冲的格式通常是不透明的厂商私有格式还需要自己做转换。维护成本高兼容性差不适合 Demo 阶段。我一般给出的结论是普通应用做投屏 DemoMediaProjection 是唯一合理的入口。理由有三个。第一它是官方支持的屏幕采集 API覆盖 Android 5.0 及以上的绝大多数设备不需要系统签名不需要 root。第二它通过 VirtualDisplay 输出 Surface这个 Surface 可以直接传给 MediaCodec 的编码输入中间没有 YUV 拷贝延迟可控。第三它的授权流程是标准化的应用弹窗、用户确认、回调返回结果后续代码几乎不需要为厂商做特判。需要提醒的是MediaProjection 的授权是“一次性”的。用户授予权限之后如果应用进程被杀或者投屏会话被系统回收你需要重新走一遍申请流程。最常见的坑是把 MediaProjection 对象存在 Activity 的成员变量里Activity 一重建就丢失导致画面黑屏。正确做法是把 MediaProjection 放到一个前台 Service 中持有或者至少保证 Activity 重建时能重新获取实例。在 Demo 阶段你可以在 Manifest 里声明一个普通 Service然后把投影对象跨组件传过去后面编码循环也放在同一个 Service 的线程里跑避免 Activity 生命周期干扰编码线程。2.3 编码参数起步表码率、帧率、GOP、profile 和 B 帧边界H264 编码参数看起来很多但在投屏场景里真正需要关心的只有五个码率、帧率、GOPI 帧间隔、profile/level以及 B 帧开关。下面的表是反复调过的参数起点适合一台 1080p 真机、局域网传输、30fps 的场景。参数起步值说明分辨率与屏幕一致1080p 机型直接用 1920x1080码率4-8 Mbps1080p 用 4-8Mbps720p 用 2-4Mbps帧率30 fps动态画面多可提到 60fpsI 帧间隔1-2 秒越小则花屏恢复越快但码率开销越大ProfileBaseline兼容性最好解码端最容易认Level3.1对应 1080p30 的解码能力B 帧建议关闭降低延迟避免显示顺序重排为什么用 Baseline 而不是 High Profile因为解码端也是 MediaCodecBaseline 的兼容性最好尤其当接收端是低端机或者模拟器时High Profile 使用的 CABAC 熵编码在某些硬件解码器上会直接罢工。1080p 码率拉到 8Mbps 在局域网里并不夸张WLAN 的实际吞吐量远不止这个数但码率设置过高会让编码器在画面剧烈变化时产生大量瞬时 I 帧反而拉高延迟和处理器负载。GOP 间隔的设置要结合传输协议来看。如果走 TCP 裸流I 帧间隔设 2 秒就够因为 TCP 保证有序不丢包花屏概率低。如果走 UDPI 帧间隔建议缩短到 1 秒否则一个关键数据包丢了接收端可能好几秒都恢复不了画面。这个选择会直接影响第 4 章的传输方案。还有一个经常被忽略的参数是 KEY_COLOR_FORMAT。在 Surface 输入模式下它必须设为 COLOR_FormatSurface告诉编码器输入不是 ByteBuffer 而是 Surface。如果漏掉编码器会直接抛异常或者创建的输入 Surface 画出来是黑屏。关于 B 帧我一般会额外加一行 setInteger(max-bframes, 0) 尝试关闭 B 帧。B 帧压缩率更高但会带来两个副作用编码顺序和显示顺序不一致要求接收端做重排同时它会把一个帧的编码时间分摊到前后几帧上提高端到端延迟。投屏对延迟比对压缩率敏感所以优先关 B 帧。部分厂商的编码器不支持这个 key设置会静默失效此时可以接受默认行为后面排坑再看。3. 采集与编码端落地把屏幕变成 H264 裸流的核心代码3.1 权限申请与 VirtualDisplay 创建三个必须校验的结果代码部分先从权限申请开始。MediaProjection 的授权入口是 MediaProjectionManager流程很简单创建 intent → startActivityForResult → 在 onActivityResult 里拿结果。真正的坑在拿到 resultCode 和 data 之后。private static final int REQUEST_CODE_CAPTURE 1001; private MediaProjectionManager projManager; private MediaProjection mediaProjection; private VirtualDisplay virtualDisplay; private MediaCodec encoder; private Surface encoderSurface; // 在 Activity 初始化时 projManager (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); Intent captureIntent projManager.createScreenCaptureIntent(); startActivityForResult(captureIntent, REQUEST_CODE_CAPTURE); Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode REQUEST_CODE_CAPTURE) { if (resultCode ! RESULT_OK || data null) { // 用户拒绝授权或系统回收了授权必须重新申请 return; } // 关键点data 里面带有投影会话的 token mediaProjection projManager.getMediaProjection(resultCode, data); startEncodingPipeline(); } }这段代码里onActivityResult 的 resultCode 和 data 是一个配对的整体两者缺一不可。resultCode 不是 RESULT_OK 时直接返回data 为 null 时也要返回。很多黑屏问题就出在只判断了 resultCode没有判断 data 为空的情况。创建好 MediaProjection 之后紧接着创建 VirtualDisplay。VirtualDisplay 的参数里最容易出错的是宽高、DPI 和 Surface 三者的匹配。private void startEncodingPipeline() { // 建议从 WindowManager 读取真实屏幕尺寸不要硬编码 WindowManager wm (WindowManager) getSystemService(WINDOW_SERVICE); int width wm.getDefaultDisplay().getWidth(); int height wm.getDefaultDisplay().getHeight(); int dpi getResources().getDisplayMetrics().densityDpi; // 编码器要先启动并创建输入 Surface initEncoder(width, height); encoderSurface encoder.createInputSurface(); virtualDisplay mediaProjection.createVirtualDisplay( ProjectXScreenCast, width, height, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, encoderSurface, null, null ); }VirtualDisplay 的宽高如果和编码器的输入分辨率不一致某些机型会直接黑屏或者画面被裁切。我的习惯是让它们完全一致分辨率都从 WindowManager 读取不要写死 1920x1080因为很多真机的屏幕是 2400x1080 或者 2340x1080写死会导致画面变形。DPI 参数决定了虚拟显示的逻辑密度一般取系统 densityDpi如果设置过小画面上的文字会显示得特别大影响视觉一致性。VirtualDisplay 的最后一个参数是回调 HandlerDemo 阶段传 null 即可。注意 createVirtualDisplay 有一个重载可以传 IntentSender 接收系统截图请求这里用不到。注意VirtualDisplay 对象必须在不使用时调用 release()否则会泄漏系统资源多次启动后系统可能拒绝创建新的虚拟显示。投屏结束流程里release() 的优先级高于 MediaCodec 的 stop()。3.2 MediaCodec 编码器配置从 MediaFormat 到 Surface 输入的完整写法编码器配置是整条链路里最需要抠参数的部分。下面是一份经过多次调整可用的编码器初始化代码。private void initEncoder(int width, int height) { MediaFormat format MediaFormat.createVideoFormat( MediaFormat.MIMETYPE_VIDEO_AVC, width, height); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); format.setInteger(MediaFormat.KEY_BIT_RATE, 6_000_000); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline); format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31); // 尝试关闭 B 帧降低延迟 try { format.setInteger(max-bframes, 0); } catch (Exception ignored) { // 部分机型不支持这个 key忽略即可 } try { encoder MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); } catch (Exception e) { // 硬件编码器不可用时回退到软件编码器 encoder MediaCodec.createByCodecName(c2.android.avc.encoder); } encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); encoderSurface encoder.createInputSurface(); encoder.start(); }参数含义逐个说明。KEY_BIT_RATE 是编码器的平均码率单位是 bit/s。6Mbps 对应一般 1080p 投屏场景的均衡值静态画面多可以降到 4Mbps游戏画面多建议提到 8Mbps。KEY_FRAME_RATE 设 30实际帧率会由编码器根据 Surface 输入自行调整不会严格固定所以不用把它当成硬限制。KEY_I_FRAME_INTERVAL 单位是秒1 表示每秒强制插入一个 IDR 关键帧。插得越频繁花屏恢复越快但码率开销也越大。KEY_PROFILE 和 KEY_LEVEL 不是所有编码器都认部分机型设置后 configure 会抛异常稳妥做法是先不设置跑通了再加。软件编码器的回退要单独说。createEncoderByType 在大多数真机上返回硬件编码器但在模拟器或没有硬件编码单元的设备上会抛异常。代码里用 try-catch 捕获后通过 createByCodecName(c2.android.avc.encoder) 回退到系统自带的软件编码器。这个 codec name 在 Android 10 之后是标准名老版本上可能是 OMX.google.h264.encoder你可以在循环里遍历 MediaCodecList 找到可用的名称。3.3 编码输出回调与 SPS/PPS 抓取解码端能不能认的关键编码器启动之后数据通过 dequeueOutputBuffer 循环取出来。这个循环通常放在后台线程里不断从编码器取已经编码好的 H264 数据再交给传输模块。private MediaCodec.BufferInfo bufferInfo new MediaCodec.BufferInfo(); private byte[] spsData; private byte[] ppsData; private void drainEncoder() { while (true) { int outIndex encoder.dequeueOutputBuffer(bufferInfo, 10_000); if (outIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // 编码器输出格式确定在这里拿 csd-0/csd-1 MediaFormat newFormat encoder.getOutputFormat(); ByteBuffer sps newFormat.getByteBuffer(csd-0); ByteBuffer pps newFormat.getByteBuffer(csd-1); spsData new byte[sps.remaining()]; ppsData new byte[pps.remaining()]; sps.get(spsData); pps.get(ppsData); } else if (outIndex 0) { ByteBuffer encodedData encoder.getOutputBuffer(outIndex); byte[] frame new byte[bufferInfo.size]; encodedData.get(frame); handleEncodedFrame(frame, bufferInfo); encoder.releaseOutputBuffer(outIndex, false); } else if (outIndex MediaCodec.INFO_TRY_AGAIN_LATER) { // 没有新数据休眠一会儿再继续 Thread.sleep(2); } } }这段代码里有两个关键判断。第一INFO_OUTPUT_FORMAT_CHANGED 必须在数据回调之前处理。它携带了编码器的目标格式信息里面包含 csd-0SPS和 csd-1PPS两个 ByteBuffer。如果漏掉这个事件或者没有把 SPS/PPS 保存下来接收端拿到裸流也没法初始化解码器。我在代码里把两个字段单独存成 byte[]后续发送时优先发出。第二dequeueOutputBuffer 的超时参数单位是微秒10_000 微秒就是 10 毫秒。这个值太小会忙轮询太大则让关键帧延迟明显10 毫秒是编码线程比较均衡的选择。如果返回 TRY_AGAIN_LATER说明当前没有新数据休眠 2 毫秒继续循环避免空转。关于 BufferInfo.flags 也要留意。BUFFER_FLAG_CODEC_CONFIG 表示当前 buffer 是 SPS/PPS 等配置数据BUFFER_FLAG_KEY_FRAME 表示当前是 IDR 关键帧。传输时可以给关键帧打一个特殊标记接收端收到关键帧后如果还没出画立即刷新解码器。这个标记在后面第 4 章会用到。frame 数组里可能包含一个完整的帧也可能只包含一帧的一部分。因为 H264 的 NAL 切割位置并不一定在帧边界你需要靠起始码去切 NAL或者干脆把一整个 buffer 的数据当作一帧传出去接收端再自行切。Demo 阶段我选择后者代码简单代价是多传几个字节的边界信息完全可接受。4. 传输与解码回显局域网从裸流到画面的落地路径4.1 传输选型裸流、RTSP、WebSocket 在 Demo 阶段怎么选编码器输出的 H264 裸流要到达接收端传输层有三条路可以走。先把三种方案放在一张表格里对比。方案实现难度延迟可靠性适用阶段TCP 裸流最低较低可靠Demo 首选UDP 裸流低最低会丢包进阶调优RTSP 封装中中可靠且标准接入现成播放器WebSocket中中可靠跨平台调试先说裸流。所谓裸流就是直接把 H264 的 NAL 字节打包成自定义协议发出去接收端按同样的自定义协议解包。TCP 裸流实现最简单发送端把每一帧前面加上 4 字节的帧长度接收端先读长度再读数据。UDP 裸流则需要自己处理丢包和乱序I 帧间隔必须缩短到 1 秒以内否则丢包后很难恢复。RTSP 是标准流媒体协议好处是接收端可以是现成的播放器软件但坏处是 RTSP 依赖 RTP 打包编码器输出的裸流要先做 RTP 分包还要处理时间戳和序列号代码量直接多出几百行。WebSocket 适合跨平台调试尤其当接收端是浏览器时但浏览器对 H264 的支持要看具体平台移动端的 WebView 不一定内置 H264 解码器。我的建议是Demo 阶段用 TCP 裸流把注意力集中在编码器和解码器的配合上不要一开始就把协议栈做复杂。等画面能稳定出来之后再考虑要不要切换到 UDP 或者 RTSP。提示不要在初版里同时引入 NAL 切分协议、RTP 打包、RTSP 信令否则你排错时根本分不清是编码问题还是协议问题。先把「编码 → 解码」这一条主线打通后面再谈协议增强。4.2 解码端搭建用 MediaCodec 把 H264 裸流还原成画面接收端的解码器和编码器的配置非常相似只是模式变成了 MEDIA_CODEC_DECODER输出目标变成 SurfaceView 的 Surface。private void initDecoder(Surface surface, int width, int height) { MediaFormat format MediaFormat.createVideoFormat( MediaFormat.MIMETYPE_VIDEO_AVC, width, height); // 解码器的输出颜色格式由系统决定不需要手动指定 try { decoder MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); } catch (Exception e) { decoder MediaCodec.createByCodecName(c2.android.avc.decoder); } decoder.configure(format, surface, null, 0); decoder.start(); }解码端有一个比编码端更重要的操作喂数据之前必须先喂 SPS/PPS。前面 3.3 节说编码器会在 INFO_OUTPUT_FORMAT_CHANGED 事件里暴露 csd-0 和 csd-1这两个数据必须由发送端在建立连接后第一时间传给接收端接收端通过 queueInputBuffer 把它们作为前两个输入 buffer 喂给解码器。喂数据的代码看起来这样private void feedDecoder(byte[] nalu, int flags) { int inIndex decoder.dequeueInputBuffer(10_000); if (inIndex 0) { ByteBuffer inBuffer decoder.getInputBuffer(inIndex); inBuffer.clear(); inBuffer.put(nalu); long ptsUs System.nanoTime() / 1000; // 单调递增的 PTS decoder.queueInputBuffer(inIndex, 0, nalu.length, ptsUs, flags); } }这里的关键参数是 PTS。解码器不要求 PTS 严格连续但必须单调递增否则一些硬件解码器会拒绝出帧或者输出乱序。我一般直接拿 System.nanoTime() 换算成微秒赋给每一帧简单有效。如果你从编码器那边直接转发 presentationTimeUs接收端也要注意保持单调不要因为网络重排破坏顺序。配置好 SPS/PPS 之后后续的每个 IDR 帧和 P 帧都可以直接喂。解码器内部会自动参考前面的帧进行解码只要传输不丢包画面就能持续刷新。解码端的出帧循环和编码端类似用 dequeueOutputBuffer 取出解码后的帧然后交给 Surface 渲染。这里有一个容易误解的地方解码器输出帧调用 releaseOutputBuffer(outIndex, true) 时第二个参数 render 为 true 表示立即渲染到 Surfacefalse 表示丢弃。投屏场景必须传 true否则画面会一直停留在上一帧看起来像是卡死了。4.3 延迟和花屏的现场排查发送端和接收端怎么对日志画面能出来之后延迟多少、有没有掉帧不能靠肉眼猜。这里讲一个我常用的排查套路在发送端和接收端分别打带时间戳的日志对齐到同一台机器的时间基准。具体做法是这样。发送端在每次编码器输出一帧时记录当前 SystemClock.elapsedRealtime() 作为发送时间戳把它编码进帧头部或者独立的一条日志里。接收端收到同一帧时记录接收时间戳。两个时间戳的差值减去网络单向延迟就是编码和解码的总耗时。由于两端的系统时间可能有偏差更准确的方法是让接收端回送一个 ACK测量往返时间取半作为单向延迟参考。在 Demo 阶段我一般直接用 SystemClock.elapsedRealtime() 打日志格式类似这样long sendTime SystemClock.elapsedRealtime(); // 发送帧 Log.d(CastSend, frameSeq seq , size size , sendTime sendTime , flag flags);接收端收到时打一条long recvTime SystemClock.elapsedRealtime(); Log.d(CastRecv, frameSeq seq , recvTime recvTime);然后用 logcat 把两端日志同时抓到本地用 seq 号把两条日志拼起来计算差值。如果差值在 80 毫秒以内说明链路健康超过 200 毫秒就要检查是不是解码端缓冲区堆积了。延迟这东西有时候看起来像玄学但本质上就是缓冲区堆积的数学题帧进来得太快消费得又太慢队列就会像滚雪球一样越滚越长。花屏的排查思路则要区分位置。如果花屏出现在画面局部、几秒后自愈大概率是 UDP 丢包导致参考帧缺失如果花屏后完全无法恢复需要检查 IDR 帧间隔是不是过长。走 TCP 时花屏基本只可能来自两个位置一个是初始 SPS/PPS 没喂对一个是编码器在关键帧前输出了解码器无法参考的数据。遇到后一种情况我一般直接在编码端强制每 30 帧输出一个 IDR同时把关键帧大小信息打印出来核对。5. Android 投屏避坑实录五个高频问题的排查笔记这一章是在多个设备上实测踩过的坑按「现象 → 原因 → 解决」的格式写方便直接对照。5.1 现象授权弹窗之后画面一直黑屏现象用户点击允许投屏授权VirtualDisplay 创建成功编码器也启动了但接收端屏幕一直黑的或者本地预览画面全黑。原因最常见的有两个。第一VirtualDisplay 的尺寸和编码器输入分辨率不一致导致 Surface 里的内容没有被正确消费。第二MediaCodec 的 Surface 输入模式配置漏了 COLOR_FormatSurface编码器没能从 Surface 拿到帧数据。解决首先把 VirtualDisplay 的宽高和 MediaFormat 的宽高改为同一来源都从 WindowManager 读取并打印日志确认相等。其次检查 MediaFormat 里 KEY_COLOR_FORMAT必须显式设置为 COLOR_FormatSurface不要省这一行。如果两个都确认了还是黑屏把 VirtualDisplay 的 DPI 打印出来调小或调大试试。还有一种少见情况是某些定制 ROM 在开启系统录屏或无障碍功能后会抢占 MediaProjection 的采集会话这时只有一个办法重启应用重新申请授权。5.2 现象解码端提示无 SPS/PPS或者出画面之前卡很久现象接收端拿到裸流后前几秒一直黑屏突然出画面或者直接报错提示找不到 SPS/PPS。原因发送端没有在连接建立后第一时间发送 SPS/PPS。H264 解码器是一个状态机必须先接收并解析 SPS/PPS才能正确初始化。如果发送端把这两个配置帧漏掉了或者把它们混在普通帧里没有打 CODEC_CONFIG 标记解码器就认不出来。解决在接收端建立连接后先发送一段握手数据发送端把编码器 INFO_OUTPUT_FORMAT_CHANGED 事件里拿到的 csd-0 和 csd-1 打包发给接收端。接收端收到后立即通过 queueInputBuffer 喂给解码器确保在第一个 IDR 帧之前完成配置。另外我还会把 SPS/PPS 缓存到内存里在每次发送 IDR 帧之前重发一次这样就算中途丢包或者连接重建解码器也能快速恢复。5.3 现象画面花屏、绿屏或者大面积马赛克现象画面能出来但局部区域一直花或者整个屏幕变绿过几秒偶尔恢复一下。原因花屏的本质是解码器参考帧缺失或错误。走 UDP 时丢一个数据包就会导致这一帧及后续参考它的帧全部解错直到遇到下一个 IDR 关键帧。走 TCP 时花屏大概率不是网络问题而是发送端把非关键帧数据错误地切分或者 SPS/PPS 重复发送导致解码器状态被重置。解决先确认传输协议。UDP 场景直接把 I 帧间隔缩短到 1 秒以内同时实现简单的丢包重传至少要对关键帧做重传。TCP 场景检查帧切分逻辑确保每一层协议的大小编码正确不要出现半帧数据。还有一个容易忽略的点某些编码器在低码率下会主动丢弃 P 帧的二次残差数据导致解码端在运动剧烈的画面里出现类似花屏的纹理错乱。遇到这种情况把码率上调 30%花屏通常立刻消失。5.4 现象延迟越来越高最终画面卡死现象刚启动时延迟正常运行一两分钟后延迟明显拉长最后画面完全卡住只有日志还在输出。原因这是典型的消费速率低于生产速率问题。接收端的解码线程处理速度跟不上dequeueInputBuffer 的超时时间又设得很短于是输入队列不断堆积帧全部在排队看起来就是延迟越来越大直到卡死。发送端的编码器输出队列也可能出现同样的堆积。解决接收端在 feedDecoder 之前做一个丢弃策略如果当前队列里的帧数超过一个阈值就先丢一帧。具体做法是记录 dequeueInputBuffer 返回的时间和上次成功喂帧的时间差如果间隔超过 80 毫秒就跳过当前数据不喂。投屏是直播不是回放丢掉旧帧永远比延迟播放旧帧更符合体验预期。发送端同理如果 dequeueOutputBuffer 已经多次返回 TRY_AGAIN_LATER说明编码器处理不过来这时要把帧率从 30 降到 24或者降低分辨率。5.5 现象编码器崩溃或者码率不受控制现象程序跑着跑着编码器线程抛出 MediaCodec.CodecException或者日志显示码率忽高忽低和设置值完全对不上。原因MediaCodec 硬件编码器在部分厂商机型上对参数极为挑剔。常见的触发点是设置了不支持的 profile/level或者 KEY_BIT_RATE 与编码器支持的范围不匹配也有一些机型在编码过程中遇到 Surface 尺寸突变比如横竖屏切换直接崩溃。解决所有参数设置都要加 try-catch 并打印日志configure 失败时不要直接崩溃回退到软件编码器再试一次。横竖屏切换时先把编码器 stop 再重新 configure不要在运行中修改 MediaFormat。码率不受控通常和码率控制模式有关KEY_BIT_RATE_MODE 可以尝试设置为 BITRATE_MODE_VBR相对温和如果该机型 VBR 表现太差换回 CBR。我在模拟项目X里最终用的策略是启动阶段 CBR 保证稳定性运行稳定后切换到 VBR 节省带宽。6. 进阶验证用帧间隔和 PTS 量化你的投屏质量6.1 帧间隔统计判断卡顿来自编码还是传输画面流畅不流畅不能只靠人眼。我一般会在发送端加一个帧间隔统计直接看编码器输出是否均匀。private long lastPtsUs -1; private long lastWallTimeMs SystemClock.elapsedRealtime(); // 在编码器输出的每个 buffer 处理逻辑里 long currentPtsUs bufferInfo.presentationTimeUs; if (lastPtsUs 0) { long ptsGapMs (currentPtsUs - lastPtsUs) / 1000; long wallGapMs SystemClock.elapsedRealtime() - lastWallTimeMs; if (ptsGapMs 100 || wallGapMs 100) { Log.w(CastStat, gap large, ptsGapMs ptsGapMs , wallGapMs wallGapMs); } } lastPtsUs currentPtsUs; lastWallTimeMs SystemClock.elapsedRealtime();一段一段说。PTS 是编码器内部的时间戳理论上严格等于帧间隔的整数倍。当 ptsGapMs 超过 100 毫秒说明编码器这边已经丢帧或停顿了问题大概率在采集端或系统负载。wallGapMs 是真实墙上时钟的变化如果它远大于 ptsGapMs说明线程调度有问题编码线程被其他任务抢占了 CPU。6.2 端到端延迟粗测两块屏幕对拍最直接的验证方法是用两台手机。发送端开一个秒表 App把屏幕投到接收端接收端再用另一台手机拍下两块屏幕的对比照片读取两个秒表读数的差值。这个方法测出来的是整体延迟包含了采集、编码、传输、解码、渲染的全部耗时。我一般接受 120 毫秒以内的结果超过 200 毫秒就要回头调参。如果手边没有两台手机还有一个更省事的办法在发送端日志里记录发送帧的 elapsedRealtime在接收端渲染该帧时记录接收端的 elapsedRealtime。只要两端时间大致同步差值就能反映端到端延迟。时间不同步也没关系可以只关注差值的波动趋势波动异常往往比绝对值更能暴露问题。从那以后我每次拿到一个新的投屏 Demo第一件事不是看界面而是先跑一遍帧间隔日志和丢帧统计把「流畅」这个主观词变成一个可以对比的数字。如果这两个指标过了再谈画质和码率的细节。希望这能帮到你。本文还有配套的精品资源点击获取