
简介面向安卓开发者的录屏直播完整实现方案围绕屏幕采集、录音、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 10API 29以后MediaProjection授权是一次一用的。也就是说授权成功后创建的MediaProjection如果释放了stop()下一次必须重新弹窗授权不能复用旧的resultCode和data。很多开发者把授权数据存起来想下次直接使用结果在Android 12以上的设备上直接崩溃或黑屏。另外一个坑是Android 14API 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构建参数里设置允许捕获的播放器应用UIDval 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编码成AACval 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的dequeueInputBufferqueueInputBuffer方式输入必须严格按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. 把数据塞进RTMPlibrtmp集成与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 Headerlibrtmp会自动生成如果再加FLV文件头会导致播放器解析出错。我最初实现时就是直接把这9字节发出去了结果VLC能播、但网页Flash播放器直接黑屏排查了半天。4.2 FLV Tag的封装逻辑每个FLV Tag由11字节Tag头 数据区 4字节PreviousTagSize组成字段长度说明TagType1字节8音频9视频18脚本DataSize3字节数据区长度Timestamp3字节1字节扩展时间戳毫秒StreamID3字节通常为0Data不定长音视频数据PreviousTagSize4字节前一个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普通帧7AVC uint8_t video_header (frameType 4) | 0x07;关键帧之后紧跟着AVCPacketType0AVC序列头1AVC NALU再接4字节的CompositionTimeCTS用于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位音频格式 10AAC // 第3、4位采样率 344kHz // 第2位位深 016bit // 第1位声道 0单声道 1双声道 // 组合结果0xAF 表示 AAC 44kHz 16bit 单声道 uint8_t audio_header 0xAF;然后是AACPacketType0AAC序列头1AAC原始数据。序列头时发送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里把网络缓存调到300msjitter 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或1080val 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%的推流端看着在推播放端就是不出画面的问题。工具虽土但排查效率极高。本文还有配套的精品资源点击获取