SipDroid源码解析:从SIP注册到RTP音视频通话实现
简介:面向Android开发者的SipDroid语音及视频通话源码包,基于SIP协议实现VoIP通信,完整覆盖用户注册、呼叫建立、RTP媒体传输、音视频编解码、权限申请与通话事件处理等核心模块。开发者可通过该工程快速理解Android平台上构建实时音视频应用所需的系统交互流程,适合有一定基础、准备深入VoIP方向的移动端工程师学习参考。资源为zip压缩格式,整体大小4.32MB,内部为完整Android工程源码,当前文件总数与具体类型明细暂无数据。已有280人学习下载,适用于网络通信、多媒体开发等场景。仔细阅读源码,可以学习SIP注册器与通话引擎的实现方式,掌握G.711、Opus、VP8等编解码器的集成思路,以及如何处理网络切换、来电通知、静音挂断等异步事件。同时,代码展示了Android多媒体框架与Socket编程的结合方法,为构建稳定、低延迟的语音视频通话应用提供了具有实战价值的参考。
1. 从一份安卓源码包看SipDroid的语音视频通话实现
拿到这份 安卓Android源码 包时,我第一件事不是打开Android Studio看工程结构,而是先翻了它的AndroidManifest.xml。原因很简单: sipdroid语音及视频通话 这个项目虽然流传多年,但真正能把它跑起来的人并不多,大多数问题都出在权限声明和网络配置上。作为一个基于SIP协议的开源VoIP客户端,SipDroid的价值不只是给你一个能打电话的App,而是把注册、呼叫、媒体协商、音视频编解码、硬件适配这一整条链路都摊开了放在你面前。对想自己动手实现VoIP的开发者来说,这份源码就是一份同时包含信令交互、实时传输、系统服务绑定和UI状态机设计的参考实现。读完你会明白,为什么SIP客户端在Android上的难点不在协议本身,而在于异步事件怎么处理、媒体流怎么接、以及在不同网络环境下怎么保活。
2. SIP协议栈与SipDroid的注册器设计
2.1 为什么SipDroid选择SIP而不是WebRTC
在分析注册器之前,先回答一个问题:为什么SipDroid的早期架构选SIP而不是后来流行的WebRTC。原因其实很实际——SipDroid诞生时,WebRTC还没有在移动端形成成熟的Native方案,而SIP作为IETF标准(RFC 3261)已经有一套完整的运营商级基础设施,从Asterisk到FreeSWITCH,都能直接对接。SIP是应用层信令协议,负责创建、修改、终止多媒体会话,但它不传输媒体数据,媒体走RTP(实时传输协议)。这种信令与媒体分离的设计,让SipDroid可以把呼叫控制和音视频传输拆成两个独立的模块来维护。
SipDroid源码里对SIP栈的处理方式是:自己实现了一个精简的SIP协议栈,而不是直接依赖JAIN-SIP这种重量级框架。原因也很直接——Android早期的Dalvik虚拟机对大量反射和动态代理的支持并不好,自研轻量栈更容易控制在弱网下的超时表现。这个栈主要实现了UAC(用户代理客户端)侧的REGISTER、INVITE、ACK、BYE、CANCEL等方法,以及对应的状态码处理逻辑。
2.2 注册流程的状态机与关键参数
SipDroid的注册器维护了一个典型的状态机,从 UNREGISTERED 到 REGISTERING ,再到 REGISTERED ,中间还有 REGISTER_FAILED 分支。整个注册过程用一个Java后台服务 SipdroidEngine 来驱动,而不是放在Activity里——这是Android上做VoIP的第一条铁律:任何需要长期维持的网络连接都不能依赖UI组件的生命周期。
注册请求的核心代码逻辑大致如下:
// 构造REGISTER请求的核心片段,省略了部分头域拼接细节
private String buildRegisterRequest(String username, String domain, String authHeader) {
StringBuilder req = new StringBuilder();
req.append("REGISTER sip:").append(domain).append(" SIP/2.0\r\n");
req.append("Via: SIP/2.0/UDP ").append(localIp).append(":").append(localPort).append(";branch=z9hG4bK").append(generateBranch()).append("\r\n");
req.append("From: <sip:").append(username).append("@").append(domain).append(">;tag=").append(generateTag()).append("\r\n");
req.append("To: <sip:").append(username).append("@").append(domain).append(">\r\n");
req.append("Contact: <sip:").append(username).append("@").append(localIp).append(":").append(localPort).append(">\r\n");
req.append("Call-ID: ").append(UUID.randomUUID().toString()).append("\r\n");
req.append("CSeq: ").append(cSeq).append(" REGISTER\r\n");
req.append("Expires: ").append(expires).append("\r\n");
if (authHeader != null) {
req.append("Authorization: ").append(authHeader).append("\r\n");
}
req.append("Content-Length: 0\r\n\r\n");
return req.toString();
}
这段代码里最关键的是 Expires 头域和 Authorization 头域。 Expires 的值直接决定了注册的刷新周期,SipDroid默认是3600秒,但很多实际部署会改成300秒甚至更短,原因在下面的保活机制里说。 Authorization 头是第一次收到401响应后,用用户名、密码、realm、nonce四个参数做MD5摘要计算出来的,计算逻辑形如 Digest MD5(username:realm:password:nonce) 。
2.3 注册保活与NAT穿透的取舍
SipDroid的注册保活机制是 定时重发REGISTER 和 UDP Keep-Alive包 双管齐下。前者在 Expires 快到期时触发重新注册,后者是每隔25秒往服务器发一个 \r\n\r\n 空包,目的是维持NAT会话。为什么是25秒?因为很多家用路由器的UDP映射表老化时间是30秒,如果你想稳一点,可以把这个间隔改到20秒,代价是多一点流量消耗。
这里有一个我实际踩过的坑:SipDroid默认用UDP传输SIP信令,在Wi-Fi环境下问题不大,但在移动网络下,运营商的NAT老化时间可能更短,这时候光靠应用层的Keep-Alive是不够的。更现实的做法是把信令传输切换成TCP或TLS,SipDroid源码里其实预留了传输层抽象接口,但没有完整实现TCP信令分支。如果你想做商用级别,建议参考 javax.sip 的 SipProvider 封装逻辑,自己补一个TCP实现进去,否则在弱网场景下的注册掉线率会让你怀疑人生。
3. 通话引擎:从INVITE到RTP媒体流
3.1 呼叫建立的SDP协商过程
注册完成之后才是重头戏:拨打一通电话。SipDroid的通话引擎在收到用户按下拨号键后,会构造一个INVITE请求,其中最关键的部分是SDP(Session Description Protocol)媒体描述。SDP协商决定了这通电话用什么编解码器、什么IP和端口收媒体流。看SipDroid源码里的 SipdroidEngine ,你会发现它在发起呼叫前会遍历本机支持的所有编解码器,然后按优先级排序写进SDP里。
一个典型的SDP片段长这样:
v=0
o=user1 53655765 2353687637 IN IP4 192.168.1.100
s=-
c=IN IP4 192.168.1.100
t=0 0
m=audio 7078 RTP/AVP 0 8 3 111
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:3 GSM/8000
a=rtpmap:111 opus/48000/2
m=video 7079 RTP/AVP 96 97
a=rtpmap:96 VP8/90000
a=rtpmap:97 H264/90000
注意 m=audio 这一行,后面的数字是RTP接收端口, 0 8 3 111 是payload type编号。在SIP协议里,0号固定是PCMU(G.711 μ-law),8号是PCMA(G.711 A-law),3号是GSM,而111通常是动态分配的Opus。 m=video 后面的96、97分别对应VP8和H264。SipDroid源码里默认启用了PCMU、PCMA、GSM和VP8,但Opus的支持要看具体版本,老版本确实没有集成Opus,新版本才有。如果你在找SipDroid为何在某些机型上通话质量差,先查SDP里实际协商出来的编解码器是什么。
3.2 RTP媒体流的封装与接收
SIP负责把通话双方的SDP信息交换完,真正的语音数据就交给RTP了。SipDroid的RTP实现是一个独立的 RtpStream 类,它做的事情非常纯粹:从AudioRecord拿PCM数据,编码成选定格式,封装成RTP包,通过预先建立的UDP Socket发出去。接收方向是反向流程,解包、解码、写到AudioTrack。
下面这段代码是RTP音频包的发送核心逻辑,注意它的时间戳递增方式:
// RTP音频包发送,采样率8000Hz时每个包20ms对应160个采样点
public void sendAudio(byte[] pcmData, int length) {
int payloadType = getAudioPayloadType();
int sequence = getNextSequence(); // 每发送一包递增1
long timestamp = getNextTimestamp(); // 每次递增采样率/50 = 160
rtpPacket.setPayload(pcmData, length);
rtpPacket.setPayloadType(payloadType);
rtpPacket.setSequenceNumber(sequence);
rtpPacket.setTimestamp(timestamp);
rtpPacket.setSsrc(localSsrc);
udpSocket.send(rtpPacket.toByteArray());
}
时序上, timestamp 的递增依据是采样率除以每秒包数。对8000Hz采样、每20ms一包的情况,每包恰好160个采样点,所以时间戳增量是160。这个逻辑看似简单,但它是音画同步和抖动缓冲的基础——接收端就是根据时间戳的相对间隔来判断网络抖动程度,进而调整缓冲区长度的。如果你的代码改用了48000Hz采样率的Opus,那时间戳增量就该是960,如果你漏改这个值,会出现通话正常但语音时长统计严重不准的诡异现象。
3.3 音频编解码器的选择与对比
SipDroid源码里可以找到多种音频编解码器的接入点,这里把它们的适用场景整理成表:
| 编解码器 | 采样率 | 码率 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| PCMU / PCMA | 8000 Hz | 64 kbps | 极低 | 带宽充足、兼容性优先 |
| GSM | 8000 Hz | 13 kbps | 低 | 弱网、低带宽 |
| Opus | 48000 Hz | 6–510 kbps | 中 | 网络波动大、追求音质 |
| VP8(视频) | — | 动态 | 高 | 软件编码、兼容性好 |
从实际测试角度看,PCMU虽然音质上限不高,但它在丢包环境下的抗损伤能力其实不错,因为G.711没有复杂的帧间依赖。GSM则是在带宽极度受限时的兜底方案。如果你想把SipDroid改成自家产品,我一般建议把编解码器优先级调成 Opus > PCMU > GSM ,并在SDP里关闭那些用不到的payload type,避免协商到不期望的编码。视频端保持VP8是稳妥的,H264涉及专利授权问题,Android硬编码器在 MediaCodec 层的实现差异也大,调试成本高。
4. Android系统集成:权限管理、网络变化与事件处理
4.1 从AndroidManifest到运行时权限的血泪史
SipDroid是Android早期项目的典型代表,它的权限声明方式有着鲜明的时代烙印。在 AndroidManifest.xml 里,你会看到一组完整的权限声明: INTERNET 用于网络通信, RECORD_AUDIO 用于采集麦克风, CAMERA 用于视频通话, WAKE_LOCK 用于保持屏幕常亮, MODIFY_AUDIO_SETTINGS 用于调整音频路由,还有 READ_CONTACTS 和 CALL_PHONE 这些电话相关权限。
但问题来了——SipDroid原生源码只适配了Android 6.0以下的安装时授权模式。如果你直接把它扔进Android 12的设备上编译运行,一进拨号界面就会因为缺少运行时权限申请而崩溃。所以在集成这份源码时,必须手动补上动态权限申请:
// Android 6.0+必须动态申请敏感权限,不能只写在Manifest里
private void requestVoipPermissions() {
String[] permissions = {
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA,
Manifest.permission.READ_CONTACTS
};
// 检查是否已授权,未授权则弹窗请求
if (ContextCompat.checkSelfPermission(this, permissions[0])
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, permissions, REQUEST_CODE_VOIP);
}
}
这串代码的逻辑不复杂:先检查录音权限,没有就弹一次系统授权框,用户同意后再继续初始化通话引擎。注意这里的 REQUEST_CODE_VOIP 是一个自定义常量,在 onRequestPermissionsResult 回调里要判断该请求码。实际开发中你需要在这个回调里处理三种情况:全部同意、部分同意、全部拒绝。部分同意的情况往往最坑——你可能拿到了录音权限但没拿到摄像头权限,这时候视频通话得降级成纯语音通话。SipDroid原生代码没有这种降级逻辑,需要你自己加。
4.2 网络状态变化对SIP会话的影响
SIP会话对网络变化极其敏感,这一点在移动场景下尤其明显。Wi-Fi切换到4G,客户端的IP地址和端口都会变,原有的SIP对话和RTP通道全部失效。SipDroid源码里用了一个 ConnectivityReceiver 来监听网络变化,但它的处理方式比较粗暴——网络变化时直接触发重新注册。
如果你要做健壮性更强的方案,我一般会在网络切换后做三件事:一是立即停止当前的RTP收发,释放UDP Socket;二是重新获取本机IP和可用端口,更新Contact头域;三是发起重注册并携带 Replaces 头去恢复通话。注意SIP协议里恢复通话的标准做法是 INVITE 携带 Replaces 头,SipDroid并没有实现这个,所以网络切换后的通话恢复实际上是无法做到的,这是架构层面的限制,不是调几个参数能解决的。
另外, WAKE_LOCK 权限的使用也值得留意。SipDroid在通话期间会持有 PARTIAL_WAKE_LOCK ,防止CPU休眠导致RTP包发送中断。但如果你照抄这个写法到自己的项目里,别忘了在通话结束时释放WakeLock,否则手机会异常耗电。核实一下源码里挂断电话的代码路径,确保每个分支都走到释放逻辑。
4.3 通话事件驱动的UI状态机
SipDroid的UI层处理通话状态的方式很值得学习——UI不是直接操作Socket,而是通过Handler消息机制接收来自 SipdroidEngine 的状态变更事件。来电响铃、呼叫中、通话中这三个核心状态的切换,在 Sipdroid 主Activity里由 Handler 驱动。
来电流程是这样的:UDP Socket收到INVITE请求后,引擎解析出 From 头域的号码,然后发一个 EVENT_INCOMING_CALL 消息到主线程Handler,Handler弹出来电界面并播放铃声,同时启动一个后台线程做 180 Ringing 响应。用户点击接听按钮后,Activity发消息给引擎,引擎回复 200 OK 并等待ACK,完成三次握手后进入通话状态。
这个基于消息驱动的设计在Android上特别实用,因为它把Binder、Socket回调、用户交互全部汇聚到一个串行的消息队列里,避免了多线程并发修改UI状态的崩溃问题。你不要看到 Handler 就觉得旧,在通话这种对时序敏感的场景,消息队列比 LiveData 这种观察者模式更可控,因为消息的先后顺序是确定的。
5. 源码实战:编译配置、真机调试与通话质量调优
5.1 Android Studio 构建环境的一次性配置
拿到这份源码后不要急着点Run,先做三件事:升级Gradle版本到与当前Android Studio匹配的版本,把 compileSdkVersion 和 targetSdkVersion 改到本机已安装的SDK版本,然后在 build.gradle 里关闭 minifyEnabled 防止混淆破坏SIP栈的反射调用。很多网上反馈说SipDroid编译不过,多半是这三种情况里的一种。
一个比较隐蔽的问题是SipDroid源码里引用了一个 org.zoolu.sip 包,这个包是SipDroid自带的精简SIP协议栈,但因为年代久远,里面的代码用了大量已废弃的API,比如 HttpClient 相关的类在Android 6.0之后被移除了, android.net.http.AndroidHttpClient 只适用于API 27以下。如果你的构建环境报找不到类,我的处理方式是把使用到这些类的方法体直接清空,因为SipDroid只在HTTP隧道模式下才用到AndroidHttpClient,常规SIP/UDP注册和通话根本不走这条路。
5.2 在Android Studio里跑通第一通SIP电话
编译通过后,准备一个SIP服务器来做联调。本地可以用FreeSWITCH,也可以用Asterisk。配置一个分机账号后,在SipDroid的设置界面里填入服务器地址、用户名、密码,然后在同一局域网内用两部手机互相拨打。如果你只有一部手机,可以在同一设备上装SipDroid和另一个SIP客户端,注册两个不同分机号,互拨测试。
调试时多用logcat过滤标签,SipDroid源码里几乎所有关键路径都有 Log.d 输出,最常用的是这三个标签:
# 抓取SIP信令日志,观察REGISTER和INVITE的收发
adb logcat -s SipDroidEngine:S SipDroid:S
# 抓取RTP音频流日志,确认媒体包是否在收发
adb logcat -s RtpStream:S
# 抓取系统音频设备相关错误
adb logcat -s AudioTrack:E AudioRecord:E
信令日志里你要重点看 SIP/2.0 401 Unauthorized 后面跟的 WWW-Authenticate 头,如果服务器返回的realm和你的账号不属于同一个域,注册必然失败。RTP日志里如果看到 SENDING 正常而 RECEIVING 没有输出,优先检查手机防火墙和路由器是否开了UDP回程转发。
5.3 音频丢包和延迟的参数级调优技巧
最后一层是通话质量调优,这里给出三个可操作的调整点,全部在源码层面生效。
第一个调整点是音频发送缓冲区。SipDroid的 AudioRecord 缓冲区大小可以通过 getMinBufferSize 获取,但很多机型返回的值偏保守。我的做法是在这个基础上乘以1.5作为 samplesBuffer 的大小,减少底层音频数据丢失导致的咔哒声。第二个调整点是RTP包的发送间隔,SipDroid固定是20ms一包,但如果你所在网络丢包率高,可以改成40ms一包,这样每秒只发25包,拥塞压力小一半,代价是延迟增加20ms。改这个值要同时调整时间戳增量倍数,否则对端会按错误的时序播放。第三个调整点是SIP注册周期,前面提到默认3600秒,配合Keep-Alive包时建议把 Expires 改成120秒,刷新频率高了,服务器上的绑定状态就不容易僵死。
还有一个容易被忽视的参数是 localPort 的选择,SipDroid默认在5000附近选端口,但这个范围很容易和系统别的服务冲突。建议在8000到20000之间随机选一个高位端口作为SIP和RTP的绑定端口,因为很多SIP服务器对同一个端口承载信令和媒体流并不介意,SipDroid默认也是这么做的——信令和媒体共用UDP Socket,这能少打一个洞,NAT穿透的成功率高不少。
更多推荐

所有评论(0)