实现 1v1 私密房
在陌生人社交场景中,用户可以根据头像、个人简介等筛选到感兴趣的用户进行 1v1 私密视频通话;也可以通过地理位置、标签随机匹配的方式进行 1v1 私密视频通话。1v1 私密通话中,默认通话双方都开启摄像头和麦克风,双向发送接收音视频流。
本文以环信为例,介绍如何使用 CallAPI
和自定义的三方信令管理类 CallEasemobSignalClient
实现 1v1 私密房场景。
技术架构
声网 1v1 私密房三方信令方案使用如下模块搭建:
CallAPI
:对 1v1 呼叫中 RTC 相关实现的封装,以及状态和业务的管理。CallEasemobSignalClient
:基于环信开发的信令管理类,用于处理信令的连接、登录、呼叫等操作。
业务流程
1v1 私密房常见的业务流程如下图所示,你可以参考该流程实现相关业务。
下图仅体现使用声网 CallAPI
的流程。你还需要根据场景需求,自行添加业务相关流程,如加入、离开可通话列表等。

前提条件
开始前,请确保满足如下前提条件:
- Git。
- CocoaPods。
- Xcode 12.0 及以上。
- 有效的苹果开发者账号。
- 参考开通服务在控制台创建项目,获取 App ID 和 App 证书。
- 参考集成 CallAPI 和三方信令将
CallAPI
和CallEasemobSignalClient
集成到项目中。
实现 1v1 私密房
本节介绍如何基于 CallAPI
和自定义的三方信令管理类 CallEasemobSignalClient
来快速实现一个 1v1 私密房场景。
完整的 API 调用时序如下图所示。其中:
- 用户 A 和用户 B 指代集成了声网 RTC 和
CallAPI
的客户端 App。 CallEasemobSignalClient
指代基于环信即时通讯 SDK 开发的信令管理类。RtcEngine
指代声网 RTC SDK 的 API。CallAPI
指代声网 1v1 场景化 API,实现了 RTC 互动、呼叫等功能。- 业务服务器指代你的 App 业务后台,需要自行部署实现。
出于篇幅考虑,下图省略了用户 B 在初始化和离开并释放资源中的 API 调用时序。这部分时序可以直接参考用户 A。
1. 初始化 RTC 引擎
调用 RTC SDK 的 sharedEngineWithConfig
方法初始化 RtcEngine
。
let config = AgoraRtcEngineConfig()
config.appId = <#AppId#>
config.channelProfile = .liveBroadcasting
config.audioScenario = .gameStreaming
config.areaCode = .global
let rtcEngine = AgoraRtcEngineKit.sharedEngine(with: config,
delegate: nil)
2. 创建环信信令对象
基于 CallEasemobSignalClient
类初始化一个信令对象 signalClient
,用于管理信令服务的登录、注销、Token 续期、收发消息、网络状态变化通知等。你需要在初始化时填入环信的 App Key。
let signalClient = CallEasemobSignalClient(appKey: <#环信AppKey#>, userId: <#UserId#>)
3. 建立并实现信令回调机制
通过 ICallEasemobSignalClientListener
注册并监听信令相关的状态和事件回调。
signalClient.delegate = self
extension EMPure1v1RoomViewController: ICallEasemobSignalClientListener {
func onConnected() {
// 连接成功
}
func onDisconnected() {
// 连接断开
}
}
4. 初始化 CallAPI 并绑定信令
调用 CallAPI
的 initialize
方法初始化 CallAPI
。你需要在该方法中设置 appId
、userId
、rtcEngine
和 signalClient
参数,其中 signalClient
就是在第一步中初始化的 CallEasemobSignalClient
对象。
let config = CallConfig()
config.appId = <#AppId#>
config.userId = <#UserId#>
config.rtcEngine = rtcEngine
config.signalClient = signalClient
let api = CallApiImpl()
api.initialize(config: config)
5. 添加并监听 CallAPI 的回调
调用 addListener
方法注册 CallAPI
回调事件。然后实现 CallAPI
的回调 CallApiListenerProtocol
,并监听 onCallStateChanged
回调。CallAPI
在运行过程中,会通过该回调报告当前的通话状态。你需要跟踪报告的状态实现不同的后续处理逻辑。
callApi.addListener(listener: self)
extension Pure1v1UserListViewController: CallApiListenerProtocol {
func onCallStateChanged(with state: CallStateType,
stateReason: CallStateReason,
eventReason: String,
eventInfo: [String : Any]) {
// 省略其他逻辑
}
}
6. 处理环信信令登录
调用 CallAPI
进行呼叫等操作时,需要保证信令可用。因此在准备通话前,需要调用 login
方法登录信令服务。
signalClient.login() {[weak self] err in
guard let self = self else {return}
if let err = err {
// 错误处理
return
}
// 登录成功,可以进行 CallAPI 的通话准备了
}
7. 准备通话环境
调用 prepareForCall
准备通话环境。
- 请确保将该方法中的
rtcToken
设置为通配频道名的 Token,即在生成 Token 时将channelName
设为通配符或空字符串。详见使用通配 Token。 - 请确保用于生成
rtcToken
的uid
和调用initialize
方法初始化 CallAPI 中传入的userId
一致。
// 准备通话环境
let prepareConfig = PrepareConfig()
prepareConfig.rtcToken = <#万能 RTC Token#>
prepareConfig.roomId = <#需要呼叫的频道 ID#>
prepareConfig.localView = callVC.localCanvasView.canvasView
prepareConfig.remoteView = callVC.remoteCanvasView.canvasView
// 如果期望把扩展信息带到对端,可以通过该参数实现
prepareConfig.userExtension = nil
callApi.prepareForCall(prepareConfig: prepareConfig) { err in
// 成功即可以开始进行呼叫
}
8. 主叫发起呼叫
通过 CallAPI
的 call
方法,呼叫用户列表里的任意一个用户。
为了保证通话安全性,建议每次呼叫前调用
prepareForCall
更新
roomId
。详见 13. 更新频道号。
- 发起视频呼叫
- 发起音频呼叫
如下示例代码调用 call [1/2]
来发起视频呼叫。
private func _call(user: Pure1v1UserInfo) {
// 需要检查是否已经可以拨打电话,如果完成 CallAPI 状态会是 prepared
if callState == .idle || callState == .failed {
// 通话环境没有准备完成或者异常了,需要重新准备通话环境
// 异常提示
return
}
let remoteUserId = UInt(user.userId)!
callApi.call(remoteUserId: remoteUserId) {[weak self] err in
guard let err = err, self?.callState == .calling else {return}
// 呼叫失败,取消呼叫,返回空闲状态
self?.callApi.cancelCall(completion: { err in
})
}
}
如下示例代码调用 call [2/2]
,将 callType
设置为 audio
,可发起音频呼叫。
private func _call(user: Pure1v1UserInfo) {
// 需要检查是否已经可以拨打电话,如果完成 CallAPI 状态会是 prepared
if callState == .idle || callState == .failed {
// 通话环境没有准备完成或者异常了,需要重新准备通话环境
// 异常提示
return
}
let remoteUserId = UInt(user.userId)!
callApi.call(remoteUserId: remoteUserId, callType: .audio, callExtension: [:]) {[weak self] err in
guard let err = err, self?.callState == .calling else {return}
// 呼叫失败,取消呼叫,返回空闲状态
self?.callApi.cancelCall(completion: { err in
})
}
}
呼叫返回失败时,可以判断当前状态,如果仍然处于呼叫中(callState == .calling
),此时发起取消呼叫 cancelCall
可避免呼叫等待过长。
9. 监听到呼叫事件并处理
主叫发起呼叫,主叫和被叫都需要监听呼叫状态,并根据当前的状态实现后续逻辑。具体实现步骤如下:
- 通过
CallAPI
的回调onCallStateChanged
接收当前的呼叫状态。 - 根据发起呼叫的用户 ID,判断当前用户是主叫还是被叫,然后引导用户选择接受、拒绝或取消呼叫操作。
func onCallStateChanged(with state: CallStateType,
stateReason: CallStateReason,
eventReason: String,
eventInfo: [String : Any]) {
switch state {
case .calling:
let fromUserId = eventInfo[kFromUserId] as? UInt ?? 0
let fromRoomId = eventInfo[kFromRoomId] as? String ?? ""
let toUserId = eventInfo[kRemoteUserId] as? UInt ?? 0
if currentUid == "\(toUserId)" {
// 当前用户为被叫
// 获取用户信息,供弹出框展示
let user = userList.first {$0.userId == "\(fromUserId)"}
let dialog = Pure1v1CalleeDialog.show(user: user)
// 接受呼叫
dialog?.acceptClosure = { [weak self] in
guard let self = self else {return}
self.callApi.accept(remoteUserId: fromUserId) {[weak self] err in
guard let err = err else { return }
// 如果接受消息出错,则发起拒绝,回到初始状态
self?.api.reject(remoteUserId: fromUserId, reason: err.localizedDescription, completion: { err in
})
}
}
// 拒绝呼叫
dialog?.rejectClosure = { [weak self] in
self?.callApi.reject(remoteUserId: fromUserId, reason: "reject by user") {err in
}
}
} else if currentUid == "\(fromUserId)" {
// 当前用户为主叫
// 获取用户信息,供弹出框展示
let user = userList.first {$0.userId == "\(toUserId)"}
let dialog = Pure1v1CallerDialog.show(user: user)
// 取消呼叫
dialog?.cancelClosure = {[weak self] in
self?.callApi.cancelCall(completion: { err in
})
}
}
default:
break
}
}
接受返回失败时,可以发起拒绝请求,避免对端没收到接受请求导致的异常。
10. 收到通话成功
被叫点击接受后,主叫和被叫都会收到状态变更回调,且状态为通话成功。你可以在该回调中展示通话页面。
func onCallStateChanged(with state: CallStateType,
stateReason: CallStateReason,
eventReason: String,
eventInfo: [String : Any]) {
switch state {
case .connected:
// 显示通话页面
present(callVC, animated: false)
break
default:
break
}
}
11. 主叫结束呼叫
主叫通过调用 CallAPI
的 hangup
方法来结束和对端的通话。
func _hangupAction() {
callApi?.hangup(remoteUserId: UInt(targetUser?.userId ?? "") ?? 0, reason: nil, completion: { err in
})
// 此处省略其他处理逻辑
}
12. 收到挂断消息
主叫结束呼叫后,CallAPI
会通过回调将当前的呼叫状态同步给主叫和被叫端。相应的步骤和实现逻辑如下:
- 点击挂断后,主叫和被叫都会通过
CallApiListenerProtocol
的onCallStateChanged
回调监听到状态变化。 - 呼叫状态会变更为空闲
(state: prepared)
,变更原因为本地挂断(stateReason: localHangup)
或远端挂断(stateReason: remoteHangup)
。 - 你可以根据接收到的状态移除对应的通话页面。
func onCallStateChanged(with state: CallStateType,
stateReason: CallStateReason,
eventReason: String,
eventInfo: [String : Any]) {
let currentUid = userInfo?.userId ?? ""
switch state {
case .prepared:
switch stateReason {
case .localHangup, .remoteHangup:
// 移除通话页面
callVC.dismiss(animated: false)
// 展示拒绝信息
default:
break
}
default:
break
}
}
如果由于网络等问题,通话一方(用户 A)发起挂断的信令消息未能成功发送到另一方(用户 B),会导致用户 B 的通话页面无法结束。为避免该问题,我们建议你采用客户端 + 服务端配合的方式,关注并处理通话异常情况。具体操作步骤如下:
-
客户端:如果用户 B 收到
onCallEventChanged
回调,且event
为remoteLeave
,表示用户 A 已离开通话频道。此时你可以调整用户界面,例如使用缺省的图像填充用户 A 的视图,并给出用户 A 暂时离开的提示。 -
服务端:通过 RTC 频道事件回调
103
和104
识别待观察用户。- 收到用户 A 离开频道事件
104
后,记录事件中的uid
、channelName
及离开的时间戳ts
到缓存中,并将该用户列为待观察用户。设置用户的超时离开时间,如 30 秒,然后每 5 秒或 10 秒检索待观察用户列表。如果用户 A 离开的时间超过 30 秒,则调用封禁用户权限 RESTful API 将用户 A 从频道中踢出。 - 收到用户 A 加入频道事件
103
后,识别事件中的channelName
和uid
。如果缓存中该uid
在当前频道中有记录,且未申请下麦,则将该uid
从待观察用户列表中移除。
- 收到用户 A 离开频道事件
-
客户端:如果用户 B 收到
onCallError
回调,且errorEvent
为rtcOccurError
,errorType
为rtc
,errorCode
为123
,表示用户 A 被踢出 RTC 频道。此时用户 B 可以调用CallAPI
的hangup
方法结束当前通话并离开通话页面。
13. 更新频道号
调用 prepareForCall
更新频道号。
主叫发起呼叫后,被叫会获取到主叫在准备通话环境中设置的 roomId
,从而加入对应的 RTC 频道进行通话。通话结束后,如果主叫不更新 roomId
就发起新的呼叫,可能会有一定的安全风险。
以用户 A、B、C 为例,主叫 A 向用户 B 发起呼叫后,B 就获取了 A 的 RTC 频道名(roomId
)。呼叫结束后,如果 A 不更新 roomId
就向用户 C 发起呼叫,用户 B 可以通过技术手段使用之前的 roomId
和通配 Token 加入用户 A 的频道进行盗流。
因此为确保通话安全,你需要在每次发起呼叫前,都调用 prepareForCall
方法更新 roomId
,以保证每次通话使用不同的 RTC 频道,进而确保通话的私密性。
由于 PrepareConfig
为全量更新,因此调用 prepareForCall
更新频道号时,请确保 PrepareConfig
中的其他属性均已设值,否则会调用失败。你可以将之前的 PrepareConfig
中的设置保存起来,修改 roomId
后进行传参。
// 准备通话环境
let prepareConfig = PrepareConfig()
// 设置新的频道号
prepareConfig.roomId = <#需要呼叫的频道 ID#>
// 此处省略其他属性的设置。请确保同步设置其他属性
callApi.prepareForCall(prepareConfig: prepareConfig) { err in
// 成功即可以开始进行呼叫
}
14. 离开并释放资源
如果要离开业务场景,还需要清理通话环境,释放相关资源。
// 清除 CallAPI 缓存
callApi.deinitialize {
}
// 销毁 RTC 实例
AgoraRtcEngineKit.destroy()
//登出 环信
signalClient.logout()
// 其它业务逻辑
开发注意事项
为确保通话安全,你需要在每次发起呼叫前,都调用 prepareForCall
方法更新 roomId
,以保证每次通话使用不同的 RTC 频道,进而确保通话的私密性。详见 13. 更新频道号。
参考文档
在开发过程中,你可以参考如下文档,获取更多 API 调用详情:
如果希望在项目中实现更高阶的功能,可以参考进阶集成指引。