简介:本资源是一个面向Android应用开发者的技术实践Demo,聚焦于移动端屏幕镜像(Mirroring/Casting)功能的完整实现,适用于具备Java/Kotlin基础与Android SDK开发经验的中高级工程师,解决在智能终端间实时共享屏幕内容的核心技术难点。压缩包共2000个文件,主体为1588个编译后class字节码、472个UI资源png图、451个布局与配置xml、127个JSON配置及14个核心java源文件,辅以jar依赖、aidl接口定义(如MediaSessionCompat.aidl、PlaybackStateCompat.aidl等)和调试用ap_资源包,整体33.55MB,结构体现典型MediaProjection+VirtualDisplay+MediaCodec编码链路。已有7812人学习下载,开发者可直接复用ScreenCaptureService架构、权限申请流程、MediaProjection回调处理逻辑及音视频同步传输雏形,快速构建自有投屏App或集成至现有项目。
1. Android端投屏demo:不是“把手机画面发到电视上”那么简单,而是搞懂“谁在推、谁在拉、帧怎么不卡、音频怎么不同步”的最小闭环
很多人第一次搜“Android端投屏demo”,心里想的是:“找个开源项目,改两行IP,连上电视就能播”。结果跑起来要么黑屏、要么延迟3秒、要么声音断成电报、要么一锁屏就断——这根本不是demo没配好,是连投屏的协议分层逻辑都没对齐。这个标题里的“Android端投屏demo”,本质是构建一个可控、可调试、可定位瓶颈的端到端链路:从Android设备采集屏幕/应用画面(Source),经编码压缩(Encoder),按标准协议封装(RTP/RTSP/HTTP-FLV等),再被接收端(Sink,如PC模拟器、自研接收器、或标准DLNA Renderer)解码渲染。它不依赖厂商预装服务(如Miracast系统级支持),也不绑定特定硬件(如带Wi-Fi Direct的芯片),而是用纯Java/Kotlin + NDK能力,在Android 8.0+主流机型上跑通“采集→编码→传输→接收→解码→显示”六步中最易出问题的前三步。适合正在做远程协作App、教育类白板工具、IoT设备调试面板,或需要把Android界面嵌入Windows/Linux桌面系统的开发者。如果你的诉求是“快速验证投屏可行性”“排查线上用户反馈的花屏/卡顿”“为后续加入美颜/标注/低延迟优化打底”,那这个demo就是你该亲手搭的第一块砖。
2. 用MediaProjection + MediaCodec在本地跑通最小投屏链路:从截屏到H.264裸流的5行核心代码
投屏不是“录屏+发文件”,而是实时流式传输。Android原生提供两套关键能力:MediaProjection负责无root截取屏幕(含系统UI和应用窗口),MediaCodec负责硬编码(省电、低延迟)。很多demo失败,第一步就卡在权限申请或Surface配置错误。下面这段是真正能跑通的最小闭环,不依赖任何第三方库,只用Android SDK。
2.1 申请MediaProjection权限并获取VirtualDisplay
// Java部分:Activity中触发权限请求 private void startProjection() { if (mMediaProjection == null) { Intent captureIntent = mMediaProjectionManager.createScreenCaptureIntent(); startActivityForResult(captureIntent, REQUEST_CODE_CAPTURE_PERMISSION); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_CODE_CAPTURE_PERMISSION) { if (resultCode == RESULT_OK && data != null) { mMediaProjection = mMediaProjectionManager.getMediaProjection(resultCode, data); // 关键:创建VirtualDisplay,尺寸必须与目标分辨率一致,否则编码器初始化失败 mVirtualDisplay = mMediaProjection.createVirtualDisplay( "screen_capture", 1280, 720, // 宽高:建议固定为720p,避免动态适配引发编码器重置 DisplayMetrics.DENSITY_DEVICE_STABLE, mSurface, // 编码器输入Surface,见2.2节 DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR ); } } }逻辑说明:
createVirtualDisplay本质是创建一个“虚拟显示器”,所有屏幕内容被实时绘制到mSurface上。这个Surface必须由MediaCodec.createInputSurface()生成,不能是SurfaceView.getHolder().getSurface()——后者是显示用Surface,无法作为编码输入。
参数注意:density必须用DENSITY_DEVICE_STABLE(约等于160dpi),若传DENSITY_HIGH等常量,某些机型会报IllegalStateException: Invalid display density;宽高建议固定(如1280×720),动态取Display.getSize()在折叠屏/分屏模式下极易导致MediaCodec初始化失败。
2.2 配置MediaCodec硬编码器:H.264 baseline profile + 关键帧间隔控制
// Java部分:初始化编码器 private MediaCodec initVideoEncoder() throws IOException { MediaFormat format = MediaFormat.createVideoFormat( MediaFormat.MIMETYPE_VIDEO_AVC, 1280, 720 ); format.setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000); // 2Mbps,平衡画质与带宽 format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); // 帧率必须与VirtualDisplay刷新率匹配 format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2); // 关键帧间隔2秒,降低首帧等待时间 format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); // 必须设为Surface输入 format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline); // 强制baseline,兼容性最好 format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel3); // Level3对应720p@30fps MediaCodec encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); mSurface = encoder.createInputSurface(); // 此Surface即2.1节中传给VirtualDisplay的 encoder.start(); return encoder; }逻辑说明:
KEY_COLOR_FORMAT设为COLOR_FormatSurface,表示编码器从Surface读取数据(而非ByteBuffer),这是低延迟的关键;AVCProfileBaseline比Main或HighProfile兼容性高得多——实测某国产中端机对HighProfile解码失败率超60%;KEY_I_FRAME_INTERVAL=2让关键帧每2秒强制插入一次,避免接收端因丢包无法重建画面。
血泪经验:不要设KEY_BIT_RATE_MODE=BITRATE_MODE_CBR(恒定码率),Android硬编码器对此支持极差,会导致码率剧烈抖动;用默认的BITRATE_MODE_VBR(可变码率)更稳。
2.3 从编码器取出H.264 NALU并打时间戳
// Java部分:编码循环(简化版) private void encodeLoop() { MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); while (mIsEncoding) { int outputBufferId = encoder.dequeueOutputBuffer(info, 10000); // 10ms超时 if (outputBufferId >= 0) { ByteBuffer encodedData = encoder.getOutputBuffer(outputBufferId); if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { // SPS/PPS头数据,需在首帧前发送给接收端 sendSpsPps(encodedData, info); } else if (info.size > 0) { // 普通NALU,提取start code(0x00000001)并加PTS byte[] nalData = new byte[info.size]; encodedData.get(nalData, info.offset, info.size); long ptsMs = System.nanoTime() / 1_000_000; // 毫秒级时间戳 sendNalu(nalData, ptsMs, (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0); } encoder.releaseOutputBuffer(outputBufferId, false); } } }逻辑说明:
dequeueOutputBuffer返回的info.flags中,BUFFER_FLAG_CODEC_CONFIG标识SPS/PPS(编解码器配置头),必须在第一帧前单独发送,否则接收端无法初始化解码器;BUFFER_FLAG_KEY_FRAME标识I帧,接收端靠它判断是否可随机接入;ptsMs用System.nanoTime()而非System.currentTimeMillis(),避免系统时间跳变导致音画不同步。
玄学提示:某些SoC(如某款联发科芯片)在dequeueOutputBuffer返回INFO_TRY_AGAIN_LATER后,若立即重试,可能死锁。实际做法是:连续3次INFO_TRY_AGAIN_LATER后,sleep 1ms再试。
3. 把H.264裸流封装成RTSP流:用NanoHTTPD搭轻量级服务器,绕过FFmpeg编译地狱
“投屏”最终要让接收端能拉流。直接发裸H.264 RTP包?接收端得自己组帧、处理丢包、同步音视频——90%的demo在这里翻车。更务实的做法是:用RTSP协议封装,让接收端用标准VLC、ffplay甚至浏览器(通过WebRTC网关)就能播。而RTSP服务器不必重造轮子——NanoHTTPD这个仅200KB的Java HTTP服务器,稍作改造就能当RTSP服务器用,省去交叉编译FFmpeg的3小时。
3.1 RTSP响应头与SDP协商:为什么你的VLC连不上?
RTSP交互分三步:OPTIONS探活 →DESCRIBE获取媒体描述(SDP)→SETUP/PLAY建流。其中DESCRIBE返回的SDP字符串,决定了接收端能否正确解码。常见错误是SDP里写错a=rtpmap或漏a=fmtp参数:
// NanoHTTPD中处理DESCRIBE请求 private String generateSdp() { return "v=0\r\n" + "o=- 0 0 IN IP4 127.0.0.1\r\n" + "s=Android Screen Stream\r\n" + "c=IN IP4 0.0.0.0\r\n" + "t=0 0\r\n" + "m=video 0 RTP/AVP 96\r\n" + // payload type 96,自定义 "a=rtpmap:96 H264/90000\r\n" + // 采样率90000Hz,H.264必须 "a=fmtp:96 packetization-mode=1;profile-level-id=42C01F;sprop-parameter-sets=" + Base64.encodeToString(spsBytes, Base64.NO_WRAP) + "," + Base64.encodeToString(ppsBytes, Base64.NO_WRAP) + "\r\n" + "a=control:trackID=0\r\n"; }参数深挖:
packetization-mode=1表示NALU分包模式(mode 0已废弃),profile-level-id=42C01F对应Baseline Profile Level 3.0(42是baseline,C0是level 3.0的十六进制),必须与MediaCodec配置完全一致;sprop-parameter-sets是SPS/PPS的Base64编码,接收端靠它初始化解码器——漏掉或编码错误,VLC会显示“no video track”。
避坑点:a=control:trackID=0不能写成a=control:*,某些老版本VLC会因此拒绝PLAY。
3.2 RTP打包:每个NALU如何切成RTP包并加时间戳?
H.264裸流不能直接塞进RTP包。单个NALU可能超1500字节(MTU限制),需按RFC 3984切片。最简方案是用Single NAL Unit模式(小NALU)+ STAP-A模式(聚合多个小NALU),但为保兼容性,我们用FU-A分片(Fragmentation Unit):
// Java:FU-A分片逻辑(简化) private List<byte[]> splitNaluToRtp(byte[] nalData, long ptsMs) { List<byte[]> rtpPackets = new ArrayList<>(); int payloadSize = 1400; // 留足IP/UDP头空间 int nalHeader = nalData[0] & 0xFF; // 原NALU头 int nalType = nalHeader & 0x1F; for (int offset = 1; offset < nalData.length; offset += payloadSize) { byte[] rtpPacket = new byte[12 + payloadSize]; // RTP头12字节 // RTP头:version=2, padding=0, extension=0, csrc=0, marker=0(最后1片才置1) rtpPacket[0] = (byte) 0x80; // version=2, padding=0, extension=0 rtpPacket[1] = (byte) 0x60; // payload type=96, marker=0 // sequence number(此处简化,实际需递增) rtpPacket[2] = (byte) 0x00; rtpPacket[3] = (byte) 0x01; // timestamp(关键!必须用ptsMs * 90,因H.264时钟频率是90kHz) int ts = (int) (ptsMs * 90); rtpPacket[4] = (byte) (ts >> 24); rtpPacket[5] = (byte) (ts >> 16); rtpPacket[6] = (byte) (ts >> 8); rtpPacket[7] = (byte) ts; // SSRC(填固定值即可) rtpPacket[8] = (byte) 0xDE; rtpPacket[9] = (byte) 0xAD; rtpPacket[10] = (byte) 0xBE; rtpPacket[11] = (byte) 0xEF; // FU-A头:S=1(首片), E=1(末片), R=0, Type=28(FU-A) int fuHeader = 0; if (offset == 1) fuHeader |= 0x80; // S bit if (offset + payloadSize >= nalData.length) fuHeader |= 0x40; // E bit fuHeader |= 0x1C; // FU indicator: Type=28 (FU-A) fuHeader |= nalType; // FU header: NAL unit type rtpPacket[12] = (byte) fuHeader; // 复制NALU数据(跳过原始NALU头) int copyLen = Math.min(payloadSize - 1, nalData.length - offset); System.arraycopy(nalData, offset, rtpPacket, 13, copyLen); rtpPackets.add(rtpPacket); } return rtpPackets; }时间戳玄机:RTP timestamp不是毫秒,而是以90kHz为基准的tick数(
ptsMs * 90),因为H.264的时钟频率是90kHz。填错会导致VLC播放速度异常(快进或慢放)。
分片原则:FU-A模式下,首片S=1、末片E=1、中间片S=E=0,接收端靠此重组NALU。若某片丢失,只需丢弃整个NALU,不影响后续帧——比STAP-A更鲁棒。
3.3 NanoHTTPD集成RTSP:3个方法搞定协议路由
// 继承NanoHTTPD,重写serve方法 public class RtspServer extends NanoHTTPD { public RtspServer(int port) { super(port); } @Override public Response serve(IHTTPSession session) { String uri = session.getUri(); Method method = session.getMethod(); if ("OPTIONS".equals(method.toString()) && uri.equals("*")) { return newFixedLengthResponse("RTSP/1.0 200 OK\r\n" + "CSeq: " + getCseq(session) + "\r\n" + "Public: OPTIONS, DESCRIBE, SETUP, PLAY, PAUSE, TEARDOWN\r\n\r\n"); } if ("DESCRIBE".equals(method.toString())) { return newFixedLengthResponse("RTSP/1.0 200 OK\r\n" + "CSeq: " + getCseq(session) + "\r\n" + "Content-Type: application/sdp\r\n" + "Content-Base: rtsp://127.0.0.1:8080/\r\n" + "Content-Length: " + sdp.length() + "\r\n\r\n" + sdp); } if ("SETUP".equals(method.toString())) { // 返回Transport头,告知客户端用UDP还是TCP,端口多少 return newFixedLengthResponse("RTSP/1.0 200 OK\r\n" + "CSeq: " + getCseq(session) + "\r\n" + "Transport: RTP/AVP;unicast;client_port=50000-50001;server_port=50002-50003\r\n" + "Session: 1234567890\r\n\r\n"); } return newFixedLengthResponse("RTSP/1.0 405 Method Not Allowed\r\n\r\n"); } }关键字段:
Transport头中的client_port是客户端声明的接收RTP/RTCP端口(VLC默认50000-50001),server_port是我们服务器发RTP的端口(需用DatagramSocket绑定);Session是会话ID,后续PLAY请求需携带。
实操提醒:CSeq必须从请求头中解析(session.getHeaders().get("cseq")),不能硬编码,否则VLC会认为序列号错乱而断连。
4. 避坑:Android投屏demo的5个高频翻车点与现场排查法
投屏demo的调试难度远超普通App,因为问题横跨Android框架、硬件编解码、网络协议、接收端实现四层。以下5条是某开发者在3台不同品牌旗舰机上累计踩出的血泪记录,每条都附带adb logcat关键词和快速验证法。
4.1 现象:黑屏但logcat有“start preview failed”
原因:VirtualDisplay尺寸与MediaCodec配置的宽高不一致,或Surface未正确传递。某些OEM(如某国产A系列)要求VirtualDisplay宽高必须是16的倍数,1280×720中720÷16=45,符合;但若设1280×721则必败。
解决:检查createVirtualDisplay参数,强制用1280×720或1920×1080;用adb shell dumpsys SurfaceFlinger确认VirtualDisplay是否创建成功(搜索screen_capture)。
4.2 现象:VLC显示“no suitable decoder module”
原因:SDP中a=fmtp的profile-level-id与MediaCodec实际输出不符。例如MediaCodec配置AVCLevel3,但SoC硬编码器偷偷升到AVCLevel31(对应42C020),而VLC的解码器只认42C01F。
解决:抓包看SDP,对比profile-level-id;用adb shell dumpsys media.codec | grep -A 20 "H264"查SoC真实支持等级;降级KEY_LEVEL至AVCLevel3并重启App。
4.3 现象:首帧正常,后续全绿屏或马赛克
原因:RTP timestamp计算错误,或MediaCodec输出的info.presentationTimeUs未对齐。presentationTimeUs是编码器内部时间戳,比System.nanoTime()更准,但需转换为RTP timestamp(除以1000再乘90)。
解决:在encodeLoop中打印info.presentationTimeUs,确认是否线性增长;RTP timestamp改为(int) (info.presentationTimeUs / 1000 * 90)。
4.4 现象:锁屏后投屏中断,唤醒后无法恢复
原因:MediaProjection对象在锁屏时被系统回收,但VirtualDisplay未及时释放,再次调用createVirtualDisplay时抛IllegalStateException。
解决:在onPause()中调用mVirtualDisplay.release(),onResume()中重新createVirtualDisplay;监听ACTION_SCREEN_OFF广播,提前清理。
4.5 现象:Wi-Fi下流畅,4G下严重卡顿且频繁重连
原因:未做码率自适应。2Mbps在4G弱网下必然拥塞,但MediaCodec的KEY_BIT_RATE是静态的,无法动态调整。
解决:用MediaCodec.setParameters()动态修改码率(Android 8.0+支持),根据NetworkStatsManager统计的实时带宽,每5秒调整一次:encoder.setParameters(Bundle().apply { putInt(MediaFormat.KEY_BIT_RATE, newBitrate) })。
5. 验证投屏质量:用ffplay + 自定义脚本量化卡顿、延迟、丢包率
跑通只是开始,上线前必须量化质量。别信“看起来还行”,要用数据说话。我一般用ffplay作为接收端基准,配合自研脚本分析日志,3分钟内出报告。
5.1 用ffplay拉RTSP流并记录关键指标
# 启动ffplay,开启详细日志 ffplay -rtsp_transport tcp "rtsp://192.168.1.100:8080/" \ -v info \ -stats \ -loglevel debug \ 2>&1 | tee ffplay.log关键日志解读:
frame= 123 fps= 29.5 q=12.0 size= 12345kB time=00:00:04.10:fps=29.5表示当前解码帧率,低于30说明卡顿;drop=12:已丢12帧,持续增长说明网络或解码瓶颈;bufq=0.200:缓冲区延迟0.2秒,超过0.5秒用户明显感知卡顿。
5.2 解析ffplay日志:Python脚本自动计算卡顿率与平均延迟
# analyze_ffplay.py import re import sys def parse_log(log_file): frames = [] drops = 0 delays = [] with open(log_file) as f: for line in f: # 匹配帧信息:frame= 123 fps= 29.5 ... time=00:00:04.10 m = re.search(r'frame=\s*(\d+)\s+fps=\s*([\d.]+)\s+.*?time=(\d\d):(\d\d):(\d\d).(\d\d)', line) if m: frame_num = int(m.group(1)) fps = float(m.group(2)) h, m, s, ms = map(int, m.groups()[2:]) total_ms = (h*3600 + m*60 + s)*1000 + ms*10 frames.append((frame_num, total_ms)) # 匹配丢帧:drop=12 m = re.search(r'drop=(\d+)', line) if m: drops = int(m.group(1)) # 匹配缓冲延迟:bufq=0.200 m = re.search(r'bufq=([\d.]+)', line) if m: delays.append(float(m.group(1))) # 计算卡顿率:帧间隔>40ms(30fps容忍阈值)的次数占比 gaps = [] for i in range(1, len(frames)): gap_ms = frames[i][1] - frames[i-1][1] gaps.append(gap_ms) jitters = [g for g in gaps if g > 40] print(f"总帧数: {len(frames)}") print(f"丢帧数: {drops}") print(f"卡顿率: {len(jitters)/len(gaps)*100:.1f}% (阈值>40ms)") print(f"平均缓冲延迟: {sum(delays)/len(delays):.3f}s") if __name__ == "__main__": parse_log(sys.argv[1])执行命令:
python analyze_ffplay.py ffplay.log
结果示例:卡顿率: 2.3% (阈值>40ms)—— 可接受(<5%)平均缓冲延迟: 0.182s—— 优秀(<0.3s)
若卡顿率>10%,优先查网络丢包(ping -c 20 192.168.1.100);若延迟>0.5s,检查MediaCodec是否启用了KEY_LATENCY(Android 10+支持,设为1可降延迟)。
5.3 真机网络压测:用tc模拟弱网环境,验证自适应码率效果
在开发机上用tc(traffic control)模拟2G/3G网络,测试码率自适应是否生效:
# 模拟3G网络:1Mbps带宽,100ms延迟,5%丢包 sudo tc qdisc add dev wlan0 root handle 1: tbf rate 1mbit burst 32kbit latency 400ms sudo tc qdisc add dev wlan0 parent 1:1 handle 10: netem delay 100ms loss 5% # 运行投屏demo,观察ffplay日志中fps是否稳定在25+ # 测试完清除规则 sudo tc qdisc del dev wlan0 root我的习惯:每次提交前必跑
tc压测10分钟,记录最低fps和最高丢帧数。如果弱网下卡顿率突增至15%,说明自适应策略太激进——把码率调整步长从±500kbps降到±200kbps,牺牲一点画质换稳定性。
最后一句:投屏不是炫技,是让用户感觉“就像在操作本地设备”。所有技术选择,都应该服务于这个目标。希望帮到你。
本文还有配套的精品资源,点击获取