news 2026/10/14 19:34:26

Android录屏直播推流全解析:MediaProjection采集到RTMP发送

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android录屏直播推流全解析:MediaProjection采集到RTMP发送

简介:面向中高级 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。二是录屏推流几乎都是长时间后台运行场景,应该把采集和推流逻辑放进前台服务,并声明foregroundServiceType="mediaProjection",否则在 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?token=xxx,这种情况下 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=序列头, 1=NALU 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 的结构类似,第一字节固定为0xAF(AAC、44kHz、16bit、单声道),第二字节是 AACPacketType,0 表示 AudioSpecificConfig,1 表示裸 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 直接拼进了第一个关键帧的数据里,而不是单独作为 AVCPacketType=0 的 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 章的顺序排查,先确认纯视频能出画面,再加音频,别奢望一次到位。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 19:34:20

plugins插件加载失败:根因排查与系统设计指南

先说个实话&#xff1a;plugins 这个单词看起来简单&#xff0c;但几乎每个和工程打过交道的人都在它身上浪费过至少一下午。要么是 IAR 里装了个插件没反应&#xff0c;要么是构建工具启动时直接抛 failed to load plugins web boot&#xff0c;要么是测试 harness 组件加载失…

作者头像 李华
网站建设 2026/10/14 19:34:19

box-shadow 属性值详解

语法 box-shadow: offset-x offset-y blur-radius spread-radius color inset;只有 offset-x 和 offset-y 是必填的&#xff0c;其余参数可选&#xff0c;且顺序不能乱。 参数说明参数是否必填说明offset-x必填水平偏移量&#xff0c;正值向右&#xff0c;负值向左offset-y必填…

作者头像 李华
网站建设 2026/10/14 19:34:02

OpenShell:一套配置统一管理 zsh/bash/fish 终端环境

1. 项目背景与整体设计思路1.1 为什么会有 OpenShell 这个项目先说说我自己的痛点。做开发这些年&#xff0c;我在本机和服务器上来回切换&#xff0c;用的是不同的 shell&#xff1a;本地 macOS 用 zsh&#xff0c;Ubuntu 服务器上是 bash&#xff0c;偶尔还要在 Windows 的 G…

作者头像 李华
网站建设 2026/10/14 19:34:01

Python港股实时行情API接入:恒生股票数据获取与AH股溢价监控实战

做跨境量化或者港股通策略的开发者&#xff0c;接港股数据是绕不开的。港股跟A股有个很特别的联动关系——同一家公司在A股和港股两地上市&#xff0c;价格经常不一样&#xff0c;这就是所谓的AH股溢价。这个价差不仅是金融从业者关心的套利机会&#xff0c;也是技术开发者做跨…

作者头像 李华
网站建设 2026/10/14 19:33:40

MiniCPM-V 4.7 正式适配英特尔® 硬件,OpenVINO™ 首发同步支持

紧凑多模态模型正让端侧视觉与语言 AI 变得愈发实用&#xff0c;而英特尔 致力于让开发者能够即刻上手使用最新模型。英特尔今日宣布&#xff0c;对 MiniCPM-V 4.7 提供上线首日支持。该模型是 ModelBest 与清华大学推出的高效多模态模型系列最新版本&#xff0c;包含两个版本&…

作者头像 李华
网站建设 2026/10/14 19:32:30

华为突围韬定律:任正非重断道德经背后的生存哲学

当外部技术封锁成为常态&#xff0c;企业该如何寻找生路&#xff1f;近期&#xff0c;华为公司董事、半导体业务部总裁何庭波发表署名论文&#xff0c;披露了旨在突破摩尔定律极限的“韬&#xff08;τ&#xff09;定律”及芯片路线图。华为创始人任正非在转发相关专访时&#…

作者头像 李华