MediaProjection实战:Android屏幕共享与远程控制全攻略
发布时间:2026/9/19 18:07:01 作者:尧图编辑部 阅读量:1,286

做Android的屏幕共享和远程控制MediaProjection API是绕不开的那道门槛。我当初接到项目要做一个“手机端实时投屏到电脑并支持反向控制”的方案时第一反应就是用MediaProjection采集屏幕可真的上手才发现权限获取、VirtualDisplay配置、编码参数调优、远端指令回传每一环都有不少坑。这篇文章就把我从零到一构建这套方案的完整思路、核心代码和踩坑记录整理出来给准备做屏幕共享、远程协助、投屏类App的开发者做参考。先说结论官方MediaProjection是Android 5.0API 21开始提供的屏幕采集API它不需要root、不需要厂商私有接口只要用户弹窗授权就能拿到屏幕帧数据是所有屏幕共享方案里通用性最好的选择。文章会覆盖授权流程、VirtualDisplay数据流向、ImageReader与MediaCodec两条采集路线的取舍、远程控制链路的输入事件回传以及我在Android 10和Android 14上适配时遇到的各种系统限制。1. 项目背景为什么屏幕共享和远程控制绕不开MediaProjection1.1 选型对比MediaProjection、ROOT方案与厂商录屏接口屏幕采集是整条链路的起点采集方案选错后面传输和播放做得再好都白费。我在动手前对比了市面上三类主流做法ROOT方案通过Root权限直接读取SurfaceFlinger或调用screencap命令获取帧数据。优点是权限拿到后可以做很多底层操作比如全局注入触摸事件但缺点也很明显——必须root、兼容性差、严重的安全风险用户不可能为了一个远程协助功能去root手机所以这个方案只适合内部玩票。厂商录屏接口部分手机厂商开放了自己的录屏SDK但每个品牌的接入方式和权限策略都不一样没有通用性做一次方案适配十几个厂商维护成本直接爆炸。MediaProjection官方APIAndroid 5.0以上全机型一套代码。虽然它在Android 10之后增加了后台启动限制Android 14又加了很多强制约束但它依然是屏幕共享和远程控制项目最合理的起点。方案是否需root通用性主要问题ROOT/screencap是差兼容性差、安全风险高厂商录屏SDK否极差各厂商适配成本高MediaProjection否好Android 10需前台服务Android 14限制更多1.2 整体架构设计思路屏幕共享和远程控制本质上是一条“数据面 控制面”的双通道链路数据面采集端Android设备通过MediaProjection VirtualDisplay拿到屏幕帧交给MediaCodec硬编码成H.264/H.265流通过网络传输到观看端手机/电脑/Web观看端解码渲染。控制面观看端把用户的触摸、点击、滑动指令通过网络发送回采集端采集端通过AccessibilityService的无障碍能力模拟手势或者通过更底层的方式注入事件形成“远程控制”的闭环。我在设计架构时最看重的点是采集端不要做任何耗时操作所有图像处理都要避免在主线程执行。屏幕原始分辨率动辄1080P甚至2K如果直接拿ImageReader回调里的数据去做RGB转换、Bitmap拷贝主线程瞬间卡死。所以必须先跑通一条“零拷贝”链路MediaCodec从createInputSurface()拿到输入SurfaceVirtualDisplay直接往这个Surface写数据编码器输出的就是H.264流整个过程不出现一帧Bitmap效率和延迟才有保障。这套架构在局域网内端到端延迟能压到200ms左右在公网环境下结合码率自适应也能维持可用状态。想做得更完善可以在数据面加入WebRTC或自研UDP传输协议在控制面加入信令服务和指令鉴权后面会详细展开。2. MediaProjection核心原理与关键API解析2.1 MediaProjectionManager授权流程的完整生命周期MediaProjection的设计思路很清晰它把“系统级屏幕采集能力”包装成一次用户授权操作开发者拿到MediaProjection实例后才能创建VirtualDisplay获取屏幕数据。授权流程的入口是MediaProjectionManager通过系统服务获取MediaProjectionManager mpManager (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); Intent permissionIntent mpManager.createScreenCaptureIntent(); startActivityForResult(permissionIntent, REQUEST_CODE_CAPTURE);用户同意弹窗后在onActivityResult里拿到的resultCode和data是建立投影的核心凭据Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode REQUEST_CODE_CAPTURE resultCode RESULT_OK) { MediaProjection projection mpManager.getMediaProjection(resultCode, data); // 保存projection实例启动前台服务开始采集 } }这里有个非常关键的细节getMediaProjection()拿到的实例和系统回收机制绑定如果MediaProjection被垃圾回收、进程被杀或者系统资源不足投影会自动停止。所以实际项目里不能在Activity里裸持有这个实例必须放到一个前台Service中统一管理。另外resultCode和data不能长期保存复用应用进程重启后即使你缓存了这两个值调用getMediaProjection()也拿不到有效的实例必须让用户重新授权一次。我在第一版设计时犯过这个错把data序列化后存到本地重启后直接恢复结果投影永远是黑屏。2.2 VirtualDisplay与数据流向屏幕画面是怎么变成帧的理解VirtualDisplay是理解整个屏幕共享体系的关键。可以把它想象成一根系统级的虚拟HDMI线屏幕内容被系统SurfaceFlinger合成后会同时输出到真实屏幕和所有活跃的VirtualDisplay而VirtualDisplay的出口可以绑定到任意一个Surface上。数据流向是这样一条链路屏幕合成器SurfaceFlinger → VirtualDisplay → Surface → 数据接收端ImageReader/MediaCodec输入Surface。VirtualDisplay负责把“系统屏幕”和“上层业务”解耦开发者的任务就是指定这个Surface以及分辨率、DPI密度projection.createVirtualDisplay( ScreenShareDisplay, width, height, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, surface, null, null );宽度和高度建议直接用系统的真实屏幕分辨率DPI可以用getResources().getDisplayMetrics().densityDpi这两个参数会影响采集画面的清晰度和编码器输入大小。如果只想采集屏幕的一部分区域可以手动调小width和height不过远程控制场景一般用全屏因为用户操作的位置映射会更简单。2.3 Android版本差异与适配要点MediaProjection从Android 5.0一路走到Android 14系统对它的限制是越来越多但整体逻辑还在“用户必须知道有人在录屏”这一条主线上Android 5.0~9API 21~28授权后即可在后台采集Activity退到后台也不受影响开发者相对自由。Android 10API 29系统禁止应用在后台启动屏幕采集必须配合前台Service并在通知栏持续显示采集状态否则MediaProjection创建后会立即回调onStop。Android 14API 34约束进一步加强前台Service必须声明mediaProjection类型并且应用需要声明FOREGROUND_SERVICE_MEDIA_PROJECTION权限每次启动投影时系统还要校验前台服务状态如果发现采集发生在非预期场景例如没有可见通知的普通Service会直接杀掉投影。适配策略上我建议从一开始就把“采集服务”设计成独立的前台Service并且Service类型声明按targetSdk版本动态区分。targetSdk 34的用户需要检查AndroidManifest.xml里的service声明uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION / service android:name.ScreenCaptureService android:foregroundServiceTypemediaProjection android:exportedfalse /还有一个绕不开的点是FLAG_SECURE。如果被采集的页面设置了WindowManager的FLAG_SECURE标记比如银行App、网易云音乐的歌词页、部分视频播放器的DRM内容VirtualDisplay出口上对应区域会是一片黑色。这是系统级的安全策略没有合理的绕过方式在远程协助产品里必须提前告知用户“受保护内容无法共享”避免用户以为设备出了问题。3. 从零到一完整实现采集与编码3.1 环境准备与工程配置我用的开发环境是Android Studio最新稳定版工程最低配置建议minSdk 21、targetSdk 34。MediaProjection相关API在API 21就能用但Android 14的前台服务类型声明和目标版本行为差异决定了你必须在targetSdk 34上编译并做适配。工程里需要准备的基础依赖很少官方没有额外引入第三方库只要在build.gradle里确认compileSdk和targetSdk版本android { compileSdk 34 defaultConfig { minSdk 21 targetSdk 34 } }除了前面提到的FOREGROUND_SERVICE和FOREGROUND_SERVICE_MEDIA_PROJECTION还需要申请WAKE_LOCK防止采集过程中CPU休眠导致掉帧uses-permission android:nameandroid.permission.WAKE_LOCK /3.2 申请授权并创建MediaProjection实例授权逻辑最好封装在一个工具类里Activity只负责发起授权真正干活的是前台Service。我习惯做一个ScreenCaptureService在onStartCommand里接收从Activity传递过来的resultCode和data然后初始化MediaProjectionpublic class ScreenCaptureService extends Service { private MediaProjection mediaProjection; private VirtualDisplay virtualDisplay; private MediaCodec mediaCodec; Override public int onStartCommand(Intent intent, int flags, int startId) { int resultCode intent.getIntExtra(resultCode, Activity.RESULT_CANCELED); Intent data intent.getParcelableExtra(data); startForeground(1, buildNotification()); MediaProjectionManager mpManager (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); mediaProjection mpManager.getMediaProjection(resultCode, data); mediaProjection.registerCallback(new MediaProjection.Callback() { Override public void onStop() { // 投影被系统中断需要释放资源并通知界面 releaseResources(); } }, new Handler(Looper.getMainLooper())); startEncode(); return START_NOT_STICKY; } }注意onStop回调很重要Android 14下系统随时可能因为前台服务规范校验失败而中断投影不要等崩溃了才处理要在onStop里做资源释放和状态同步否则MediaCodec、Surface这些底层句柄会泄漏导致后续重试授权时黑屏或崩溃。3.3 用ImageReader抓帧最简单但别踩性能坑早期做屏幕共享时很多人喜欢用ImageReader方案因为直观VirtualDisplay把帧写入ImageReader然后通过setOnImageAvailableListener拿到Image对象再转成Bitmap或者字节数组ImageReader imageReader ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2); imageReader.setOnImageAvailableListener(reader - { Image image reader.acquireLatestImage(); if (image null) return; // 读取plane数据转换为Bitmap或YUV进行后续处理 image.close(); }, backgroundHandler);这段逻辑虽然简单但我强烈不建议在30fps全屏共享场景下用它。原因是ImageReader方案会经历以下性能损耗屏幕全屏RGBA_8888数据一帧1080P大概就是8MB的原始数据每次回调都要进行大块内存拷贝而且Android的Image获取ByteBuffer后还要做格式转换RGB转YUV或NV21在CPU上跑是很重的活实测1080P 30fps下CPU占用会轻松飙到80%以上手机发烫严重。ImageReader适合低分辨率、低帧率的截图式分享比如抓取静态页面或者PPT投屏。高帧率实时共享场景一定要走MediaCodec硬编码。3.4 用MediaCodec硬编码H.264高效方案的落地代码正确的高性能方案是MediaCodec创建一个输入SurfaceVirtualDisplay把屏幕帧直接写到这个Surface上编码器硬件内部完成YUV转换、H.264编码应用层拿到的直接就是编码后的字节流。这条链路里几乎没有内存拷贝编码工作由GPU/专用编码器完成CPU占用能控制在10%~20%以内。初始化编码器的核心代码private void startEncode() { try { MediaFormat format new MediaFormat(); format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_AVC); format.setInteger(MediaFormat.KEY_WIDTH, width); format.setInteger(MediaFormat.KEY_HEIGHT, height); 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_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); mediaCodec MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface inputSurface mediaCodec.createInputSurface(); mediaCodec.start(); virtualDisplay mediaProjection.createVirtualDisplay( RemoteAssistanceDisplay, width, height, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null); // 启动输出线程从编码器取出H.264数据 startDequeueThread(); } catch (IOException e) { e.printStackTrace(); } }几个参数我实际调优后的经验值KEY_BIT_RATE1080P建议4Mbps~8Mbps720P建议2Mbps~4Mbps。定太低画面会出现明显马赛克定太高网络压力大。如果走公网远程控制最好做成码率自适应根据往返时延和丢包率动态调整。KEY_FRAME_RATE远程控制场景30fps已经够了追求极致省流可以用15fps代价是操作时画面流畅感下降。KEY_I_FRAME_INTERVAL我固定设成1也就是每秒一个关键帧。远程控制场景最关键的是用户在操作后要尽快看到画面刷新如果关键帧间隔太长弱网下解码端会长时间停留在模糊画面体验非常差。1秒一个关键帧虽然会牺牲少量码率但能大幅降低“操作后黑屏/花屏”的感知。编码器输出的H.264数据通过dequeueOutputBuffer循环取出通常是把ByteBuffer转成byte数组后交给后面的传输模块。注意这里要用getOutputBuffer和getOutputFormat配合处理编码器的格式变化事件INFO_OUTPUT_FORMAT_CHANGED比如SPS/PPS参数变化远程解码端需要这个信息才能初始化解码器。4. 远程控制链路传输协议与输入事件回传4.1 视频流传输裸推流、RTSP与WebRTC的选择采集端拿到H.264裸流后下一步是传输给观看端。传输方案直接影响端到端延迟和公网可用性我整理过三条路线裸推流 自研协议适合局域网内的快速验证场景。比如通过WebSocket或自定义TCP/UDP协议把H.264的NALU一个一个发出去接收端收到后用MediaCodec或FFmpeg解码。优点是简单直接缺点是没有拥塞控制、丢包重传、音视频同步这些能力公网环境很容易卡顿。RTSP/RTMP推流需要架设流媒体服务器比如SRS、ZLMediaKit接收端通过播放器拉流。优点是生态成熟、播放器多缺点是多一跳服务器的中转延迟互动性要求高的远程控制场景往往不达标但做屏幕共享直播完全够用。WebRTC天然适合双向实时通信包含编码、传输、拥塞控制和抖动缓冲的全套方案。P2P直连时延迟最低公网通过ICE打洞也能建立连接但需要自己搭建信令服务Mobile端实现成本高。我的建议是如果只做局域网内测试直接走UDP裸推流简单高效如果要上公网产品优先考虑WebRTC。远程控制对交互延迟的要求远高于直播场景WebRTC的带宽估计和丢包重传机制能明显提升弱网下的操作手感。4.2 输入事件回传从远端把指令发回手机端屏幕共享做到这一步只是单向投屏远程控制需要把观看端的操作指令回传手机端。我在项目中设计了独立的控制通道一般用WebSocket长连接消息格式就是简单的JSON包含坐标、事件类型、时间戳{ action: touch, type: down, x: 0.432, y: 0.211 }坐标统一使用0~1的归一化值接收端乘上屏幕真实分辨率即可这样即使远程设备分辨率不同也能准确映射。手机端收到指令后借助Android的无障碍能力模拟手势。核心是AccessibilityService的dispatchGesture方法它能模拟点击、滑动、长按行为也是目前非root环境下最可靠的注入方案private void dispatchTap(float x, float y) { Path path new Path(); path.moveTo(x, y); GestureDescription.StrokeDescription stroke new GestureDescription.StrokeDescription(path, 0, 50); GestureDescription gesture new GestureDescription.Builder().addStroke(stroke).build(); accessibilityService.dispatchGesture(gesture, null, null); }无障碍服务需要用户手动在系统设置里开启开发者可以引导用户跳转到无障碍设置页。如果设备已经root还可以通过sendevent注入更底层的触摸事件但这种方式在产品化阶段基本不会用。4.3 延时优化与QoS经验做完整个远程控制链路后我把端到端延迟拆开分析过各段的耗时分布大约是屏幕采集16~30ms → 硬编码5~10ms → 网络传输局域网1~5ms / 公网20~100ms → 解码显示10~30ms。局域网内200ms内的体验完全可接受公网如果超过500ms操作时就要考验用户的耐心了。我能给的几个降延迟经验采集端不要做任何缓存dequeueOutputBuffer取到数据立刻发送不要为了凑包大小而等待。接收端播放器不要把解码缓冲设太大很多播放器默认会缓冲500ms以上适合视频播放但不适合远程控制。要设成极低延迟模式比如ExoPlayer可以设置setBufferForPlaybackMs(50)。网络波动时优先降低分辨率而不是帧率因为远程控制场景更看重每一帧的新鲜度而不是连续帧的顺滑度。降到540p比降到10fps在操作体验上更友好。安全方面特别提醒远程控制属于高危敏感能力服务端必须做严格的设备绑定和身份鉴权传输链路加密TLS/DTLS不能省而且要明确告知用户当前处于“被控制”状态最好在手机端实时显示“远程控制已开启”的常驻通知。不要在用户不知情的情况下开启采集或注入事件这既是产品底线也是法律风险问题。5. 常见问题与踩坑实录5.1 黑屏、白屏、无画面问题排查黑屏是最常见的现象至少有一半情况是FLAG_SECURE页面导致的。可以先打开桌面再测试采集如果桌面正常、切到银行App就黑那就是安全标记问题代码改不了只能提示用户。另一类是VirtualDisplay创建失败或Surface使用错误导致的黑屏排查时要确认createVirtualDisplay传入的Surface没有被提前释放。MediaCodec的输入Surface生命周期和编码器强绑定如果你在编码器stop/start后复用了旧的Surface就会出现“授权成功但画面一直黑的”诡异状态正确做法是每次编码器重启都重新走一遍createInputSurface。白屏有画面但是全屏纯色在MediaProjection里少见通常是编码器输出被错误解析导致的。接收端如果只显示第一帧的纯色画面先检查SPS/PPS是否正常传递很多自绘播放器会漏掉INFO_OUTPUT_FORMAT_CHANGED里的csd-0和csd-1数据。5.2 帧率低、延迟高、CPU占用异常帧率上不去的最大元凶是我前面反复提到的ImageReader方案只要用ImageReader做全屏30fps采集CPU就会被内存拷贝和格式转换打穿。切到MediaCodec Surface输入后性能问题会缓解很多。如果用了MediaCodec还是CPU高检查编码器选择的MIME类型和ColorFormat个别设备对COLOR_FormatSurface的底层实现偏软编可以考虑换video/avc硬编设备白名单或者针对特定机型降低分辨率。延迟突然变高先看是不是网络拥塞导致的发送队列堆积。我遇到过一个问题发送线程往Socket写数据时弱网下Socket缓冲区写满write方法阻塞导致采集线程和编码器背压整个链路延迟从100ms直接飙到2秒。解决方式是给网络线程设置超时并且加一个待发送队列的上限超过上限直接丢帧保证新帧优先发送。5.3 Android 10/14前后台限制与前台服务Android 10之后最典型的报错是“Permission Denial: MediaProjection requires a foreground service of type mediaProjection”。这个我在测试机上踩过一次原因是Service没有调用startForeground或者在后台直接创建了MediaProjection。记住一个原则MediaProjection的获取、创建VirtualDisplay、注册回调这些动作全部发生在已经处于前台状态的Service中。Android 14更加严格foregroundServiceTypemediaProjection声明和权限一个都不能少而且系统会校验启动前台服务时是否处于合法状态。另外Android 14要求应用在启动屏幕采集之前必须确认用户已经看到了通知栏的前台服务通知。如果实现时发现Android 14上MediaProjection.Callback.onStop频繁被调用优先检查Service类型声明和startForeground的时序问题。5.4 快速排查表格现象可能原因排查与解决授权后立即onStop未使用前台服务 / 服务类型不符合targetSdk要求确认Service调用startForegroundtargetSdk 34声明mediaProjection类型画面黑屏页面设置FLAG_SECURE / Surface被误释放先测桌面是否正常每次重启编码器重新创建输入SurfaceCPU占用过高ImageReader拷贝大体积像素 / 软编换MediaCodec Surface输入模式1080P下应小于20%画面花屏缺SPS/PPS / 关键帧间隔过长传递csd-0/csd-1参数I帧间隔设为1秒延迟突然飙升Socket写阻塞导致背压 / 接收端缓冲过大发送线程设超时接收端缓冲设50ms待发送队列丢帧重启App后需要重新授权系统不保留授权凭据这是设计行为提示用户重新授权不要缓存resultCode我这个项目从第一版ImageReader疯狂卡顿到后来切成MediaCodec硬编码再补全控制通道前前后后折腾了两周。要说最重要的心得就是屏幕采集必须走“零拷贝”思路VirtualDisplay直接对接编码器输入Surface远程控制指令的回传链路从一开始就要做好鉴权和加密别等功能完善了再补安全。这套方案在局域网内的投屏和远程协助场景已经能稳定跑起来如果你想做得更深入可以往WebRTC方向演进把采集、编码、传输、控制整合成一套实时通信系统那就完全是产品级的远程协助方案了。