ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

安卓投屏开发实战:屏幕采集、编码与传输链路解析

安卓投屏开发实战:屏幕采集、编码与传输链路解析 简介Android设备投屏功能Demo是一份面向Android开发者的实战工程演示如何将手机屏幕实时投射到大屏设备。项目基于MediaProjection完成屏幕捕获借助MediaCodec实现H264硬编解码并通过Socket建立局域网传输通道形成完整的投屏链路。资源共1536个文件压缩包约4.87MB其中Java源码36个可用于逻辑分析XML文件228个对应布局与配置JSON文件218个常用于数据结构另有PNG图标与Gradle构建文件等目录结构清晰方便按模块检索便于二次开发。目前已有3235人学习下载适合想掌握Android多媒体开发与远程显示技术的初中级开发者。通过该Demo可理解系统录屏权限申请、MediaCodec缓冲区输入输出、H264 NAL单元切分与解析、Socket分包发送及接收端解码渲染等核心环节也可在此基础上扩展音视频同步、码率自适应等高级功能学习价值较高。1. 投屏Demo到底在做什么一条完整的镜像链路先说结论Android设备投屏功能Demo本质就是把手机屏幕的每一帧画面“搬”到另一个显示设备上。听起来简单但里面涉及画面采集、编码压缩、网络传输、解码显示四个环节任何一个环节没做好结果就是黑屏、花屏、延迟高到没法用。这两年我在各种项目里做过不止一次投屏类的开发从早期的Miracast盒子到现在的软件投屏方案踩过的坑足够写成一本书了。这篇文章我把自己在“Android设备投屏功能Demo”上沉淀的东西完整梳理一遍从原理到代码再到排查套路尽量做到拿过去就能用。先看一个最常见的场景你想把手机上的视频、游戏画面或者演示文稿投到电脑、电视上。市面上成熟工具比如scrcpy走的是“ADB 编码转发”的纯软件方案极低成本实现低延迟投屏。而我们自己写一个Demo通常是为了一些定制需求比如只投某个应用、加水印、自定义码率策略、或者把画面喂给后端做进一步处理。理解了这条链路你就知道为什么网络上那么多人在搜“scrcpy投屏了不显示画面”“QtScrcpy投屏黑屏”之类的问题——这些问题绝大多数不是工具本身的问题而是对这条链路里某个环节理解不到位。适合看这篇文章的人我默认你已经有Android开发基础了解Activity、Service、线程这些基本概念。如果你只是想知道“怎么把手机投到电脑”那直接装个现成软件就行不需要看代码。但如果你想自己写一个投屏Demo、或者想搞清楚投屏为什么会有延迟、为什么黑屏那这篇文章应该能帮你省下不少时间。投屏这个Demo的技术栈非常清晰核心只有四部分MediaProjection负责请求录屏权限拿到屏幕画面的数据源。VirtualDisplay把屏幕内容“投射”到一个虚拟显示设备上——其实就是一个Surface。MediaCodec对Surface上的画面做硬件编码压缩成H.264/H.265码流。传输通道把编码后的码流通过Socket/UDP方式发出去接收端解码显示。链路看似不复杂但每一环都有讲究。下面我把整个设计和实现过程拆开来讲尤其是那些网上资料很少讲的细节。2. 方案选型为什么“采集-编码-传输”比截屏靠谱2.1 常见的三种投屏实现方案我在做技术选型的时候会在三种方案里权衡。这里列个表格把我实测下来的感受放进去方案实现路径延迟表现适用场景缺点系统无线投屏Miracast/AirPlay系统底层实现应用层调用80~200ms日常演示、看视频延迟不稳、定制能力弱ADB软编码转发scrcpy方案PC端控制ADB传数据30~100ms开发调试、工具类应用依赖ADB适合单机调试MediaProjection MediaCodec Socket完全应用层实现50~150ms定制投屏、远程控制需要自己处理所有链路我们这个Demo采用的是第三种方案也就是应用内实现完整投屏链路。选它的原因很简单一是可以不依赖ADB用户安装App就能用二是一切环节都握在自己手里后续要做什么低延迟优化、画质调节、多端分发改造起来都方便。当然代价也很明显——所有坑都得自己踩比如权限适配、编码器兼容性、传输粘包、接收端解码延迟等等。折腾过一遍之后好处是对Android多媒体这块整个体系你会有一个非常清晰的认识以后再做摄像头推流之类的事情会发现很多经验可以复用。2.2 延迟在哪里产生——先弄懂“为什么慢”做投屏Demo最大的痛点永远是延迟。我见过不少新手把每个环节都做对了但整体延迟就是压不下去。这不是代码写得不对而是对“延迟是从哪里来的”没有概念。一条典型的投屏链路延迟主要来自四个节点画面采集延迟屏幕画面从GPU渲染完成到被编码器读到中间要经过VirtualDisplay这一层。Android的Surface机制本身有缓冲一般来说会带来1~2帧的固有问题。编码延迟这是大头。硬件编码器虽然快但也需要积累一定帧数据才能工作。很多编码器默认的“延时”设置是为了画质优化不是为实时传输设计的。网络传输延迟这个取决于你的传输协议和通道质量。走局域网Wi-Fi的话延迟在5~20ms之间不算大但如果走TCP且路由器拥塞会有排队延迟和重传延迟性能直接血崩。解码渲染延迟接收端如果做得不讲究——比如缓冲区开得过大或者解码器配置了较大的DPB参考帧缓冲——画面会积压延迟直线上升。注意这四项是叠加关系不是取最大值。所以优化一个点不够每个点都要压。后面我会提到具体的策略比如编码器配置里调低KEY_LATENCY、传输层不用TCP改用UDP自定义重传都是为了同时压低多个节点。3. 一步步实现投屏Demo从权限到画面输出3.1 申请录屏权限MediaProjection的坑Android从5.0开始提供MediaProjection用来捕获屏幕内容。使用前必须先弹窗让用户授权这个授权不能静默获取是系统强制要求的。核心代码如下MediaProjectionManager mpManager (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); Intent permissionIntent mpManager.createScreenCaptureIntent(); startActivityForResult(permissionIntent, REQUEST_CODE_CAPTURE);用户在弹窗点了允许之后你在onActivityResult里拿到resultCode和data再用它们创建MediaProjection实例Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode REQUEST_CODE_CAPTURE resultCode RESULT_OK) { mMediaProjection mpManager.getMediaProjection(resultCode, data); // 到这里你才算真正拿到了“录屏钥匙” } }这里有几个坑必须提醒MediaProjection实例不能长期复用。不同Android版本上MediaProjection的生命周期策略不一致。比如在Android 14API 34上系统对MediaProjection的管控更严格每次投屏建议重新申请权限再创建实例不要尝试存起来下次直接用。Android 10API 29以上MediaProjection必须要有一个前台Service配合否则录屏会出问题后台一会儿就被系统杀掉。VirtualDisplay的尺寸建议和屏幕真实分辨率一致。不要为了偷懒直接写死一个1920x1080因为不同设备的屏幕比例不同强行拉伸会导致画面变形。3.2 创建VirtualDisplay让屏幕画面“流”进编码器MediaProjection拿到的是“屏幕的访问权”真正把画面输出去要靠VirtualDisplay。VirtualDisplay可以理解为一块虚拟屏幕Android系统会把真实的屏幕内容同时渲染到你设定的这个虚拟屏上。创建VirtualDisplay的代码长这样VirtualDisplay virtualDisplay mMediaProjection.createVirtualDisplay( ScreenCast, // 名称调试时会在系统日志里出现 width, height, // 虚拟屏的宽高 dpi, // 像素密度建议和真实屏幕一致 DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, // 自动镜像 mSurface, // 这个Surface必须来自编码器的输入 null, null);这里最关键的参数是mSurface。它不能随便创建必须用MediaCodec的createInputSurface()来拿——这样虚拟屏的画面才会“喂”给编码器。我之前遇到过有人在这里直接把SurfaceView的Surface传进去然后各种黑屏。原因就是SurfaceView的Surface是用来“显示画面”的而“编码器的输入Surface”是用来“消费画面”的两者虽然都叫Surface但数据流向完全不同。记住一句话VirtualDisplay的Surface永远指向数据消费者不要指向显示控件。3.3 MediaCodec编码配置延迟和画质的平衡艺术编码器的配置是整个Demo的灵魂也是我花时间最多调试的地方。一个最小可用的硬件编码器配置如下MediaFormat 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, bitRate); // 码率 format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); // 帧率 format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2); // 关键帧间隔单位秒 mEncoder MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); mEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); mSurface mEncoder.createInputSurface(); mEncoder.start();这几个参数看着简单背后的门道不少码率KEY_BIT_RATE怎么定我用的经验公式是码率 宽度 × 高度 × 帧率 × 系数。系数在0.07~0.1之间。以720p1280×72030帧为例1280×720×30×0.08 ≈ 221万bps也就是2.2Mbps左右。这个码率在普通Wi-Fi环境下传输非常稳画质也能接受。如果画面内容动态变化大比如游戏画面码率可以适当调高到4Mbps但不要无限高——码率高了延迟也会升高因为编码器的码控算法倾向于把更多数据塞进缓冲里。关键帧间隔KEY_I_FRAME_INTERVAL这个参数决定了每隔多少秒插入一个I帧关键帧。I帧是解码器“重新找基准”的帧没有I帧接收端就无法开始解码。但I帧体积大传输耗时所以间隔不能太短也不能太长。默认2秒是经验值。如果你的接收端是异步连接比如中途才加入投屏可以在编码期间主动请求一次I帧mBundle.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)编码器会在下一帧输出关键帧接收端就能快速出画面。Profile/B帧的问题很多人忽略了这个。Android硬件编码器默认可能会开启B帧双向预测帧B帧压缩率高画质好但会引入额外延迟——因为编码器要“等”后面的帧才能编码当前的B帧。在低延迟场景尽量指定为Baseline Profile它不带B帧延迟会低很多。配置方式是在MediaFormat里加format.setFeatureEnabled(CodecCapabilities.FEATURE_PARTIAL_FRAME, false);不过实测发现并不是所有设备的硬件编码器都支持手动关闭B帧部分芯片可能忽略该设置。这也解释了为什么同一套代码在不同手机上的延迟表现差异巨大。3.4 数据打包与Socket发送编码器输出的数据是H.264裸流它通过dequeueOutputBuffer拿到的每个buffer可能是完整的帧数据也可能只是半个帧H.264允许一个帧拆成多个NAL单元。所以发送前必须做组帧工作。我的做法比较简单直接用MediaCodec.BufferInfo里的flags来判断帧边界。如果flag MediaCodec.BUFFER_FLAG_KEY_FRAME ! 0说明这是关键帧如果是BUFFER_FLAG_CODEC_CONFIG那是编码器配置信息SPS/PPS需要单独保存并在每个关键帧前带上。发送端把每个原始帧的数据加上自定义头部比如4字节长度字段、2字节帧类型标记打包成一段完整的byte[]再通过Socket发出去。接收端按同样的协议拆包、拼接、喂给解码器。如果你接收端用的是FFmpeg/VLC做验证那就更简单了。把编码器输出的裸流直接通过UDP发到某个端口FFmpeg用ffplay udp://0.0.0.0:1234就能直接拉流播放。我第一次打通的时候看到FFplay弹出画面那一刻心情相当激动——这是一条完全属于你自己的投屏链路。传输层的选择上我的建议是局域网内优先UDP。UDP不需要三次握手没有重传机制天然低延迟。缺点是丢包但局域网丢包率极低实测完全可接受。跨公网/弱网环境才考虑TCP。TCP在丢包时会阻塞重传导致延迟飙升但它保证数据完整。在延迟和可靠之间你要有自己的取舍。4. 连接与验证让Demo端到端跑起来4.1 搭建接收端验证环境如果你和我一样不想一开始就撸接收端App的代码强烈推荐先用现成工具做端到端验证。最简单的验证路径是手机端安装你写的投屏Sender App。发送端把H.264码流通过UDP发到电脑的某个端口例如udp://0.0.0.0:8888。电脑上装FFplay运行命令ffplay -fflags nobuffer -probesize 32 -analyzeduration 0 udp://0.0.0.0:8888。我这里解释一下命令里的几个参数-fflags nobuffer关闭缓冲降低延迟。-probesize 32减少探测数据量让播放器更快启动。-analyzeduration 0不分析时长启动更快。如果能看到手机画面实时出现在电脑窗口里说明你的采集、编码、发送链路全部通了。之后你再往接收端加自己的解码器、渲染逻辑就只剩“接收端怎么解”这一个纯前端问题了。4.2 第一个可跑通的Demo骨架在真正写完整工程之前你可以先用这个最小骨架验证整条链路。我把发送端的核心逻辑写个伪代码流程// 1. 申请权限见上一节的MediaProjectionManager流程 // 2. 创建编码器 VirtualDisplay MediaCodec encoder createScreenEncoder(width, height, bitRate); VirtualDisplay vd createVirtualDisplay(encoder.createInputSurface()); // 3. 循环取出编码器输出并发送 while (isRunning) { MediaCodec.BufferInfo info new MediaCodec.BufferInfo(); int index encoder.dequeueOutputBuffer(info, TIMEOUT_US); if (index 0) { ByteBuffer data encoder.getOutputBuffer(index); if (info.flags MediaCodec.BUFFER_FLAG_CODEC_CONFIG ! 0) { // 保存SPS/PPS后续跟着关键帧一起发 saveConfigBytes(data, info.size); } else { // 组帧并发送 sendDataFrame(data, info); } encoder.releaseOutputBuffer(index, false); } }这个骨架看起来简单但其实是完整的投屏发送端了。项目里的Service会包一层前台通知保证进程不被杀。接收端的话你用一个现成播放器验证即可不需要在这一步继续加码。5. 排坑实录黑屏、花屏、延迟高的排查套路网上一堆人在搜“scrcpy投屏了不显示画面”、“QtScrcpy投屏黑屏”其实这些问题的根源和我自己在调试Demo时遇到的基本是同一类。我把常见的现象、原因和解决办法整理成了表格方便你直接对号入座现象可能原因解决办法完全没有画面进程运行但黑屏VirtualDisplay创建的线程没有LooperVirtualDisplay的createVirtualDisplay必须在有Looper的线程里执行或者传入Handler黑屏且日志报“Surface abandoned”Surface被提前释放检查编码器是否先于VirtualDisplay被销毁顺序必须是VirtualDisplay.release()再MediaCodec.stop()画面花屏/马赛克发送端没带SPS/PPS或只带了不完整的NAL确保BUFFER_FLAG_CODEC_CONFIG的数据被保存并在每个关键帧前拼接发送花屏但声音正常关键帧间隔太长接收端中途接入后长时间等不到I帧调低KEY_I_FRAME_INTERVAL到1秒或在新连接请求时触发编码器输出关键帧延迟越拉越高几十秒后明显画音不同步TCP拥塞导致重传累积延迟换UDP传输或者TCP接收端缓冲区改小实时丢弃过期帧画面模糊文字看不清码率不够调高bitrate至少到达上面公式算出的底线值也可以提高I帧频率辅助清晰度投屏到一半突然黑屏Android 10进程被系统回收必须使用前台Service并在Android 13声明对应的前台服务类型这里面我想额外多说一句“黑屏”的问题。因为它实在太常见了。我见过的最多的黑屏原因是VirtualDisplay在无Looper线程中创建导致内部消息循环无法执行虚拟屏表面一直没有收到帧数据。代码上看一切正常没有异常没有崩溃就是黑屏。这个问题排查起来很耗时间因为系统不会给你任何明确的错误提示。解决方案也简单创建VirtualDisplay时传入一个带Handler的线程上下文或者直接在IntentService/HandlerThread里创建。另一个容易翻车的是权限合规。安卓系统对录屏权限的审查越来越严如果你的Demo准备上架应用市场必须保证录屏用途明确、有显著提示并且录制过程有常驻通知栏。我在调试阶段无所谓但后面在客户那边做POC展示时被提示权限不足差点翻车。6. 进阶优化从“能跑”到“好用”6.1 音频怎么加先想清楚需求投屏如果只投画面不投声音在演示场景里基本没法用。但Android的音视频采集和编码是两套体系音频要走AudioRecord或AAudio再用AudioCodec做AAC编码然后和视频流做时间戳对齐。时间戳对齐是另一大坑视频和音频的时钟源不同如果不做同步播放端画面和声音会对不上。我的建议是第一版不要加音频先把视频链路验证稳定再逐步加入音频采集和合流。不要一上来就追求大而全调试难度会翻倍。6.2 参数动态调整码率和分辨率自适应网络质量是会波动的。我后面给Demo加了一个简单的自适应逻辑发送端统计最近1秒的丢包率或者传输时长如果延迟超过阈值就降低分辨率比如从1080p降到720p或者降低码率。这个过程最好是动态的不用重启编码器就能调整码率——MediaCodec支持运行时通过setParameters修改码率参数Bundle params new Bundle(); params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, newBitRate); encoder.setParameters(params);不过要提醒的是并不是所有编码器都会立刻响应这个调整。有些硬件实现要等好几个关键帧周期之后才生效。所以如果你发现调了参数画面没有明显变化先别慌等待一两秒再观察。6.3 多端分发一份码流投给多个屏幕如果你的使用场景是“一个手机投给多个大屏”也千万别做“采集-编码-发送N份”这种事性能和功耗都受不了。正确做法是发送端只编码一份码流然后在传输层做分发——UDP组播是局域网内最优雅的方案一个数据包能同时到达所有订阅端。Windows端和Android端同时收流解码后各自显示完全没问题。我实际做过一个展厅项目一台Android盒子编码局域网里挂了8台电视同时拉流UDP组播跑得很稳延迟和单端投屏几乎没差别。7. 写在最后的几个心得这个投屏Demo如果从头到尾完整跑通你对Android的多媒体框架会有一个相当扎实的认知。我在做这个项目的过程中最深的体会是投屏的核心不是“怎么采集画面”而是“怎么让每一帧以最小的延迟、最大的保真度到达接收端”。技术原理不复杂但真正做出来一个能用的投屏需要花大量时间调编码参数、处理边界情况。再分享一个小技巧调试时尽量用有线网络验证编码和传输链路排除Wi-Fi干扰。我一开始在办公室Wi-Fi下调试延迟起伏明显一直以为是自己代码的问题后来插上网线一切正常。Wi-Fi的信道拥挤、干扰抖动对UDP传输的影响非常大这不是代码能解决的是物理世界的问题。最后建议你拿到代码后先在两台真机上跑通一版最简功能再开始调优。投屏这种涉及硬件编解码、网络传输、实时渲染的项目模拟器上基本跑不出真实效果真机验证是唯一可靠路径。另外不同SoC高通、联发科、麒麟的硬件编码器行为差异不小我强烈建议你在至少两三个不同品牌的设备上测一遍宁可多做兼容性验证也不要等到现场演示的时候才发现问题。本文还有配套的精品资源点击获取
返回列表