☰
Android投屏Demo:低延迟、自适应分辨率与多厂商设备发现实战
2026/10/19 15:00:01 网站建设 项目流程

简介:本资源是一个面向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”这个题目,第一反应是找现成的MediaProjection+VirtualDisplay代码一粘就交——结果答辩时导师一问“断连后怎么自动重连?”、“横竖屏切换时接收端黑屏几秒怎么破?”、“为什么在小米/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。最轻量、最可控,但需双方约定协议格式。

提示:三通道非互斥,而是启动时并发开启,任一通道发现设备即触发后续流程。实际测试中,小米13(HyperOS)下mDNS成功率92%,UPnP仅37%;而华为Mate 50(HarmonyOS 4)下UDP广播100%命中,mDNS完全失效。没有银弹,只有冗余。

2.2 会话管理层:状态机驱动,支持锁屏/后台/权限变更热恢复

会话管理采用有限状态机(FSM)建模,共定义7个状态:IDLE→DISCOVERING→CONNECTING→NEGOTIATING→STREAMING→PAUSING→DISCONNECTED。关键设计点在于状态迁移不依赖UI线程,所有状态变更通过HandlerThread投递消息,避免ANR。例如当用户锁屏时,系统可能回收MediaProjection资源,此时状态机收到PROJECTION_LOST事件,不立即跳转DISCONNECTED,而是进入RECOVERING子状态:

  1. 检查MediaProjectionManager是否仍可用;
  2. 若不可用,请求用户重新授权(弹出系统授权Dialog);
  3. 授权成功后,重建VirtualDisplay并恢复Surface绑定;
  4. 向接收端发送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硬编码器。原因很现实:软编码在中低端机型(如骁龙665)CPU占用率达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引用,负责创建VirtualDisplay;
  • CastEncodeService:绑定服务,通过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-B:0x00000001 + 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之上叠加轻量ARQ(Automatic 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:i+4] 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 设备发现协议栈:mDNS+UPnP+UDP广播的统一抽象层

为避免上层业务逻辑感知底层发现协议差异,设计统一DeviceDiscoverer接口:

public interface DeviceDiscoverer { void startDiscovery(DiscoveryCallback callback); void stopDiscovery(); void sendHelloPacket(InetAddress address); // 通用心跳 } // 具体实现类:MdnsDiscoverer、UpnpDiscoverer、UdpBroadcastDiscoverer

DiscoveryCallback回调统一返回CastDevice对象,其字段标准化:

字段类型说明
deviceIdStringMD5(deviceIp + devicePort)唯一标识
deviceNameString可读名称(如"LivingRoom_TV")
ipAddressInetAddressIPv4地址
portint接收端监听端口(默认56789)
protocolProtocolENUM: 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中调用会静默失败。
解决:

  1. 确保startActivityForResult()在Activity的onResume()后调用;
  2. 在AndroidManifest.xml中为CastForegroundService声明android:foregroundServiceType="specialUse";
  3. 授权成功后,立即在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.WakeLock:
    wakeLock = 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,仅开热点,观察发现日志任一通道发现设备即√√
小米13(HyperOS)下UDP通道100%命中在小米13上启动App,抓包验证UDP广播发出Wireshark捕获到ANDROID_CAST_HELLO√
会话管理锁屏后3秒内自动恢复投屏投屏中按电源键锁屏,3秒后解锁解锁后画面无缝续传,无黑屏√
权限被拒后提示用户重新授权手动关闭“屏幕录制”权限,点击连接弹出系统授权Dialog,非Toast提示√
编码传输端到端延迟≤300ms使用Oscilloscope测量手机触控到TV显示延迟平均267ms,P95=292ms√
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)。为保障方案可交付,我们选择自研轻量协议栈,用UDP+ARQ+FEC替代DTLS/SCTP,实测在相同网络下延迟比WebRTC低112ms(证据:压测表第2行)。如果项目后期需对接WebRTC接收端,我们预留了RtpPacketizer接口,可无缝替换。”

导师问:“你们怎么保证安全性?投屏内容会不会被截获?”
答:“安全性是我们设计的底线。首先,所有UDP包启用AES-128-GCM加密(证据:CryptoUtils.encrypt()函数),密钥由设备发现阶段Diffie-Hellman协商生成,不硬编码;其次,接收端必须校验deviceId签名,伪造设备无法接入(证据:CastDevice.verifySignature()调用);最后,我们禁用所有远程调试端口,APK已启用android:debuggable="false"。当然,这属于基础防护,如需金融级安全,可叠加TLS隧道——这正是我们架构中‘传输层’模块的设计弹性。”

从那以后我每次做毕设答辩,都强制自己先写好这三张表,再对着镜子练三遍话术。不是为了背答案,而是逼自己把每个设计决策背后的约束、权衡、实证都刻进肌肉记忆。当导师的问题砸过来时,你脱口而出的不是“可能”“大概”,而是“实测数据在这里”“代码在第XX行”“日志截图我已备好”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询