恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Android录屏直播推流全解析:MediaProjection采集到RTMP发送
首页
资讯中心
/
Android录屏直播推流全解析:MediaProjection采集到RTMP发送
Android录屏直播推流全解析:MediaProjection采集到RTMP发送
发布时间:2026/10/14 19:34:26
简介面向中高级 Android 开发者的录屏直播实战方案用于解决在移动端完成屏幕画面与音频同步采集、编码并通过 RTMP 协议推送到流媒体服务器的完整链路问题资源围绕 Android 原生技术展开涵盖 MediaProjection 录屏、AudioRecord 录音、librtmp 推流、Nginx-RTMP 服务器配置及 FLV 流分析验证适合直播应用开发、音视频学习或工程实战参考。包体为 zip 压缩包共 1409 个文件大小 31.2MB文件类型以 dex、class、jar、java、c/cpp 等源码与编译中间产物为主同时包含 xml/json 配置、so 动态库、gradle 构建脚本、apk 测试包及 Nginx-RTMP 搭建 PDF 文档从目录到编译文件结构齐全可作为完整 Android 工程对照学习。另有 librtmp 动态库和 flvAnalyser 等辅助工具方便定位推流与封装问题目前已有 2603 人学习下载通过阅读可掌握屏幕录制权限申请、音频同步处理、RTMP 地址配置与带宽优化思路并借助资料中的服务器配置示例完成从本机推流到公网观看的全流程演练。1. 录屏直播推流为什么 Android 上 RTMP 至今仍是首选方案录屏直播是个高频需求手游直播、操作演示、课程录制、App 测试回放都要先把屏幕内容实时采下来推出去。在 Android 上做这件事最成熟的组合就是 MediaProjection 拿屏幕、MediaCodec 编码、RTMP 协议推流。这套链路在 CDN 接入、播放器兼容和首帧秒开上都有现成路径至今仍是「录屏 直播」方案的首选。本文从数据链路讲起直接落到录屏采集、H.264/AAC 编码、FLV 封装和 RTMP 推流的完整实现并把时间戳同步、关键帧、弱网这几个最折磨人的地方单独拆开讲。适合准备在 Android 端做录屏直播的开发者以及想把手游直播或操作演示功能集成进自家 App 的团队。2. 录屏采集与视频编码先把「屏幕 → H.264」这一段走通2.1 整条数据链路从屏幕像素到 RTMP 流的每一站在动手写代码之前先把全链路画清楚。一次完整的 Android 录屏直播屏幕内容从底层像素到远端播放器要经过这几个环节屏幕画面通过 MediaProjection 授权后的 VirtualDisplay 镜像到一块 Surface这块 Surface 不落地渲染而是直接作为 MediaCodec 视频编码器的输入编码器输出 H.264 码流与此同时麦克风经 AudioRecord 采集的 PCM 由第二个 MediaCodec 编码成 AAC最后一个 RTMP 客户端把 H.264 和 AAC 按 FLV 格式打包发送到 RTMP 服务器播放端从服务器拉流看到的就是你的屏幕。之所以用 MediaProjection VirtualDisplay Surface 这套组合而不是直接读帧缓冲区的像素数组核心原因是性能。Surface 输入模式下屏幕画面由 GPU 流转到编码器几乎不经过 CPU 拷贝而如果走 Bitmap 像素数组一帧 1080p 的 RGB 转 YUV 再喂给软件编码器光是格式转换和内存拷贝就能让 CPU 冲上高位发热随之而来录屏帧率还会对不上。所以判断一个录屏直播实现是否靠谱先看它是不是 Surface 链路。另一点是 RTMP 协议虽然老但 CDN 和流媒体服务器的兼容性最好WebRTC 转拉流、直播转点播都有现成路径这也是它至今没被替代的原因。2.2 MediaProjection 申请一次性授权与前台服务的坑MediaProjection 的授权是一次性的。用户点了同意系统会回调 resultCode 和 data应用需要用这份授权创建投影如果应用进程死了或者用户从系统的屏幕共享状态栏点掉「正在共享」投影立刻失效。所以启动代码要确保在 onActivityResult 里拿到授权后立刻交给一个持久化的采集服务去持有不要放在某个 Activity 的临时状态里。下面是一段最简申请流程。private static final int REQUEST_MEDIA_PROJECTION 1001; private MediaProjectionManager mProjectionManager; private void startProjection() { mProjectionManager (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); Intent captureIntent mProjectionManager.createScreenCaptureIntent(); startActivityForResult(captureIntent, REQUEST_MEDIA_PROJECTION); } Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode REQUEST_MEDIA_PROJECTION resultCode RESULT_OK) { // resultCode 和 data 是 MediaProjection 的唯一凭证必须立刻传给服务持有 MediaProjection projection mProjectionManager.getMediaProjection(resultCode, data); ScreenRecorder recorder new ScreenRecorder(projection); recorder.start(); // 内部会创建 VirtualDisplay 和编码器 } }这段代码里有几个必须注意的点。一是getMediaProjection必须在onActivityResult回调里同步调用不能把 resultCode 和 data 存起来过一会儿再用某些系统版本上延迟获取会直接返回 null。二是录屏推流几乎都是长时间后台运行场景应该把采集和推流逻辑放进前台服务并声明foregroundServiceTypemediaProjection否则在 Android 14 及之后的版本上录屏很难稳定持续超过几分钟。三是MediaProjection对象一旦创建整个推流生命周期内不要调用它的stop()或release()否则录屏会立即中断表现为画面定格后推流断开。2.3 VirtualDisplay 尺寸与编码器参数分辨率、码率、帧率的三角关系拿到 MediaProjection 之后下一步是创建 VirtualDisplay把屏幕镜像到编码器的输入 Surface。这里最需要决策的是分辨率。默认建议把目标分辨率定在 720p录屏推流大多数场景是操作演示和教程720p 配 2~3Mbps 码率在直播链路上画质和成本比较平衡1080p 对编码器的压力和弱网风险是成倍增长的不是线性增长。尺寸换算时注意不要直接拉伸到固定值要按原屏幕宽高比等比缩放且宽高都必须是偶数否则部分编码器会直接拒绝配置。private VirtualDisplay createVirtualDisplay(MediaProjection projection, Surface inputSurface) { WindowManager wm (WindowManager) getSystemService(WINDOW_SERVICE); DisplayMetrics metrics new DisplayMetrics(); wm.getDefaultDisplay().getRealMetrics(metrics); // 用 getRealMetrics 拿到真实屏 int srcW metrics.widthPixels; int srcH metrics.heightPixels; int dstW 720; int dstH dstW * srcH / srcW; // 等比缩放避免画面拉伸变形 if (dstH % 2 ! 0) dstH 1; // 编码器通常要求宽高为偶数否则 configure 报错 return projection.createVirtualDisplay( ScreenRecorderDisplay, dstW, dstH, metrics.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null); }VirtualDisplay 建好之后紧接着配置视频编码器。Surface 输入模式用createInputSurface创建输入 Surface编码器参数只有五个需要碰宽、高、码率、帧率、关键帧间隔。下面这段是经过多个真机验证过的配置方式。private MediaCodec createVideoEncoder(int width, int height, int bitrate, int fps, int iFrameInterval) { MediaFormat format new MediaFormat(); format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_AVC); format.setInteger(MediaFormat.KEY_WIDTH, width); format.setInteger(MediaFormat.KEY_HEIGHT, height); format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate); format.setInteger(MediaFormat.KEY_FRAME_RATE, fps); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval); // Surface 输入模式下编码器会自己选 color format不要设置 KEY_COLOR_FORMAT MediaCodec codec MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface inputSurface codec.createInputSurface(); // 这步要在 start 之前完成 codec.start(); return codec; }参数取值直接给一组参考720p 配 2.5Mbps、30fps、关键帧间隔 2 秒1080p 配 4~6Mbps、30fps、间隔 2 秒。KEY_I_FRAME_INTERVAL单位是秒表示编码器多久强制出一个关键帧这个值直接决定了播放端的起播速度后面第 4 章会展开。帧率方面纯讲解演示类内容用 25fps 即可录游戏内容建议 30fps 起。这里有一个容易被忽略的点Surface 模式下不要设置KEY_COLOR_FORMAT编码器会按内部默认的 color format 跑强行指定反而可能 configure 失败或输出格式异常。严格来说这带有一些「玄学」成分各家芯片的 H.264 编码器实现不同最稳妥的做法是让编码器自己选。2.4 编码输出循环关键帧标记与数据提取编码器启动之后需要起一个独立的循环从输出端取数据。这个循环是整条视频链路的出口要注意几点dequeueOutputBuffer返回的 index 在每次循环里必须释放INFO_OUTPUT_FORMAT_CHANGED出现的时机很重要SPS/PPS 只能在这个时候拿到presentationTimeUs是这一帧在编码器内部的时间基后面音视频同步也要对齐这一个时间逻辑。private void drainEncoder() { MediaCodec.BufferInfo info new MediaCodec.BufferInfo(); while (mCapturing) { int outIndex mVideoCodec.dequeueOutputBuffer(info, 10_000); if (outIndex MediaCodec.INFO_TRY_AGAIN_LATER) { continue; } else if (outIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // 编码器输出格式变化SPS/PPS 在这里取出并保存 MediaFormat newFormat mVideoCodec.getOutputFormat(); ByteBuffer sps newFormat.getByteBuffer(csd-0); ByteBuffer pps newFormat.getByteBuffer(csd-1); saveCodecConfig(sps, pps); } else { if (outIndex 0) { ByteBuffer data mVideoCodec.getOutputBuffer(outIndex); boolean isKeyFrame (info.flags MediaCodec.BUFFER_FLAG_KEY_FRAME) ! 0; // 编码器输出 Annex-B 格式需要转成 FLV 的 length-prefixed 格式 sendToRtmp(data, info.presentationTimeUs, isKeyFrame); mVideoCodec.releaseOutputBuffer(outIndex, false); } } } }这个循环里有三个容易出错的地方。第一编码器输出的码流是 Annex-B 格式也就是 NALU 之间用00 00 00 01起始码分隔而 RTMP/FLV 要求的是 AVCC 格式即每个 NALU 前用 4 字节大端长度代替起始码。转换规则不复杂但新手容易漏掉或者转错字节序结果就是播放端花屏。第二SPS/PPS 必须单独保存作为 FLV 里的 sequence header 发送不能混在第一个关键帧里一起发这一点会在第 4 章详述。第三isKeyFrame的判断依赖BUFFER_FLAG_KEY_FRAME标记不要在数据里靠猜测来找关键帧那个标记比数据内容可靠得多。3. 音频采集、AAC 编码与音视频时间戳对齐音频链路相对于视频来说简单一些但它的坑更隐蔽因为大部分人会把视频调到能出画面就跳过音频直到播放端不出声或者音画不同步才回头排查。这一章把音频采集参数、AAC 编码器和时间戳对齐一次讲清楚。3.1 AudioRecord 采集参数为什么 44.1kHz 单声道 16bit 是默认组合AudioRecord 是音频采集的入口。参数可以直接抄一套默认值采样率 44100Hz、单声道、PCM 16bit。单声道不是偷懒录屏直播场景的音频以语音为主双声道的码率开销翻倍但听感几乎没有提升44.1kHz 是兼容性最广的采样率AAC 编码器对这个采样率的支持也最稳定16bit 是通用的 PCM 位深24bit 在部分机型上虽然能采但 MediaCodec 的 AAC 编码器输入未必支持。下面是采集循环的完整写法。private static final int SAMPLE_RATE 44100; private static final int CHANNEL_MODE AudioFormat.CHANNEL_IN_MONO; private static final int PCM_FORMAT AudioFormat.ENCODING_PCM_16BIT; private void startAudioCapture() { int minBuf AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_MODE, PCM_FORMAT); // buffer 至少给 minBuf 的 2 倍给底层留余量避免采集抖动丢失数据 AudioRecord recorder new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, CHANNEL_MODE, PCM_FORMAT, minBuf * 2); byte[] pcm new byte[minBuf]; long frameCount 0; recorder.startRecording(); while (mCapturing) { int n recorder.read(pcm, 0, pcm.length); if (n 0) { // 时间戳从采样帧数换算不要用系统时钟这是音画同步的关键 long ptsUs frameCount * 1000000L / SAMPLE_RATE; sendAudioPcm(pcm, n, ptsUs); frameCount n / 2; // 16bit 单声道一帧 1 采样 2 字节 } } }read 是阻塞调用所以这条循环必须放在独立线程里并且要和视频编码线程并行运行。frameCount的累加要放在 read 成功之后否则阻塞期间的采样数会算错。另外录音权限RECORD_AUDIO要在启动采集前申请否则startRecording()不报错但读到的数据全是静音零字节。如果发现推流上去没有声音先检查是不是这个原因而不是先去翻编码器配置。还有一个机型适配问题少数设备的CHANNEL_IN_MONO采集会失败表现为 read 一直返回 0这种情况下可以退回CHANNEL_IN_STEREO采集虽然数据是双声道但 AAC 编码时强制设成单声道输出不会影响最终效果。3.2 AAC 编码器配置ADTS 头是个陷阱AAC 编码器的配置参数极少MIME 用audio/mp4a-latm。需要先厘清一个关键概念MediaCodec 的 AAC 编码器输出的是裸 AAC 帧不带 ADTS 头而 RTMP/FLV 里要求的是两种东西——一是 AudioSpecificConfig简称 ASC二是裸 AAC 帧。很多人把从 mp4 文件里读出来的 AAC带 ADTS 头直接丢给 FLV结果播放端有声音但声音发闷、花音或者直接解码错误。ADTS 和 ASC 的区别可以这样理解ASC 描述「这个流是什么编码、什么采样率、几个声道」整个流只发一次ADTS 是每一帧前面都带一个冗余的描述头文件播放器需要RTMP 不需要带上反而是污染。private MediaCodec createAudioEncoder() { MediaFormat format new MediaFormat(); format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_AAC); format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE); format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1); format.setInteger(MediaFormat.KEY_BIT_RATE, 64000); // 语音 48~64kbps 足够 MediaCodec codec MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC); codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); return codec; }AAC 码率取 64kbps针对语音讲解场景 48kbps 也够用追求音质的课程类内容可以到 96kbps再高就是浪费。音频编码器的输出循环和视频类似同样在INFO_OUTPUT_FORMAT_CHANGED时从输出格式里读csd-0这一段就是 AudioSpecificConfig要单独保存起来。之后每帧编码数据都直接封装发送不要再夹带任何配置信息。有些实现会试图从 ADTS 头里解析 ASC如果你的数据源是文件读出来的 AAC需要先剥掉 ADTS 头再喂给 FLV但用 MediaCodec 直接编码就没有这个问题。3.3 音视频时间戳对齐为什么录屏比普通直播更容易音画不同步这一节解决的是新手最容易崩溃的「音画不同步」。视频的 PTS 来自编码器输出的BufferInfo.presentationTimeUs音频的 PTS 是我们自己在 read 循环里按采样帧数换算出来的。两套时间轴本身是独立的但只要有同一个起点就能对齐。最常见的错误是用System.currentTimeMillis()或System.nanoTime()给音频帧打时间戳——这两个时钟和编码器内部时钟没有任何对齐关系短时间看不出来一旦系统休眠、编码器缓冲或者线程调度抖动误差就从毫秒级累积到秒级。正确做法上文已经写出音频 PTS 用采样帧计数换算公式是frameCount * 1_000_000 / sampleRate视频 PTS 直接用编码器给的presentationTimeUs。然后在推流端做一次统一基准记录首个视频帧和首个音频帧的 PTS每条流各自减去自己的起点再发送。这样两条时间轴都归零播放端才能按同一时间轴渲染。这一步看起来简单却是整个录屏直播里最容易被忽略的细节。还有一类特殊情况FLV tag 里对视频有 CompositionTime 字段用于表示 B 帧的显示偏移。录屏直播场景下编码器不启用 B 帧这个字段恒为 0直接填 0 就行。如果哪天为了画质开了 B 帧注意CompositionTime PTS - DTS很多播放器对这个字段算错之后画面会抖动甚至卡住。所以我的建议是录屏推流始终关掉 B 帧少一个变量就少一类问题。4. RTMP 推流实现握手、FLV Tag 打包与关键参数4.1 RTMP 协议的基本流程握手到 Publish 只做四件事RTMP 握手是固定的 1537 字节流程客户端发 C0/C1服务器回 S0/S1/S2客户端再回 C2之后 TCP 连接被 RTMP 层接管。后续就是协议命令交互connect 里带上 app 名createStream 创建一个流publish 开始发布之后所有音视频数据都按块流方式封装发送。实际上在 Android 端不需要手动实现握手这些工作都由 C 客户端库比如 librtmp做好了但你必须理解 URL 里每一段的含义因为 CDN 平台解析 app 和 playpath 的方式各不相同。一个典型的 RTMP URL 长这样rtmp://主机地址/app/流名其中 app 段通常对应服务器上的应用路径流名是推流时自定义的。很多平台会要求流名带签名参数比如rtmp://主机/app/stream_720p?tokenxxx这种情况下 app、stream 名和参数必须拼接正确。最典型的坑是 app 段配错服务器回的不是 connect failed而是正常握手但 publish 失败或连接被静默断开排查起来很费劲。所以拿到任何平台的推流地址第一件事是核对 app 和 stream 的划分而不是直接开推。4.2 客户端搭建路线librtmp 的 Java 封装还是纯 Java 重写在 Android 上做 RTMP 推流常见路线是二选一基于 librtmp 做 JNI 封装或者纯 Java 重写 RTMP 协议层。librtmp 是 C 写的成熟稳定、体积小握手、chunk 分块、ack 确认这些底层逻辑都已经实现且被大量生产环境验证过适合绝大多数场景纯 Java 重写灵活可控但握手、chunk 拆包、ack 窗口这些细节都要自己处理稍不留神就会跟某些 CDN 不兼容。我一般直接走 librtmp 路线把精力留给前两章的编码和同步问题这是整套方案里最不值得自己造轮子的部分。JNI 封装的最小接口设计如下。public class RtmpClient { static { System.loadLibrary(rtmp); // 对应编译好的 librtmp JNI 封装库 } public native long open(String url); // 建立连接并进入 Publish 状态 public native int sendVideoSeqHeader(long handle, byte[] sps, byte[] pps); public native int sendVideoFrame(long handle, byte[] frame, long ptsUs, boolean isKeyFrame); public native int sendAudioSeqHeader(long handle, byte[] asc); public native int sendAudioFrame(long handle, byte[] frame, long ptsUs); public native void close(long handle); }JNI 层的 C 代码核心就是调用这几件事RTMP_Connect建连RTMP_ConnectStream进入发布模式RTMP_SendPacket发送封装好的RTMPPacket。注意 JNI 里从byte[]到char*的拷贝不可避免但如果每帧都 new 一个大数组再拷贝GC 压力会很大。建议在 Java 侧用 ByteBuffer 复用池或者至少在发送线程里复用同一个 byte[] 缓冲避免频繁分配触发 GC 导致卡顿。另外一个重要细节librtmp 默认的 socket 行为并不适合实时流默认开了 Nagle 算法小包会被攒在一起发送延迟能到几百毫秒需要在连接成功后立刻关闭具体做法见 4.4 节。4.3 FLV Tag 封装两个 sequence header 与首帧秒开的关系RTMP 发送的每个数据单元在 FLV 封装里是一个 tag。tag 结构由 11 字节头加数据组成第 1 字节类型8 表示音频9 表示视频接下来 3 字节是数据长度3 字节时间戳加 1 字节扩展时间戳最后 3 字节 StreamID 固定填 0。视频 tag 的数据区前 5 字节是信息头后面才是真正的 NALU音频 tag 的数据区前 2 字节是信息头后面跟一个字节的 AACPacketType 和 AAC 数据。下面这段是拼接视频 tag 的示例重点在于每个字节的语义。private byte[] buildFlvVideoTag(byte[] nalData, long ptsUs, boolean isKeyFrame, boolean isSeqHeader) { // tag 数据长度 5 字节 flv 视频头 NALU 数据 int dataSize nalData.length 5; ByteBuffer buf ByteBuffer.allocate(11 dataSize); buf.put((byte) 9); // TagType: 视频 putInt24(buf, dataSize); putInt24(buf, (int) ptsUs 0xFFFFFF); // 时间戳低 24 位 buf.put((byte) ((ptsUs 24) 0x7F)); // 时间戳高 8 位作为扩展 putInt24(buf, 0); // StreamID固定填 0 // FrameType(1bit) CodecID(7bit)H.264 固定为 0x17关键帧或 0x27普通帧 buf.put(isKeyFrame ? (byte) 0x17 : (byte) 0x27); buf.put(isSeqHeader ? (byte) 0x00 : (byte) 0x01); // AVCPacketType: 0序列头, 1NALU putInt24(buf, 0); // CompositionTime无 B 帧填 0 buf.put(nalData); return buf.array(); } private void putInt24(ByteBuffer buf, int value) { buf.put((byte) ((value 16) 0xFF)); buf.put((byte) ((value 8) 0xFF)); buf.put((byte) (value 0xFF)); }这段代码里的putInt24是自定义的 24 位大端写入因为 FLV 的 tag header 里长度和时间戳都是 3 字节而 Java 的 ByteBuffer 原生不支持 3 字节操作只能自己拼。音频 tag 的结构类似第一字节固定为0xAFAAC、44kHz、16bit、单声道第二字节是 AACPacketType0 表示 AudioSpecificConfig1 表示裸 AAC 帧后面跟着真正的数据。整个推流过程中发送顺序必须严格保持先发视频 sequence header再发音频 sequence header然后才开始发正常的帧数据。这个顺序直接决定了播放端能否首帧秒开。如果你发现播放器要么黑屏、要么没有声音先检查这两个 header 有没有按顺序发出去。4.4 推流网络参数TCP_NODELAY、发送缓冲与丢帧策略网络层的参数是坑最少但影响最大的地方。librtmp 连接建立成功后应该立刻关闭 Nagle 算法并调大发送缓冲下面是 C 层的处理。// 在 RTMP 连接成功、开始发送音视频数据之前调用 int fd RTMP_Socket(rtmp); // 获取 librtmp 内部的 socket int one 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, one, sizeof(one)); int sndbuf 256 * 1024; setsockopt(fd, SOL_SOCKET, SO_SNDBUF, sndbuf, sizeof(sndbuf)); // 同时建议加 connect timeout避免 DNS 或对端无响应时长时间阻塞另一个关键设计是发送队列的水位控制。Java 侧的编码输出线程负责组装 tag网络线程负责把 tag 写入 RTMP socket。两个线程之间用有界队列连接队列满了不能无脑等——视频非关键帧可以丢音频绝对不能丢。我之前排查播放端频繁卡顿最后发现是本地队列里堆了几百帧视频实时性全耗在了本地排队上。丢帧策略就一条队列水位超过 80%优先丢视频 P 帧保留关键帧和音频帧。弱网下音频优先的体验远好于视频保真这也是移动直播的通用做法。5. 避坑与排查录屏直播里最容易翻车的 5 个问题5.1 推流黑屏服务器正常连接播放器什么都看不到现象RTMP 已经成功 publish服务端能看到流在推但播放器打开后黑屏没有任何画面。原因最常见的是视频 sequence header 没发或者发送顺序不对。很多人把 SPS/PPS 直接拼进了第一个关键帧的数据里而不是单独作为 AVCPacketType0 的 tag 先发出去。播放器在 FLV 流里等不到解码配置信息就一直处于等待状态表现就是黑屏。解决在INFO_OUTPUT_FORMAT_CHANGED回调里把csd-0、csd-1保存下来推流开始时第一帧先发视频 sequence header然后发音频 sequence header之后才能发帧数据。如果确认顺序没问题还黑屏检查 SPS/PPS 长度是否被截断FLV 要求的是不带起始码的裸 SPS/PPS不能把00 00 00 01开头也一起发进去。5.2 音画不同步声音正常画面慢或快几秒现象推流后画面和声音对不上有时画面快有时声音快误差持续累积。原因音频时间戳和视频时间戳用了两套时钟源。音频如果用了System.currentTimeMillis()打点视频时间戳来自编码器内部时钟两套时钟的起点和速率都有细微差异时间一长累积成秒级偏差。解决音频 PTS 一律用采样帧计数换算frameCount * 1_000_000 / SAMPLE_RATE不要用系统时钟。推流端同时做一个统一基准把首帧视频和首帧音频的 PTS 都归零后续帧各自减去自己的起点。这样两条时间轴从同一个零点开始播放端渲染才不会偏。注意修改后测试要推流超过 10 分钟短时间的音画同步看不出真实效果。5.3 静止画面卡顿屏幕上没内容时播放端卡几秒现象录屏内容长时间不动比如讲解 PPT 翻页停顿播放端画面会卡住直到下一次画面变化才恢复。原因VirtualDisplay 只在屏幕内容变化时产生新帧静态画面几乎不输出帧编码器输入频率掉到接近 0关键帧迟迟不出现。播放端一直在等关键帧才能渲染中间只能干等。解决在视频编码线程加一个看门狗超过 800ms 没有收到新帧时主动调用videoCodec.setParameters(Bundle)请求一个关键帧。具体做法是构造一个包含KEY_REQUEST_SYNC_FRAME的 Bundle 传给编码器强制它立即出关键帧。这让静态画面场景下的播放端也能保持秒级起播和恢复是录屏直播区别于摄像头直播的一个必修优化。5.4 长时间推流发热闪退CPU 飙高、内存持续上涨现象推流 5~10 分钟后设备明显发热CPU 占用降不下来偶发闪退。原因通常是编码器输出 buffer 没释放或者释放不及时。dequeueOutputBuffer返回的 index 在异常分支被跳过releaseOutputBuffer没有被调用底层 buffer 池被耗尽编码器阻塞甚至崩溃。另一个常见原因是把编码器输出的 ByteBuffer 拷贝成 byte[] 再传给 JNI每帧多一次全量拷贝GC 压力成倍增加。解决确认循环里所有分支都会走到releaseOutputBuffer包括outIndex 0的跳过分支。数据传递尽量用 ByteBuffer 直接传给 JNI 层不要在中间 new 大数组。推流 30 分钟观察内存曲线如果只涨不降优先检查是否有 ByteBuffer 泄漏。5.5 RTMP 连接不稳定connect failed 与重连失败现象推流过程中偶发 connect failed按重连逻辑重试依然失败或者要等很久才连上。原因librtmp 默认的连接超时控制不足目标服务器不可达时会长时间阻塞。另外很多重连实现犯的错误是复用旧连接没有先 close 再 open。解决连接前设置connectTimeout和socketTimeout超时控制在 3~5 秒。重连逻辑必须是「先 close 旧句柄再重新 open 新连接」不能复用同一个 RTMP 实例。重连成功后立刻请求一个关键帧让播放端能快速恢复画面否则要等下一个 GOP 到达才能看到内容可能黑屏好几秒。6. 进阶优化与验证从能推到能商用还差这三步6.1 用 requestKeyFrame 做秒级起播和弱网恢复把关键帧请求封装成一个可随时调用的方法在三个时机主动触发推流刚开始时、弱网重连成功后、看门狗发现超过 1.5 秒没有新帧时。调用方式是videoCodec.setParameters(syncBundle)其中 syncBundle 里放MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME。这一步不需要重新配置编码器也不会中断码流但能显著改善播放端的起播速度和卡顿恢复速度。6.2 动态码率根据发送队列水位调整编码器码率网络波动时最怕的是码率不降导致 socket 缓冲堆积。可以在推流线程统计发送队列水位水位连续 2 秒超过 80%通过setParameters把KEY_BIT_RATE调低一档比如 2.5Mbps 降到 1.5Mbps水位回到 40% 以下再恢复。调整频率控制在 3~5 秒一次不要频繁抖动。注意不能重新configure来改码率那会打断编码过程轻则丢帧重则直接黑屏。6.3 本地压测与线上问题定位上线前的验证流程建议分两步走。第一步推流到本地起的 RTMP 服务端用播放器观察画面是否正常、音频是否出声、音画是否同步第二步再切到正式 CDN 环境连续推流 30 分钟以上观察发热、掉帧和断线重连。排查问题时重点抓三个时间点RTMP 握手完成时间、FLV 序列头到达时间、首帧画面出现时间。这三个时间点依次正常基本就可以判断问题出在编码参数或网络而不是协议实现。这套方案里最容易花时间的永远是 FLV 封装和时间戳同步这两块理顺了录屏直播就成功了一半。如果你正在做类似的功能按第 3 章和第 4 章的顺序排查先确认纯视频能出画面再加音频别奢望一次到位。希望帮到你。本文还有配套的精品资源点击获取