简介:这个Android毕设项目基于DroidAirPlay框架,实现了支持AirPlay协议的Android系统接收端,工程为AndroidStudio项目,可直接编译运行。适用于学习Android多媒体传输、网络协议解析的毕业设计或课程设计场景,具有一定Android与Java基础即可上手,也可作为底层系统与嵌入式编程方向的扩展参考。压缩包共502个文件,类型涵盖Java源码、DroidAirPlay核心库、jar依赖、JSON/XML配置、Gradle构建脚本以及已签名的app-debug.apk,整体大小约41.19MB,既保留了完整开发链路,也提供了可直接安装的成品。目前已有48人学习使用,源码经过测试可稳定运行。通过阅读源码可掌握AirPlay双向交互流程、服务发现与媒体数据封装细节,在此基础上可进一步扩展音频推送、屏幕镜像等功能,同时问题反馈渠道畅通,适合系统化研究。
1. 毕设选型可以很简单:Android 接收端为什么偏偏要用 DroidAirPlay
如果你手头有一台吃灰的安卓手机,或者毕设题目里写着“做一个无线投屏接收端”,那么 DroidAirPlay 大概是 Android 生态里唯一能让你直接复用的 AirPlay 接收端方案。它不是三五行代码的封装库,而是一个完整可跑的 Android Studio 工程,内部实现了 Bonjour 服务发现、RTSP 信令、RTP 收流和 MediaCodec 硬解码,开机注册服务后,iPhone、iPad 从屏幕镜像列表就能直接找到你的安卓设备。对于毕设来说,最怕的不是写代码,而是协议文档不完整、调试时间远超预期,这个工程恰好帮你把链路铺好了。接下来的内容按照我自己的落地习惯展开:先讲透协议和内部模块,再带你把它塞进 Android Studio,调通第一帧画面,最后把参数和常见坑一次说完。适合想拿这个题目做毕设、但又不想当“调包侠”的学生。
2. 原理先立住:AirPlay 在 Android 上该如何理解“接收端”这个角色
很多资料把 AirPlay 接收端描述成“像电视一样接收投屏”,这个说法不够准确。从 TCP/IP 视角看,AirPlay 接收端真正扮演的是RTSP 服务器 + RTP 接收器,iPhone 是客户端,主动发起连接。Android 设备要做的事,就是把自己伪装成一个 Apple TV 端点。DroidAirPlay 之所以能实现,是因为 AirPlay 的控制层基于 RTSP,音视频数据基于 RTP/RTCP,这些协议格式是公开且可逆的,苹果只是用 Bonjour 加了一层服务发现作为门槛。
2.1 AirPlay 协议栈拆解:Http、RTSP、RTP 各管一段
一次完整的 AirPlay 屏幕镜像会经历三个阶段。第一阶段是服务发现,接收端在局域网内广播“我是一个 Apple TV”,iPhone 上的控制中心才能看到设备;第二阶段是会话建立,iPhone 向接收端的 RTSP 端口发起请求,交换播放参数;第三阶段是数据流传输,音频和视频通过 RTP 包连续发送到接收端,接收端解码后同步渲染。
下面这张表是我在做协议抓包时整理的对照关系,也是 DroidAirPlay 内部代码分层的依据:
| 协议层 | 使用的协议 | 典型端口 | 作用 | 对应 DroidAirPlay 模块 |
|---|---|---|---|---|
| 服务发现 | Bonjour / mDNS | 5353 UDP | 让 iPhone 搜索到设备 | JmDNS / NsdManager |
| 控制面 | RTSP/TCP | 7100 | 播放、暂停、停止、参数协商 | RtspServer |
| 数据面 | RTP/UDP | 动态端口 | 传输 H.264 视频和 AAC 音频 | RtpStream |
| 渲染面 | MediaCodec + AudioTrack | - | 硬解码和播放 | VideoPlayer / AudioPlayer |
需要特别注意的是控制面和数据面的端口关系。RTSP 使用 TCP 7100 作为信令入口,真正的媒体流端口是 RTSP 响应报文里Transport头动态指定的,并没有固定值。所以如果你在抓包时看到 UDP 端口一直乱跳,不用奇怪,那是 iPhone 按协商结果在发流。
2.2 DroidAirPlay 的模块架构:四个进程内组件协同
DroidAirPlay 并没有把所有功能写在一个 Activity 里,而是拆成四个大组件。这种拆法值得直接参考,因为毕设答辩时,老师大概率会问“你的接收端在系统里是如何组织的”。
AirPlayService:后台服务,负责生命周期管理,持有 RTSP Server 引用。BonjourRegistrar:注册_airplay._tcp服务,每秒发送 mDNS 广播。RtspController:解析 RTSP 请求,回放/暂停逻辑全在这里。DecodePipeline:组合 MediaCodec 和 AudioTrack,负责同步。
2.2.1 为什么服务发现用_airplay._tcp而不是自定义 type
iPhone 上的控制中心只会去找类型为_airplay._tcp的 Bonjour 服务,这是苹果设备识别的硬条件。DroidAirPlay 内部用 JmDNS 库来注册,一个最小可用的注册代码是这样写的:
ServiceInfo serviceInfo = ServiceInfo.create( "_airplay._tcp.local.", "Android-Studio-Rx", 7100, "deviceid=AA:BB:CC:DD:EE:FF model=AppleTV2,1 Dv=1" ); JmDNS jmDNS = JmDNS.create(InetAddress.getLocalHost()); jmDNS.registerService(serviceInfo);上面的代码里,_airplay._tcp.local.是必填的服务类型,7100必须和 RTSP Server 的监听端口一致。deviceid字段必须是接收端的 MAC 地址,它对后续 FairPlay 配对有意义,如果写成随机值,iPhone 可能在第二次连接时认不出设备。model字段建议保持AppleTV2,1,这是被最多 iOS 版本兼容的型号标识。
2.2.2 RTSP 控制面是“请求-响应”模型,别死等同步
RTSP 端点的核心任务是接收类似于SETUP、PLAY、PAUSE的指令。DroidAirPlay 的RtspController内部维护了一个解析循环,收到请求后返回对应的RTSP/1.0 200 OK。有一个容易踩坑的地方是:iOS 在发SETUP时会要求接收端回答Transport头里携带的服务器端口,这个端口必须提前绑定好,否则视频流会发到一个无人监听的端口上导致黑屏。
建议用adb shell netstat -tlnp --udp查看实际端口占用的状态,能快速定位这类协商问题。
3. 在 Android Studio 里把 DroidAirPlay 工程搬到自己机器上
这一章解决从“下载 zip”到“跑起来”的完整落地过程。你拿到的 DroidAirPlay 应该是一个可以被 Gradle 直接打开的项目,但机器环境不同,基本会遇到三个绕不开的问题:NDK 版本不匹配、ABI 缺失、权限没给全。下面按我自己的操作顺序来。
3.1 导入工程前的环境检查:SDK、JDK、NDK 三件套
建议先在本机上确认三个环境变量。JDK 要求 11 及以上,Android SDK 版本建议编译时使用 31 以上。DroidAirPlay 的 C 部分代码是用 JNI 写的,所以还需要配置 NDK 和 CMake。打开工程后,Gradle 会报NDK not configured,这时在local.properties里写入本机的 ndk 路径:
sdk.dir=/Users/你的用户名/Library/Android/sdk ndk.dir=/Users/你的用户名/Library/Android/sdk/ndk/21.4.7075529NDK 版本不要随便升到 r23 以上,因为老版本 JNI 代码里的 ABI 构建方式在新 NDK 中可能被废弃。我一般固定用 21.x。这一步完成后先执行./gradlew assembleDebug,编译一次看爆出的具体错误。
3.2 用 CMake 原生编译 DroidAirPlay 的 JNI 层
DroidAirPlay 依赖一些 C 语言实现的协议解析模块,比如 RTP over TCP 的分包逻辑。工程内的CMakeLists.txt负责把这些 C 文件编成 libairplay.so。下面是一个精简版,作用是把协议解析源码打包进主库:
cmake_minimum_required(VERSION 3.18.1) project(droidairplay) add_library( airplay_rtsp SHARED src/main/jni/rtsp/rtsp_server.c src/main/jni/rtsp/rtp_depacketizer.c src/main/jni/rtsp/mdns_responder.c ) find_library( log-lib log ) target_link_libraries( airplay_rtsp ${log-lib} )这一段 CMake 里最值得解释的是add_library里传的源码文件必须和实际 JNI 逻辑一一对应。如果 DroidAirPlay 的 Java 代码里有System.loadLibrary("airplay_rtsp"),那么add_library的第一参数必须一致。编译完成后,在app/build/outputs/apk/debug能看到 APK 文件。
3.3 AndroidManifest 必须声明的一组权限,少一个都白搭
接收端不是普通应用,它要长期驻留后台、接收组播数据、保持屏幕常亮。下面是 DroidAirPlay 运行所必需的最小权限集,逐条解释:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />CHANGE_WIFI_MULTICAST_STATE是最容易被忽略的权限。Wi-Fi 在默认情况下会过滤多播包,而 Bonjour 和 RTP 都可能用组播传输,没有它 iPhone 永远发现不了设备。WAKE_LOCK的作用是锁屏后保持 CPU 和 Wi-Fi 继续工作,否则屏幕镜像会在锁屏瞬间断掉。
3.4 用 adb 安装到真机并确认 Bonjour 注册成功
先开启 Android 设备的“开发者选项”,然后用 USB 连接。安装完成后,在电脑上执行:
adb install -r app-debug.apk adb shell dns-sd -B _airplay._tcpdns-sd -B命令会持续监听到局域网内的服务广播。如果 DroidAirPlay 已经启动,能看到一条类似Add 2 4 _airplay._tcp.local. Broadcast的记录。没有看到这条记录就回到 3.2,确认 JmDNS 有没有在子线程中调用。
4. 参数怎么调:DroidAirPlay 的四个关键配置点
DroidAirPlay 不是装上就能满帧运行,它需要根据 Android 设备的 CPU 能力、Wi-Fi 信道条件和屏幕分辨率调整参数。下面是我实际调过的四个位置,都在工程内的AirPlayService.java或build.gradle里。
4.1 服务名和设备标识:毕设展示时的第一印象
在AirPlayService.java中有一个SERVER_NAME常量,默认值是DroidAirPlay。建议改成 “学生姓名+机顶盒” 这样的命名,比如ZhangSan-Recv。原因是苹果设备显示名称时,会用 Bonjour 的 displayName 字段,如果和局域网里其他设备重名,iOS 可能拒绝连接。
设备标识deviceid也建议同步修改。做法是读取Settings.Secure.ANDROID_ID,用它来生成一个固定 MAC 地址,这样重启后 iPhone 能记住配对关系。
String deviceId = "0" + android.provider.Settings.Secure.getString( getContentResolver(), android.provider.Settings.Secure.ANDROID_ID ); deviceId = deviceId.substring(0, 12);这里deviceId必须是 12 位十六进制,少了前面补 0。它会被写入 RTSP 响应头的Apple-DeviceID字段,配合 4.3 FairPlay 认证逻辑使用。
4.2 视频渲染参数:分辨率、帧率和 I 帧间隔
AirPlay 的镜像视频流由 iPhone 按接收端能力编码。DroidAirPlay 在DecodePipeline里创建了 MediaCodec,你可以在创建时强制指定一个最大解码分辨率,防止低端设备解码 4K 时卡死:
MediaFormat format = MediaFormat.createVideoFormat("video/avc", 1920, 1080); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_MAX_WIDTH, 1920); format.setInteger(MediaFormat.KEY_MAX_HEIGHT, 1080);KEY_MAX_WIDTH和KEY_MAX_HEIGHT只是向系统申请的能力上限,真正等 iPhone 发来的 SPS/PPS 后才会决定最终分辨率。如果你的 Android 设备是几年前的中端芯片,建议把 1920 改成 1280,这样能显著降低解码延迟。COLOR_FormatYUV420SemiPlanar是大多数视频硬解码器最常支持的输出格式,如果改用其他格式,Surface渲染会灰屏。
4.3 音频播放缓冲:影响音画同步的直接参数
AirPlay 音频默认是 AAC-LC,通过 RTP 包传输。DroidAirPlay 内部使用AudioTrack进行播放,有两个参数必须手动调整:
| 参数 | 推荐值 | 作用 |
|---|---|---|
AudioTrack.BUFFER_SIZE_IN_BYTES | 单次 20 ms 帧大小 × 10 | 缓冲越大抗抖动越好,延迟也越高 |
PLAYBACK_RATE | 1.0f(不要调) | 强制改速率会导致声音变调 |
我在调试中发现,把缓冲从默认 500ms 调低到 200ms 之后,音画同步从明显可感降到了基本不可察。但要注意,低于 100ms 会出现在网络抖动时声音断断续续。DroidAirPlay 的源码里,你可以在AudioPlayer.java的initAudioTrack()里看到这两个值。
5. 实战排错:配对、黑屏和保活的三个深坑
毕设答辩现场最容易出现的状况,就是前一天能连,第二天换了个 Wi-Fi 就死活搜不到。这一章集中讲三个我已经遇到并处理过的问题。
5.1 服务搜不到:十有八九是多播锁没拿
就算你注册了 Bonjour 服务,Android 的 Wi-Fi 驱动默认还是会丢弃多播包。DroidAirPlay 必须在初始化时获取多播锁:
WifiManager wifiManager = (WifiManager) getApplicationContext() .getSystemService(WIFI_SERVICE); MulticastLock lock = wifiManager.createMulticastLock("airplay_boot"); lock.setRefCounted(false); lock.acquire();setRefCounted(false)很重要,它用一个独立锁保护整个进程的组播收发,避免多个组件重复 acquire/release 时把锁意外释放。调试时用wifiManager.isMulticastEnabled()确认状态,如果返回 false 但代码已经执行到 acquire,说明是手机厂商的省电策略在作怪,需要在系统设置里把该应用设定为“无限制”。
5.2 iPhone 一直显示连接中,RTSP 握手之后无数据
这种问题通常出现在SETUP之后的RTP-Info头参数不完整。使用adb logcat抓取日志:
adb logcat -s RtspController:V MediaCodec:W日志中如果出现SocketException: address already in use,说明 DroidAirPlay 的上一次 RTSP 连接没有释放端口。重启应用前要确保RtspController.stopAll()被调用。还有一种情况是 iPhone 发送的 RTP 包到达了,但是包的头类型不符合 H.264 的 NAL 单元格式,这是 Android 解码器要求 SPS/PPS 前置导致的。可以在DecodePipeline里打印第一个 RTP 包的前 8 字节,对照 Wireshark 抓包确认是否发生了分包。
5.3 锁屏秒断、切后台被杀:保活方案
AirPlay 接收端是投屏场景,锁屏时还必须继续工作。最简单的做法是用前台服务提升进程优先级:
Notification notification = new Notification.Builder(this, "airplay_channel") .setContentTitle("AirPlay接收端运行中") .setSmallIcon(R.mipmap.ic_launcher) .build(); startForeground(1, notification);记着在onCreate里注册PowerManager的 WakeLock,对屏幕锁定时保持 Wi-Fi 和数据卷积很重要:
PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); mWakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "airplay"); mWakeLock.acquire();PARTIAL_WAKE_LOCK不需要点亮屏幕,只让 CPU 保持唤醒状态,这是接收端最合理的模式。
6. 进一步把 DroidAirPlay 封装成系统级接收服务
毕设如果只做一个点击按钮才能运行的 App,答辩老师会追问“用户怎么控制、能不能自动启动”。常见做法是把接收逻辑移入一个START_STICKY的前台服务,并对外暴露 AIDL 接口,让其他应用可以查询状态。
在onStartCommand里返回START_STICKY,服务被系统杀掉后会自动重建。再用 AIDL 定义两个方法:
interface IAirPlayControl { boolean isRunning(); int getConnectedClients(); }通过ServiceManager注册到系统,其他模块就能通过ServiceManager.getService("airplay")调用。这才是从“工程能跑”迈向“系统级接收端”的收尾动作。这样处理之后,DroidAirPlay 不再只是一个投屏工具,而可以成为整个设备固件能力的一部分,你在毕设论文里也就能顺势把“服务化架构”写成一章。
本文还有配套的精品资源,点击获取