简介:Android端投屏demo是一份面向Android开发者的示例工程,其核心是演示基于MediaProjection API的手机屏幕镜像到电视、电脑或投影仪等设备的完整流程。压缩包大小33.55MB,共2000个文件,以class、png、xml、json、jar、java等为主,涵盖编译产物、界面资源、配置清单、源代码及辅助脚本,便于直接导入项目分析运行。已有7795人学习下载。项目代码中详细实现了运行时权限申请、MediaProjectionManager获取、ScreenCaptureService搭建、VirtualDisplay与ImageReader捕获屏幕帧、MediaCodec编码及音视频传输等关键环节,并附带了录制与回放功能。开发者可据此快速搭建投屏应用,理解屏幕捕获、编码和跨设备传输的完整链路,同时参考其目录组织与资源管理方式,为二次开发或性能优化提供直接样板。 手机投屏这个需求,说大不大,说小真不小。会议共享、远程协助、游戏观战、多屏协作,哪个场景离了“把这块屏幕搬到另一块屏幕上”都不行。我最早接触“Android端投屏demo”时以为就是调个系统API的事,真动手才发现,屏幕采集、编码、传输、解码,整条链路上每一步都有隐藏关卡。这篇文章把我自己从零撸出来的一套可运行Android投屏demo完整拆开讲,核心链路怎么设计、关键代码怎么落、黑屏和权限这些坑分别怎么踩过去,都会聊到。适合正准备做投屏功能、或者想拿一个最小可用demo做二次开发的Android开发者参考。
我采用的技术主路线是:系统自带的MediaProjection采集屏幕,用MediaCodec硬编码成H.264,走自建的Socket通道发给接收端,接收端拿到码流后硬解并渲染到Surface。这套方案链路短、可控性强,延迟基本能压在100毫秒左右,不依赖第三方SDK,也不碰厂商私有协议。接下来我按照从方案选型到代码落地、再到排坑调优的顺序,把每一步都掰开揉碎讲清楚。
1. 项目概述:这个投屏demo到底解决了什么问题
1.1 典型使用场景与用户痛点
投屏demo本身不是一个完整产品,它更像是投屏功能的“最小可行性验证”。我见过最典型的几种需求分别是:手机画面实时共享到会议室大屏,解决开会时一群人围着一块小屏幕看PPT的尴尬;工程师远程帮客户排查手机问题,需要看到对方屏幕才能定位是哪个界面卡了;还有游戏玩家想把操作画面分享给队友观看,对延迟特别敏感。
这些场景共同的要求是:实时性要好、画面不能糊、接入成本要低。很多现成方案要么绑死了特定硬件(比如必须用同一品牌电视),要么走云端中转导致延迟高到没法用。自己做一套基于局域网传输的投屏demo,就能在最基础的层面把这些问题验证清楚,后续无论往哪个方向扩展都有底子。
1.2 主流投屏方案对比,为什么我选自研
市面上常见的投屏技术路线有好几条,我整理了一张对比表,方便你快速判断自己的场景适合哪条路:
| 方案 | 传输基础 | 延迟表现 | 优点 | 缺点 |
|---|---|---|---|---|
| Miracast | Wi-Fi Direct | 100~200ms | 系统级支持,无需装App | 厂商兼容性参差,扩展性差 |
| Google Cast | Wi-Fi局域网 | 200ms以上 | 生态成熟,支持后台播放 | 定制困难,需要认证 |
| DLNA/AirPlay | 局域网 | 200~500ms | 多媒体场景体验好 | 屏幕镜像支持弱,实时性差 |
| 自研协议(本项目) | Socket/局域网 | 80~150ms | 完全可控、可深度定制 | 需要自己处理编码和传输细节 |
从表里能看出来,自研方案的核心优势不是“技术碾压”,而是可控性。你可以自由决定分辨率、码率、是否加密、是否支持双向控制,也可以针对特定硬件做优化。我在做这个demo时的目标很明确:不需要覆盖所有设备,只需要在自己的Android手机和接收端之间建立一条足够稳、足够快的通路。
1.3 为什么这套链路值得自己写一遍
很多刚入门的朋友问过我:“直接用scrcpy改一改不行吗?”行,但改完你得到的还是scrcpy的思路。自己写一套MediaProjection加MediaCodec的链路,你会被迫理解每一帧画面是怎么从屏幕进入编码器、变成二进制流、穿过网络、再被还原成画面的。这些知识点在面试、性能调优、适配问题排查中都能直接复用。
2. 核心链路拆解:从采集到显示的四段式架构
2.1 采集端:MediaProjection和VirtualDisplay的原理与局限
Android屏幕采集的官方入口是MediaProjection,它本质上是系统给你发的一张“截屏许可证”。调用createScreenCaptureIntent()会弹出一个用户确认对话框,只有用户点了允许,系统才会把屏幕内容授权给你。这个设计是为了防止App在后台偷偷录屏。
拿到授权之后,需要创建一个VirtualDisplay(虚拟显示器)。你可以把它理解成系统里凭空多出来的一块“隐形屏幕”,系统会把真实屏幕的内容镜像到这个虚拟屏幕上。创建时需要传一个Surface作为输出目标,这个Surface可以直接是编码器的输入Surface,也可以是一个普通Surface供你读取像素数据。我在demo里选的是前者,让画面直接从VirtualDisplay流向编码器,中间不经过CPU拷贝,效率最高。
需要注意的一个坑是:MediaProjection的授权是一次性的,进程被杀或者用户主动撤销后,需要重新发起授权流程。部分厂商系统还会在后台长时间采集时自动中断投影,所以采集必须配合前台服务使用。
2.2 编码端:MediaCodec硬编H.264的关键参数
编码是整个链路里最容易出问题、也最影响画质和延迟的环节。我选择H.264而不是H.265,核心原因是兼容性。H.264在Android设备上的硬编解码支持率接近100%,H.265虽然在同等码率下画质更好,但不少老设备的硬解能力跟不上,软解又会带来发热和卡顿。
编码器的关键参数配置如下:
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height) 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) format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh) format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel32) codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)这里耗费我最多时间的是Profile和Level的选择。Profile决定编码复杂度,Level决定分辨率、帧率的上限区间。很多教程只写分辨率不写Profile,结果某些设备能编不能解。我实测下来,Profile High配合Level 4.0在绝大多数设备上都是安全的,1080p@30fps的规格正好在这个区间内。
2.3 传输层:TCP、UDP还是WebSocket
传输层选型是个经典的权衡问题。TCP可靠但重传会导致延迟抖动,UDP快但丢包会直接造成花屏。WebSocket则是在TCP之上的协议封装,天然适合跨语言、跨端的对接场景。
我在demo里优先选择TCP,理由很实在:局域网场景下网络质量本身比较好,TCP的拥塞控制机制虽然会引入一定延迟,但能够保证画面完整性。对于投屏这种交互场景来说,“画面完整但偶尔延迟波动”比“低延迟但画面破损”的体验要好得多。
传输协议需要自己定义一个轻量帧格式。我的方案很简单:每帧数据前加8个字节的头部,前4字节存数据长度,后4字节存帧类型(关键帧或非关键帧)。接收端先读头部,再按长度读取完整数据块,这样能有效避免TCP粘包和半包问题。
2.4 解码端:MediaCodec硬解与Surface渲染
接收端解码相对简单,配置一个解码用MediaCodec,把网络收到的数据直接喂给它,输出Surface绑到SurfaceView或者TextureView上即可。这里有一个容易踩的坑:SurfaceView的画面更新是异步的,如果View没有正确进入可见状态,可能会导致画面卡在最后一帧。TextureView则更容易和动画、缩放等UI操作配合,代价是性能略低于SurfaceView。
在demo中我默认使用SurfaceView,因为它的性能更好,且投屏场景一般不需要复杂的UI叠加效果。如果你的接收端还需要绘制触摸指令、显示控制面板之类的叠加层,再换成TextureView会更合适。
3. 实操落地:从零搭建一个可运行的投屏demo
3.1 环境准备与工程配置
开发环境方面,我使用的是Android Studio最新稳定版,SDK版本支持到Android 14,编译目标设成34。测试设备我准备了两台:一台是小米系的手机,负责实测高版本Android和厂商定制的适配问题;另一台是老旧的Android 9设备,用来验证低版本兼容性。
工程结构上,我建了两个模块:app作为采集端(也就是被投屏的手机端),receiver作为接收端(可以是Android电视、盒子,也可以是另一台手机)。如果你只想在电脑上看画面,接收端也可以替换为PC上的VLC或者ffplay,但为了完整性,我建议先做Android接收端,方便统一用Android Studio调试。
项目依赖很少,不需要引入额外的第三方库。整个demo的核心代码只有三个类:ScreenCaptureService负责采集和编码,StreamSender负责网络发送,StreamReceiver负责网络接收和解码。这种极简结构方便你把注意力全部放在核心链路上。
3.2 权限申请和前台服务声明
投屏功能必须申请以下权限:
FOREGROUND_SERVICE:前台服务运行必需FOREGROUND_SERVICE_MEDIA_PROJECTION:Android 14新增,专门用于媒体投影前台服务POST_NOTIFICATIONS:Android 13以上需要动态申请通知权限,否则前台服务通知不显示
Manifest里还要声明前台服务的类型:
<service android:name=".ScreenCaptureService" android:foregroundServiceType="mediaProjection" android:exported="false" />权限申请顺序是:先动态申请通知权限,再发起MediaProjection授权。很多新手只处理了MediaProjection的弹窗,忘了通知权限,结果服务在后台跑了一会儿就被系统杀掉。另外,从Android 14开始,如果前台服务类型没有正确声明为mediaProjection,运行时会直接抛出ForegroundServiceStartNotAllowedException,这一条需要特别注意。
3.3 采集端编码与发送模块实现
采集端代码的核心流程如下:
- 通过
MediaProjectionManager发起屏幕采集授权 - 在
onActivityResult里拿到MediaProjection实例 - 创建编码器并获取输入Surface
- 用输入Surface创建VirtualDisplay
- 循环从编码器输出缓冲区取出编码后的数据
- 通过Socket发送给接收端
关键代码片段如下:
// 申请屏幕采集权限 val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_CAPTURE) // 在 onActivityResult 中获取 MediaProjection 并启动服务 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQUEST_CAPTURE && resultCode == RESULT_OK) { val serviceIntent = Intent(this, ScreenCaptureService::class.java) serviceIntent.putExtra("resultCode", resultCode) serviceIntent.putExtra("resultData", data) startForegroundService(serviceIntent) } }在ScreenCaptureService里,VirtualDisplay和编码器的绑定是核心:
val inputSurface = codec.createInputSurface() virtualDisplay = mediaProjection.createVirtualDisplay( "ScreenCastDisplay", width, height, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null )当VirtualDisplay创建成功后,屏幕内容就会持续不断地流向编码器。此时只需在单独的线程里循环读取编码器输出:
while (isRunning) { val index = codec.dequeueOutputBuffer(bufferInfo, 10_000) if (index >= 0) { val buffer = codec.getOutputBuffer(index) // 写入自定义帧头 + 编码数据到Socket sendFrame(buffer, bufferInfo) codec.releaseOutputBuffer(index, false) } }这几段代码连起来就是一条完整的采集编码链路。注意dequeueOutputBuffer的超时时间我设置为10毫秒,这个值在延迟和CPU占用之间比较平衡。设置太短会导致循环空转浪费电量,设置太长则可能在帧率波动时引入额外延迟。
3.4 接收端解码与显示模块实现
接收端相对简单,启动一个ServerSocket监听端口,等待发送端连接。连接建立后,从输入流中按帧头解析数据块,然后喂给解码器。
解码器配置:
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height) codec.configure(format, surface, null, 0) // surface来自SurfaceView codec.start()解码循环:
while (isRunning) { val index = codec.dequeueInputBuffer(10_000) if (index >= 0) { val inputBuffer = codec.getInputBuffer(index) val bytesRead = socketInputStream.read(inputBuffer!!) if (bytesRead > 0) { codec.queueInputBuffer(index, 0, bytesRead, pts, 0) } } val outputIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000) if (outputIndex >= 0) { codec.releaseOutputBuffer(outputIndex, true) } }看到releaseOutputBuffer的第二个参数我传的是true,意思是让解码器把画面直接渲染到Surface上。这一步有个隐藏细节:如果你传false,解码器不会自动渲染,画面就一直在缓冲区里积累,表现出来就是“解码在跑但屏幕不亮”。很多黑屏问题其实就出在这一个参数上。
3.5 Android高版本适配要点
Android 10开始强制分区存储后,App不再能随意访问外部存储的任意目录。如果你的投屏demo需要读取相册里的视频文件再投出去,切记不能直接拼/sdcard/DCIM/xxx.jpg路径去读,而是要用MediaStore或SAF。同理,如果接收端需要保存截图或录屏文件,也要写入应用专属目录,或者通过MediaStore写入公共目录。
Android 13和14的适配重点放在通知权限和前台服务类型上。我实测在小米HyperOS和原生Android 14上,只要前台服务类型漏了mediaProjection,服务必然启动失败。Android 14还要求在前台服务启动后的短时间内必须调用startForeground,否则会抛异常。
最稳妥的做法是:在onCreate里就立即调用startForeground,而不是等到网络连接或编码器准备好之后再调用。系统对前台服务启动窗口卡得很严,这个时机不能赌。
4. 实战排坑:那些让人抓狂的问题与解法
4.1 投屏黑屏:排查链路与方法
投屏黑屏是出现频率最高的问题,scrcpy和qtscrcpy的用户反馈里也大量提到。我总结下来,黑屏基本逃不出这几个原因:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 发送端黑屏(自己看不到画面) | VirtualDisplay与编码器输入Surface绑定失败 | 检查Surface是否有效,打印编码器状态 |
| 接收端黑屏但CPU在跑 | 解码器没有渲染到Surface | 检查releaseOutputBuffer第二个参数是否为true |
| 画面卡在第一帧 | 解码器缺少关键帧 | 确认编码器配置了KEY_I_FRAME_INTERVAL |
| 花屏后黑屏 | 网络丢包导致码流损坏 | 降低码率或改用TCP传输 |
我最常遇到的情况是编码器输出正常、网络也正常,但接收端一直黑屏。后来定位到是解码器初始化时没有拿到正确的宽高信息。解码器需要知道分辨率才能配置输出缓冲区,而我最初写死了1920x1080,但发送端实际采集的是手机竖屏分辨率,两者不匹配导致解码器罢工。解决方法是发送端把宽高信息写在自定义帧头的扩展字段里,接收端动态配置。
4.2 高版本Android的权限与URI坑
热词里出现的content://com.ss.android.uri.key/external_root/android/data/...这类路径,本质上是分区存储背景下,App之间共享文件的URI授权问题。你在投屏demo里如果还要处理“投屏前选一个文件先展示”这类交互,就会直接碰到这个问题。
一个典型报错是FileNotFoundException,原因是拿到了URI却没有持久化读取权限。解决办法是在onActivityResult里调用:
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)这样才能在后续的Service里持续读取这个URI指向的文件。另外,file://开头的路径在Android 7.0以上默认会被FileUriExposedException拦截,所有跨App文件共享都应该使用FileProvider生成content://URI。
4.3 老机型与低版本系统兼容
热词里有“安卓4.4.2支持的远程投屏”,这部分我专门验证过。Android 5.0以下没有MediaProjection API,无法通过常规方式采集屏幕,只能在root设备上读取/dev/graphics/fb0帧缓冲,或者依赖ADB的screenrecord命令抓取。4.4.2还有一种折中做法是用MediaRecorder直接录屏到文件,再用流媒体服务器转发,但延迟会高到无法接受。
如果你的产品必须支持Android 5.0以下设备,我的建议是:放弃自研采集,改接Miracast硬件方案。在软件层面硬撑老设备,投入产出比太低。这个demo可以从Android 7.0起步,老设备数量已经很少,强行兼容反而会拖累整体体验。
4.4 延迟、帧率和画质的调优思路
调优的核心思路是“先保证可用,再追求极致”。当链路能跑通出现画面后,延迟偏高是正常的,需要逐段排查。我实际测试中发现,传输层占用的延迟通常在20~40毫秒,编码器本身会引入30~50毫秒,解码端还有一帧的缓冲延迟。如果整体延迟超过200毫秒,优先检查网络发送端是否因为TCP拥塞而阻塞了编码循环。
一个有效的优化手段是设置KEY_LOW_LATENCY为1,部分设备的编码器会启用低延迟模式。但实测发现这个参数在部分高通芯片上是有效的,在联发科上却没什么变化,所以不能作为万能药。另一个更通用的方式是降低编码分辨率而不是降低码率:把1080p降到720p,画面锐度损失不大,但编码时间和传输带宽都会显著降低。
音频投屏我没把它放进这个demo里,原因是音频采集需要额外的AudioRecord权限和AAC编码链路,复杂度会翻倍。如果你的业务场景需要声音,建议把音频作为一个独立模块后续加上,先保证视频链路稳定再扩展音频,排错会轻松很多。
最后再分享一个我踩了三次的坑:不要试图用同一个Socket同时传视频和触摸控制指令。视频数据量大会挤占控制指令的发送时机,导致远端操作卡成幻灯片。最佳实践是单独开一条控制通道,哪怕多占用一个端口也值得。这个小改动让整个demo的交互体验提升了一个档次,你按这个思路去扩展遥控功能时会感谢这个决定。
本文还有配套的精品资源,点击获取