Android投屏Demo:低延迟、自适应分辨率与多厂商设备发现实战

发布时间:2026/10/19 15:00:03
Android投屏Demo:低延迟、自适应分辨率与多厂商设备发现实战
简介本资源是一个面向Android开发者的投屏功能实战Demo聚焦于手机屏幕镜像Mirroring/Casting技术落地适用于需要在App中集成投屏能力的中高级开发者及音视频方向学习者。项目基于Android原生MediaProjection API实现屏幕捕获、编码与传输全流程涵盖权限申请、VirtualDisplay创建、ImageReader帧采集、MediaCodec硬编码及服务端资源释放等核心环节可作为投屏模块开发的完整参考骨架。压缩包共2000个文件主体为1588个class字节码含编译后逻辑、472个png资源图、451个xml布局与配置文件、127个json配置及18个jar依赖库整体33.55MB内容预览显示包含MediaSessionCompat、PlaybackStateCompat等AIDL接口及resources-debug.ap_等构建产物体现完整的Android Studio工程结构与调试态输出特征。目前已有7816人学习下载读者可直接复用服务架构、理解投屏生命周期管理并借鉴音视频同步与性能优化思路。1. Android端投屏demo不是“把手机画面发到电视上”就完事而是解决低延迟、自适应分辨率、设备发现不可靠这三大落地卡点很多同学拿到“Android投屏demo”这个题目第一反应是找现成的MediaProjectionVirtualDisplay代码一粘就交——结果答辩时导师一问“断连后怎么自动重连”、“横竖屏切换时接收端黑屏几秒怎么破”、“为什么在小米/OPPO真机上根本搜不到同网段的接收设备”当场哑火。这套方案不是为演示而生是为可稳定运行、可调试验证、可扩展集成的真实场景设计的。它覆盖从Android 8.0到14的主流系统版本核心聚焦三个工程级问题一是端到端延迟压到300ms内非理论值实测Wi-Fi 5G频段下平均267ms二是投屏会话生命周期管理——支持后台保活、锁屏续传、系统权限变更热恢复三是设备发现层兼容mDNS、UPnP、自定义UDP广播三套并行机制规避单一协议在不同厂商ROM上的兼容性黑洞。适合正在做毕业设计、课程设计或需要快速验证投屏链路可行性的嵌入式/Android开发初学者。你不需要懂编解码原理但必须能看懂Surface如何跨进程传递、MediaCodec如何复用编码器实例、以及为什么WifiManager.MulticastLock在Android 12必须配合MANAGE_WIFI_ROUTER权限才能生效。2. 总体架构设计四层结构与五个关键模块的职责边界这套投屏demo不是单个Activity堆出来的玩具而是按“控制流”与“数据流”分离原则组织的四层结构设备发现层 → 会话管理层 → 编码传输层 → 渲染接收层。每一层都封装明确职责避免逻辑缠绕。比如设备发现层不碰视频帧渲染层不处理SDP信令会话管理层像交通指挥中心只管建立、维持、销毁连接不管具体用H.264还是AV1编码。这种分层让调试变得可定位——当出现“能发现设备但连不上”问题一定在会话管理层或其依赖的网络配置当“连接成功但画面卡顿”则锁定在编码传输层的缓冲策略或码率控制逻辑。2.1 设备发现层三通道并行探测绕过厂商ROM魔改陷阱Android原生WifiP2pManager在多数定制ROM尤其国内主流品牌中被深度阉割discoverPeers()调用后常无回调、requestPeers()返回空列表、甚至直接抛SecurityException。本方案弃用该API改用三通道并行探测mDNS通道监听_androidcast._tcp.local.服务名使用NsdManager注册服务并扫描。优势是跨平台iOS/macOS/Windows接收端可复用同一套服务发现逻辑劣势是部分路由器禁用mDNS转发UPnP SSDP通道向239.255.255.250:1900发送M-SEARCH广播解析响应中的LOCATION头获取设备描述XML。适配老式电视盒子但需手动解析XML且存在超时不确定性自定义UDP广播通道固定端口56789发送ANDROID_CAST_HELLO明文报文接收端回ANDROID_CAST_ACK。最轻量、最可控但需双方约定协议格式。提示三通道非互斥而是启动时并发开启任一通道发现设备即触发后续流程。实际测试中小米13HyperOS下mDNS成功率92%UPnP仅37%而华为Mate 50HarmonyOS 4下UDP广播100%命中mDNS完全失效。没有银弹只有冗余。2.2 会话管理层状态机驱动支持锁屏/后台/权限变更热恢复会话管理采用有限状态机FSM建模共定义7个状态IDLE→DISCOVERING→CONNECTING→NEGOTIATING→STREAMING→PAUSING→DISCONNECTED。关键设计点在于状态迁移不依赖UI线程所有状态变更通过HandlerThread投递消息避免ANR。例如当用户锁屏时系统可能回收MediaProjection资源此时状态机收到PROJECTION_LOST事件不立即跳转DISCONNECTED而是进入RECOVERING子状态检查MediaProjectionManager是否仍可用若不可用请求用户重新授权弹出系统授权Dialog授权成功后重建VirtualDisplay并恢复Surface绑定向接收端发送RESUME_STREAM信令携带当前帧序号避免花屏。// 会话状态机核心迁移逻辑简化 private void onProjectionLost() { if (currentState STATE_STREAMING) { currentState STATE_RECOVERING; // 启动恢复定时器防无限等待 recoveryHandler.postDelayed(this::attemptRecovery, 5000); } } private void attemptRecovery() { if (mediaProjection null) { // 触发系统授权请求需Activity上下文 startActivityForResult(intent, REQUEST_CODE_PROJECTION); return; } // 重建VirtualDisplay virtualDisplay mediaProjection.createVirtualDisplay( CastDisplay, width, height, density, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, surface, null, null ); currentState STATE_STREAMING; }这段代码的关键在于attemptRecovery不是简单重连而是带上下文的状态恢复width/height/density取自上次成功创建时的参数surface复用原有Surface对象避免接收端重新分配纹理内存VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR确保镜像模式与之前一致。若强行新建VirtualDisplay而不校验参数接收端解码器可能因分辨率突变而崩溃。2.3 编码传输层硬编码复用动态码率控制平衡画质与延迟编码层放弃软编码libx264全程使用MediaCodec硬编码器。原因很现实软编码在中低端机型如骁龙665CPU占用率达85%以上导致系统调度失衡触控延迟飙升而硬编码由DSP专用核处理CPU占用稳定在12%以内。但硬编码有坑——MediaCodec实例不能跨Surface复用。本方案采用编码器池化预创建3个MediaCodec实例H.264 Baseline Profile每次VirtualDisplay重建时从池中取出一个已配置好的编码器调用configure()重新绑定新Surface而非每次都createEncoder()。实测冷启动编码耗时从320ms降至87ms。动态码率控制基于两套信号帧率反馈接收端每5秒上报一次解码帧率decoded_fps若连续2次decoded_fps 25则降低目标码率15%网络抖动发送端统计UDP丢包率loss_rate若loss_rate 8%则启用KEY_FRAME_REQUEST强制插入I帧并临时提升码率5%以补偿参考帧丢失。// 动态码率调整核心逻辑Kotlin fun adjustBitrate(decodedFps: Int, lossRate: Float) { val targetBitrate when { decodedFps 25 consecutiveLowFps 2 - currentBitrate * 0.85f lossRate 0.08f - { requestKeyFrame() currentBitrate * 1.05f } else - currentBitrate } encoder.setParameters( Bundle().apply { putInt(MediaFormat.KEY_BIT_RATE, targetBitrate.toInt()) } ) }注意setParameters()必须在STARTED状态下调用且部分芯片如联发科MT6765对码率调整步长敏感——单次降幅超过20%会导致编码器卡死。因此0.85f是经过23台真机压测得出的安全阈值。2.4 渲染接收层OpenGL ES 3.0纹理映射规避SurfaceView撕裂接收端如TV盒子不使用SurfaceView或TextureView直接显示Surface而是通过EGL将Surface绑定为EGLImageKHR再在OpenGL ES 3.0 Shader中采样。此举彻底规避SurfaceView的VSync同步缺陷——当投屏帧率30fps与TV刷新率60Hz不匹配时SurfaceView会出现明显撕裂而Shader采样可精确控制纹理更新时机配合eglSwapInterval(1)实现帧对齐。关键代码在onFrameAvailable()回调中// OpenGL渲染线程中 public void onFrameAvailable(SurfaceTexture surfaceTexture) { surfaceTexture.updateTexImage(); // 同步新帧 // 获取当前帧时间戳纳秒级 surfaceTexture.getTimestamp(timestamp); // 计算该帧应显示时长用于平滑播放 long frameDurationNs timestamp[0] - lastTimestamp[0]; lastTimestamp[0] timestamp[0]; // 绘制前检查是否超时 if (frameDurationNs 50_000_000L) { // 50ms丢弃 return; } GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4); }这里frameDurationNs是两帧间的时间差单位纳秒。若超过50ms即低于20fps说明解码或传输严重滞后主动丢弃该帧而非堆积渲染防止后续帧持续延迟。这是保障“低延迟”承诺的硬性守门员。3. 核心模块实现从MediaProjection初始化到H.264 Annex-B打包3.1 MediaProjection安全初始化绕过Android 10 Scoped Storage限制Android 10起MediaProjection创建后无法直接访问VirtualDisplay的Surface除非应用声明android.permission.FOREGROUND_SERVICE且在前台运行。本方案采用双Service架构CastForegroundService前台服务持有MediaProjection引用负责创建VirtualDisplayCastEncodeService绑定服务通过AIDL接收Surface的IBinder代理将其转换为Surface对象进行编码。关键在于VirtualDisplay创建时的Surface必须来自CastForegroundService的HandlerThread否则MediaCodec无法绑定。以下是CastForegroundService中创建VirtualDisplay的完整流程// CastForegroundService.java private void createVirtualDisplay() { // 1. 确保在HandlerThread中执行 projectionHandler.post(() - { // 2. 创建Surface非View相关纯内存Surface surface new Surface(surfaceTexture); // 3. 创建VirtualDisplay绑定此Surface virtualDisplay mediaProjection.createVirtualDisplay( CastDisplay, displayWidth, displayHeight, displayDensity, DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, surface, null, null ); // 4. 通过AIDL将Surface传递给EncodeService try { iEncodeService.onSurfaceReady(surface); } catch (RemoteException e) { Log.e(TAG, Failed to pass surface, e); } }); }注意DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC是关键标志。若使用FLAG_SECURE则Surface无法被MediaCodec编码会抛IllegalStateException若漏掉此标志部分Android 12设备会静默失败。这是血泪经验——曾因少写一个flag在三星S22上调试3天。3.2 H.264 Annex-B格式打包去除start code添加NALU长度前缀MediaCodec输出的H.264数据是AVCC格式含SPS/PPS的csd-0/csd-1Buffer但接收端如FFmpeg通常要求Annex-B格式每个NALU以0x00000001开头。本方案在编码器dequeueOutputBuffer()后立即进行格式转换// 输出Buffer处理Java private void processOutputBuffer(int index, MediaCodec.BufferInfo info) { ByteBuffer outputBuffer encoder.getOutputBuffer(index); if ((info.flags MediaCodec.BUFFER_FLAG_CODEC_CONFIG) ! 0) { // SPS/PPS配置数据单独处理 byte[] configData new byte[info.size]; outputBuffer.get(configData, info.offset, info.size); // 转换为Annex-B0x00000001 configData byte[] annexBConfig new byte[4 configData.length]; System.arraycopy(new byte[]{0,0,0,1}, 0, annexBConfig, 0, 4); System.arraycopy(configData, 0, annexBConfig, 4, configData.length); sendToNetwork(annexBConfig); return; } // 普通NALU数据 byte[] nalData new byte[info.size]; outputBuffer.get(nalData, info.offset, info.size); // 移除AVCC中的length前缀添加Annex-B start code byte[] annexBNAL new byte[4 nalData.length]; System.arraycopy(new byte[]{0,0,0,1}, 0, annexBNAL, 0, 4); System.arraycopy(nalData, 0, annexBNAL, 4, nalData.length); sendToNetwork(annexBNAL); }这段代码的玄学点在于MediaCodec输出的info.offset不一定为0某些高通芯片如骁龙8 Gen1会在ByteBuffer头部预留16字节对齐空间若直接outputBuffer.array()会读到脏数据。因此必须用get(byte[], offset, length)方法精准提取有效载荷。这是翻车高发区——曾有同学用array()导致接收端解码器持续报invalid NALU size排查两周才发现是字节数组越界。3.3 UDP可靠传输层应用层ARQ前向纠错FEC纯UDP传输H.264易受丢包影响尤其I帧丢失会导致后续P帧全部花屏。本方案在UDP之上叠加轻量ARQAutomatic Repeat reQuest每个UDP包携带序列号seq_num和包类型I/P/Slice接收端维护滑动窗口大小128检测seq_num空缺通过ACK_NACK包请求重传对I帧和SPS/PPS包启用FEC每4个原始包生成1个XOR校验包接收端可用任意4个包恢复全部5个。FEC校验包生成逻辑# Python伪代码实际在C JNI中实现 def generate_fec_packet(packets): # packets: list of 4 bytes objects fec_payload bytearray(len(packets[0])) for i in range(len(packets[0])): xor_byte 0 for pkt in packets: xor_byte ^ pkt[i] fec_payload[i] xor_byte return b\x00\x00\x00\x02 fec_payload # FEC type header # 发送端每4个原始包后发1个FEC包 for i in range(0, len(raw_packets), 4): batch raw_packets[i:i4] for pkt in batch: send_udp(pkt) if len(batch) 4: fec_pkt generate_fec_packet(batch) send_udp(fec_pkt)FEC开销仅增加20%带宽4:1但实测在15%丢包率下I帧恢复成功率从0%提升至99.3%。这是用带宽换稳定性的务实选择——比起TCP重传的数百毫秒延迟FEC在10ms内完成恢复。3.4 设备发现协议栈mDNSUPnPUDP广播的统一抽象层为避免上层业务逻辑感知底层发现协议差异设计统一DeviceDiscoverer接口public interface DeviceDiscoverer { void startDiscovery(DiscoveryCallback callback); void stopDiscovery(); void sendHelloPacket(InetAddress address); // 通用心跳 } // 具体实现类MdnsDiscoverer、UpnpDiscoverer、UdpBroadcastDiscovererDiscoveryCallback回调统一返回CastDevice对象其字段标准化字段类型说明deviceIdStringMD5(deviceIp devicePort)唯一标识deviceNameString可读名称如LivingRoom_TVipAddressInetAddressIPv4地址portint接收端监听端口默认56789protocolProtocolENUM: MDNS / UPNP / UDP这样会话管理层调用discoverer.startDiscovery(...)后无论底层走哪种协议收到的设备信息结构完全一致后续连接逻辑无需修改。这是典型的“面向接口编程”落地——当某天需要新增蓝牙LE发现时只需新增BleDiscoverer实现类上层零改动。4. 常见问题排查五个必踩的坑与对应解法4.1 现象App能发现设备但点击连接后无响应Logcat显示MediaProjection not created原因MediaProjection创建需用户手动授权但授权Dialog被系统拦截如锁屏、多任务界面遮挡或startActivityForResult()未在Activity主线程调用。更隐蔽的是Android 12要求MediaProjection必须在FOREGROUND_SERVICE中创建若在普通Service中调用会静默失败。解决确保startActivityForResult()在Activity的onResume()后调用在AndroidManifest.xml中为CastForegroundService声明android:foregroundServiceTypespecialUse授权成功后立即在onActivityResult()中调用createVirtualDisplay()而非延后到其他线程。4.2 现象投屏开始后10秒内自动断连Logcat报E/MediaCodec: Codec reported err 0x80000000原因MediaCodec硬编码器在部分芯片如联发科Helio G95上若输入Surface尺寸非2的幂次方如1080x1920会触发内部校验失败。Android系统不会抛异常而是静默关闭编码器。解决创建VirtualDisplay前将宽高向上取整至最近的2的幂int alignedWidth Integer.highestOneBit(displayWidth) * 2; int alignedHeight Integer.highestOneBit(displayHeight) * 2;同时在OpenGL Shader中添加UV坐标缩放确保画面不拉伸。4.3 现象小米/OPPO手机无法发现同网段接收设备但华为/三星正常原因小米/OPPO ROM深度定制了WifiManager禁用MulticastLock和WifiManager.addNetwork()等API导致mDNS/UPnP广播包无法发出。解决强制启用UDP广播通道UdpBroadcastDiscoverer并设置WifiManager.createMulticastLock()后立即acquire()若acquire()失败返回false降级为单播探测遍历192.168.1.1-254网段向每个IP的56789端口发送HELLO包此操作需ACCESS_NETWORK_STATE和CHANGE_WIFI_MULTICAST_STATE权限且在Android 12需动态申请。4.4 现象横屏投屏时接收端画面被裁剪竖屏时又留黑边原因VirtualDisplay创建时宽高比与接收端屏幕不匹配且MediaCodec编码器未启用KEY_CROP_LEFT/RIGHT/TOP/BOTTOM参数裁剪。解决在MediaFormat中显式设置裁剪区域format.setInteger(MediaFormat.KEY_CROP_LEFT, 0); format.setInteger(MediaFormat.KEY_CROP_RIGHT, displayWidth - 1); format.setInteger(MediaFormat.KEY_CROP_TOP, 0); format.setInteger(MediaFormat.KEY_CROP_BOTTOM, displayHeight - 1);接收端OpenGL Shader中根据displayWidth/displayHeight动态计算UV缩放系数而非硬编码。4.5 现象低电量模式下投屏卡顿严重CPU频率被锁在最低档原因Android省电策略会限制后台Service的CPU调度CastEncodeService的编码线程得不到足够时间片。解决在CastForegroundService中调用PowerManager.WakeLockwakeLock powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, CastService::WakeLock ); wakeLock.acquire(10*60*1000L); // 10分钟到期自动释放同时在AndroidManifest.xml中声明WAKE_LOCK权限关键WakeLock必须在onStartCommand()中获取而非onCreate()否则系统可能提前回收。5. 验收与答辩用三张表、两个现场演示、一个答辩话术通关5.1 功能验收表按模块逐项打钩拒绝模糊描述验收不是“能跑就行”而是对照设计文档逐条验证。以下表格是答辩前必须填满的自查清单每项需附截图或录屏证据模块验收项测试方法通过标准实测结果打√/×设备发现支持mDNS/UPnP/UDP三通道并行关闭WiFi仅开热点观察发现日志任一通道发现设备即√√小米13HyperOS下UDP通道100%命中在小米13上启动App抓包验证UDP广播发出Wireshark捕获到ANDROID_CAST_HELLO√会话管理锁屏后3秒内自动恢复投屏投屏中按电源键锁屏3秒后解锁解锁后画面无缝续传无黑屏√权限被拒后提示用户重新授权手动关闭“屏幕录制”权限点击连接弹出系统授权Dialog非Toast提示√编码传输端到端延迟≤300ms使用Oscilloscope测量手机触控到TV显示延迟平均267msP95292ms√I帧丢失后1秒内恢复画面网络模拟工具注入20%丢包手动触发I帧第2帧起画面正常无花屏√渲染接收横竖屏切换无撕裂TV遥控器切换横竖屏模式画面平滑过渡无撕裂/闪烁√低电量模式下帧率≥25fps开启省电模式运行30分钟压力测试adb shell dumpsys gfxinfo显示FPS≥25√提示答辩时不要说“我测试过了”而是打开这张表指着“实测结果”列说“请看第3行这是我在小米13上锁屏恢复的录屏时间戳显示从锁屏到画面恢复共2.8秒符合设计指标”。5.2 性能压测表用真实数据堵住“理论可行”的质疑导师最爱问“你们怎么证明延迟真的低”。光说“实测267ms”不够要展示压测过程和环境。这张表必须包含测试场景网络环境设备型号连续测试时长平均延迟P95延迟备注Wi-Fi 5G华为AX3 Pro路由器小米1310分钟267ms292ms信道36无干扰Wi-Fi 2.4GTP-Link TL-WR845NOPPO Reno810分钟342ms389ms同一信道有3个邻居WiFi移动热点小米13开热点华为Mate 505分钟418ms476ms4G网络RSRP-92dBm数据来源必须可追溯延迟用System.nanoTime()在触控事件和OpenGL绘制完成时打点网络环境用WifiManager.getConnectionInfo()获取SSID和频率设备型号调用Build.MODEL。答辩时可当场打开ADB命令行输入adb shell getprop ro.product.model验证设备型号。5.3 答辩话术用“问题-解法-证据”三段式回应所有质疑遇到技术质疑切忌解释原理要用“问题-解法-证据”结构回答。例如导师问“为什么不用WebRTC它不是标准方案吗”答“您指出WebRTC是标准方案这点我完全认同。但我们遇到的具体问题是WebRTC在Android端依赖PeerConnection而PeerConnection在小米/OPPO ROM上常因libwebrtc.so符号冲突崩溃证据我们编译了WebRTC 114分支在小米13上启动即SIGSEGV。为保障方案可交付我们选择自研轻量协议栈用UDPARQFEC替代DTLS/SCTP实测在相同网络下延迟比WebRTC低112ms证据压测表第2行。如果项目后期需对接WebRTC接收端我们预留了RtpPacketizer接口可无缝替换。”导师问“你们怎么保证安全性投屏内容会不会被截获”答“安全性是我们设计的底线。首先所有UDP包启用AES-128-GCM加密证据CryptoUtils.encrypt()函数密钥由设备发现阶段Diffie-Hellman协商生成不硬编码其次接收端必须校验deviceId签名伪造设备无法接入证据CastDevice.verifySignature()调用最后我们禁用所有远程调试端口APK已启用android:debuggablefalse。当然这属于基础防护如需金融级安全可叠加TLS隧道——这正是我们架构中‘传输层’模块的设计弹性。”从那以后我每次做毕设答辩都强制自己先写好这三张表再对着镜子练三遍话术。不是为了背答案而是逼自己把每个设计决策背后的约束、权衡、实证都刻进肌肉记忆。当导师的问题砸过来时你脱口而出的不是“可能”“大概”而是“实测数据在这里”“代码在第XX行”“日志截图我已备好”。希望帮到你。本文还有配套的精品资源点击获取