Android投屏内核开发:MediaProjection+MediaCodec硬编码实战

发布时间:2026/10/19 15:00:24
Android投屏内核开发:MediaProjection+MediaCodec硬编码实战 简介本资源是一个面向Android开发者的投屏功能实战Demo聚焦于在移动设备端实现屏幕镜像Mirroring/Casting的核心技术落地适用于中高级开发者学习MediaProjection API集成、音视频采集与编码传输等关键能力。压缩包共2000个文件含1588个class字节码文件体现完整编译产物、472个png资源图UI适配与图标、451个xml布局与配置文件含Service、Activity及权限声明、127个json配置如设备发现与协议参数整体33.55MB结构完整覆盖从权限申请、VirtualDisplay创建、ImageReader帧捕获到MediaCodec硬编码的全流程。已有7816人学习下载项目包含ScreenCaptureService服务实现、MediaSessionCompat等AIDL接口定义、resources-debug.ap_等构建产物可直接运行调试帮助读者掌握投屏启动授权、帧率控制、资源释放及Wi-Fi Direct/RTSP传输对接等真实工程要点。1. Android端投屏demo不是“把手机画面发到电视上”那么简单而是让你亲手搭出可嵌入、可调试、可二次封装的投屏能力底座你肯定试过用系统自带的“无线显示”或第三方App把手机屏幕投到电视上——点一下就连动一动就卡断开重连要重启App想改个分辨率或加个水印没门。这个Android端投屏demo恰恰反其道而行之它不提供开箱即用的UI也不绑定任何品牌协议Miracast/Chromecast/AirPlay而是一套纯Java/Kotlin实现的、基于MediaProjection VirtualDisplay MediaCodec硬编码链路的最小可行投屏内核。它跑起来只有两个Activity一个权限申请页一个状态监控页但背后暴露了从屏幕捕获、H.264编码、RTP组包、UDP推流到接收端解码渲染的全链路控制点。适合正在做教育类远程监课、工业设备远程协同、车载中控多屏联动的开发者——你需要的不是“能投”而是“知道每一帧怎么来、怎么走、在哪卡、怎么调”。项目结构干净无Gradle插件黑盒无混淆SDK所有关键路径都留了Log开关和回调钩子。我拿它在某高校实验室的安卓12平板上实测过720p30fps稳定推流超4小时内存波动控制在±8MB内这才是真·可集成的投屏能力。2. 工程结构与技术栈解析从build.gradle到AndroidManifest.xml看清这个投屏底座的骨架这个demo不是靠“一键生成”堆出来的它的目录结构就是一张投屏能力的拓扑图。整个工程采用AndroidX Java 8兼容Kotlin混写构建未引入任何商业SDK核心依赖仅3个androidx.core:core权限适配、androidx.lifecycle:lifecycle-viewmodel状态管理、androidx.media:mediaMediaProjection兼容封装。没有RxJava、没有协程封装层、没有MVP/MVVM框架胶水——所有异步逻辑直走HandlerThread Looper所有状态变更直触View更新。这种“裸写”风格让每个线程切换、每次Surface传递、每帧编码耗时都清晰可见也意味着你接手后改一行代码就能验证效果不用猜“到底是谁在什么时候调用了谁”。2.1 核心模块目录树每个包名都在说“这里管什么”项目根目录下只有5个一级包全部按职责垂直切分com.example.screenmirror/ ├── capture/ // 屏幕捕获层MediaProjection初始化、VirtualDisplay创建、Surface生命周期管理 ├── codec/ // 编码层MediaCodec硬编码器配置、输入Surface绑定、ByteBuffer输出队列调度 ├── network/ // 网络层RTP封包规则RFC 3550、UDP Socket管理、丢包重传模拟开关可关 ├── utils/ // 工具层YUV420SP转NV21适配不同厂商Surface格式差异、时间戳同步器、FPS计算器 └── ui/ // UI层仅2个Activity 1个状态观察者零业务逻辑纯状态反射提示capture/包里的ScreenCaptureManager是整个投屏链路的启动开关它不持有Activity引用只通过OnCaptureStateListener回调通知状态变化——这是为将来迁移到Service后台运行埋的伏笔。2.2 build.gradle关键配置为什么必须用compileSdk 33且targetSdk 31这个demo对API版本极其敏感低版本无法启用MediaProjection的前台服务模式高版本又受限于后台启动Activity限制。app/build.gradle里最关键的三处配置如下android { compileSdk 33 defaultConfig { applicationId com.example.screenmirror minSdk 21 targetSdk 31 // 注意不是3331是MediaProjection在后台保活的临界点 versionCode 1 versionName 1.0 } buildFeatures { viewBinding true } }targetSdk 31是硬性要求Android 12API 31起MediaProjection必须配合前台Service或Activity持续展示Notification否则系统会主动kill捕获进程。设为32/33会导致startActivityForResult被废弃而本demo仍用传统方式申请权限为兼容旧设备。minSdk 21意味着放弃Lollipop以下设备因为VirtualDisplay在API 21才正式支持Surface共享。viewBinding true不是为了炫技而是避免findViewById在ui/包中产生空指针——所有UI操作都通过ActivityMainBinding完成连Toast提示都绑在binding.root上。2.3 AndroidManifest.xml权限与组件声明少一个都会在小米/华为上静默失败别信“只要加了uses-permission android:nameandroid.permission.PROJECT_MEDIA /就行”的说法。实际在国产ROM上缺任何一个都会导致MediaProjectionManager.createScreenCaptureIntent()返回null。完整声明如下uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.RECORD_AUDIO / !-- 若需音频同步 -- uses-feature android:nameandroid.hardware.camera.any android:requiredfalse / uses-feature android:nameandroid.hardware.microphone android:requiredfalse / application ... activity android:name.ui.MainActivity android:exportedtrue android:launchModesingleTask android:themestyle/Theme.AppCompat.Light.DarkActionBar intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity android:name.ui.PermissionActivity android:exportedfalse android:themestyle/Theme.AppCompat.Translucent / /applicationFOREGROUND_SERVICE和POST_NOTIFICATIONS在Android 12是强制要求否则Notification无法展示MediaProjection会因缺少前台锚点被系统回收。WRITE_EXTERNAL_STORAGE和READ_EXTERNAL_STORAGE虽然demo本身不存文件但部分厂商ROM如vivo Funtouch OS 12会校验该权限是否存在否则拒绝授予PROJECT_MEDIA。PermissionActivity的Translucent主题不是为了美观而是防止它遮挡系统权限弹窗——某些Oppo ColorOS版本下非透明主题会导致权限Dialog被盖住用户点不到“允许”按钮。3. 核心链路代码逐行拆解从MediaProjection初始化到H.264帧发出看懂每一帧的生死旅程投屏不是“打开摄像头→编码→发出去”这么简单。Android屏幕捕获本质是“把GPU渲染管线的输出Surface喂给MediaCodec编码器”中间隔着Surface跨进程传递、Buffer队列同步、时间戳对齐三座大山。下面这段代码就是整个demo最核心的capture → encode → network流水线主干我把它拆成三段每段都带真实日志输出和参数含义说明。3.1 ScreenCaptureManager.startCapture()MediaProjection不是“拿到就用”而是“拿到再等Surface就绪”// capture/ScreenCaptureManager.java public void startCapture(int resultCode, Intent data) { if (resultCode ! Activity.RESULT_OK || data null) return; mMediaProjection mProjectionManager.getMediaProjection(resultCode, data); // 关键MediaProjection对象创建后必须立即创建VirtualDisplay // 否则Surface可能在后续encode阶段不可用 createVirtualDisplay(); } private void createVirtualDisplay() { // width/height来自DisplayMetrics但注意不是手机物理分辨率 // 而是VirtualDisplay期望的输出尺寸建议设为720p1280x720以平衡性能与画质 int width 1280; int height 720; int dpi Resources.getSystem().getDisplayMetrics().densityDpi; // VirtualDisplay的Surface将作为MediaCodec的输入源 // format必须为PixelFormat.RGBA_8888或PixelFormat.RGBX_8888部分厂商仅支持后者 mVirtualDisplay mMediaProjection.createVirtualDisplay( screen-mirror-display, width, height, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mInputSurface, // 这个Surface由MediaCodec.createInputSurface()提供 null, null); }createVirtualDisplay()的mInputSurface参数必须是MediaCodec.createInputSurface()返回的Surface不能是new Surface(...)或TextureView.getSurface()——后者会导致IllegalStateException: surface was abandoned。VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR是关键标志它告诉系统“我要镜像当前屏幕”而不是捕获某个特定窗口。去掉它你捕获到的可能是黑屏或系统UI。dpi建议用Resources.getSystem().getDisplayMetrics().densityDpi而非getResources().getDisplayMetrics().densityDpi后者在某些Context下为空。3.2 MediaEncoder.prepareEncoder()H.264编码不是“设个码率就完事”而是“跟GPU抢Buffer”// codec/MediaEncoder.java private void prepareEncoder() { try { mMediaCodec MediaCodec.createEncoderByType(video/avc); MediaFormat format MediaFormat.createVideoFormat(video/avc, mWidth, mHeight); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); format.setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000); // 2Mbps非固定值可动态调整 format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); // 每秒一个IDR帧 format.setInteger(MediaFormat.KEY_CAPTURE_RATE, 30.0f); // 实际采集帧率上限 mMediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); mInputSurface mMediaCodec.createInputSurface(); // 此Surface将传给VirtualDisplay mMediaCodec.start(); } catch (IOException e) { Log.e(TAG, Failed to prepare encoder, e); } }COLOR_FormatSurface是唯一安全的color format它表示MediaCodec接受Surface输入而非ByteBuffer——这正是VirtualDisplay需要的。KEY_BIT_RATE设为2Mbps是经验值低于1.5Mbps会出现明显马赛克高于3Mbps在Wi-Fi 5环境下易丢包。实际项目中应根据网络质量动态调节本demo预留了setBitRate(int bps)接口。KEY_I_FRAME_INTERVAL 1表示每秒强制插入一个IDR帧这对网络抖动下的快速恢复至关重要。若设为2则2秒内丢失IDR帧会导致整段花屏。3.3 RtpSender.sendFrame()RTP不是“把H.264塞进UDP包”而是“按NALU边界切片打时间戳算序列号”// network/RtpSender.java public void sendFrame(ByteBuffer encodedData, MediaCodec.BufferInfo info) { if ((info.flags MediaCodec.BUFFER_FLAG_CODEC_CONFIG) ! 0) { // SPS/PPS头帧单独发送不参与RTP序列号递增 sendSpsPps(encodedData, info); return; } long rtpTimestamp System.nanoTime() / 1000; // 单位微秒RTP标准要求 int sequenceNumber mSequenceNumber 0xFFFF; // 16位无符号溢出回绕 // H.264 NALU切片逻辑简化版实际含FU-A分片 byte[] nalUnit new byte[info.size]; encodedData.position(info.offset); encodedData.get(nalUnit); // 构造RTP Header12字节固定头 ByteBuffer rtpPacket ByteBuffer.allocate(12 nalUnit.length); rtpPacket.put((byte) 0x80); // V2, P0, X0, CC0 rtpPacket.put((byte) 0x60); // M0, PT96 (dynamic) rtpPacket.putShort((short) sequenceNumber); rtpPacket.putInt((int) rtpTimestamp); rtpPacket.putInt(mSsrc); // 随机生成的同步源标识符 // 写入NALU数据 rtpPacket.put(nalUnit); // UDP发送此处省略Socket异常处理 mDatagramSocket.send(new DatagramPacket(rtpPacket.array(), rtpPacket.position())); }BUFFER_FLAG_CODEC_CONFIG标志位代表SPS/PPS头信息必须单独发送且不计入RTP序列号——接收端靠它重建解码器上下文。rtpTimestamp必须用System.nanoTime()计算而非System.currentTimeMillis()前者精度达纳秒级后者毫秒级抖动会导致音画不同步。sequenceNumber每帧1接收端靠它检测丢包。本demo未实现NACK重传但预留了onPacketLost(int seq)回调接口。4. 运行与调试全流程从真机授权到Wireshark抓包手把手跑通第一帧别被“投屏demo”四个字骗了——它不是点开就跑的玩具。从首次安装到看到第一帧画面至少要过5道关卡。我用一台Pixel 4aAndroid 13和一台Redmi Note 12MIUI 14实测过全部流程下面步骤已过滤掉所有ROM定制化陷阱。4.1 真机部署前必做的3项检查确认开发者选项已开启且“USB调试”和“USB安装”均打开尤其是MIUI/EMUI必须手动开启“USB调试安全设置”否则adb install会报Failure [INSTALL_FAILED_USER_RESTRICTED]。关闭手机厂商的“省电优化”白名单外杀进程在小米手机上设置 → 省电与电池 → 应用省电 → 找到本App → 选择“无限制”在华为上手机管家 → 应用启动管理 → 找到本App → 关闭“自动管理”手动启用“允许后台活动”。检查目标设备是否支持MediaProjection并非所有Android设备都开放此API。执行以下ADB命令验证adb shell cmd media_projection list # 正常应返回Projection service is running # 若返回空或报错则设备不支持常见于部分Android TV盒子4.2 首次运行四步法权限申请→Surface就绪→编码启动→RTP发出启动App点击“Start Capture”系统弹出权限Dialog选择“立即开始”。此时PermissionActivity会短暂闪现透明主题不要跳过。观察Logcat过滤关键词MediaProjection和VirtualDisplay正常日志流应为I/MediaProjection: Created virtual display id12345 I/VirtualDisplay: InputSurface created, size1280x720 I/Encoder: Encoder started, input surface ready打开Wireshark过滤udp.port5004默认RTP端口若看到连续RTP包Packet Length 100 bytes说明编码网络链路已通。若只有零星几个包大概率是MediaCodec未正确获取到mInputSurface。用VLC播放测试流需提前配置接收端VLC菜单媒体 → 打开网络串流 → 输入rtp://:5004→ 播放。首次可能黑屏2~3秒这是SPS/PPS头帧传输延迟属正常现象。4.3 常见问题排查血泪经验总结的5个翻车现场注意以下问题全部来自真实设备复现非理论推测。现象1点击“Start Capture”后无任何反应Logcat无MediaProjection日志原因PermissionActivity被厂商ROM拦截未真正调起系统Dialog。常见于ColorOS 13、OriginOS 3。解决在AndroidManifest.xml中为PermissionActivity添加android:exportedtrue并在startActivity()前加intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)。现象2VLC播放黑屏Wireshark看到RTP包但长度恒为12字节原因MediaCodec未正确绑定mInputSurface导致编码器输出空Buffer。解决检查MediaEncoder.prepareEncoder()中mMediaCodec.createInputSurface()是否在configure()之后调用确认VirtualDisplay创建时传入的Surface与createInputSurface()返回的完全一致可用System.identityHashCode(surface)打印比对。现象3投屏画面卡顿严重Logcat频繁打印D/Encoder: Draining output buffer timeout原因MediaCodec.dequeueOutputBuffer()超时通常因MediaCodec.releaseOutputBuffer()未及时调用导致Buffer队列积压。解决在MediaEncoder.drainEncoder()方法中确保每个dequeueOutputBuffer()后必有releaseOutputBuffer()即使info.size 0也要调用releaseOutputBuffer(index, false)。现象4Wireshark抓到RTP包但VLC报错no suitable decoder module for fourcc avc1原因VLC未启用H.264解码器或RTP包中缺失SPS/PPS头帧。解决在VLC偏好设置 → 输入/编解码器 → 视频解码器 → 选择“FFmpeg”同时检查RtpSender.sendFrame()中是否漏掉了BUFFER_FLAG_CODEC_CONFIG分支的SPS/PPS发送逻辑。现象5投屏10分钟后自动断开Logcat出现W/MediaProjection: Stopping projection due to timeout原因Android系统对MediaProjection会话有默认超时约30分钟但本demo未实现心跳保活。解决在ScreenCaptureManager中添加Handler.postDelayed()定时任务每25分钟调用一次mMediaProjection.stop()再重新startCapture()需用户再次授权故建议在UI层提示“即将续期请勿退出”。5. 二次开发实战指南如何把demo变成你的产品模块三个可立即落地的改造方案这个demo的价值不在于它“能跑”而在于它“好改”。我把它集成进某工业设备远程诊断系统时只改了不到200行代码就完成了三项关键升级支持横竖屏自适应、增加硬件加速开关、对接公司私有信令服务器。下面这三个改造方案我都附上了可直接复制的代码块和验证方法你不需要理解整个链路照着改就能见效。5.1 改造一支持横竖屏自动旋转避免投屏画面被拉伸原demo固定使用1280x720分辨率当手机旋转时VirtualDisplay输出仍是横屏导致画面变形。解决方案是监听OrientationEventListener动态重建VirtualDisplay。// capture/ScreenCaptureManager.java 新增 private OrientationEventListener mOrientationListener; private void initOrientationListener() { mOrientationListener new OrientationEventListener(mContext) { Override public void onOrientationChanged(int orientation) { if (orientation ORIENTATION_UNKNOWN) return; // 只在0°、90°、180°、270°时触发避免抖动 if (Math.abs(orientation % 90) 15) { int newWidth (orientation % 180 0) ? 1280 : 720; int newHeight (orientation % 180 0) ? 720 : 1280; if (newWidth ! mWidth || newHeight ! mHeight) { stopCapture(); // 先停 mWidth newWidth; mHeight newHeight; startCapture(mLastResultCode, mLastIntentData); // 再启 } } } }; mOrientationListener.enable(); }验证方法在MainActivity的onCreate()中调用initOrientationListener()然后旋转手机用Wireshark观察RTP包中的分辨率字段需自行解析NALU中的SPS是否变化或直接用VLC查看画面是否保持正常比例。注意stopCapture()会销毁MediaProjection所以必须缓存mLastResultCode和mLastIntentData否则重连需用户再次授权。5.2 改造二增加软编/硬编切换开关适配低端设备部分Android 6.0以下设备硬编码器不稳定需降级为MediaCodec.createDecoderByType(video/avc)软解实际是软编但MediaCodec不提供软编API此处用FFmpeg替代。本demo预留了EncoderType枚举// codec/MediaEncoder.java public enum EncoderType { HARDWARE, // 默认 SOFTWARE // 需额外引入FFmpeg库 } public void setEncoderType(EncoderType type) { mEncoderType type; if (type EncoderType.SOFTWARE) { // 初始化FFmpeg编码器此处省略JNI调用细节 mFfmpegEncoder new FfmpegH264Encoder(mWidth, mHeight, 30, 2_000_000); mFfmpegEncoder.start(); } } // 在onFrameAvailable()回调中 if (mEncoderType EncoderType.HARDWARE) { // 走原MediaCodec路径 } else { // 将Surface内容读取为Bitmap传给FFmpeg编码 Bitmap bitmap getBitmapFromSurface(mInputSurface); mFfmpegEncoder.encodeFrame(bitmap); }验证方法在UI层加一个Switch控件绑定setEncoderType()在低端机如三星Galaxy J3上开启SOFTWARE模式用adb shell dumpsys media.codec确认硬编码器未被占用。避坑getBitmapFromSurface()需用PixelCopy.request()不能用Surface.lockCanvas()——后者在部分ROM上会返回null。5.3 改造三对接私有信令服务器替换UDP直连为WebSocket中转原demo用UDP直连不适合穿透NAT或企业内网。我们将其升级为WebSocket中转信令交互走HTTPS媒体流仍走UDP减少延迟。// network/WebSocketRtpSender.java 继承 RtpSender private WebSocket mWebSocket; public void connectToSignalingServer(String url) { Request request new Request.Builder().url(url).build(); mWebSocket mOkHttpClient.newWebSocket(request, new WebSocketListener() { Override public void onOpen(NotNull WebSocket webSocket, NotNull Response response) { // 发送SDP Offer String offer generateSdpOffer(); webSocket.send({\type\:\offer\,\sdp\:\ offer \}); } Override public void onMessage(NotNull WebSocket webSocket, NotNull String text) { // 收到Answer提取UDP端口和IP JSONObject json new JSONObject(text); if (answer.equals(json.optString(type))) { String ip json.optString(ip); int port json.optInt(port); setupUdpSocket(ip, port); // 重置RtpSender的DatagramSocket } } }); }验证方法启动本地WebSocket服务器如wstunnel修改connectToSignalingServer(ws://localhost:8080)用Chrome开发者工具的Network → WS标签页确认收发Offer/Answer消息。关键点setupUdpSocket()必须在收到Answer后调用且DatagramSocket需bind()到0.0.0.0:0系统分配随机端口再通过信令告知对方——这是UDP打洞的核心。从那以后我每次接手新投屏需求都先拉这个demo用git checkout -b feature/xxx建分支然后按上述三个方案之一切入要么先搞定横竖屏要么先适配低端机要么先打通信令。绝不从零写MediaProjection绝不碰厂商SDK黑盒。因为真正的投屏能力不在“能不能连”而在“连不上时你知道哪一行代码该打Log”。希望帮到你。本文还有配套的精品资源点击获取