简介:这份Android设备投屏功能Demo面向具备一定Android基础的开发者,聚焦手机、平板屏幕内容向电视或电脑显示器投放的完整实现路径。项目以MediaProjection捕获屏幕帧,借助MediaCodec完成H264编解码,并通过Socket在设备间传输视频流,接收端再解码还原画面,覆盖从采集、编码到网络通信与显示的关键环节。压缩包共1536个文件,约4.87MB,以flat、xml、json等构建与配置产物为主,另有36个java源码文件、14张png图片及若干gradle、properties、jar等工程依赖,便于直接导入Android Studio研究。目前已有3256人学习下载。读者可从中掌握屏幕捕获权限申请、H264编码流程、NAL单元解析及网络延迟与丢包处理思路,并可按需扩展音视频同步与传输优化,适合作为Android多媒体与远程投屏方向的学习参考。
1. 从一块黑屏说起:这个投屏 Demo 到底解决了什么问题
很多开发者第一次做 Android 投屏,都会卡在同一个地方:手机端代码跑起来了,PC 端接收窗口也开了,但屏幕上就是一片黑,或者延迟高到没法看。这个 Demo 要解决的就是这件事——把 Android 设备的屏幕画面实时采集出来,用 H264 编码压缩,再通过 Socket 推到接收端解码显示。它适合两类人:一类是想快速验证投屏链路能不能跑通的移动端开发者,另一类是想在现有项目里嵌入屏幕共享能力、但不想从零啃 MediaCodec 和 H264 裸流封装的人。整个项目不依赖第三方投屏 SDK,核心就是 Android 的 MediaProjection + MediaCodec + Socket 三件套,代码量不大,但把采集、编码、传输、解码这条链路完整串了起来。你拿到手之后,改分辨率、改码率、改传输协议都有明确的入口,不是那种封装成黑盒的库。
2. 技术选型拆解:为什么是 MediaProjection + MediaCodec + H264
2.1 采集层为什么用 MediaProjection 而不是截屏轮询
Android 从 5.0 开始提供 MediaProjection API,它是系统级的屏幕捕获方案,和早期那种定时截屏再拼帧的做法有本质区别。截屏轮询的问题在于:每次调用都要走一遍 SurfaceFlinger 合成,帧率上不去,CPU 占用还高,而且截出来的图是 RGB 格式,数据量巨大,后面编码压力更大。MediaProjection 拿到的是一个虚拟屏幕的 Surface,你可以直接把内容渲染到编码器的输入 Surface 上,整个链路是 GPU 到编码器,不经过 CPU 拷贝。
Demo 里申请权限的代码大致是这样:
// 申请 MediaProjection 权限,结果在 onActivityResult 里处理 MediaProjectionManager projectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); Intent captureIntent = projectionManager.createScreenCaptureIntent(); startActivityForResult(captureIntent, REQUEST_CODE_CAPTURE);这里有个关键点:createScreenCaptureIntent弹出的系统对话框是强制的,用户必须手动确认,没有绕过的方式。REQUEST_CODE_CAPTURE是自定义的请求码,在onActivityResult里拿到resultCode和data之后,才能创建MediaProjection对象。很多新手在这里翻车,是因为在onActivityResult里直接拿data去创建编码器,但data只是一个 Intent,真正的MediaProjection要通过projectionManager.getMediaProjection(resultCode, data)获取。
2.2 编码层为什么选 H264 而不是 H265
H264 的压缩率确实不如 H265,但在投屏这个场景里,H264 的兼容性是压倒性的。接收端可能是 PC、可能是另一台 Android、也可能是浏览器,H264 的解码支持几乎无处不在。H265 虽然码率能省 30% 左右,但专利授权复杂,部分设备硬解支持不完整,调试阶段容易遇到解码器初始化失败的问题。
Demo 里用 MediaCodec 做硬编,配置如下:
// 配置 H264 编码器,分辨率 720p,码率 4Mbps,帧率 30 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, 2); MediaCodec encoder = MediaCodec.createEncoderByType("video/avc"); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface inputSurface = encoder.createInputSurface(); encoder.start();COLOR_FormatSurface是关键,它告诉编码器输入来自 Surface 而不是 ByteBuffer,这样才能实现 GPU 直通。KEY_I_FRAME_INTERVAL设为 2 表示每 2 秒一个关键帧,这个值影响接收端花屏后恢复的速度——设太大,丢包后画面要等很久才恢复;设太小,码率会上去。4Mbps 码率在 720p 下画质够用,局域网传输压力也不大。
2.3 传输层为什么用裸 Socket 而不是 RTMP
RTMP 适合公网推流,有成熟的 CDN 生态,但它的握手和封包开销在局域网投屏场景里是多余的。Demo 用 TCP Socket 直接传 H264 裸流,接收端按 NAL 单元边界拆包。这里要注意 TCP 的粘包问题——H264 的 NAL 单元之间没有天然分隔符,所以发送端需要在每个 NAL 前面加长度前缀。
// 发送端:每个 NAL 单元前加 4 字节长度头 DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); byte[] nalData = ...; // 从 MediaCodec 取出的编码数据 dos.writeInt(nalData.length); dos.write(nalData); dos.flush();接收端先读 4 字节得到长度,再按长度读满整个 NAL。这个方案简单可靠,缺点是 TCP 的拥塞控制会在网络抖动时主动降速,导致延迟累积。如果对实时性要求极高,可以换成 UDP + 自定义重传,但 Demo 为了代码清晰选了 TCP。
3. 核心模块拆解:从采集到发送的完整链路
3.1 虚拟屏幕的创建与数据流向
拿到MediaProjection之后,需要创建一个虚拟屏幕,把屏幕内容投射到这个虚拟屏幕上,再把这个虚拟屏幕的输出 Surface 交给编码器。代码是这样的:
// 创建虚拟屏幕,输出到编码器的 inputSurface int width = 1280; int height = 720; int densityDpi = getResources().getDisplayMetrics().densityDpi; VirtualDisplay virtualDisplay = mediaProjection.createVirtualDisplay( "ScreenCapture", width, height, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null);VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR这个 flag 保证虚拟屏幕镜像真实屏幕内容。inputSurface就是前面encoder.createInputSurface()返回的对象。数据流向是:真实屏幕 → SurfaceFlinger → 虚拟屏幕 → inputSurface → MediaCodec 编码器。整个过程没有 CPU 参与图像数据搬运,这是低延迟的基础。
3.2 编码输出与 NAL 单元提取
编码器启动后,需要在一个循环里不断从输出缓冲区取数据:
// 编码输出循环 MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); while (isRunning) { int outputIndex = encoder.dequeueOutputBuffer(bufferInfo, 10_000); if (outputIndex >= 0) { ByteBuffer outputBuffer = encoder.getOutputBuffer(outputIndex); if (outputBuffer != null && bufferInfo.size > 0) { byte[] data = new byte[bufferInfo.size]; outputBuffer.position(bufferInfo.offset); outputBuffer.limit(bufferInfo.offset + bufferInfo.size); outputBuffer.get(data); // 发送 data 到接收端 sendData(data); } encoder.releaseOutputBuffer(outputIndex, false); } }dequeueOutputBuffer的超时参数设为 10ms,避免死等。bufferInfo.flags里会标记关键帧(BUFFER_FLAG_KEY_FRAME),接收端可以根据这个信息做丢包恢复。注意releaseOutputBuffer的第二个参数传false,因为这是编码输出,不需要渲染到 Surface。
3.3 接收端解码与画面渲染
接收端的逻辑是反过来的:收数据 → 喂给解码器 → 渲染到 SurfaceView。解码器配置和编码器类似,但要用createDecoderByType:
// 接收端解码器配置 MediaFormat format = MediaFormat.createVideoFormat("video/avc", 1280, 720); MediaCodec decoder = MediaCodec.createDecoderByType("video/avc"); decoder.configure(format, surfaceView.getHolder().getSurface(), null, 0); decoder.start();这里configure的第二个参数是渲染目标 Surface,通常来自SurfaceView或TextureView。解码器收到数据后,通过queueInputBuffer喂入,再通过dequeueOutputBuffer取出解码后的帧,releaseOutputBuffer的第二个参数传true就会自动渲染到 Surface。
3.4 线程模型与缓冲区管理
整个链路涉及三个线程:采集线程(虚拟屏幕输出)、编码线程(MediaCodec 输出循环)、发送线程(Socket 写入)。Demo 里把编码和发送放在同一个线程,避免额外的队列同步开销。但要注意,dequeueOutputBuffer是阻塞的,如果 Socket 发送慢,会反压到编码器,导致编码器缓冲区满,进而丢帧。
常见做法是给发送单独开一个线程,中间用一个有界队列缓冲,队列满了就丢非关键帧。这样网络抖动时不会卡住编码器,代价是延迟会略微增加。Demo 为了代码简洁没做这个优化,但你在二次开发时可以根据场景加上。
4. 跑起来之前:环境配置与权限避坑
4.1 Android 版本与权限声明
MediaProjection 需要 Android 5.0(API 21)以上,但实际测试建议用 Android 8.0 以上,因为早期版本在编码器兼容性上有不少坑。AndroidManifest.xml里需要声明前台服务权限:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />从 Android 10 开始,MediaProjection 必须配合前台服务使用,否则系统会在几秒后自动停止投屏。前台服务的onStartCommand里要调用startForeground并传入通知,通知渠道在 Android 8.0 以上必须创建。
4.2 接收端环境准备
接收端如果是 PC,Demo 里提供了一个 Java 版本的接收程序,依赖 JDK 8 以上。运行命令:
# 编译接收端 javac -d out src/com/example/receiver/*.java # 运行,监听 8888 端口 java -cp out com.example.receiver.Main 8888接收端启动后,Android 端在设置里填入 PC 的 IP 和端口,点击开始投屏。如果连接不上,先检查防火墙是否放行了 8888 端口,再确认手机和 PC 在同一局域网。
4.3 常见问题排查
现象一:投屏开始后几秒自动停止。原因是没有启动前台服务,或者前台服务通知被用户手动划掉。解决方法是确保startForeground在MediaProjection创建之前调用,并且通知的NotificationChannel重要性设为IMPORTANCE_LOW,避免被系统折叠。
现象二:画面花屏或绿屏。通常是编码器颜色格式不匹配。部分设备不支持COLOR_FormatSurface,需要回退到COLOR_FormatYUV420Flexible,但这样就要自己处理 YUV 数据转换,延迟会上去。排查时可以先打印MediaCodecInfo.CodecCapabilities里支持的 color format 列表。
现象三:延迟越来越高。TCP 发送端没有做背压处理,编码器输出速度大于网络发送速度,数据在缓冲区堆积。解决方法是给发送队列设上限,超过阈值时丢弃非关键帧,或者降低编码码率。
现象四:接收端解码器初始化失败。常见于 PC 端没有安装对应的 H264 解码器。Java 端用的是 JCodec 做软解,如果换成硬解方案,需要确认系统里有可用的解码器。排查时可以先保存一段裸流用播放器打开,确认编码数据本身没问题。
现象五:Android 14 上无法获取 MediaProjection。Android 14 收紧了 MediaProjection 权限,每次投屏都需要用户重新确认,不能复用之前的授权。解决方法是每次开始投屏都重新走一遍createScreenCaptureIntent流程。
5. 二次开发入口:改分辨率、换协议、加音频
5.1 调整分辨率与码率的正确姿势
改分辨率不是简单改两个数字。虚拟屏幕的宽高、编码器的MediaFormat、接收端解码器的MediaFormat三处必须一致,否则解码器会报错。另外,宽高最好是 16 的倍数,因为 H264 的宏块是 16x16,非对齐的宽高会导致编码器内部做裁剪,增加延迟。
// 统一的分辨率配置,建议抽成常量 public static final int VIDEO_WIDTH = 1280; public static final int VIDEO_HEIGHT = 720; public static final int VIDEO_BITRATE = 4_000_000;码率调整有个经验公式:码率 = 宽 × 高 × 帧率 × 运动因子,运动因子在 0.07 到 0.15 之间。720p30 帧用 4Mbps 属于中等偏上,如果网络带宽有限,可以降到 2Mbps,画质会糊但能跑。
5.2 把 TCP 换成 WebSocket 或 UDP
TCP 的优点是可靠,缺点是延迟抖动。如果接收端是浏览器,WebSocket 是更自然的选择,因为浏览器原生支持。改造时把DataOutputStream换成 WebSocket 的send方法,NAL 长度头保留,接收端用onmessage回调处理二进制数据。
如果追求极致低延迟,可以换 UDP。UDP 没有粘包问题,但需要自己处理丢包和乱序。常见做法是给每个 NAL 加序列号,接收端维护一个重排缓冲区,超时未到的包直接丢弃,等下一个关键帧恢复。这个改造工作量不小,但延迟能从 TCP 的 200ms 降到 50ms 以内。
5.3 加入音频采集的扩展思路
Demo 只做了视频,加音频需要用到AudioRecord采集 PCM,再用MediaCodec编码成 AAC,最后和 H264 一起封装成 FLV 或 MPEG-TS 流。音视频同步是个难点,常见做法是以音频时间戳为基准,视频帧根据时间戳做丢帧或重复帧。如果不想自己封装,可以引入MediaMuxer把音视频合成为 MP4,但那样就不是实时投屏了。
5.4 一个容易忽略的细节:编码器重启
当分辨率或码率改变时,不能直接改MediaFormat,必须停止编码器、重新配置、重新启动。这个过程会导致接收端短暂黑屏,所以最好在关键帧边界做切换。Demo 里没有做动态切换,但你在二次开发时如果要做「自适应码率」,就需要处理编码器重启时的状态同步。
// 安全重启编码器的顺序 encoder.stop(); encoder.release(); // 重新创建和配置 encoder = MediaCodec.createEncoderByType("video/avc"); encoder.configure(newFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); inputSurface = encoder.createInputSurface(); encoder.start(); // 虚拟屏幕需要重新绑定新的 inputSurface virtualDisplay.setSurface(inputSurface);注意virtualDisplay.setSurface这个调用,很多人在重启编码器后忘了重新绑定,结果虚拟屏幕还在往旧的 Surface 写数据,画面直接卡死。这个坑我踩过一次,排查了半天才发现是 Surface 没更新。从那以后我每次改编码参数,都会强制走一遍「停编码器 → 重建 → 重绑虚拟屏幕」的完整流程,不敢偷懒。希望这个 Demo 能帮你少走点弯路。
本文还有配套的精品资源,点击获取