【通信协议】Kotlin 实现 SIP 通信协议

前言

最近工作上需要实现一个语音通话的 APP,在数据传输层使用网关的信道,通话的建立需要应用层自己实现,于是就有了这篇文章。由于涉及到工作上的一些业务逻辑,所以这里只会介绍一些实现的思路和接口。废话不多说,直接开始!🚀

协议选择

了解到 Android 层相关的协议有这些:Voice over Internet Protocol (VoIP)、Session Initiation Protocol (SIP)、Real-time Transport Protocol (RTP)、Web Real-Time Communication (WebRTC) 。了解了一圈下来,发现 SIP 协议比其他的更加简单实用,Android 源码也有实现 SIP 协议的相关逻辑。但是我们没办法直接使用,因为 Android 源码的 SIP 协议和 Android 底层 Service 进行通信,而我们现在是和网关进行通信。所以接下来我们自己实现一下。

SIP 协议在 Android 12 及以上版本处于废弃状态,至于为什么被废弃,Android 官方并没有明确说明。看了这篇 blog ,得到的解释大致是,各 APP 并没有使用到 Android 的 SIP 实现,而是使用自己定义的或者第三方的 SIP 实现,所以就停止了这部分代码的维护。

SIP 协议的简单理解

SIP 是一种应用层协议,用于建立通话双方的语音连接,而其在传输层可以采用 TCP 或 UDP 。

如下是使用 SIP 协议进行一次简单通信的流程,F1、F2 等表示的是通信的步骤。更加具体的解释可以查看这个 链接

可以看到有如下几种消息类型:INVITETryingRingingOKACKBYE

这几种消息类型的作用如下:

  • INVITE:用于建立会话的请求。比如,在电话系统中,当一个用户尝试拨打另一个用户的电话时,将会发送一个 INVITE 消息。
  • Trying:预期响应的消息,指示用户代理服务器(UAS)已收到请求并正在处理它,但还没有完成。这不是最终的响应,只是一种临时响应,用于表示正在处理请求。
  • Ringing:这是另一种预期响应,表示正在对呼叫的目标端进行警告,例如,使电话振铃或播放呼叫等待音乐。Ringing 消息的响应码是 180。
  • OK:这是一种最终响应,表示请求已经被成功处理。对于 INVITE 消息,200 OK 表示被呼叫方接受了呼叫;对于 BYE 消息,200 OK 表示会话已经被成功结束。
  • ACK:这是一个特殊的请求,用于确认接收到了 INVITE 请求的最终响应。比如,在电话系统中,当被呼叫方接受了呼叫并发送了 200 OK 消息后,呼叫方将会发送一个 ACK 消息来确认接收到了这个响应。
  • BYE:这是用于结束已经建立的会话请求。在电话系统中,当一方想要挂断电话时,将会发送一个 BYE 消息。

当前业务场景下的 SIP 协议

具体到当前的业务场景,我们通过网关作为中介来建立连接,这是和通用的 SIP 协议不太一样的点。简单来说,我们需要用网关来代替通用 SIP 协议的代理服务器。思考之后,我们可以得出如下的 SIP 协议时序图(不考虑异常情况,一次成功的通信流程):

其实和通用的 SIP 协议还是有很多相似之处的,一次成功的通信包含以下几个步骤(当前场景为设备 A 邀请设备 B 进行语音通信):

  1. 设备 A 发送多条 INVITE 消息用于建立会话
  2. 设备 B 接收到 INVITE 消息之后,立即回复 RING 消息,告知设备 A 消息已接收到
  3. 设备 B 如果选择接听,则发送 ACCEPT 消息
  4. 设备 A 接收到 ACCEPT 消息之后立即回复 PREPARE_CONFIRM 消息给设备 B
  5. 双方互相传递 VOICE_DATA 进行语音通信
  6. 设备 A 此时需要挂断电话时,向设备 B 发送 HANGUP_END 消息

逻辑看起来非常完备,但是还有一些问题我们需要进行理解和分析:

  • 为什么需要有 RING 消息?

    为了让设备 A 知道,对方已经开始准备和你打电话了,因为如果不发送 RING 消息,那么就会发送拒绝接听或者正忙的消息,取消连接的建立。

  • 为什么需要有 PREPARE_CONFIRM 消息?

    • 保证语音数据传输的时序问题。在有 PREPARE_CONFIRM 消息时,A 在收到 ACCEPT 消息之后开始发送数据,B 在接收到 PREPARE_CONFIRM 消息之后开始发送数据。
    • 保证两者的状态同步。如果没有 PREPARE_CONFIRM 消息,B 在点击接听之后开始发送数据, ACCEPT 消息如果 A 没有收到,B 会一直处于接听中的状态。
  • 为什么 HANGUP_END 消息不需要应答?

    这里如果 A 向 B 发送 HANGUP_END 消息,B 没有收到。A 会进入已挂断的状态,B 检测音频数据的码率,如果码率为 0 并持续一段时间,则自动挂断。所需可以省去 HANGUP_END 消息的应答。

SIP 协议的实现细节分析

通过如上的介绍,我们大致可以了解到一些 SIP 协议的基本逻辑,但是如果需要真正实现还有一些细节问题需要处理,比如错误状态要如何处理等等。要处理相关的细节,我们得画出接收方和发送方的状态图。实际上,状态图会比时序图稍微复杂一点。SIP 状态图:

在状态图中,我们有以下几种元素:开始状态、结束状态、发送消息、接收消息、中间状态、同步逻辑、用户操作等。这里由于接收方和发送方的状态流转不太一样,所以我们使用两个状态图进行展示。具体的流转逻辑可以结合时序图进行理解,这里我们先简单描述一下状态和消息的类别。

状态的类别,这里由于结束的状态过多,我们单独使用 enum class 表示:

kotlin 复制代码
object IDLE // 初始状态
object CALLING // 呼叫中
object RINGING // 响铃中
object PREPARE // 通话准备中
object CHATTING // 通话中
enum class ENDED { // 由于结束的状态过多,单独使用 enum class 表示
    HANGUP, // 手动结束
    OTHER_TERMINATED, // 对方终止对话连接结束
    SELF_TERMINATED, // 自己终止对话连接结束
    NETWORK_ERROR, // 网络错误结束
    RINGING_TIMEOUT, // 响铃超时结束
    CALLING_TIMEOUT, // 呼叫超时结束
    PREPARE_TIMEOUT, // 接听准备阶段超时结束
    OTHER_BUSY, // 对方正忙,忙线结束
    SELF_BUSY, // 自己正忙,忙线结束
    DATA_INTERRUPT, // 数据中断结束
}

消息的类别:

kotlin 复制代码
enum class MessageType(val code: Int) {
    INVITE(101), // 邀请通话
    RING(102), // 邀请通话呼叫应答
    ACCEPT(200), // 接受通话邀请
    PREPARE_CONFIRM(201), // 确认接受通话邀请
    CONTINUE(202), // 继续通话
    VOICE_DATA(300), // 传输音频数据
    BUSY_END(401), // 用户正忙
    TERMINATE_END(402), // 用户终止对话连接的建立
    HANGUP_END(403), // 用户挂断电话
    TIMEOUT_END(404), // 超时结束
    DATA_INTERRUPT_END(405) // 数据中断结束
}

如上就是协议层需要处理的细节,包括:状态的流转,消息的处理及发送,将状态流转告知感兴趣的模块等。

SIP 协议的具体实现

终于到了协议的实现环节,通过上小节的细节分析,我们大致可以得到如下的实现思路:

  1. 协议模块需要拿到发送消息的句柄,进行消息的发送
  2. 收到相关消息之后,协议模块使用状态模式进行状态的跳转操作,并通过 eventBus 通知给关注状态流转的模块
  3. 若收到消息后没有状态流转,但需要做相关操作(如发送消息),或者状态流转由用户触发。此时,通过调用协议模块的相关函数实现。如 start()hangUp() 方法等
  4. 需要在某个流转节点上进行操作的逻辑,通过拦截器来实现对应的逻辑(如:接收到 ACCEPT 消息时,需要取消对应的计数器)

第二点和第四点是比较关键的环节,我们来详细看一下它们的接口定义。

状态流转的实现

这里使用到了状态模式来实现协议状态的流转,我们可以简单查看一下 SessionStatus 接口:

kotlin 复制代码
sealed interface SessionStatus {
    fun transferStatus(messageType: MessageType): SessionStatus
 
    fun canCreateNewReceiverSession(): Boolean
 
    fun canCreateNewCallerSession(): Boolean
 
    fun isActive(): Boolean
}
  • transferStatus(messageType: MessageType) 方法接收消息返回下一个的状态,如此来处理状态的流转。
  • canCreateNewReceiverSession() 当前状态(呼叫方/接收方)是否可以创建新的接收者会话
  • canCreateNewCallerSession() 当前状态(呼叫方/接收方)是否可以创建新的发起者会话
  • isActive 是否为活跃的状态

如下是 RINGING 状态的简单实现:

kotlin 复制代码
object RINGING: SessionStatus {
    override fun transferStatus(messageType: MessageType): SessionStatus {
        return when (messageType) {
            MessageType.ACCEPT -> CHATTING
            MessageType.TIMEOUT_END -> ENDED.RINGING_TIMEOUT
            MessageType.TERMINATE_END -> ENDED.TERMINATED
            else -> this
        }
    }
 
     
    override fun canCreateNewReceiverSession() = false
 
    override fun canCreateNewCallerSession() = true
 
    override fun isActive() = true
}

canCreateNewCallerSessioncanCreateNewReceiverSession 分别返回 truefalse,表示该状态可以拨打电话,但是不能接听电话。其他状态的流转可以参考状态图,逻辑类似,就不过多赘述了。

状态流转的拦截

在协议的状态流转过程中,我们还需要在状态流转的路径上执行一些操作。比如:从 RING 状态接收到 ACCEPT 消息跳转到 CHATTING 状态之后,我们需要取消 RINGING 状态的计时器(可以回顾一下细节分析时的状态图)。

简单来看,这些逻辑我们可以写在 SessionStatustransferStatus 方法里面,因为这里处理了接收消息,进行状态跳转的逻辑。但这样会违背 SessionStatus 类的单一职责原则,SessionStatus 类应该只用来处理状态跳转。

一个比较合理的解决方案是我们使用代理模式对协议流转的过程进行拦截,将协议流转过程中的逻辑处理放在子类中实现。

如下是拦截器的接口:

kotlin 复制代码
interface ISessionInterceptor {
    val messageReceived: MessageType // 接收到的消息
    val transferredState: SessionStatus // 流转到的状态

    fun doIntercept(sessionData: SessionData)
}

RING 状态接收到 ACCEPT 消息跳转到 CHATTING 状态的具体实现:

kotlin 复制代码
class AcceptToChattingInterceptor: ISessionInterceptor {
    override val messageReceived = MessageType.ACCEPT
    override val transferredState = CHATTING

    override fun doIntercept(sessionData: SessionData) {
				// ... logic code
    }
}

实现上是非常简单的,由于我们有多个拦截器,方便起见,我们使用工厂来管理一下:

kotlin 复制代码
class SessionInterceptorFactory(
    private val sessionData: SessionData
) {

    private val interceptors = mutableListOf(
        AcceptToChattingInterceptor(),
        // ... other interceptor
    ).associateBy {
        Pair(it.messageReceived, it.transferredState)
    }

    fun doIntercept(messageType: MessageType, sessionStatus: SessionStatus) {
        interceptors[Pair(messageType, sessionStatus)]?.doIntercept(sessionData)
    }
}

实际使用时,调用 doIntercept() 方法即可,具体使用哪个拦截器由工厂类去分发。

kotlin 复制代码
interceptorFactory.doIntercept(messageType, newStatus)

实在是太优雅了!🥰

多线程下竞争态问题解决

创建 Session 的时候是在非主线程中操作的,需要一定的时间,大概 50ms,没有限定具体的线程。这个时候会有多线程竞态问题需要处理。

问题项:

  1. 收到 INVITE 消息,会创建 RecieverSession(接收方的 Session),此时如果收到了 TERMINATE 消息,那么协议将不会对此进行处理,导致接收方不会中断
  2. 同一时刻对多个设备进行拨号,会多次创建 CallerSession(拨打方 Session),会导致最终的 Session 不一定是最后一个拨号的设备

这两者在本质上其实是同一个问题:多线程环境下,代码执行不同步

解决方案:

  1. 加锁。加锁是一种比较符合直觉的简单实现,但如果代码实现逻辑散布在多个类中,需要在多个类中进行加锁,其实不太方便代码的维护
  2. 使用 Handler。将任务 postHandler 中处理。处理消息前开启同步屏障,暂停消息的处理。消息处理之后,关闭同步屏障,重新开始处理消息
  3. 使用 Flow。收到消息时 emit 到流中,使用 collect 对消息进行阻塞式处理

最终的实现:

最终选择使用 Flow 来实现,因为在代码书写层面简单易懂,同时也非常方便后期的维护,拓展性也很强。

kotlin 复制代码
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val sipMessages = MutableSharedFlow<String?>()

override fun onVoiceDataReceive(dataJson: String?) {
    scope.launch {
				// produce
        sipMessages.emit(dataJson)
    }
}

fun registerProcessSipMessages() {
    scope.launch {
        sipMessages.map {
						// do transform
            it?.toBean<MessageWrapper>()
        }.flowOn(Dispatchers.Default)
						// consume
            .collect { message ->
                // process message
            }
    }
}

总结

在实现这个协议之前,个人感觉这项任务还是有一定难度的,因为要处理的细节点太多了,状态、消息、操作都比较多,并且有点凌乱。如果这时候直接写代码的话,会让人感觉一头雾水。想到哪写到哪,写的有问题再回来修修补补,效率会比较低。

最好的姿势是:先把时序图、状态图、类图这些都画出来,把心里想象的实现和逻辑具体化。写代码之前,先把大致想法和逻辑梳理清楚。一边书写,一边发现问题,再一边更新。我想,这样操作完之后,会让复杂代码的书写变得更加容易~

REFERENCE

RFC 3261: SIP: Session Initiation Protocol

SIP协议入门指南

github.com/Tinder/Stat...

相关推荐
Kotlin上海用户组1 小时前
Koin vs. Hilt——最流行的 Android DI 框架全方位对比
android·架构·kotlin
zzq19961 小时前
Android framework 开发者模式下,如何修改动画过度模式
android
木叶丸1 小时前
Flutter 生命周期完全指南
android·flutter·ios
阿幸软件杂货间1 小时前
阿幸课堂随机点名
android·开发语言·javascript
小何学计算机1 小时前
HTTPS工作原理
网络协议·http·https
没有了遇见1 小时前
Android 渐变色整理之功能实现<二>文字,背景,边框,进度条等
android
没有了遇见3 小时前
Android RecycleView 条目进入和滑出屏幕的渐变阴影效果
android
游戏开发爱好者83 小时前
iOS 出海 App 安全加固指南:无源码环境下的 IPA 加固与防破解方法
websocket·网络协议·tcp/ip·http·网络安全·https·udp
站在巨人肩膀上的码农3 小时前
去掉长按遥控器power键后提示关机、飞行模式的弹窗
android·安卓·rk·关机弹窗·power键·长按·飞行模式弹窗
呼啦啦--隔壁老王3 小时前
屏幕旋转流程
android