sipdroid源码解析:Android SIP语音视频通话与RTP/RTCP实现
简介:面向安卓开发者的SipDroid语音与视频通话源码包,基于SIP协议实现VoIP通信,适合希望深入理解安卓多媒体框架、网络编程及服务组件的开发者。资源为zip格式,大小4.32MB,包含SipDroid项目源码,可直接导入工程分析,目前已有280人学习。源码涵盖SIP注册器、通话引擎、RTP媒体流传输、音视频编解码器、界面交互和权限管理等关键模块,可帮助读者掌握呼叫建立与挂断流程、事件处理机制,以及如何在真实设备上优化通话质量;读者也可以从中学习来电提醒、通话保持、静音、挂断等功能的实现,理解Android权限申请与音频焦点管理。通过研读源码,能够快速搭建自己的VoIP应用骨架,并学习安卓系统中网络与硬件异步事件的协作方式,同时理解如何集成SIP库、处理网络连接变化,以及设计更友好稳定的通话界面。这份源码对初学者和中级开发者均有参考价值,既能作为课堂项目学习,也能作为企业级通信应用的基础原型。
1. 安卓源码里最值得读的 VoIP 落地方案:sipdroid 语音及视频通话
当你的项目需要在一周内拿出“能打电话”的 Android 原型,大多数人会直接打开声网或腾讯云 SDK 文档,但如果你所在网络环境要求私有化部署、信令必须走 SIP,那情况就完全不同了。这时候一个“安卓 Android 源码”级别的参照物就很重要,而 sipdroid 几乎是唯一一个把 SIP 注册、音频 RTP、视频传输、NAT 穿透全部摊在 Java 层给你看的开源客户端。它最大的价值不是“能跑出 apk”,而是你能从 SipdroidEngine 到 RtpStream 完整读懂一次呼叫的生命周期。这篇博文适合两类人:一类是要在安卓内网环境快速搭一套语音及视频通话能力的应用工程师,另一类是做协议层二次开发、需要动编解码与端口分配的系统工程师。按“先编译、再语音、后视频、最后改造”的顺序,你会看到一套不算新但足够可靠的落地方案。
2. 从 zip 到工程:编译 sipdroid 前必须搞清的 Android 源码目录和依赖
2.1 解压后先看这 4 个目录:sipdroid 源码的结构与职责
拿到一个 sipdroid_语音及视频通话.zip 这样的压缩包,别急着用 Android Studio 打开。先解压到不包含中文的路径,比如 D:\work\sipdroid 或 ~/work/sipdroid ,然后用文件管理器或命令行确认解压结果。sipdroid 不是标准的多模块 Gradle 工程,它的核心代码集中在 src/org/sipdroid 之下,但你真正需要关注的目录只有四个。
第一个是 src/org/sipdroid/sipua ,里面是 SIP 协议栈的状态机,包括注册、INVITE、ACK、BYE 逻辑和账号管理。第二个是 src/org/sipdroid/media ,这里封装了 RTP/RTCP 发送接收、编码器选择和音频流控制。第三个是 src/org/sipdroid/ui ,拨号盘与通话界面都在这个包里,把它们替换成自己的界面最容易,也最适合做二次开发的起点。第四个是 libs 或 jni ,老版本里会有预编译的 so 库或者 C 源码,用于支持 G.729 之类的非标准编解码。
用命令行验证一下结构:
unzip sipdroid_writable.zip -d sipdroid
cd sipdroid
find . -maxdepth 2 -type d | head -20
unzip 把压缩包解压到 sipdroid 目录, -d 是指定目标路径; find ... | head -20 只显示前 20 个目录,避免刷屏。解压后如果根目录没有 build.gradle ,说明这是 Eclipse 时代的老工程,直接导入 Android Studio 会失败,需要按 2.2 做一次工程迁移。
2.2 用 Android Studio 打开并配置 NDK、SDK 参数
老版本的 sipdroid 工程依赖 Android SDK 21 以下的部分接口,如果你手里的 Android Studio 是最新的 Arctic Fox 或更高版本,直接 Open 会报一堆 SDK location not found 或者 Attribute source 错误。常见做法是用 Android Studio 的 Import Project 选择 Eclipse 导入模式,让它自动生成 Gradle 工程;如果导入失败,就新建一个空工程,手动把 src 、 res 、 AndroidManifest.xml 拷进去。
无论用哪种方式, local.properties 都要先配好:
sdk.dir=/Users/you/Library/Android/sdk
ndk.dir=/Users/you/Library/Android/ndk/android-ndk-r14b
sdk.dir 指向你的 Android SDK 根目录, ndk.dir 指向 sipdroid 能够识别的 NDK 版本。sipdroid 早在 NDK r14b 时代编译,太高版本如 r21 之后的构建脚本会变,导致 jni 目录里 Android.mk 无法被正确解析。如果你不确定自己系统里装了哪个 NDK,可以用 ls $ANDROID_HOME/ndk 查一下,然后把这个绝对路径填进去。之后是模块级的 build.gradle ,至少需要这样配置:
android {
compileSdkVersion 21
buildToolsVersion "25.0.2"
defaultConfig {
minSdkVersion 14
targetSdkVersion 21
versionCode 179
versionName "4.2"
}
}
这段配置把 targetSdkVersion 锁定在 21,是为了避开 Android 6.0 之后的动态权限模型。如果保持 23 或更高,旧版 sipdroid 里直接读取 IMEI 和网络状态的代码会崩溃,需要额外封装权限申请逻辑。 minSdkVersion 14 不是随意选的,sipdroid 的代码里大量依赖 android.hardware.Camera 旧接口,高于 14 时它仍然可用,但低于 14 则无法保证 SurfaceView 渲染。
2.3 编译常见失败:gradle 版本、targetSdk 与 avd 的 3 个坑
处理过几十次类似源码的导入后,编译失败基本都是三选一。第一个坑:Gradle 版本太高导致 NDK 集成报 Execution failed for task ':acompileDebugNdk' ,因为新版 Gradle 已经移除了对 ndk.dir 的自动加载。解决方法是把 NDK 集成关掉,让模块直接捡起预编译好的 so 库:
sourceSets.main {
jniLibs.srcDir 'libs'
jni.srcDirs = []
}
这段配置的意思是:告诉 Gradle 直接从 libs 目录复制 JNI 库,而不要再对 jni 目录里的 C 源码执行编译。这样能绕开 NDK 版本错配问题,代价是你不能修改 C 层代码,只做 Java 层二次开发完全够用。
第二个坑是 targetSdkVersion 与实际安装设备不匹配。当你按上面配成 21 之后,在 Android 10 及以上真机上安装会被系统限制权限,安装后 SIGPIPE 崩溃。需要用 adb 强制授予权限:
adb install -g app-debug.apk
adb shell am start -n org.sipdroid.sipua/.ui.SipHomeScreen
-g 参数表示授予所有运行时权限,适合调试阶段;用 am start 启动主界面,是为了绕过桌面图标找不到的问题。
第三个坑是 avd 模拟器。即使是带 Google APIs 的模拟器,SIP 对实时性要求极高,模拟器的时钟抖动容易导致注册超时。建议先用真机编译调试,等 3.3 节修改抖动缓冲参数后再回到模拟器做覆盖面测试。真机调试时,需要保证两台手机 IP 在同一网段,并且已知 SIP 服务器地址,下一章的核心就在这里。
3. 语音通话链路:sipdroid 里 RTP/RTCP 与音频编解码的关键实现
3.1 Sipdroid 的 SIP 信令和你必须知道的状态机
语音通话的第一步是向服务器注册账号。sipdroid 在 SipdroidEngine.java 中维护一个状态机,状态包括 INIT 、 REGISTERING 、 READY 、 CALLING 、 INCALL 和 DISCONNECTED 。调试时你只要关心 READY 和 INCALL 。 READY 表示注册成功且当前空闲, INCALL 表示正处于通话中。源码里通过 EngineListener 的 onRegistation 回调更新状态,你可以在回调里打断点或写日志:
@Override
public void onRegistration(int code) {
if (code == 200) {
Log.i("Sipdroid", "registration success");
} else {
Log.e("Sipdroid", "registration failed, err=" + code);
}
}
代码逻辑很直白: code==200 代表 REGISTER 成功, code 是 SIP 响应码。多数失败场景是 401/407,说明密码或认证方法不对;408/480 则是服务器不可达。如果需要修改服务器地址,不要只改代码,最可靠的是在账号配置里绑定内网 IP:
SipProfile profile = new SipProfile("1001", "192.168.1.10", 5060);
profile.setAuth("1001", "your_password");
SipdroidEngine.getInstance().setProfile(profile);
SipProfile 的第一个参数是通话分机号码,第二个是 SIP 服务器 IP,第三个是端口。 setAuth 中的密码用于 Digest 认证。这里写死 IP 而不写域名,是因为 sipdroid 的早期 DNS 查询会阻塞 UI 线程,严重时按拨号键后界面卡住 3 秒。
3.2 音频会话从建立到挂断:回调、错误码与调试日志
语音链路建立后,音频数据不再经过 SIP 信令通道,而是直接走 RTP。sipdroid 的核心音频模块是 SipdroidAudio.java ,它用 AudioRecord 从麦克风读 PCM 数据,编码成 G.711 包,再交给 RtpStream 发送;对端接收后由 AudioTrack 播放。整个生命周期中, EngineListener 的 onCallStart 和 onCallEnd 是你要关注的两个回调。
实际调试时,用 logcat 过滤三个关键标签:
adb logcat -s SipdroidAudio:S SipdroidEngine:S RtpStream:S
这样只输出音视频传输相关日志,避免被系统噪音淹没。当看到 Packet lost 或 Timestamp discontinuity 时,说明网络抖动已经超出接收端缓冲区。sipdroid 的缓冲区长度由常量控制:
private static final int DAR = 15;
DAR 是抖动缓冲指数,不是毫秒数,内部通过 1 << DAR 来分配队列长度。设为 15 意味着约 32768 字节,内网环境下够用;如果走公网且丢包率超过 1%,建议调成 20,也就是 1 MB 左右。注意这个文件改动后要重新编译,不能热更新。
3.3 参数表:采样率、码率、网络端口怎么在源码层修改
语音编码参数和端口的选择决定了通话质量与防火墙友好度。sipdroid 默认支持 PCMU/PCMA,即 G.711u 和 G.711a,这两个编码在 Codec.java 里静态注册。想要换成 Opus 或 G.729,那就不能沿用老代码,而要在 RtpStream 里额外封装 MediaCodec ,后面会讲。这里先把默认参数列清楚:
| 参数 | 默认值 | 修改位置 | 说明 |
|---|---|---|---|
| 采样率 | 8000 Hz | AudioRecord 构造第一参数 | 8000 是电话标准,16000 需要重算 RTP 时间戳增量 |
| 音频包长 | 20 ms | RtpStream.samplesPerPacket | 20 ms 每包 320 字节,改成 10 ms 更抗抖动但带宽增加 |
| 本地 RTP 端口 | 0(随机) | SipdroidSocket 构造方法 | 0 表示随机,写成固定端口方便防火墙上放行 |
| 抖动缓冲区 | 15 | SipdroidAudio.DAR | 数值越大延迟越高,抗抖动越强 |
修改端口对应代码如下:
DatagramSocket socket = new DatagramSocket(40004, localAddr);
这里指定本地 RTP 端口为 40004, localAddr 是本机 IP。同一时刻如果打两通电话只写一个端口,会导致第二通电话报 BindException 。解决办法是把端口写在共享的端口池里,每次拨打时从池里取一个未占用的。
4. 视频通话:H.263/H.264 在 Android 硬编解码上的坑与绕过
4.1 视频通话的取流、渲染和 Camera 生命周期
视频通话比语音多了一条额外的视频通道,sipdroid 在媒体协商阶段会确认视频编码,然后打开前置摄像头。它的视频采集点在 CameraStreamer.java ,核心方式是老接口 Camera.setPreviewCallbackWithBuffer 配合 onPreviewFrame ,每帧 YUV420 数据送入编码器。渲染则由 SipdroidVideo 配合 SurfaceView 完成,远端视频码流解码后直接 draw 到 SurfaceHolder 。
生命周期上最容易出问题的地方在挂断时。很多人直接调用 camera.release() 放在 onPause 里,但 SIP 来电页面和通话页面共享同一个摄像头时,过早释放会抢走正在使用的硬件资源。常见做法是延迟释放:
handler.postDelayed(() -> {
if (camera != null) {
camera.stopPreview();
camera.release();
camera = null;
}
}, 2000);
这段代码的意图是:在界面切走 2 秒后再释放摄像头,给底层重新初始化留出时间。如果不加这个延迟,挂断第一个电话后立刻接第二通,摄像头会停留在黑屏状态, onPreviewFrame 不再回调。
4.2 视频通话必调参数:分辨率、I 帧间隔、码率控制
视频编码器的参数集中在 VideoChatHandler.java 里。典型配置如下:
parameters.setPreviewSize(352, 288); // CIF
parameters.setPreviewFormat(ImageFormat.NV21);
parameters.set("video-encoding-bitrate", 256*1024);
第一节 setPreviewSize(352, 288) 是 CIF 分辨率,对早期的硬编码器友好;现在的安卓机普遍支持 640x480 甚至 1280x720,但 sipdroid 的取流逻辑没有对高分辨率做分片处理,强行调高会把预览帧超过编码器上限。 video-encoding-bitrate 控制在 256 kbps,这是 H.263 时代的保守值。如果你把码率调到 512 kbps 以上,预览分辨率不变时,画面马赛克会减少,但公网下行带宽占用翻倍。
I 帧间隔更多是由 SDP 协商决定。如果你抓包发现视频流长时间没有关键帧,可以用 adb 强制触发:
adb shell setprop debug.video.keyframe 1
注意这是全局属性,重启后失效。真实项目中不应依赖这条属性,而是在编码器每次 MediaCodec.BufferInfo 拿到 BUFFER_FLAG_KEY_FRAME 时向前端上报,方便网关做关键帧请求。
4.3 在 Android 模拟器上验证视频通话失败的定位方法
模拟器上的虚拟摄像头输出的是合成画面,且部分镜像没有内置 H.264 硬编码器。当你发现真机正常但模拟器黑屏或花屏,先不要怀疑代码,而是用一段最小的 MediaCodec 脚本启动 H.264 编码:
MediaCodec codec = MediaCodec.createEncoderByType("video/avc");
MediaFormat format = MediaFormat.createVideoFormat("video/avc", 640, 480);
format.setInteger(MediaFormat.KEY_BIT_RATE, 512 * 1024);
format.setInteger(MediaFormat.KEY_FRAME_RATE, 15);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2);
如果这个脚本直接在 createEncoderByType 抛出 CodecException ,说明你当前的 AVD 镜像不包含硬件编解码器。这时该做的不是改源码,而是换用 Google APIs 镜像,或者在 AVD 设置里开启 Enable host GPU ,让宿主的 GPU 能力透传给虚拟机。每次修改 AVD 配置后要在 adb shell 里验证编解码器列表:
adb shell dumpsys media.player | grep -i encoder
输出里能看到 video/avc 对应的 encoder 名称就说明环境 OK。
5. 以 sipdroid 为基线做企业级 VoIP 的三处补强
5.1 用 OkHttp 拉取拨打计划:把静态拨号改成动态路由
直接拿 sipdroid 做生产项目,第一层的短板就是拨号逻辑写死。你可以在拨号键按下后先请求企业内部 API,拿到这个分机实际应该走的网关再发起 SIP 呼叫。这样能实现“一个客户端适配多个区域服务器”,而不是每次发版都要改源码。
实现位置放在 CallScreenActivity 里,替换原来的 sipdroidEngine.call() :
new Thread(() -> {
OkHttpClient client = new OkHttpClient();
Request req = new Request.Builder()
.url("https://your.company.com/api/route?callee=1002")
.build();
try (Response resp = client.newCall(req).execute()) {
String serverIp = resp.body().string();
runOnUiThread(() -> sipdroidEngine.call(serverIp));
} catch (IOException e) {
runOnUiThread(() -> sipdroidEngine.call(defaultServer));
}
}).start();
这里把 serverIp 解析出来的地址传给 call ,相当于把路由决策从本地搬到了服务端。回调里用 runOnUiThread 切换线程是因为 OkHttp 的回调线程不能直接操作引擎内部对象。
5.2 STUN/TURN 与内网穿透
sipdroid 源生支持简单的 UDP 打洞,但只对公网 IP 直连的办公室网络有效。真实企业环境里域名解析后经常拿到私网地址,这时 RTCP 互通必须走 STUN/TURN。可以用 ice4j 作为 ICE 实现,把 RtpStream 的收发 Socket 桥接进去。关键动作是在建立 RTP 之前先做连通性检测:
IceAgent agent = new IceAgent(localCandidates, stunServerAddr);
agent.startConnectivityChecks(remoteCandidates);
localCandidates 是本机跟网卡相关的候选地址, stunServerAddr 是内网可控的 STUN 服务器地址。注意 startConnectivityChecks 不是立即返回的,需要等它拿到底层协议栈选出的 pair,才能继续后面的通话建立。
5.3 验证方法:用 adb shell 模拟弱网和通话质量对比
最后一处补强不是代码,而是你验证通话质量的工具箱。在音频和视频都调通后,把两台真机连接到同一个 Wi-Fi,用 tc 命令注入延迟和丢包,对比调整前后 RtpStream 日志里的丢包率:
adb -s device1 shell "su 0 tc qdisc add dev wlan0 root netem delay 80ms 10ms drop 1%"
adb -s device2 shell "su 0 tc qdisc add dev wlan0 root netem delay 100ms 10ms drop 2%"
tc qdisc add 是在网卡 wlan0 上增加一个 root 队列规则, delay 80ms 10ms 表示固定延迟 80 毫秒再加 10 毫秒随机抖动, drop 1% 表示随机丢弃 1% 的数据包。连续打两通 5 分钟的电话,对比第 3 章里 DAR=15 和 DAR=20 的丢包率差异。等这套验证流程跑熟,你就能根据真实的 RTP 时间戳差值决定要不要把 G.711 换成压缩率更高的编码,而不是靠感觉调参。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)