实现秀场转 1v1
主播在直播过程中,用户可以付费发起 1v1 视频通话。在通话接通后,主播的原直播间不关闭但不推流,主播转场到 1v1 与付费用户进行视频通话;当 1v1 视频通话结束后,主播转场回原直播间继续直播的场景玩法。
本文介绍如何使用 CallAPI
实现秀场转 1v1 的功能。
示例项目
声网在 agora-ent-scenarios
仓库中提供秀场转 1v1 私密房源代码供参考。
技术架构
声网 CallAPI
中包含如下模块:
CallRtmManager
,基于声网 RTM SDK 开发,将 RTM 相关操作进行了封装,是对 RTM 服务的管理类。CallApi
:对 1v1 呼叫中 RTC 相关实现、状态和业务管理的封装。CallRtmSignalClient
:基于声网 RTM SDK 开发的信令协议实现类,负责连接CallRtmManager
和CallApi
;CallApi
通过该对象进行消息的收发。
其中,CallRtmManager
和 CallRtmSignalClient
是对信令服务的封装。如果你希望基于三方信令 SDK 实现信令相关功能,可以参考三方信令方案。
业务流程
秀场转 1v1 常见的业务流程如下图所示,你可以参考该流程实现相关业务。
下图仅体现使用声网 CallAPI
的流程。你还需要根据场景需求,自行添加业务相关流程,如创建、加入和离开直播间等。
前提条件
开始前,请确保满足如下前提条件:
- Git。
- Java Development Kit。
- Android Studio 4.1 及以上。
- 参考开通服务在控制台创建项目,获取 App ID 和 App 证书,并开通项目的 RTM 服务权限。
- 参考集成 CallAPI 将
CallAPI
集成进项目中。
实现步骤
本节介绍如何基于 CallAPI
来快速实现一个秀场转 1v1 场景。
完整的 API 调用时序如下图所示。其中:
- 主播指代以主播角色(
broadcaster
)加入直播间的客户端 App。 RtcEngine
指代声网 RTC SDK 的 API。CallAPI
指代声网 1v1 场景化 API。- 业务服务器指代你的 App 业务后台,需要自行部署实现。
- 观众指代以观众角色(
audience
)加入直播间的客户端 App。
出于篇幅考虑,下图省略了观众用户在初始化和离开并释放资源中的 API 调用时序。这部分时序可以直接参考主播用户。
1. 初始化 RTC 引擎
调用 RTC SDK 的 create
方法初始化 RtcEngine
。
val config = RtcEngineConfig()
config.mContext = this
config.mAppId = <#AppId#>
config.mEventHandler = object : IRtcEngineEventHandler() {
// override callback methods
}
config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING
config.mAudioScenario = Constants.AUDIO_SCENARIO_GAME_STREAMING
try {
val rtcEngine = RtcEngine.create(config) as RtcEngineEx
} catch (e: Exception) {
e.printStackTrace()
Log.e(TAG, "RtcEngine.create() called error: $e")
}
2. 初始化 CallRtmManager
RTM 服务的登录、注销、Token 续期、网络状态变化通知等是通过声网基于 RTM SDK 封装的 CallRtmManager
类实现的。参考如下示例代码初始化一个 RTM 实例。
val rtmManager = CallRtmManager(appId = <#AppId#>,
userId = <#UserId#>,
client = null)
3. 添加并监听 CallRtmManager 状态回调
通过 ICallRtmManagerListener
监听 RTM 相关的状态和事件回调。
// 监听 rtm manager 事件
rtmManager?.addListener(object : ICallRtmManagerListener {
override fun onConnected() {
// 网络连接上,可以正常收发信令
}
override fun onDisconnected() {
// 网络未连接,此时无法收发信令,业务层可以根据当前状态处理异常
}
override fun onTokenPrivilegeWillExpire(channelName: String) {
// Token 过期,需要重新更新 RTM Token
}
})
4. 初始化 CallRtmSignalClient
初始化 CallAPI 中的 CallRtmSignalClient
对象,该对象负责实现消息发送和接收。
val signalClient = createRtmSignalClient(rtmManager.getRtmClient())
5. 初始化 CallAPI
调用 CallAPI
的 initialize
方法初始化 1v1 场景化 API。你需要在该方法中设置 appId
、userId
、rtcEngine
和 signalClient
参数。
// 初始化配置
val api = CallApiImpl(this)
val config = CallConfig(
appId = <#AppId#>,
userId = <#UserId#>,
rtcEngine = rtcEngine,
signalClient = signalClient
)
api.initialize(config)
6. 添加并监听 CallAPI 的回调
调用 addListener
方法注册 CallAPI
回调事件。然后实现 CallAPI
的回调 ICallApiListener
,并监听 onCallStateChanged
回调。CallAPI
在运行过程中,会通过该回调报告当前的通话状态。你需要根据报告的状态实现不同的后续处理逻辑。
api.addListener(this)
override fun onCallStateChanged(
state: CallStateType,
stateReason: CallReason,
eventReason: String,
eventInfo: Map<String, Any>) {
// ...
}
7. 处理 CallRtmManager 的登录
调用 CallAPI
进行呼叫等操作时,需要保证信令可用。因此在准备通话环境前,需要调用 CallRtmManager
的 login
方法登录信令服务。
rtmManager?.login(rtmToken = rtmToken) {
if (it == null) {
// 登录成功,可以开始准备通话环境
}
}
8. 准备通话环境
调用 prepareForCall
准备通话环境。
- 请确保将该方法中的
rtcToken
设置为通配频道名的 Token,即在生成 Token 时将channelName
设为通配符或空字符串。详见使用通配 Token。 - 请确保用于生成
rtcToken
的uid
和调用initialize
方法初始化 CallAPI 中传入的userId
一致。
// 准备通话环境
val prepareConfig = PrepareConfig()
prepareConfig.rtcToken = <#万能 rtc token#>
prepareConfig.roomId = <#需要呼叫的频道id#>
prepareConfig.localView = <#本地视频ViewGroup#>
prepareConfig.remoteView = <#远端视频ViewGroup#>
prepareConfig.userExtension = null // 如果希望把扩展信息带到对端,可以通过该参数实现
api.prepareForCall(prepareConfig: prepareConfig) { err ->
// 成功即可以开始进行呼叫
}
9. 主播进房间并发流
调用 RTC API 让主播用户加入直播房间,并开始发布音、视频流。
joinChannel
中的 token
需要设为通配 Token,详见使用通配 Token。你可以使用在 prepareForCall
中设置的 RTC Token。
rtcEngine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER)
rtcEngine.enableLocalVideo(true)
rtcEngine.enableLocalAudio(true)
val options = ChannelMediaOptions()
options.publishMicrophoneTrack = true
options.publishCameraTrack = true
options.autoSubscribeAudio = false
options.autoSubscribeVideo = false
val ret = rtcEngine.joinChannel(
<#RTC 通配 Token#>,
<#开播的频道号#>,
<#主播自己的 Uid#>,
options
)
// 设置画布 setUpLocalVideo
10. 观众进房间并收流
调用 RTC API 让观众用户加入直播房间,并开始接收音、视频流。
joinChannel
中的 token
需要设为通配 Token,详见使用通配 Token。你可以使用在 prepareForCall
中设置的 RTC Token。
rtcEngine.setClientRole(Constants.CLIENT_ROLE_AUDIENCE)
val options = ChannelMediaOptions()
options.autoSubscribeAudio = true
options.autoSubscribeVideo = true
val ret = rtcEngine.joinChannel(
<#RTC 通配 Token#>,
<#开播的频道号#>,
<#观众自己的 Uid#>,
options
)
// 设置画布 setUpRemoteVideo
11. 观众发起呼叫
通过 CallAPI
的 call
方法,呼叫主播。
为了保证通话安全性,建议每次呼叫前调用 prepareForCall
更新 roomId
。详见 16. 观众端更新频道号。
- 发起视频呼叫
- 发起音频呼叫
如下示例代码调用 call [1/2]
来发起视频呼叫。
private fun call(user: Pure1v1UserInfo) {
// 需要检查是否已经可以拨打电话,如果完成 CallAPI 状态会是 prepared
if (mCallState == CallStateType.Idle || mCallState == CallStateType.Failed) {
// 通话环境没有准备完成或者异常了,需要重新准备通话环境
...
// 异常提示
...
return
}
api.call(user.showUserId.toInt()) { error ->
if (error != null) {
// 呼叫失败,取消呼叫,返回空闲状态
api.cancelCall { err -> }
}
}
}
如下示例代码调用 call [2/2]
,将 callType
设置为 audio
,可发起音频呼叫。
private fun call(user: Pure1v1UserInfo) {
// 需要检查是否已经可以拨打电话,如果完成 CallAPI 状态会是 prepared
if (mCallState == CallStateType.Idle || mCallState == CallStateType.Failed) {
// 通话环境没有准备完成或者异常了,需要重新准备通话环境
...
// 异常提示
...
return
}
api.call(user.showUserId.toInt(), CallType.Audio, mapOf()) { error ->
if (error != null) {
// 呼叫失败,取消呼叫,返回空闲状态
api.cancelCall { err -> }
}
}
}
呼叫返回失败时,可以判断当前状态,如果仍然处于呼叫中(callState == .calling
),此时发起取消呼叫 cancelCall
可避免呼叫等待过长。
12. 监听到呼叫事件并处理
观众发起呼叫,主播和观众都需要监听呼叫状态,并根据当前的状态实现后续逻辑。具体实现步骤如下:
- 通过
CallAPI
的回调onCallStateChanged
接收当前的呼叫状态。 - 根据发起呼叫的用户 ID,判断当前用户是主叫还是被叫,然后引导主播用户选择接受、拒绝或取消呼叫操作。
注意
在收到
Calling
状态时,需要保证校验摄像头和麦克风权限成功才能调用accept
/reject
,否则会出现呼叫异常。 - 主播用户在收到呼叫时,需要先停止向直播房间发布音、视频流,把主播频道的音视频推流关闭。
private fun publishMedia(publish: Boolean) {
val options = ChannelMediaOptions()
options.publishMicrophoneTrack = publish
options.publishCameraTrack = publish
options.autoSubscribeVideo = publish
options.autoSubscribeAudio = publish
rtcEngine.updateChannelMediaOptions(options)
}
override fun onCallStateChanged(
state: CallStateType,
stateReason: CallStateReason,
eventReason: String,
eventInfo: Map<String, Any>
) {
when (state) {
CallStateType.Calling -> {
val fromUserId = eventInfo[kFromUserId] as? UInt ?: 0
val fromRoomId = eventInfo[kFromRoomId] as? String ?: ""
val toUserId = eventInfo[kRemoteUserId] as? UInt ?: 0
if (currentUid == toUserId.toString()) {
// 被叫(即主播)
...
// 主播关闭直播频道推流
publishMedia(false)
// 不询问直接接受呼叫
api.accept(fromUserId) { err ->
if (err != null) {
// 如果接受消息出错,则发起拒绝,回到初始状态
api.reject(fromUserId, err.msg) {}
}
}
} else if (currentUid == fromUserId.toString()) {
// 主叫(观众)
...
// 获取用户信息,供弹出框展示
val user = roomList.first { it.uid == toUserId.toString() }
val dialog = CallerDialog.show(user)
dialog?.cancelClosure = {
api.cancelCall { err -> }
}
}
}
else -> {
// 其他状态
}
}
}
接受返回失败时,可以发起拒绝请求,避免对端没收到接受请求导致的异常。
13. 收到通话成功
主播用户点击接受后,主叫和主播用户都会收到状态更新回调,且状态为通话成功。你可以在该回调中展示通话页面。
override fun onCallStateChanged(
state: CallStateType,
stateReason: CallStateReason,
eventReason: String,
eventInfo: Map<String, Any>
) {
when (state) {
CallStateType.Connected -> {
// 显示通话页面
...
}
else -> {
// 其他状态
}
}
}
14. 观众挂断
观众通过调用 CallAPI
的 hangup
方法来结束和主播的通话。
private fun hangupAction() {
val connectedUserId = connectedUserId ?: return
api.hangup(connectedUserId, "hangup by user") {}
}
15. 收到挂断消息
观众结束呼叫后,CallAPI
会通过回调将当前的呼叫状态同步给主叫和被叫端。相应的步骤和实现逻辑如下:
- 点击挂断后,主叫和被叫都会通过
ICallApiListener
的onCallStateChanged
回调监听到状态变化。 - 呼叫状态会变更为空闲
(state: prepared)
,变更原因为本地挂断(stateReason: localHangup)
或远端挂断(stateReason: remoteHangup)
。 - 你可以根据接收到的状态移除对应的通话页面。
- 主播用户回到直播页面恢复发布音视频流。
override fun onCallStateChanged(
state: CallStateType,
stateReason: CallStateReason,
eventReason: String,
eventInfo: Map<String, Any>
) {
when (state) {
CallStateType.Prepared, CallStateType.Idle, CallStateType.Failed -> {
// 主播恢复直播频道推流
publishMedia(true)
when (stateReason) {
CallStateReason.LocalHangup, CallStateReason.RemoteHangup -> {
// 移除通话页面
...
}
else -> {
}
}
}
else -> {
}
}
}
如果由于网络等问题,通话一方(用户 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
方法结束当前通话并离开通话页面。
16. 观众端更新频道号
完成一轮呼叫后,观众端调用 prepareForCall
更新频道号。
在秀场转 1v1 场景中,观众(即主叫)发起呼叫后,主播(即被叫)会获取到观众在准备通话环境中设置的 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
后进行传参。
// 准备通话环境
val prepareConfig = PrepareConfig()
// 设置新的频道号
prepareConfig.roomId = <#需要呼叫的频道 ID#>
// 此处省略其他属性的设置。请确保同步设置其他属性
api.prepareForCall(prepareConfig: prepareConfig) { err ->
// 成功即可以开始进行呼叫
}
17. 离开并释放资源
如果要离开业务场景,还需要清理通话环境,释放相关资源。
// 清除 CallAPI 缓存
callApi.deinitialize {
// 销毁 RTC 实例
RtcEngineEx.destroy()
// 登出 RTM 服务
rtmManager.logout()
// 其他业务逻辑
}
开发注意事项
为确保通话安全,你需要在每次发起呼叫前,都调用 prepareForCall
方法更新 roomId
,以保证每次通话使用不同的 RTC 频道,进而确保通话的私密性。详见 16. 观众端更新频道号。
参考文档
在开发过程中,你可以参考如下文档,获取更多 API 调用详情:
如果希望在项目中实现更高阶的功能,可以参考进阶集成指引。