资讯详情

资讯详情

Android投屏Demo实战:MediaProjection采集与H264硬编码全链路解析

简介这份Android设备投屏功能Demo面向具备一定Android基础的开发者聚焦手机、平板屏幕内容实时投放到电视或电脑显示器这一常见需求。项目以MediaProjection捕获屏幕帧借助MediaCodec完成H264编解码并通过Socket在设备间传输视频流接收端再解码还原画面完整串联了录屏、编码、网络通信与解码显示的关键链路。资源包共1536个文件以975个flat、228个xml、218个json等构建与配置产物为主另有36个java源码、14个png及若干gradle、jar文件压缩包约4.87MB目录结构便于按模块查阅。目前已有3256人学习下载。读者可从中掌握MediaProjectionManager权限申请、MediaCodec缓冲区填充与输出提取、ServerSocket连接建立及NAL单元解析等实现细节并据此扩展音视频同步、优化网络传输效率适合作为Android多媒体与远程投屏方向的学习参考。1. 从一块黑屏说起Android 投屏 Demo 到底在解决什么问题手机连上大屏画面却卡成 PPT或者干脆黑屏只出声——这类问题我见过太多次。很多人第一反应是线材不行、电视太老其实根子往往在编码链路采集到的原始帧没被正确喂给编码器或者编码器吐出的 H264 码流在接收端解不出来。这份 Android 设备投屏功能 Demo 要解决的就是把「屏幕采集 → 硬编码 → 推流 → 解码显示」这条链路完整跑通让你手里有一个能改、能调、能定位问题的可运行骨架。它适合两类人一是想给自己的 App 加投屏能力但不知道从哪下手的 Android 开发二是做音视频方向、需要一份能对照排查 H264 编码参数的学生或初级工程师。Demo 本身不追求低延迟到极致但每个环节都留了可观测的接口这点比很多「能跑但黑盒」的示例强。2. 投屏链路拆解MediaProjection 采集与 H264 硬编码怎么接投屏的本质是把屏幕像素变成另一台设备能播放的视频流。Android 侧的标准入口是 MediaProjection它拿到的是屏幕合成的原始帧格式通常是 RGBA 或 YUV。原始帧体积巨大1080p 一帧就接近 8MB不编码根本没法传。所以链路里必须有一个 H264 编码器把原始帧压成几十 KB 的码流。Demo 用的是 MediaCodec 硬编码而不是软编原因很直接硬编走 GPU/DSP功耗低、延迟小软编在手机上跑 1080p 很容易把 CPU 吃满导致掉帧。2.1 申请权限与创建 VirtualDisplayMediaProjection 不是普通权限必须走系统弹窗授权。拿到 resultCode 和 data 之后才能创建 VirtualDisplay把屏幕内容「投影」到一个 Surface 上。这个 Surface 就是编码器的输入源。// 在 Activity 中发起投屏授权 MediaProjectionManager mpm (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); Intent intent mpm.createScreenCaptureIntent(); startActivityForResult(intent, REQ_CODE); // onActivityResult 中拿到授权结果 Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode REQ_CODE resultCode RESULT_OK) { MediaProjection projection mpm.getMediaProjection(resultCode, data); // 创建编码器输入用的 Surface Surface inputSurface mediaCodec.createInputSurface(); // 把屏幕内容投射到这个 Surface VirtualDisplay vd projection.createVirtualDisplay( ScreenCast, // 名称调试时能看到 width, height, // 建议用屏幕真实分辨率 densityDpi, // 屏幕密度 DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null); } }这里有个容易忽略的点width 和 height 最好取屏幕真实分辨率不要随手写 1280x720。VirtualDisplay 会按你给的分辨率缩放如果和编码器配置不一致画面会被拉伸或裁切。densityDpi 同理填错会导致内容显示比例异常。授权回调里拿到的 data 只能使用一次重复 getMediaProjection 会抛异常所以 projection 对象要保存好后续停止投屏时调用 release。2.2 配置 MediaCodec 编码器编码器配置决定了输出码流的规格。Demo 里用 H264 的 baseline 或 main profile具体看设备支持。关键参数有四个分辨率、帧率、码率、关键帧间隔。分辨率跟 VirtualDisplay 保持一致帧率一般 30 够用60 对性能要求高码率 1080p 建议 4~8 Mbps太低会糊太高网络扛不住关键帧间隔I 帧间隔设 1~2 秒太短码率浪费太长接收端花屏恢复慢。MediaFormat format MediaFormat.createVideoFormat(video/avc, width, height); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); // 必须用 Surface 模式 format.setInteger(MediaFormat.KEY_BIT_RATE, 6_000_000); // 6 Mbps format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); // 1 秒一个 I 帧 MediaCodec codec MediaCodec.createEncoderByType(video/avc); codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface inputSurface codec.createInputSurface(); codec.start();COLOR_FORMAT 必须选 COLOR_FormatSurface因为我们是把 VirtualDisplay 的输出直接喂给编码器走的是 Surface 直通不经过 CPU 拷贝。如果选成 YUV420 之类的格式createInputSurface 会失败。这一点在官方文档里写得比较隐晦实际调试时如果 configure 报错先检查这个参数。2.3 取出码流并封装编码器开始工作后需要循环从 output buffer 里取数据。取出来的是 H264 裸流每个 buffer 可能包含一个或多个 NAL 单元。Demo 里为了简单直接把裸流通过 socket 发出去接收端按 Annex-B 格式起始码 00 00 00 01解析。MediaCodec.BufferInfo info new MediaCodec.BufferInfo(); while (isRunning) { int outIndex codec.dequeueOutputBuffer(info, 10_000); // 等 10ms if (outIndex 0) { ByteBuffer buffer codec.getOutputBuffer(outIndex); byte[] data new byte[info.size]; buffer.get(data); // 这里 data 就是 H264 裸流可以推流或存文件 sendData(data); codec.releaseOutputBuffer(outIndex, false); } else if (outIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // 格式变化时通常会带 SPS/PPS接收端需要这个才能解码 MediaFormat newFormat codec.getOutputFormat(); // 可以把 csd-0 / csd-1 取出来发给接收端 } }注意 INFO_OUTPUT_FORMAT_CHANGED 这个分支。H264 解码需要 SPS 和 PPS 参数集它们通常在格式变化时通过 csd-0 和 csd-1 给出。如果接收端只拿到普通帧没有 SPS/PPS解码器初始化会失败表现就是黑屏。常见做法是在格式变化时把 csd 数据单独发一包或者把 SPS/PPS 拼在每个 I 帧前面。3. 接收端解码与显示从 H264 裸流到画面发送端把码流推出去之后接收端要能解出来。Demo 的接收端可以是一个简单的 Android 应用也可以是一个桌面程序。核心逻辑是拿到 H264 数据 → 喂给解码器 → 拿到 YUV 或 RGB 帧 → 渲染到 SurfaceView 或 TextureView。这里最容易翻车的地方是帧边界处理因为 socket 是流式的一次 recv 可能拿到半帧也可能拿到两帧半。3.1 用 MediaCodec 做硬解码接收端同样用 MediaCodec配置成解码器。关键是要把 SPS/PPS 先喂进去否则 configure 会报错。如果发送端没有单独发 SPS/PPS可以在收到第一个 I 帧时从码流里提取。MediaFormat format MediaFormat.createVideoFormat(video/avc, width, height); // 如果发送端给了 csd直接设置 format.setByteBuffer(csd-0, spsBuffer); format.setByteBuffer(csd-1, ppsBuffer); MediaCodec decoder MediaCodec.createDecoderByType(video/avc); decoder.configure(format, outputSurface, null, 0); // outputSurface 来自 SurfaceView decoder.start(); // 收到数据后喂给解码器 int inIndex decoder.dequeueInputBuffer(10_000); if (inIndex 0) { ByteBuffer inBuf decoder.getInputBuffer(inIndex); inBuf.clear(); inBuf.put(h264Data); decoder.queueInputBuffer(inIndex, 0, h264Data.length, pts, 0); } // 取解码后的帧 int outIndex decoder.dequeueOutputBuffer(info, 10_000); if (outIndex 0) { decoder.releaseOutputBuffer(outIndex, true); // true 表示渲染到 Surface }outputSurface 一般从 SurfaceView 的 SurfaceHolder 拿。如果 Surface 还没创建好就 configure会抛 IllegalStateException所以要在 surfaceCreated 回调之后再初始化解码器。另外 pts 时间戳要单调递增随便填 0 可能导致渲染节奏异常。3.2 处理粘包与半包TCP 是字节流没有消息边界。发送端一次 send 的 10KB 数据接收端可能分三次 recv 才收完。如果直接把每次 recv 的数据当一帧喂给解码器大概率解不出来。常见做法是自定义一个简单协议每帧前面加 4 字节长度头。# 接收端伪代码按长度头拆帧 buffer b while True: data sock.recv(4096) buffer data while len(buffer) 4: frame_len int.from_bytes(buffer[:4], big) if len(buffer) 4 frame_len: break # 半包继续收 frame buffer[4:4frame_len] feed_decoder(frame) buffer buffer[4frame_len:]这个逻辑看着简单但实际写的时候容易在 buffer 拼接和切片上出错。血泪经验是每次循环都要重新判断 buffer 长度不要用固定偏移量。另外 frame_len 如果异常大比如超过 10MB说明流已经乱了直接断开重连比继续解析更省事。3.3 渲染与延迟控制解码出来的帧通过 releaseOutputBuffer(index, true) 直接渲染到 Surface。如果发现画面延迟越来越大通常是解码器输出队列堆积了。可以在每次 release 之后检查队列深度必要时丢帧。另一个影响延迟的因素是编码端的 B 帧B 帧需要缓存后续帧才能解码会增加延迟。投屏场景建议编码器关掉 B 帧或者用 baseline profile它本身不支持 B 帧。4. 避坑排查投屏 Demo 最容易翻车的五个地方这个 Demo 跑通不难但要在不同设备上稳定运行下面这几个坑基本都会遇到。我按「现象 → 原因 → 解决」整理方便对照排查。现象一授权后画面全黑但日志显示编码器在出帧。原因通常是 VirtualDisplay 的 Surface 和编码器的 inputSurface 不是同一个或者 createVirtualDisplay 时 flag 设错。解决确认 createVirtualDisplay 的第五个参数传的是 codec.createInputSurface() 返回的对象flag 用 VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR。现象二接收端解码报错提示「no SPS/PPS」。原因是发送端只发了普通帧没有把 csd-0/csd-1 传过去。解决在 INFO_OUTPUT_FORMAT_CHANGED 时把 csd 数据单独发一包接收端收到后先 configure 解码器再喂普通帧。现象三画面能显示但颜色不对偏绿或偏紫。这是 YUV 和 RGB 转换时的色彩空间问题。Android 硬编硬解一般走 NV12 或 I420如果中间某一步用了错误的转换矩阵颜色就会偏。解决尽量走 Surface 直通避免 CPU 侧做 YUV 转换如果必须转确认用的是 BT.601 还是 BT.709 矩阵两者不能混用。现象四投屏几秒后卡死编码器 dequeueOutputBuffer 一直返回 -1。常见原因是输入 Surface 没有数据进来VirtualDisplay 被系统回收了。部分厂商 ROM 在应用切后台时会自动停止投屏。解决投屏期间保持前台服务或者申请前台权限另外检查是否在 onPause 里误调了 projection.stop()。现象五不同手机分辨率适配混乱画面被拉伸。原因是编码分辨率写死了 1280x720但 VirtualDisplay 用了屏幕真实分辨率。解决统一用屏幕真实分辨率创建 VirtualDisplay 和编码器接收端按码流里的 SPS 解析出真实宽高再渲染不要写死。5. 进阶技巧用码率自适应和 I 帧请求稳住弱网投屏Demo 跑通之后下一步通常是让它能在网络波动时也不崩。这里分享两个我实际调过的技巧码率自适应和 I 帧请求。码率自适应的思路是接收端定期统计丢包率和缓冲深度通过一个反向通道告诉发送端调整编码码率。MediaCodec 支持动态改码率不需要重启编码器。// 发送端收到码率调整指令后 Bundle params new Bundle(); params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, newBitrate); codec.setParameters(params);newBitrate 不要一次调太猛比如从 6Mbps 直接降到 1Mbps画面会糊得没法看。我一般按 20% 步进调整每 2 秒调一次给编码器一个缓冲期。接收端统计周期也别太短500ms 一次就够了太频繁反而震荡。I 帧请求是另一个实用手段。当接收端发现花屏或者刚加入时可以主动向发送端请求一个 I 帧快速恢复画面。发送端收到请求后通过 setParameters 触发关键帧。Bundle params new Bundle(); params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); codec.setParameters(params);这个调用不是所有设备都支持部分 ROM 会忽略。如果发现请求无效可以退而求其次在发送端维护一个计数器每收到 N 次请求就重启一次编码器强制出 I 帧。重启编码器有 100~200ms 的黑屏所以只作为兜底。验证这两个机制是否生效我习惯用两个指标一是接收端缓冲队列深度稳定在 3~5 帧算正常持续增长说明码率偏高二是花屏恢复时间从请求 I 帧到画面正常理想情况 500ms 以内。如果超过 1 秒检查一下网络 RTT 和编码器关键帧间隔是不是设太大了。从那以后我每次调投屏参数都强制先跑一遍弱网模拟用限速工具把带宽压到 2Mbps、丢包 5%观察码率自适应能不能把画面稳住。这个习惯帮我省了很多「实验室正常、现场翻车」的后悔药。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →