Android录屏直播推流:MediaProjection采集、MediaCodec编码与RTMP推送完整方案
简介:面向安卓开发者的录屏直播完整实现方案,围绕屏幕采集、录音、RTMP推流及流媒体服务器配置展开,覆盖权限申请、编码参数设定、推流地址配置、服务器搭建等全流程,帮助从零搭建可运行的屏幕直播功能。压缩包共1409个文件,约31.2MB,包含可直接编译的安卓工程源码、编译产物、构建脚本、配置文件,以及服务器搭建指南和流媒体分析工具,既能直接安装体验,也可对照源码剖析推流协议、封装格式和音视频同步原理,遇到卡顿或延迟问题时还可借助分析工具定位编码或传输环节。已有2593人学习浏览,适合正在开发录屏直播功能、需要参考真实工程结构和推流实现的安卓工程师,也可作为学习多媒体采集与流媒体传输的进阶案例,无论是快速集成还是深入原理均有较高参考价值。
Android录屏直播推流:MediaProjection采集、MediaCodec编码、RTMP推送的完整方案
做直播开发这几年,我最大的体感是:录屏直播的需求越来越多——游戏攻略录制、App使用演示、线上教学、甚至故障排查,都需要把手机屏幕实时分享出去。而在Android端,从采集屏幕到推流上行的完整链路,RTMP协议依然是目前兼容性最好的选择,没有之一。
这篇文章不是贴几个大而全的第三方SDK,而是基于Android原生API手把手拆一套可用的录屏直播方案:MediaProjection做屏幕采集,AudioRecord抓取麦克风,MediaCodec分别做H.264视频编码和AAC音频编码,最后走librtmp把封装成FLV的流推到RTMP服务器。无论你是刚开始接触音视频开发,还是已经在做推流但没理清同步逻辑,这篇都值得从头读一遍。文章里的坑,都是我实实在在踩过的。
1. 为什么录屏直播绕不开RTMP协议
先说结论:如果只是自己做着玩,HLS或WebRTC也能看;但要兼顾"低延迟、高兼容、易分发",RTMP依然是Android推流端的首选协议。
1.1 RTMP协议的核心特点
RTMP全称Real Time Messaging Protocol,基于TCP长连接,本质上是一条持续的二进制流管道。它把音视频数据拆成消息(Message),再按块(Chunk)传输,服务端收到后可以直接转发给播放端或转封装为HLS/FLV。
它最大的优势有两个:一是 延迟可控 ,端到端通常能压在1到3秒内;二是 生态成熟 ,nginx-rtmp、SRS、Red5等开源服务器都能直接支持,播放端用VLC、ffplay、网页播放器都能拉流。对于录屏直播这种"屏幕内容变化剧烈、码率波动大"的场景,RTMP基于TCP的可靠性反而比UDP更有优势,至少不会因为丢包出现花屏和马赛克。
1.2 为什么不建议直接裸写RTMP协议
RTMP的握手和Chunk编码规则并不复杂,但纯手写协议栈要处理乱序、分块、消息聚合、时间戳回绕等细节,开发周期会拉得很长。我见过不少团队想"从零实现一个RTMP推流器",最后都被各种诡异的粘包问题折磨到放弃。
更务实的做法是直接集成开源的librtmp库——它是RTMP规范的标准实现,在Android上可以通过JNI调用。如果你不想碰JNI,也可以搜一些纯Java的RTMP实现(比如RtmpClient库),虽然性能略逊,但胜在维护简单。下面我讲的推流部分,默认基于librtmp封装。
提示:选库之前先确认License。librtmp是LGPL协议,商业化产品要注意开源合规问题。如果公司有法务要求,可以考虑自己基于RTMP规范写一版精简实现,原理和我后面讲的FLV封装完全一样。
1.3 准备一个RTMP测试服务器
接入推流代码之前,先在本地或云服务器上搭一个RTMP接收端。以nginx-rtmp为例,在Linux服务器上:
# 安装nginx和nginx-rtmp-module,然后配置:
rtmp {
server {
listen 1935;
chunk_size 4096;
application live {
live on;
record off;
}
}
}
启动后,推流地址就是 rtmp://你的服务器IP/live/test ,播放端可以用VLC打开同一个地址验证。这一步建议提前做,因为后面所有调试都依赖它。
2. 录屏与音频采集:MediaProjection的边界与陷阱
采集是整个推流链路的源头,这里出问题,后面编码和推流全都白搭。Android屏幕录制核心API是 MediaProjectionManager ,音频采集则用 AudioRecord 。
2.1 MediaProjection录屏的完整流程
录屏授权必须先通过 MediaProjectionManager 发起系统弹窗,让用户确认"允许录制屏幕":
// 1. 获取系统服务
val mpManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
// 2. 发起授权弹窗(必须在Activity中调用)
startActivityForResult(mpManager.createScreenCaptureIntent(), CAPTURE_REQUEST_CODE)
// 3. 在onActivityResult中拿到resultCode和data后创建MediaProjection
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == CAPTURE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
mediaProjection = mpManager.getMediaProjection(resultCode, data!!)
startCapture()
}
}
拿到 MediaProjection 实例后,核心操作是创建 VirtualDisplay ,把屏幕内容渲染到 MediaCodec 的输入Surface上:
// 创建编码器输入Surface(下一节详细说)
val inputSurface = videoEncoder.createInputSurface()
// 创建虚拟屏幕显示
virtualDisplay = mediaProjection.createVirtualDisplay(
"ScreenCapture",
width, // 录屏宽度,建议与屏幕分辨率一致
height, // 录屏高度
dpi, // 像素密度,用 resources.displayMetrics.densityDpi
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
inputSurface, // 直接把画面送到编码器
null,
null
)
2.2 授权弹窗的几个坑
这里我踩过一个很隐蔽的坑: Android 10(API 29)以后,MediaProjection授权是"一次一用"的 。也就是说,授权成功后创建的 MediaProjection 如果释放了( stop() ),下一次必须重新弹窗授权,不能复用旧的resultCode和data。很多开发者把授权数据存起来想下次直接使用,结果在Android 12以上的设备上直接崩溃或黑屏。
另外一个坑是 Android 14(API 34)更严格了 ,系统只允许创建有限的 MediaProjection 实例,超出会抛异常。所以务必管理好生命周期,在 onDestroy 里释放:
override fun onDestroy() {
super.onDestroy()
virtualDisplay?.release()
mediaProjection?.stop()
}
2.3 音频采集:AudioRecord还是MediaRecord?
录屏直播除了画面还得有声音。有两种声音: 麦克风声音 和 系统内部声音 。
麦克风采集用 AudioRecord 最直接:
private fun createAudioRecord(): AudioRecord {
val sampleRate = 44100 // 采样率
val channelConfig = AudioFormat.CHANNEL_IN_MONO // 单声道
val audioFormat = AudioFormat.ENCODING_PCM_16BIT // 16位PCM
val minBufferSize = AudioRecord.getMinBufferSize(
sampleRate, channelConfig, audioFormat
)
return AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
maxOf(minBufferSize * 2, 2048) // 缓冲区设大一点,减少欠载
)
}
系统内部声音则是Android 10推出的 AudioPlaybackCapture ,需要在 AudioRecord 构建参数里设置允许捕获的播放器应用UID:
val builder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.REMOTE_SUBMIX) // 部分设备可用
// 或者
.setAudioPlaybackCaptureConfig(
AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
实测提醒: 系统内录音频在不同品牌手机上兼容性差异极大 ,很多定制ROM会直接静音。如果做商业化产品,建议做成可开关的选项,优先保证麦克风采集这条路是通的。
3. MediaCodec硬编码器的正确打开方式
采集到屏幕画面和PCM音频,下一步就是编码。Android提供 MediaCodec 硬编码,能直接调用GPU/DSP编码单元,性能和发热都优于软编码。
3.1 视频编码:H.264 + Surface输入
视频编码器配置是整条链路最讲参数的地方,每个参数都会影响直播质量:
val format = MediaFormat.createVideoFormat("video/avc", width, height)
format.setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_ColorFormatSurface
)
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) // 2~4Mbps
format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate) // 30fps
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // 关键帧间隔(秒)
format.setInteger(MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
COLOR_ColorFormatSurface 是重点。创建Surface输入编码器之后,MediaProjection的VirtualDisplay会把屏幕画面直接渲染到这个Surface上,MediaCodec自动完成颜色格式转换和编码。这一步省掉了最麻烦的YUV操作——如果不用Surface输入,你得自己读像素缓冲再转格式,性能和代码量都是灾难。
关键帧间隔设为1秒,即每1秒输出一个IDR关键帧。这个值直接影响观众端的首屏时间:RTMP播放器必须拿到关键帧才能开始解码显示,间隔太长会导致用户打开直播间半天黑屏。
3.2 音频编码:AAC + PCM输入
AudioRecord采出来的PCM裸数据,因为太大不能直接推到服务器,需要用MediaCodec编码成AAC:
val format = MediaFormat.createAudioFormat("audio/mp4a-latm", 44100, 1)
format.setInteger(MediaFormat.KEY_BIT_RATE, 64 * 1024)
format.setInteger(MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC)
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 4096 * 10)
AAC音频编码器每次输入的数据量要严格按帧大小走。采样率44100Hz、单声道、PCM 16bit,一帧AAC对应的采样点数是1024个,所以每次喂给编码器的字节数是:
1024(采样点数) × 2(每样本字节数) × 1(声道数) = 2048字节
注意:如果你用MediaCodec的
dequeueInputBuffer+queueInputBuffer方式输入,必须严格按2048字节对齐,否则部分编码器会丢帧或报错。这也是很多人音画不同步的根源之一。
3.3 编码输出:异步回调更稳妥
MediaCodec从API 21开始支持异步回调模式,相比老式的 dequeueOutputBuffer 轮询,代码更清晰也更不容易漏帧:
videoEncoder.setCallback(object : MediaCodec.Callback() {
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
val buffer = codec.getOutputBuffer(index)
// 这里拿到的是编码后的H.264数据,需要判断关键帧/非关键帧
// 然后封装成FLV格式推送到RTMP服务器
handleEncodedVideoData(buffer, info)
codec.releaseOutputBuffer(index, false)
}
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
// Surface输入模式下此回调无意义,可忽略
}
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
// 编码异常:常见于设备不支持当前分辨率/码率组合
}
})
编码器输出的H.264数据里,第一个关键帧通常会带 SPS/PPS (序列参数集/图像参数集),这些信息必须提前发给播放器,否则解码器不认识码流。在MediaCodec里通过 csd-0 和 csd-1 这两个 ByteBuffer 拿到:
val csd0 = format.getByteBuffer("csd-0") // SPS
val csd1 = format.getByteBuffer("csd-1") // PPS
拿到后要封装成AVCDecoderConfigurationRecord,放在FLV的 onMetaData 之后。具体写法在下一节FLV封装中一起带出来。
4. 把数据塞进RTMP:librtmp集成与FLV封装
编码器输出的是裸的H.264和AAC,但RTMP传输的不是裸流,而是 FLV封装格式 。你得先把编码数据按FLV规范打上tag头,再通过librtmp的 RTMP_Write 发送。
4.1 librtmp的初始化和连接
集成librtmp最简单的方式是编译成静态库,通过JNI调用。核心流程:
#include <librtmp/rtmp.h>
RTMP* rtmp = RTMP_Alloc();
RTMP_Init(rtmp);
// 设置推流URL
RTMP_SetupURL(rtmp, "rtmp://your-server/live/test");
// 明确当前角色是推流端
RTMP_EnableWrite(rtmp);
// 建立连接
RTMP_Connect(rtmp, NULL);
RTMP_ConnectStream(rtmp, 0);
连接建立后,先发送一个包含 onMetaData 的FLV脚本tag,声明视频编码格式、音频编码格式、分辨率、码率等元信息。然后立刻发送SPS/PPS,保证播放器能初始化解码器:
// 注意:librtmp每次只发送完整的FLV tag或裸tag数据。
// 常见的做法是自行拼好FLV tag头 + 数据,再调用RTMP_Write。
这里有个实践经验: 不要把FLV的9字节文件头(FLV Header)发出去 。RTMP协议本身有消息头(Message Header),librtmp会自动生成,如果再加FLV文件头会导致播放器解析出错。我最初实现时就是直接把这9字节发出去了,结果VLC能播、但网页Flash播放器直接黑屏,排查了半天。
4.2 FLV Tag的封装逻辑
每个FLV Tag由11字节Tag头 + 数据区 + 4字节PreviousTagSize组成:
| 字段 | 长度 | 说明 |
|---|---|---|
| TagType | 1字节 | 8=音频,9=视频,18=脚本 |
| DataSize | 3字节 | 数据区长度 |
| Timestamp | 3字节+1字节扩展 | 时间戳(毫秒) |
| StreamID | 3字节 | 通常为0 |
| Data | 不定长 | 音视频数据 |
| PreviousTagSize | 4字节 | 前一个Tag的总长度 |
签名因为DataSize是3字节大端整数,写代码时要自己做位运算:
uint8_t tag_header[11] = {0};
tag_header[0] = tag_type; // 0x08 = audio, 0x09 = video, 0x12 = script
tag_header[1] = (data_size >> 16) & 0xFF;
tag_header[2] = (data_size >> 8) & 0xFF;
tag_header[3] = data_size & 0xFF;
// timestamp: 4字节,低3字节时间戳,最高字节是扩展位
tag_header[4] = (timestamp >> 16) & 0xFF;
tag_header[5] = (timestamp >> 8) & 0xFF;
tag_header[6] = timestamp & 0xFF;
tag_header[7] = (timestamp >> 24) & 0xFF;
// StreamID
tag_header[8] = 0;
tag_header[9] = 0;
tag_header[10] = 0;
4.3 视频Tag和音频Tag的Data区格式
视频Tag的Data区第一个字节是帧类型+编码格式标识:
// 第1字节:帧类型(高4位) + 编码格式(低4位)
// 1=关键帧, 2=普通帧;7=AVC
uint8_t video_header = (frameType << 4) | 0x07;
关键帧之后紧跟着AVCPacketType(0=AVC序列头,1=AVC NALU),再接4字节的CompositionTime(CTS,用于B帧补偿,Android硬编码器一般输出没有B帧,这里填0):
// AVC序列头(SPS/PPS)
uint8_t avc_packet_type = 0x00;
uint8_t composition_time[3] = {0, 0, 0};
// 然后是4字节AVCDecoderConfigurationRecord长度 + AVCDecoderConfigurationRecord
// 之后才能是真正的NALU数据
// AVC NALU(真正的视频帧)
uint8_t avc_packet_type = 0x01;
uint8_t composition_time[3] = {0, 0, 0};
// 然后接4字节NALU长度 + H.264裸数据
音频Tag的Data区第一字节更讲究:
// 高4位:音频格式 10=AAC
// 第3、4位:采样率 3=44kHz
// 第2位:位深 0=16bit
// 第1位:声道 0=单声道 1=双声道
// 组合结果:0xAF 表示 AAC + 44kHz + 16bit + 单声道
uint8_t audio_header = 0xAF;
然后是AACPacketType(0=AAC序列头,1=AAC原始数据)。序列头时发送AudioSpecificConfig(之前从codec的csd-0中提取),原始帧时发送AAC数据进行。
4.4 时间戳:同步的关键
所有Tag的时间戳单位是 毫秒 ,而MediaCodec输出的 BufferInfo.presentationTimeUs 单位是 微秒 ,推流前必须除以1000。这里不能偷懒,我曾经图省事直接传微秒值,结果播放器进度条直接飞起来。
更重要的是,既然视频帧和音频帧都是同一个运行时钟采集的(录屏起点和AudioRecord起点几乎同时),我们只需要各自用编码器输出的 presentationTimeUs / 1000 作为时间戳即可。RTMP服务端和播放器会基于这个时间戳做音画对齐,不需要额外做复杂的同步算法。
5. 直播实测中的延迟调优:从3秒压到1秒
我在自己的测试环境(nginx-rtmp + VLC播放)上第一次跑通推流时,端到端延迟大概在3到4秒,这个数值对连麦、互动直播来说体验很差。通过下面四板斧,成功压到了1秒左右。
5.1 关键帧间隔与GOP
前面提到关键帧间隔设为1秒,这是一切延迟优化的大前提。播放器要等关键帧才能出画面,如果GOP(两个关键帧之间的距离)是2秒,最坏情况观众要黑屏2秒。直播场景下,1秒GOP是比较均衡的选择。
5.2 关闭VBR,改用CBR
很多直播平台要求恒定码率(CBR),因为VBR会导致码率波动,尤其在录屏场景下(画面静止时码率极低,画面剧烈变化时码率飙升),在弱网环境下容易引发拥塞:
format.setInteger(MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
同时配合码率控制:我习惯把初始码率设为2Mbps,并在采集线程每2秒统计一次真实编码码率,如果低于设定值的80%,就通过 Bundle 参数 KEY_VIDEO_BITRATE 动态调高。
5.3 降低采集到编码的中间缓冲
VirtualDisplay直接把画面送入Surface输入编码器,理论上延迟极低(通常几十毫秒)。真正的延迟来源往往是 推流端的发送缓冲区 。
librtmp默认的发送缓冲区大小是 SO_RCVBUF/SO_SNDBUF ,系统默认可能高达几十KB甚至几百KB。我建议把它调小:
int buf_size = 1024 * 64; // 64KB
setsockopt(rtmp->m_sb.sb_socket, SOL_SOCKET, SO_SNDBUF,
(const char*)&buf_size, sizeof(buf_size));
同时,当网络发送阻塞导致本地缓冲堆积时,果断 丢弃非关键帧 。这里用一个简单的策略:
// 如果当前缓冲队列中有超过200ms的数据(按时间戳计算),
// 只发送关键帧,丢弃普通帧。
// 200ms的阈值是个经验值,太小容易频繁丢帧造成卡顿,太大延迟压不住。
5.4 播放器端的缓冲参数
最后提醒一句:延迟是端到端的,推流端压到1秒,如果播放器缓冲设了3秒,观众看到的还是3秒延迟。VLC里把 网络缓存 调到300ms, jitter buffer 关闭,才能看到真实的推流端效果。自测时建议用 ffplay -fflags nobuffer -i rtmp://... ,这是最能反映真实延迟的播放方式。
6. 录屏直播上线前必须踩平的坑
最后这部分,全是血泪教训。有些问题当场很难查,一旦出现在线上就是事故,提前说给你防身。
6.1 后台切换与生命周期
录屏直播最怕用户切后台或者锁屏。Android系统会在某些设备上杀死后台的VirtualDisplay,导致直播画面定格。
我的处理方案是 启动一个前台服务 ( MediaProjection 从API 29开始强制要求配合前台服务使用),并在 onPause 时检测屏幕关闭状态,屏幕熄灭超过30秒就主动结束直播,而不是维持一个假流。玩家看完视频切回游戏时,重新走一遍"授权-采集-推流"的流程,虽然要多一步授权,但至少不会让观众看黑屏。
6.2 缺少SPS/PPS导致播放黑屏
这是所有坑里最隐蔽的。部分芯片的编码器(比如某些低端联发科)在推流过程中会 重新输出一次SPS/PPS ,或者在关键帧前一小段才带出SPS/PPS。如果你的封装逻辑只在推流开始发送一次SPS/PPS,之后播放器解码失败就会黑屏。
稳妥的做法是:维护一个 isKeyFrame 标志,每当从 onOutputBufferAvailable 中解析到帧类型是关键帧时,检查是否携带SPS/PPS,携带则在FLV视频Tag前紧跟一个AVC序列头Tag。保证播放器随时能拿到解码参数。
6.3 AudioRecord缓冲区欠载
麦克风采集对实时性要求高,但Android系统调度偶尔会卡顿。如果 AudioRecord.read() 返回值小于请求长度,说明缓冲区空转了,这一帧的时长不够。
我的做法是 把读取循环和编码循环解耦 :采集线程只负责把PCM数据写入一个无锁环形队列,编码线程从队列中按 2048字节 为单位取出喂给MediaCodec。这样即使采集偶发卡顿,只要有累积的数据,编码线程依然能按节奏运转,不会因为一帧丢失导致整条AAC编码链崩坏。
6.4 不同分辨率机型的适配策略
录屏分辨率不建议直接取屏幕的物理分辨率(很多手机是1080x2400甚至更高),因为分辨率越高,编码压力和带宽消耗就越大。常见的做法是 把长边缩放到720或1080 :
val maxLongSide = 1280 // 或 1920,看你对画质的要求
val scale = min(1.0f, maxLongSide * 1.0f / max(width, height))
val encodeWidth = (width * scale).toInt() and 0xFFF8 // 16对齐
val encodeHeight = (height * scale).toInt() and 0xFFF8
分辨率必须是16的整数倍,否则部分硬编解码器无法工作。这里用 and 0xFFF8 手动对齐,比 /16*16 更高效。实际编码分辨率定好之后,VirtualDisplay、MediaFormat和FLV的onMetaData里都要保持一致。
6.5 麦克风权限与音频焦点
录屏直播时用户很可能正在播放游戏背景音乐。如果你的App向 AudioRecord 写入时没有申请 AudioFocus ,系统可能直接静音麦克风采集,或者音乐声和麦克风声音串扰。
直播间场景建议在开始推流时申请 AudioManager.AUDIOFOCUS_GAIN ,并且用麦克风采集+系统内录音频混流的方案(Android 10以上)。混流可以自己做一个简单的加法混合器,注意PCM 16bit混流后的溢出保护,否则爆音会很刺耳:
// 最简单的混流:两个PCM样本相加后除以2,防止溢出
short mixed = (short)((sample1 + sample2) / 2);
这个除以2会让整体音量降6dB,但胜在稳定。想要更好效果,可以做饱和截断:相加后超出Short.MAX_VALUE就取上限,低于Short.MIN_VALUE就取下限,音量不会衰减。
7. 我对这套方案的实际评价
坦白说,用原生API搭这套RTMP录屏直播链路,工作量不小,但收益也实在——你完全掌控了每一帧数据从哪里来、经过什么处理、到哪里去,出了问题自己就能定位,而不是对着黑盒SDK干着急。
我个人的建议是:如果项目周期赶,可以直接用成熟的开源推流SDK;但如果你的目标是长期做音视频产品,花一两个星期把这条链路亲手打通,绝对是值得的。因为你会发现,后面不管是加美颜、加滤镜、加自定义编码参数,还是做连麦、做低延迟优化,底层逻辑都是这套东西。
最后再分享一个调优小技巧:推流前先花半小时在局域网内用 tcpdump 抓包,看RTMP的握手上行包和FLV序列头是否正常发出。这一步能排除掉80%的"推流端看着在推,播放端就是不出画面"的问题。工具虽土,但排查效率极高。
更多推荐

所有评论(0)