☰
Android投屏Demo实现:MediaProjection+MediaCodec+H.264全链路详解
2026/10/20 12:50:12 网站建设 项目流程

简介:这份资源是一套Android设备投屏功能Demo,面向对多媒体开发与远程屏幕传输感兴趣的Android开发者,重点演示了MediaProjection屏幕捕获、MediaCodec H264编解码以及Socket网络传输的完整链路。压缩包共1536个文件,大小约4.87MB,文件类型涵盖Java源码、XML布局、Gradle工程配置、JSON资源以及部分二进制文件,目录结构清晰,便于直接导入Android Studio分析或二次扩展。已有3262人学习/下载。通过该Demo可理解如何将屏幕帧实时编码为H264流并通过Socket发送到接收端,接收端再利用MediaCodec解码至大屏显示,同时涉及NAL单元解析、网络延迟与丢包处理等关键环节。对于想要掌握投屏原理、音视频数据流处理及网络通信实践的开发者,是一份值得对照研究的完整示例工程,也可在此基础上升级音视频同步与传输效率优化。

1. 设备投屏不是“截屏发送”,是一条完整的多媒体链路

很多人第一次接触 Android 设备投屏功能 Demo,会以为把屏幕截下来发出去就行,真正动手才发现这条路走不通。一个能跑的投屏方案要同时处理四件事:采集屏幕画面、用编码器压缩成 H.264、通过局域网传输、在接收端解码渲染,任何一环出问题都会直接表现为黑屏、花屏或卡成幻灯片。这个标题要做的,就是把这四环一次性打通,做成一个 A 同学在两台设备之间演示投屏的最小 Demo。它解决的是“在本地网络里把手机画面实时搬到另一个屏幕”这件事,适合正在做同屏互动、会议演示、远程协助之类应用的开发者,也适合想弄清楚系统投屏底层是怎么工作的从业者。

2. 技术选型:采集、编码、传输三条线的取舍

投屏 Demo 的技术选型决定后面的开发量。采集方案选错,要么需要系统签名,要么兼容性受限;编码方案选错,CPU 会被软编直接拖死;传输方案选错,延迟和复杂度一起失控。下面把三条线拆开说。

2.1 采集方案对比:MediaProjection 为什么是 Demo 的首选

Android 做屏幕采集有三条路:MediaProjection、SurfaceControl 和系统镜像接口。MediaProjection 从 Android 5.0 开始提供,普通应用申请权限后就能拿到屏幕内容,适合做 App 内的投屏 Demo;SurfaceControl 从 Android 10 开始才算稳定可用,但需要 platform 级别签名,普通应用编译都过不了,一般只出现在系统定制或内置应用里;系统镜像接口则基本不对三方应用开放。所以对大多数开发者来说,MediaProjection 是唯一合法且可行的入口。

选 MediaProjection 还有一个好处:它的授权流程把“用户是否同意投屏”这个产品问题顺带解决了。用户点授权弹窗后,系统会返回一个 resultCode 和 data,用来构造 MediaProjection 实例,后续所有采集都通过这个实例完成,整个过程不需要 root、不需要系统签名,能直接跑在普通手机上。

当然代价也有,采集画面会被系统录制提示遮挡(Android 12 以上有录制状态角标),并且授权是一次性的,每次进程重启都要重新走弹窗。在 Demo 阶段可以接受,真做产品再考虑用系统方案绕开。

2.2 MediaCodec 硬编与软编的取舍:不要碰 x264

屏幕画面是原始像素流,分辨率再低也是每帧上 MB 的数据量,直接走网络根本传不动。必须压缩成 H.264 之类的视频流。这里路径有两条:用 MediaCodec 走硬件编码器,或引入 x264 走软件编码。

硬件编码的优势非常明显:编码工作交给专用模块,CPU 占用低,手机不发热,延迟通常在几十毫秒级别。x264 软编虽然参数灵活、画质可调,但在移动端跑 720p30 帧会把 CPU 吃满,发热降频后延迟完全失控,Demo 阶段不值得碰。MediaCodec 是 Android 官方提供的编码接口,内部会自动选硬件编码器,开发时只要按规范配置 MediaFormat 就能用,不需要关心具体芯片型号。这也是我做的所有投屏 Demo 里固定采用的组合:MediaProjection 采集加 MediaCodec 硬编。

需要注意一点:MediaCodec 要选 Surface 输入模式,也就是把编码器启动后返回的 Surface 直接交给 VirtualDisplay,让屏幕内容直接“写”进编码器。这种模式下画面从采集到编码全程零拷贝,比先读 Bitmap 再塞给编码器的方式省掉一大截内存带宽和延迟,而且代码量也更少。只是这时候编码器输出的数据是 H.264 裸流,不再是 BMP 或者 JPEG,后续要自己处理 SPS/PPS 和帧边界。

2.3 传输方案选型:Demo 阶段用 TCP Socket 就够了

画面编码成 H.264 之后要送到接收端。常见选择有 TCP Socket、UDP Socket、WebRTC 和 MQTT。如果只做局域网 Demo,TCP Socket 是最稳的一条路:代码简单、不用搭信令服务器、Android 原生支持。技术上要处理的无非是“按帧切包”和“粘包拆包”,一个 DataOutputStream 加 4 字节长度前缀就能解决。UDP 延迟更低,但要自己处理丢包和乱序,H.264 对丢包非常敏感,一个 NALU 丢了画面可能直接花掉,需要自己实现重传或者前向纠错,工作量会翻倍。

WebRTC 适合跨网络、多人、弱网场景,它的延迟控制和带宽自适应都很好,但要搭建信令服务、还要处理 ICE 和 STUN,对 Demo 来说太重。MQTT 则只适合传指令,传视频流会很快把消息积压打满,不适合媒体数据。顺便提一句,投屏需要用到前台服务,Manifest 里要提前做好声明:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />

这两个权限分别用于在后台持续投屏时保持前台服务,以及 Android 14 之后对 MediaProjection 的强制要求。不加的话,高版本系统上投屏到一半会被杀掉。

3. 采集与编码:把屏幕变成 H.264 字节流

选型定下来,就可以动手写采集和编码。这一章的每一个代码块都可以直接抄进项目里跑通,注意跟着参数说明一起看。

3.1 申请 MediaProjection 权限:授权弹窗与实例构造

第一步是向 MediaProjectionManager 申请用户授权。这个操作必须放在 Activity 里,因为系统弹窗是基于 Activity 的。常见做法是在启动投屏按钮时构造 Intent 并 startActivityForResult,然后在 onActivityResult 里拿到 resultCode 和 data。注意 Android 14 之后这个授权每次都要重新申请,没有“记住选择”的说法。

// 获取系统 MediaProjectionManager MediaProjectionManager mpm = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); // 发起授权弹窗,用户同意后回调 onActivityResult Intent captureIntent = mpm.createScreenCaptureIntent(); startActivityForResult(captureIntent, REQUEST_MEDIA_PROJECTION);

createScreenCaptureIntent 返回的 Intent 就是系统要求的录屏授权请求。REQUEST_MEDIA_PROJECTION 是自定义请求码,在 1000 这种位置随便定义,但必须和 onActivityResult 里的判断一致。

@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_MEDIA_PROJECTION && resultCode == RESULT_OK) { // 这里拿到的 data 不能复用,必须在当前进程内使用 mediaProjection = mpm.getMediaProjection(resultCode, data); startScreenCast(); } else { // 用户拒绝授权,投屏直接结束 } }

getMediaProjection 返回的 MediaProjection 实例绑定当前进程的上下文,不能跨进程传递,也不能把这个 data 保存下来下次再拿,这是我调过最久的坑之一。拿到实例后马上调用 startScreenCast 去创建 VirtualDisplay,这个函数就是下一节的内容。

3.2 创建 VirtualDisplay 并对接编码器 Surface

VirtualDisplay 负责“把屏幕画面镜像到指定的 Surface 上”。这里的 Surface 来自 MediaCodec 编码器的输入 Surface,屏幕内容一旦写入这个 Surface,就等同于被喂给了编码器。注意点:VirtualDisplay 的宽高不一定要等于屏幕物理分辨率,通常会故意设小一些,比如 1280x720,让编码器少处理像素,延迟和码率更好控。

private void startScreenCast() { // 先启动编码器拿到输入 Surface MediaCodec encoder = createEncoder(); Surface inputSurface = encoder.getInputSurface(); // 创建虚拟显示,把屏幕镜像到编码器的输入 Surface 上 virtualDisplay = mediaProjection.createVirtualDisplay( "ScreenCastDemo", 1280, // 采集宽度,这里故意低于物理分辨率 720, // 采集高度 densityDpi, // 沿用原屏幕 density,保持 UI 比例 DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, // 画面输出目标 null, null ); }

createVirtualDisplay 的参数里,densityDpi 建议沿用原屏的 density 值,否则分辨率缩放后文字和 UI 比例会失调。VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR 表示这个显示会镜像主屏内容,不加这个 flag 虚拟屏里往往是空的。如果设备方向固定,可以不用管旋转;如果要做动态旋转,避坑章节会具体讲。

3.3 编码器配置与输出循环:码率、帧率、关键帧间隔怎么设

编码器创建和参数配置是投屏效果的核心。下面是一份能直接用的配置,720p、30 帧、4Mbps 码率,适合绝大多数局域网 Demo。注意 COLOR_FormatSurface 是硬编 Surface 输入模式的要求,不能用 COLOR_FormatYUV420Flexible,否则 configure 会直接抛异常。

private MediaCodec createEncoder() { MediaFormat format = MediaFormat.createVideoFormat("video/avc", 1280, 720); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); format.setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); MediaCodec encoder = MediaCodec.createEncoderByType("video/avc"); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); encoder.start(); return encoder; }

参数说明:格式为 video/avc,也就是 H.264。码率 4Mbps 在 720p 下画质足够,如果发现马赛克严重可以提到 6Mbps,但注意网络带宽要大于码率,否则接收端会持续堆积帧,延迟越拖越大;帧率 30 是系统屏幕刷新的典型值,做演示够用;关键帧间隔 1 秒,目的是让接收端断线重连或花屏时能在一秒内等到 IDR 帧恢复画面,间隔设太大会导致画面长期模糊。

编码器启动后,需要开一个线程循环读取输出数据。dequeueOutputBuffer 会返回编码后的 H.264 数据块,这些块就是发给接收端的原始素材。BufferInfo 里除了 size 还有 flags,关键帧的 flag 是 BUFFER_FLAG_KEY_FRAME,如果要做“非关键帧丢弃”策略,判断就在这里。

MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); while (isCasting) { // 最多阻塞 10ms 拿一个编码输出 int index = encoder.dequeueOutputBuffer(info, 10_000); if (index >= 0) { ByteBuffer data = encoder.getOutputBuffer(index); byte[] frame = new byte[info.size]; data.get(frame); // 交给传输层,同时带上关键帧标记 transport.sendFrame(frame, info.flags); encoder.releaseOutputBuffer(index, false); } }

这里有一个容易翻车的细节:编码器输出的第一个非空 buffer 通常是 SPS/PPS,它没有画面数据,但接收端解码必须依赖它。如果传输层只发普通帧,接收端会一直报“无有效解码参数”然后黑屏。处理方式是把编码器 configure 时从 MediaFormat 里取出来的 csd-0/csd-1 数据单独保存,在接收端建立连接时先发送一次,再开始传画面帧。

4. 传输与解码:局域网内把字节流还原成画面

采集和编码完成后,H.264 裸流要交给网络。这一章完成剩余两环:发送端 Socket 线程和接收端解码显示。

4.1 发送端:TCP Socket 线程与 4 字节长度前缀

发送端用一个独立线程维护 Socket。注意 H.264 输出是分块的,每块长度不定,接收端必须知道每一块从哪里开始到哪里结束。常见做法是用 DataOutputStream 的 writeInt 写入 4 字节长度,再把帧数据跟在后面,接收端先 readInt 拿到长度,再 readFully 读 payload,就能天然完成拆包。

public void sendFrame(byte[] nalu, int flags) { try { // 第一个参数写帧长度,保证接收端可以整帧读取 dataOut.writeInt(nalu.length); dataOut.write(nalu); dataOut.flush(); } catch (IOException e) { // Socket 写失败,通常是接收端断开或网络异常 stopCast(); } }

Socket 在 Demo 里可以用 java.net.Socket,连接方式是一方做 ServerSocket 监听,另一方主动连接。我通常把发送端做服务端、接收端做客户端,因为投屏发起方往往不动,IP 比较好固定。发送线程只做一件事:从编码输出循环接收 byte[],加头写入。flush 每次调用会有一点性能损耗,但 Demo 阶段保证及时性更重要,不用攒批。

4.2 接收端:MediaCodec 解码器与 SurfaceView 渲染

接收端的事情是把 Socket 流按帧还原成 H.264 数据,喂给 MediaCodec 解码器,解码后的画面直接渲染到 SurfaceView 上。解码器配置跟编码器类似,但不需要 Surface 输入,而是把输入 buffer 一一 queue 进去。

private MediaCodec createDecoder(Surface outputSurface) { MediaFormat format = MediaFormat.createVideoFormat("video/avc", 1280, 720); // 关键:把发送端提取的 SPS/PPS 写入解码格式 format.setByteBuffer("csd-0", ByteBuffer.wrap(sps)); format.setByteBuffer("csd-1", ByteBuffer.wrap(pps)); MediaCodec decoder = MediaCodec.createDecoderByType("video/avc"); decoder.configure(format, outputSurface, null, 0); decoder.start(); return decoder; }

这里有两个坑:一是 csd-0 和 csd-1 必须放在 configure 之前设置,否则解码器起不来;二是后面每帧数据的 PTS 不能都是 0,要按帧率递增,否则部分设备上解码和渲染时序会错乱。PTS 可以用 System.nanoTime() 除以 1000 换算,或者自己用一个计数器乘以 1000。

接收循环如下,注意读取数据要阻塞在当前线程里,避免空转:

while (!isStopped) { int len = dataIn.readInt(); // 先读长度 byte[] nalu = new byte[len]; dataIn.readFully(nalu); // 再读整帧 int index = decoder.dequeueInputBuffer(10_000); if (index >= 0) { ByteBuffer inBuf = decoder.getInputBuffer(index); inBuf.clear(); inBuf.put(nalu); decoder.queueInputBuffer(index, 0, len, presentationTimeUs, 0); } }

这个循环有一个数据堆积问题:接收端从网络读数据的速度远快于一帧一帧排队给解码器的速度,所以 DataInputStream 的 readInt 和 readFully 会随时阻塞,这是正常的。只要发送端不写爆 Socket 缓冲区,就不会丢帧。

4.3 最小 Demo 的画面串联流程

把发送端和接收端写完后,投屏 Demo 的运行顺序是这样的:发送端先启动 ServerSocket 等待连接;接收端连上之后,发送端立即发送 csd-0 和 csd-1,也就是 SPS/PPS;然后发送端申请 MediaProjection 授权,启动 VirtualDisplay 和编码器;编码器输出的每一帧通过 Socket 发到接收端;接收端拿到 SPS/PPS 后配置解码器,收到画面帧后 queue 进去,SurfaceView 上开始出图。

一个要注意的细节是连接和授权的前后顺序。我习惯先把网络连好再弹授权窗,这样用户点完同意,画面能马上开始出,体验更顺。如果先弹授权再等连接,用户会对着黑屏等好几秒,感受很差。

5. 投屏 Demo 避坑:5 个高频问题与排查记录

投屏 Demo“能跑”和“能稳定跑”之间隔着大量暗坑。我把开发中遇到的典型问题按现象、原因、解决整理成 5 条,可以直接对照排查。

5.1 授权弹窗之后黑屏

现象:用户点了授权“允许”,发送端没有崩溃,但接收端始终黑屏,日志里没有编码器输出。

原因:MediaProjection 实例拿到之后立即创建 VirtualDisplay,但此时编码器和 Surface 还没有准备好;还有一种情况是 Surface 被 GC 回收了,VirtualDisplay 写入的 Surface 已经失效。这类问题排查起来像玄学,但代码顺序其实很明确:先创建编码器拿到 inputSurface,再创建 VirtualDisplay,且要保证 Surface 引用在投屏期间一直存在。

解决:把 startScreenCast 改成编码器、Surface、VirtualDisplay 的严格顺序,并把 Surface 和 VirtualDisplay 都保存为成员变量,不持有局部引用。改完基本能解决九成的“授权后黑屏”。

5.2 画面卡顿与 CPU 占用过高

现象:接收端画面只有 3 到 5 帧每秒,编码器所在设备发热明显,系统负载很高。

原因:最常见的两个来源,一是分辨率设成了屏幕原生 2K,编码器像素吞吐过大;二是码率设太高导致网络持续满负荷,TCP 背压反过来拖慢编码循环。有人把码率设在 10Mbps 以上,局域网不稳定直接翻车。

解决:把 VirtualDisplay 的宽高设为 1280x720,码率压到 4Mbps,帧率保持 30。这三个参数是 Demo 阶段画质和性能的最佳折中点。另外确认编码器没有落到软编,可以通过 MediaCodecInfo.isHardwareAccelerated() 检查,如果返回 false,多半是格式配置导致硬编不可用。

5.3 接收端花屏与 SPS/PPS 丢失

现象:画面刚出来时花几秒,或者中途花屏后一直恢复不了,只能重连。

原因:如果 SPS/PPS 只在连接时发一次,接收端在丢帧或解码器重置后拿不到新的参数集,就再也无法解码。真正的问题是重连和花屏恢复机制缺失。

解决:发送端在每次建立连接后先发 csd 数据,再发画面帧;同时每隔一段时间请求一次关键帧,让接收端即使花屏也能在下一帧 IDR 处恢复。请求关键帧没有公开的 switchKeyFrame 方法,但可以通过 setParameters 传参数实现:

// 每隔 30 帧请求一个关键帧,给接收端多一次恢复机会 if (frameCount % 30 == 0) { Bundle params = new Bundle(); params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); encoder.setParameters(params); }

5.4 横竖屏切换导致黑屏与崩溃

现象:手机旋转 90 度后,接收端画面不是跟着旋转,而是直接黑掉,发送端偶尔报 surface 已销毁的异常。

原因:屏幕方向变化后,VirtualDisplay 内部的尺寸和 density 没有跟着更新,继续往旧 Surface 写数据时硬件层已经重建了 Surface。这是 MediaProjection 方案的典型坑,不是崩溃日志能直接看出来的。

解决:发送端 Activity 注册 ConfigurationListener,在 onConfigurationChanged 里重新调用 createVirtualDisplay,同时让编码器请求一个关键帧。注意 VirtualDisplay 需要先 release 再创建新的,旧 Surface 不能复用,否则还是黑屏。

5.5 Android 14 及以上版本的权限收紧

现象:在 Android 14 设备上,投屏运行十几分钟会被系统杀掉,日志里有 MediaProjection permission denied 的报错。

原因:Android 14 要求投屏必须在前台服务中运行,并且需要声明 FOREGROUND_SERVICE_MEDIA_PROJECTION。后台启动 VirtualDisplay 会被系统直接拒绝,用户授权也不会保留。这个问题在 Android 12 和 13 上不明显,但 14 上几乎必现。

解决:把 MediaProjection 的采集代码放到前台服务里,在服务启动时创建通知,然后再创建 VirtualDisplay。Manifest 里加上第 2 章提到的两个权限。这一步不做,高版本设备上投屏随时可能终止。

6. 进阶:把 Demo 的延迟从 400ms 压到 100ms 以内

Demo 能跑通之后,下一步就是追求“顺手”。局域网投屏的体验瓶颈是延迟,我的优化经验是先把延迟压到 100ms 内,再看其他。

第一刀砍在缓冲上:编码器 dequeueOutputBuffer 的 timeout 不要设成无限大,10ms 足够;传输端不要在 write 后额外 sleep,让网络层自行背压。第二刀启用编码器低延迟模式:

if (Build.VERSION.SDK_INT >= 26) { Bundle params = new Bundle(); params.putInt(MediaCodec.PARAMETER_KEY_LOW_LATENCY, 1); encoder.setParameters(params); }

第三刀把分辨率固定到 1280x720,码率压到 4Mbps。这三个操作组合下来,延迟能从 400ms 级别降到 80ms 级别。落实验证时,可以用 adb 检查编码器能力和网络状态:

# 查看当前设备编码器能力和负载 adb shell dumpsys media.codec

如果要做弱网验证,可以在 root 环境里加一点丢包,观察花屏恢复能力,网卡名按实际机器修改:

# 模拟局域网 2% 丢包 tc qdisc add dev wlan0 root netem loss 2%

以前我自己做第一版投屏 Demo 的时候,就是在缓冲区上吃了大亏:线程里加了 sleep 想“让网络缓一缓”,结果延迟直接飙到秒级,白白排查了两天。后来才明白,投屏的延迟瓶颈多半不在网络,而在每一层代码对缓冲区的执念。把代码改得“不等人”之后,画面就顺了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询