
简介Android设备投屏功能Demo是一份面向Android应用开发者的完整示例工程旨在帮助开发者实现将手机或平板屏幕内容实时投放到电视、显示器等大屏设备。项目以MediaProjection完成屏幕捕获用MediaCodec将画面编码为H264流再经Socket分包传输至接收端接收端同样通过MediaCodec解码并恢复画面。压缩包包含1536个文件整体大小约4.87MB其中主要有975个flat资源文件、228个xml配置布局、218个json数据、36个java源码以及gradle构建文件基本覆盖了从资源定义、界面布局到核心编码与通信逻辑的完整结构。该资源已有3235人学习浏览适合正在学习Android多媒体开发、希望深入理解远程投屏原理或需要自定义投屏方案的初中级开发者。除基础流程外Demo还处理了H264流的NAL单元解析、网络延迟与丢包应对并为音视频同步、传输效率优化等扩展留出空间。开发者通过分析工程可掌握投屏链路的关键细节也能在其基础上快速改造出自己的投屏应用。1. 项目概述做投屏这件事说复杂也复杂说简单也简单。市面上常见的方案里有人直接用系统自带的Miracast有人用第三方App扫码投屏还有人干脆用USB线连上开发工具直接看。但如果你想把投屏能力集成到自己的产品里或者想在特定业务场景下做定制化的屏幕分享那就得自己动手写一个投屏模块了。这篇文章讲的就是我自己从零写的一个Android设备投屏功能Demo。目标很明确把手机屏幕实时投到电脑上延迟尽量低画质尽量清晰代码结构尽量清晰方便后续扩展成完整的投屏SDK。技术上走的是MediaProjection采集屏幕画面编码后用Socket推流电脑端用VLC或者自研播放器拉流显示这条路径。这个Demo适合谁看一是刚接触屏幕采集和流媒体传输的Android开发想找一个能跑通的完整链路做参考二是产品里需要投屏能力想快速评估技术方案可行性的技术选型同学。文章里会把我踩过的坑、调试过程中的细节、参数选择的逻辑都讲清楚让你看完能直接照着搭一套属于自己的投屏Demo。2. 投屏整体方案选型与思路拆解2.1 常见投屏方案的横向对比在做技术选型之前先把市面上主流的投屏路子捋一遍这样后面选型才有依据。第一种是MiracastAndroid系统原生支持走Wi-Fi Direct通道延迟低但问题是它只能做屏幕镜像没法拿到流数据做二次处理比如加水印、录制、分析。而且Miracast接收端大多需要专门的硬件或者协议栈支持Windows电脑要额外装软件才能当接收端可定制性很差。第二种是AirPlay苹果生态的东西Android设备想投到苹果设备或者Apple TV上要么用三方SDK比如AirServer要么自己逆向AirPlay协议。逆向协议这事维护成本太高公司项目不建议碰个人学习倒是可以研究研究。第三种是Google CastChromecast那套偏向媒体内容投送适合把视频网站的播放任务丢给电视端不太适合实时屏幕镜像因为它的应用场景是“告诉接收端播什么”不是“把屏幕画面实时传过去”。第四种就是scrcpy那种方案走ADB通道用adb forward建立端口映射把手机屏幕采集数据直接通过USB线传到电脑。这个方案有个巨大的优势不需要手机和电脑在同一Wi-Fi网络下USB线传数据稳定且延迟极低。但如果你的使用场景是无线投屏或者投屏双方不在同一局域网scrcpy这套就受限了。第五种是自己搭链路MediaProjection采集屏幕编码Socket或RTMP推流电脑端解码显示。这套方案前期开发成本高但胜在完全可控编解码参数、传输协议、画面处理都能按需定制。我最终选的是第五种方案。原因很简单这个Demo的目的是验证投屏链路的核心技术可行性同时保留足够的扩展空间。用Socket自研推流协议可以完全控制延迟和缓冲策略用H.264编码兼容性和编码速度都兼顾到了。如果你以后想改成RTMP推流到服务器或者改成WebRTC做低延迟音视频通话在这套Demo的基础上改造都比从零开始要快得多。2.2 自研投屏Demo的技术架构整个Demo的技术链路分四个环节屏幕画面采集、编码压缩、网络传输、远端解码显示。每个环节都有对应的Android组件或开源库负责需要独立验证和调优。屏幕采集用的是MediaProjection这是Android 5.0API 21开始提供的系统级屏幕采集API。它做的事情本质上是把屏幕帧数据持续输出到一块虚拟屏幕上我们可以把这块虚拟屏幕的内容实时读取并送入编码器。这里要特别注意MediaProjection必须要用户授权而且Android 10API 29之后每次启动采集都必须重新弹窗让用户确认这是系统安全策略绕不过去。编码环节用的是MediaCodec硬件编码器编码格式选择H.264。H.264是目前兼容性最好的视频编码格式几乎所有终端播放器都能解码而且硬件编码器在移动端是标配编码速度非常快对延迟的影响小。分辨率我设置的是手机屏幕原始分辨率码率控制在2到4Mbps之间这样在局域网环境下的画面清晰度和网络压力能取得一个平衡。传输环节我有两种路径走的是Socket 自定义协议直接把H.264的原始码流分包后通过TCP传输。为什么不用RTMP因为RTMP需要服务器中转在这个Demo的单设备直连场景里多一层服务就多一分延迟。TCP虽然比UDP慢但能保证数据不丢包配合解码缓冲调整实际效果在局域网上已经很能打了。远端解码显示这边的逻辑是PC端用VLC配合自定义的流地址接收H.264裸流并解码播放。如果你不想依赖VLC也可以自己在PC上写一个小的解码器用FFmpeg拿到H.264裸流后解码渲染。为了方便演示VLC是最省事的选择它支持从TCP端口直接拉取裸流播放参数都不用怎么调。3. 核心环节技术拆解与关键实现3.1 屏幕采集模块MediaProjection的正确打开方式MediaProjection的用法看似简单但里面有几个细节没注意就会出问题。先看核心代码逻辑然后再逐一解释每个参数的含义。class ScreenCaptureService : Service() { private var mediaProjection: MediaProjection? null private var virtualDisplay: VirtualDisplay? null private var encoder: MediaCodec? null private var isStreaming false override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val resultCode intent?.getIntExtra(resultCode, Activity.RESULT_CANCELED) ?: Activity.RESULT_CANCELED val data: Intent? intent?.getParcelableExtra(data) mediaProjection getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager .getMediaProjection(resultCode, data) setupVirtualDisplay() setupEncoder() startStreamingThread() return START_STICKY } private fun setupVirtualDisplay() { val metrics resources.displayMetrics val width metrics.widthPixels val height metrics.heightPixels val densityDpi metrics.densityDpi virtualDisplay mediaProjection?.createVirtualDisplay( ScreenMirrorDisplay, width, height, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, encoderInputSurface, // Surface从MediaCodec的输入面获得 null, null ) } }这里最关键的一个设计是VirtualDisplay直接把画面渲染到MediaCodec的输入Surface上中间省掉了一次SurfaceTexture的拷贝过程。这是降低延迟的核心手段。如果你想让画面同时显示在手机端边投边看那就得用OpenGL把画面同时渲染到两个Surface上这个场景下延迟会稍微高一点但功能上完全可行。MediaProjection实例拿到之后要妥善管理它是一个系统级资源操作完必须调用stop()释放不然下次调用getMediaProjection的时候会抛异常。Service的onDestroy里要记得做清理override fun onDestroy() { super.onDestroy() isStreaming false virtualDisplay?.release() encoder?.stop() encoder?.release() mediaProjection?.stop() }还有一个权限问题很容易被忽略Android 14API 34开始MediaProjection要求前台服务类型必须是mediaProjection并且需要在清单文件里声明权限。如果你还在用旧的写法会发现应用在Android 14设备上直接崩溃。这个问题我在后面调试过程中踩过一次专门放到问题排查部分详细说。3.2 编码模块MediaCodec硬件编码的调优细节编码模块的几个关键参数对延迟和画质的影响非常大我直接给出我最终调优后觉得比较合理的一组配置private fun setupEncoder() { val width resources.displayMetrics.widthPixels val height resources.displayMetrics.heightPixels 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) // 4Mbps format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) format.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) format.setInteger(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 10_000) encoder MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) encoder?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) encoderInputSurface encoder?.createInputSurface() encoder?.start() }几个关键参数的具体考量码率4Mbps这个数值不是拍脑袋定的我是用手机屏幕分辨率比如1080x2400算出来的。以1080p为例4Mbps在桌面静止场景下画质足够动态画面下稍有模糊但不影响观看。如果你要演示高清视频或者玩游戏的画面码率要往上调到6到8Mbps。VBR模式下编码器会根据画面复杂程度动态调整码率实际网络占用在一定范围内浮动这是最符合实际使用的模式。关键帧间隔设置的是2秒。H.264的I帧是解码器重新同步画面的基准I帧间隔越短画面恢复越快但码流体积也会增大。2秒的设置意味着播放端最坏情况下2秒内能恢复到完整画面这个体验是可接受的。帧率设置30fps是折中的选择。高帧率对编码器的压力会成倍增加而普通场景下30fps已经足够流畅。如果你的Demo要投屏玩FPS游戏可以提高到60fps但要确认设备硬件编码器支持60fps的1080p编码否则会出现编码超时导致卡顿。编码输出处理部分要写一个独立的循环从编码器输出缓冲区拿到H.264码流数据加上自定义帧头标记后通过Socket发送。这里有个细节编码器输出的数据是以BufferInfo偏移量、大小、标志位为单位的要在读取输出缓冲时判断BUFFER_FLAG_KEY_FRAME标志这是一个关键帧需要在传输报文里标记出来方便接收端在关键帧到达时刷新解码器val bufferInfo MediaCodec.BufferInfo() while (isStreaming) { val outputIndex encoder?.dequeueOutputBuffer(bufferInfo, 10_000) // 阻塞10ms when { outputIndex MediaCodec.INFO_TRY_AGAIN_LATER - { // 没有数据继续循环 } outputIndex 0 - { val outputBuffer encoder?.getOutputBuffer(outputIndex) outputBuffer?.position(bufferInfo.offset) outputBuffer?.limit(bufferInfo.offset bufferInfo.size) // 判断是否关键帧 if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME ! 0) { socketWriter.writeKeyFrameFlag() } socketWriter.writeData(outputBuffer) encoder?.releaseOutputBuffer(outputIndex, false) } } }这里要注意的是MediaCodec的异步模式在新版本Android中更推荐使用但为了Demo代码的清晰和通用性用同步阻塞模式也完全够用。3.3 网络传输模块Socket自定义协议的设计思路传输层我用的是TCP Socket 自定义最小化帧协议。协议设计得尽量简单只在每个H.264 NAL单元前面加上4个字节的长度头和1个字节的标志位标记是否为关键帧。这个设计在可读性和传输效率之间取得了平衡。public class StreamProtocol { // 帧头格式 // [标志位:1字节] [数据长度:4字节(大端序)] [H.264裸流数据:长度字节] public static void writeFrame(OutputStream out, byte[] frameData, boolean isKeyFrame) throws IOException { // 写入标志位 out.write(isKeyFrame ? 0x01 : 0x00); // 写入长度4字节大端序 out.write((frameData.length 24) 0xFF); out.write((frameData.length 16) 0xFF); out.write((frameData.length 8) 0xFF); out.write(frameData.length 0xFF); // 写入数据 out.write(frameData); out.flush(); } }为什么不用现成的RTMP或者WebRTC协议核心原因是Demo的目标是验证技术链路自定义协议可以清晰展示数据的封装和解析过程利于学习和后续定制。如果你要快速做成产品建议直接用成熟的WebRTC方案但那涉及的信令服务和ICE穿透非常庞大已经超出了入门Demo的范畴。接收端我用VLC播放的这种方式最简单。VLC支持从TCP流读取裸H.264数据需要在VLC里设置网络流地址为tcp://电脑IP:端口同时取消“实时流缓存”或者把缓存调低到约150ms左右否则延迟会非常明显。如果你想写一个独立的PC端接收程序用FFmpeg库读取Socket数据再解码渲染成本也不高就看你的需求边界在哪。4. 实现完整投屏链路的实操路径4.1 开发环境的版本选择与配置Demo开发我用的环境组合是Android Studio最新稳定版、compileSdk 34、targetSdk 34、minSdk 24。这些参数很关键直接决定你后面能否顺利跑通MediaProjection和前台服务逻辑。minSdk选24的原因有两方面一是Android 7.0以上的设备市场占有率已经非常高兼容性足够覆盖绝大部分测试机型二是低版本设备的硬件编码器能力参差不齐如果要完全兼容代码里要写的兼容逻辑会成倍增加这违背了Demo“验证核心链路”的初衷。如果你有低版本测试机可以后续再单独加分支适配。在AndroidManifest.xml里你要注册前台服务并且声明必要权限manifest xmlns:androidhttp://schemas.android.com/apk/res/android !-- 屏幕采集需要 -- uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION / uses-permission android:nameandroid.permission.INTERNET / application service android:name.ScreenCaptureService android:foregroundServiceTypemediaProjection android:exportedfalse / /application /manifestAndroid 14API 34开始如果你没有声明FOREGROUND_SERVICE_MEDIA_PROJECTION权限或者前台服务类型不是mediaProjection启动采集服务时会直接抛SecurityException很多老代码迁移到新版本都会在这里翻车。4.2 从申请权限到开始推流的关键编码流程整个流程串联起来大概是这样一个顺序第一步在MainActivity里申请屏幕采集权限。这里的核心API是MediaProjectionManager.createScreenCaptureIntent()系统会弹出授权窗口让用户确认是否允许屏幕采集。注意resOnActivityResult里的处理要保存好返回的resultCode和data对象它们后续用来创建MediaProjection实例。class MainActivity : AppCompatActivity() { companion object { private const val REQUEST_CODE_CAPTURE 1001 } fun startProjection() { val projectionManager getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val intent projectionManager.createScreenCaptureIntent() startActivityForResult(intent, REQUEST_CODE_CAPTURE) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { REQUEST_CODE_CAPTURE - { if (resultCode RESULT_OK data ! null) { val serviceIntent Intent(this, ScreenCaptureService::class.java) serviceIntent.putExtra(resultCode, resultCode) serviceIntent.putExtra(data, data) ContextCompat.startForegroundService(this, serviceIntent) } else { // 用户拒绝了授权要提示用户 } } } } }第二步是去Mobile端启动前台服务在服务中通过MediaProjectionManager.getMediaProjection()获取MediaProjection实例。第三步是用MediaProjection创建VirtualDisplay把输出Surface指定为MediaCodec的输入Surface。这一步建立完成后编码器就会自动开始从Surface读取屏幕帧数据并编码。第四步是编码器输出循环不断取出编码后的H.264码流数据封装后通过Socket发送到电脑端。第五步是电脑端启动VLC拉取TCP流并解码显示。这个五个步骤中间每一个环节都可能出问题我在调试时把日志按模块分得很细方便快速定位问题出在采集、编码还是传输。4.3 局域网传输延时测试结果整个链路搭建完成后我做了两轮实测。第一轮是在客厅Wi-Fi下手机和电脑连接的是同一个路由器平均延迟大约在150ms到250ms之间。这个延迟已经算是不错的表现了作为演示用途完全够用。第二轮我把电脑换成了有线网络手机用Wi-Fi连接延迟进一步提升到约80ms到120ms。这说明投屏延迟的主要瓶颈不在编码和传输协议而在网络链路的稳定性和带宽。如果你希望延迟更低就要从采集端减少缓存、传输层减少缓冲、解码端减少缓冲三个方向同时下手。实测过程中的一个经验是Wi-Fi环境下5GHz频段比2.4GHz频段的稳定性和带宽都好很多如果有条件投屏演示尽量用5GHz AP画面卡顿的概率能降低一半以上。5. 常见问题排查与避坑实录5.1 最容易踩的五个坑第一个问题是启动采集服务后手机屏幕出现了“正在投屏”的安全标记而且画面无法隐藏。这个是Android 13及以上系统对隐私保护的强制策略隐藏状态栏图标和系统录制标记是不可能的但如果你投屏的是自己应用的业务页面其实不影响最终效果。如果你说用户接受不了这个标记那就要在应用层面找替代方案比如不用系统级投屏改用自己家的SDK去渲染指定页面那就是另一套技术路径了。第二个问题是VLC端画面黑屏但有声音如果你加了音频采集。我遇到这个情况多半是编码器输出的H.264码流缺少了SPS和PPS关键参数集。Android MediaCodec默认把SPS/PPS放在关键帧前面一起输出但如果你收到的第一帧数据漏了这两个参数集VLC就不知道怎么解码。解决办法是在Socket发送时第一个关键帧前强制附加SPS/PPS数据或者接收端手动配置Codec参数。第三个问题是用线程发送编码数据时Socket被堵满了。TCP是一个可靠传输协议当接收端来不及消费数据时发送端的write操作会阻塞。这在局域网内不太容易遇到但如果接收端解码性能差或者Wi-Fi网络拥塞发送端的循环会被拖慢从而影响屏幕采集和编码。解决思路是在发送线程和数据生产线程之间加一个有限容量的缓冲队列当队列满时丢弃非关键帧保留关键帧优先发送保证丢帧不丢画面同步。第四个问题是Android 14设备上启动投屏服务直接闪退。这就是我前面提到的前台服务类型问题Android 14要求MediaProjection必须运行在前台服务中且声明mediaProjection类型同时需要FOREGROUND_SERVICE_MEDIA_PROJECTION权限。很多老项目在Android 14上出问题都是这个原因注意在清单文件里严格按格式声明即可解决。第五个问题是高分辨率设备比如2K、4K屏手机编码性能不足。有些中低端机型的硬件编码器不支持高分辨率编码或者支持但编码帧率达不到30fps。这种情况需要你根据MediaCodecInfo.CodecCapabilities遍历设备支持的编码能力然后做降分辨率处理比如降到720p编码才能保证流畅度。5.2 调试工具和日志技巧分享调试投屏类项目日志分级很重要。我通常把日志分成ScreenCapture、MediaCodec、SocketSend、BufferQueue四级每一模块独立打tag方便排查。加一个临时调试开关打开后可以在手机屏幕上实时显示当前帧率、码率、帧间隔这对调试非常有用。还有一个小技巧在Socket接收端做一个简单的数据统计界面统计每秒收到的帧数和码率就可以快速判断瓶颈在哪个环节。如果手机端帧率显示30fps但接收端只有10fps那问题大概率出在接收端解码或者网络带宽上。如果手机端帧率都不到30fps那就是采集编码环节有问题和接收端没关系。6. 投屏Demo的扩展方向写到这里核心Demo已经完全跑通了。最后说几个我深入研究后认为很值得做的扩展方向。一个是加音频采集。屏幕投屏加声音体验会完整很多。Android端用AudioRecord采集麦克风或系统内部音频经过AAC编码后与H.264视频码流同步混流传输接收端用FFmpeg解码音视频。功能不难难点在音视频同步策略需要引入时间戳对齐机制技术深度一下子就会提升不少。另一个是反向控制。也就是投屏之外电脑端能模拟鼠标点击、滑动操作控制手机端的应用。这个方向在远程协助、设备演示场景下非常实用。Android原生层面可以借助AccessibilityService实现触摸事件的注入也可以直接封装ADB的input命令来做。这样投屏Demo就已经接近scrcpy的核心功能了只是传输层还是自定义协议不能直接用scrcpy兼容。再往后如果要商用到跨网络环境就要引入更标准化的协议了。比如WebRTC它自带打洞能力和自适应码率控制在网络切换、延迟控制方面比自定义TCP协议优秀很多或者走RTMP推到CDN通过HTTP-FLV或HLS协议分发给海量用户观看。这些进阶方向都是在当前Demo的技术模型上不断叠加和完善的过程。我自己在实际操作中的体会是投屏这个功能看似基础实际上涉及Android系统框架、视频编码、网络传输、播放器技术四个领域的知识能把每个环节吃透对整个移动端技术能力的提升是非常明显的。这份Demo作为一个起点后续你可以按自己的业务需求不断改造进化从局域网走向广域网从单向展示走向双向交互每一步都有值得深入的技术细节。最后再分享一个小技巧把手机连上测试电脑的USB口用adb logcat配合Tag过滤来查看报错代码里每个模块的异常都用同一Tag打印堆栈效率会好很多。祝大家都能顺利跑通自己的投屏链路少踩一些我踩过的坑。本文还有配套的精品资源点击获取