# 实现秀场转 1v1 (/zh-CN/solutions/one-to-one-live/custom-signaling/build/implement-core-features/showroom-to-one-to-one)

> For AI agents: see the complete documentation index at [llms.txt](/llms.txt).

<_PlatformTabsGroup>
<_PlatformProcessedMarker groupMode="structured" canonicalPlatform="android" platform="android" />
主播在直播过程中，用户可以付费发起 1v1 视频通话。在通话接通后，主播的原直播间不关闭但不推流，主播转场到 1v1 与付费用户进行视频通话；当 1v1 视频通话结束后，主播转场回原直播间继续直播的场景玩法。

本文以环信为例，介绍如何使用 [`CallAPI`](https://github.com/AgoraIO-Community/CallAPI) 和自定义的三方信令管理类 [`CallEasemobSignalClient`](https://github.com/AgoraIO-Community/CallAPI/blob/main/Android/app/src/main/java/io/agora/onetoone/signalClient/CallEasemobSignalClient.kt) 实现秀场转 1v1 场景。

## 技术架构 [#技术架构]

声网 1v1 私密房三方信令方案使用如下模块搭建：

- `CallAPI`：对 1v1 呼叫中 RTC 相关实现的封装，以及状态和业务的管理。
- `CallEasemobSignalClient`：基于环信开发的信令管理类，用于处理信令的连接、登录、呼叫等操作。

## 业务流程 [#业务流程]

1v1 私密房常见的业务流程如下图所示，你可以参考该流程实现相关业务。

:::warning[注意]
下图仅体现使用声网 `CallAPI`
  的流程。你还需要根据场景需求，自行添加业务相关流程，如加入、离开可通话列表等。
:::

![showroom to one-to-one call diagram](https://web-cdn.agora.io/docs-portal/img/one-to-one-live/easemob-showroom-call-diagram.png)

## 前提条件 [#前提条件]

开始前，请确保满足如下前提条件：

- [Git](https://git-scm.com/downloads)。
- [Java Development Kit](https://www.oracle.com/java/technologies/javase-downloads.html)。
- [Android Studio](https://developer.android.com/studio/) 4.1 及以上。
- 参考[开通一对一直播服务](/zh-CN/solutions/one-to-one-live/custom-signaling/build/setup-and-access/enable-service)在控制台创建项目，获取 App ID 和 App 证书。
- 参考[集成 CallAPI 和三方信令](/zh-CN/solutions/one-to-one-live/custom-signaling/get-started/integrate-third-party)将 `CallAPI` 和 `CallEasemobSignalClient` 集成到项目中。

## 实现步骤 [#实现步骤]

本节介绍如何基于 [`CallAPI`](https://github.com/AgoraIO-Community/CallAPI) 和自定义的三方信令管理类 [`CallEasemobSignalClient`](https://github.com/AgoraIO-Community/CallAPI/blob/main/Android/app/src/main/java/io/agora/onetoone/signalClient/CallEasemobSignalClient.kt) 来快速实现一个秀场转 1v1 场景。

完整的 API 调用时序如下图所示。其中：

- **主播**指代以主播角色（`broadcaster`）加入直播间的客户端 App。
- `CallEasemobSignalClient` 指代基于环信即时通讯 SDK 开发的信令管理类。
- `RtcEngine` 指代声网 RTC SDK 的 API。
- `CallAPI` 指代声网为 1v1 场景化 API。
- **业务服务器**指代**你**的 App 业务后台，需要自行部署实现。
- **观众**指代以观众角色（`audience`）加入直播间的客户端 App。

:::info[信息]
出于篇幅考虑，下图省略了观众在**初始化**和**离开并释放资源**中的 API
  调用时序。这部分时序可以直接参考主播。
:::

![](https://web-cdn.agora.io/docs-portal/img/one-to-one-live/showroom-1v1-scene-custom.svg)

### 1. 初始化 RTC 引擎 [#1-初始化-rtc-引擎]

调用 RTC SDK 的 [`sharedEngineWithConfig`](/zh-CN/api-reference/rtc/android/initialize#api_irtcengine_initialize) 方法初始化 `RtcEngine`。

```kotlin
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. 创建环信信令对象 [#2-创建环信信令对象]

基于 `CallEasemobSignalClient` 类初始化一个信令对象 `signalClient`，用于管理信令服务的登录、注销、Token 续期、收发消息、网络状态变化通知等。你需要在初始化时填入环信的 App Key。

```kotlin
val signalClient = CallEasemobSignalClient(context, appKey, userId)
```

### 3. 建立并实现信令回调机制 [#3-建立并实现信令回调机制]

通过 `ICallEasemobSignalClientListener` 注册并监听信令相关的状态和事件回调。

```kotlin
signalClient.addListener(this)

override fun onConnected() {
    // ...
}
```

### 4. 初始化 CallAPI 并绑定信令 [#4-初始化-callapi-并绑定信令]

调用 `CallAPI` 的 [`initialize`](/zh-CN/api-reference/private-room) 方法初始化 `CallAPI`。你需要在该方法中设置 `appId`、`userId`、`rtcEngine` 和 `signalClient` 参数，其中 `signalClient` 就是在第一步中初始化的 `CallEasemobSignalClient` 对象。

```kotlin
// 初始化配置
val api = CallApiImpl(this)
val config = CallConfig(
    appId = <#AppId#>,
    userId = <#UserId#>,
    rtcEngine = rtcEngine,
    signalClient = signalClient
)
api.initialize(config)
``` 

### 5. 添加并监听 CallAPI 的回调 [#5-添加并监听-callapi-的回调]

调用 [`addListener`](/zh-CN/api-reference/private-room) 方法注册 `CallAPI` 回调事件。然后实现 `CallAPI` 的回调 `ICallApiListener`，并监听 [`onCallStateChanged`](/zh-CN/api-reference/private-room) 回调。`CallAPI` 在运行过程中，会通过该回调报告当前的通话状态。你需要跟踪报告的状态实现不同的后续处理逻辑。

```kotlin
api.addListener(this)

override fun onCallStateChanged(
    state: CallStateType,
    stateReason: CallReason,
    eventReason: String,
    eventInfo: Map<String, Any>) {
    // ...
    }
``` 

### 6. 处理环信信令登录 [#6-处理环信信令登录]

调用 `CallAPI` 进行呼叫等操作时，需要保证信令可用。因此在准备通话前，需要调用 `login` 方法登录信令服务。

```kotlin
signalClient?.login {
    if (it) {
        // 登录成功，可以进行 CallAPI 的通话准备了
    } else {
        // 登录失败，需要处理异常
    }
}
```

### 7. 准备通话环境 [#7-准备通话环境]

调用 [`prepareForCall`](/zh-CN/api-reference/private-room) 准备通话环境。

:::warning[注意]
- 请确保将该方法中的 `rtcToken` 设置为通配频道名的 Token，即在生成 Token 时将
  `channelName` 设为通配符或空字符串。详见[使用通配
  Token](/zh-CN/realtime-media/rtc/build/security-and-auth/wildcard-token)。

  - 请确保用于生成 `rtcToken` 的 `uid` 和调用
  [`initialize`](/zh-CN/api-reference/private-room) 方法初始化
  CallAPI 中传入的 `userId` 一致。
:::

```kotlin
// 准备通话环境
val prepareConfig = PrepareConfig()
prepareConfig.rtcToken = <#万能 rtc token#>
prepareConfig.roomId = <#需要呼叫的频道id#>
prepareConfig.localView = <#本地视频ViewGroup#>
prepareConfig.remoteView = <#远端视频ViewGroup#>
prepareConfig.userExtension = null // 如果希望把扩展信息带到对端，可以通过该参数实现 
api.prepareForCall(prepareConfig: prepareConfig) { err ->
    // 成功即可以开始进行呼叫
}
```

### 8. 主播进房间并发流 [#8-主播进房间并发流]

调用 RTC API 让主播加入直播房间，并开始发布音、视频流。

:::warning[注意]
`joinChannel` 中的 `token` 需要设为通配 Token，详见[使用通配
  Token](/zh-CN/realtime-media/rtc/build/security-and-auth/wildcard-token)。你可以使用在
  `prepareForCall` 中设置的 RTC Token。
:::

```kotlin
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
``` 

### 9. 观众进房间并收流 [#9-观众进房间并收流]

调用 RTC API 让观众加入直播房间，并开始接收音、视频流。

:::warning[注意]
`joinChannel` 中的 `token` 需要设为通配 Token，详见[使用通配
  Token](/zh-CN/realtime-media/rtc/build/security-and-auth/wildcard-token)。你可以使用在
  `prepareForCall` 中设置的 RTC Token。
:::

```kotlin
rtcEngine.setClientRole(Constants.CLIENT_ROLE_AUDIENCE)
val options = ChannelMediaOptions()
options.autoSubscribeAudio = true
options.autoSubscribeVideo = true
val ret = rtcEngine.joinChannel(
    <#RTC 通配 Token#>, 
    <#开播的频道号#>, 
    <#观众自己的 Uid#>, 
    options
)
// 设置画布 setUpRemoteVideo
```

### 10. 观众发起呼叫 [#10-观众发起呼叫]

通过 `CallAPI` 的 `call` 方法，呼叫主播。

:::warning[注意]
为了保证通话安全性，建议每次呼叫前调用 `prepareForCall` 更新 `roomId`。详见 [15. 观众端更新频道号](/zh-CN/solutions/one-to-one-live/custom-signaling/build/implement-core-features/showroom-to-one-to-one)。
:::

<Tabs defaultValue="video" groupId="make-call" persist>
<TabsList>
  <TabsTrigger value="video">发起视频呼叫</TabsTrigger>
  <TabsTrigger value="audio">发起音频呼叫</TabsTrigger>
</TabsList>

<TabsContent value="video">

如下示例代码调用 [`call[1/2]`](/zh-CN/api-reference/private-room/android/custom-signaling/api/call-api#call) 来发起视频呼叫。

  ```kotlin
  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 -> }
          }
      }
  }
  ```

</TabsContent>

<TabsContent value="audio">

如下示例代码调用 [`call[2/2]`](/zh-CN/api-reference/private-room/android/custom-signaling/api/call-api#call2)，将 `callType` 设置为 `audio`，可发起音频呼叫。

```kotlin
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 -> }
        }
    }
}
```

</TabsContent>
</Tabs>

:::warning[注意]
呼叫返回失败时，可以判断当前状态，如果仍然处于呼叫中（`callState == .calling`），此时发起取消呼叫 [`cancelCall`](/zh-CN/api-reference/private-room) 可避免呼叫等待过长。
:::

### 11. 监听到呼叫事件并处理 [#11-监听到呼叫事件并处理]

观众发起呼叫，主播和观众都需要监听呼叫状态，并根据当前的状态实现后续逻辑。具体实现步骤如下：

1. 通过 `CallAPI` 的回调 [`onCallStateChanged`](/zh-CN/api-reference/private-room) 能收到呼叫的状态。
2. 根据发起呼叫的用户 ID，判断当前用户是主叫还是被叫，然后引导主播选择接受、拒绝或取消呼叫操作。
    :::warning[注意]
    在收到 `Calling` 状态时，需要保证校验摄像头和麦克风权限成功才能调用 `accept`/`reject`，否则会出现呼叫异常。
    :::
3. 主播在收到呼叫时，需要先停止向直播房间发布音、视频流，把主播频道的音视频推流关闭。

```kotlin
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 -> {
            // 其他状态
        }
    }
}
``` 

:::warning[注意]
接受返回失败时，可以发起拒绝请求，避免对端没收到接受请求导致的异常。
:::

### 12. 收到通话成功 [#12-收到通话成功]

主播点击接受后，主叫和主播都会收到状态更新回调，且状态为通话成功。你可以在该回调中展示通话页面。

```kotlin
override fun onCallStateChanged(
    state: CallStateType,
    stateReason: CallStateReason,
    eventReason: String,
    eventInfo: Map<String, Any>
) {
    when (state) {
        CallStateType.Connected -> {
            // 显示通话页面
            ...
        }
        else -> {
            // 其他状态
        }
    }
}
``` 

### 13. 观众挂断 [#13-观众挂断]

观众通过调用 `CallAPI` 的 [`hangup`](/zh-CN/api-reference/private-room) 方法来结束和主播的通话。

```kotlin
private fun hangupAction() {
    val connectedUserId = connectedUserId ?: return
    api.hangup(connectedUserId, "hangup by user") {}
}
``` 

### 14. 收到挂断消息 [#14-收到挂断消息]

观众结束呼叫后，`CallAPI` 会通过回调将当前的呼叫状态同步给主叫和被叫端。相应的步骤和实现逻辑如下：

1. 点击挂断后，主叫和被叫都会通过 `ICallApiListener` 的 `onCallStateChanged` 回调监听到状态变化。
2. 呼叫状态会变更为空闲 `(state: prepared)`，变更原因为本地挂断 `(stateReason: localHangup)` 或远端挂断 `(stateReason: remoteHangup)`。
3. 你可以根据接收到的状态移除对应的通话页面。
4. 主播回到直播页面恢复发布音视频流。

```kotlin
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 的通话页面无法结束。为避免该问题，我们建议你采用客户端 + 服务端配合的方式，关注并处理通话异常情况。具体操作步骤如下：

1. **客户端**：如果用户 B 收到 `onCallEventChanged` 回调，且 `event` 为 `remoteLeave`，表示用户 A 已离开通话频道。此时你可以调整用户界面，例如使用缺省的图像填充用户 A 的视图，并给出用户 A 暂时离开的提示。

2. **服务端**：通过 RTC 频道事件回调 [`103`](/zh-CN/api-reference/rtc/restful/webhook/events#103-broadcaster-join-channel) 和 [`104`](/zh-CN/api-reference/rtc/restful/webhook/events#104-broadcaster-leave-channel) 识别待观察用户。
   - 收到用户 A 离开频道事件 `104` 后，记录事件中的 `uid`、`channelName` 及离开的时间戳 `ts` 到缓存中，并将该用户列为待观察用户。设置用户的超时离开时间，如 30 秒，然后每 5 秒或 10 秒检索待观察用户列表。如果用户 A 离开的时间超过 30 秒，则调用[封禁用户权限 RESTful API](/zh-CN/api-reference/api-ref/rtc/create-ban-rule) 将用户 A 从频道中踢出。
   - 收到用户 A 加入频道事件 `103` 后，识别事件中的 `channelName` 和 `uid`。如果缓存中该 `uid` 在当前频道中有记录，且未申请下麦，则将该 `uid` 从待观察用户列表中移除。

3. **客户端**：如果用户 B 收到 `onCallError` 回调，且 `errorEvent` 为 `rtcOccurError`，`errorType` 为 `rtc`，`errorCode` 为 `123`，表示用户 A 被踢出 RTC 频道。此时用户 B 可以调用 `CallAPI` 的 `hangup` 方法结束当前通话并离开通话页面。

### 15. 观众端更新频道号 [#15-观众端更新频道号]

调用 [`prepareForCall`](/zh-CN/api-reference/private-room) 更新频道号。

在秀场转 1v1 场景中，观众（即主叫）发起呼叫后，主播（即被叫）会获取到观众在准备通话环境中设置的 `roomId`，从而加入对应的 RTC 频道进行通话。通话结束后，如果观众不更新 `roomId` 就发起新的呼叫，可能会有一定的安全风险。

以用户 A、B、C 为例，观众 A 向主播 B 发起呼叫后，B 就获取了 A 的 RTC 频道名（`roomId`）。呼叫结束后，如果 A 不更新 `roomId` 就向主播 C 发起呼叫，主播 B 可以通过技术手段使用之前的 `roomId` 和通配 Token 加入观众 A 的频道进行盗流。

因此为确保通话安全，我们建议在每次发起呼叫前，都调用 `prepareForCall` 方法更新 `roomId`，以保证每次通话使用不同的 RTC 频道，进而确保通话的私密性。

:::warning[注意]
由于 `PrepareConfig` 为全量更新，因此调用 `prepareForCall`
  更新频道号时，请确保 `PrepareConfig`
  中的其他属性均已设值，否则会调用失败。你可以将之前的 `PrepareConfig`
  中的设置保存起来，修改 `roomId` 后进行传参。
:::

```kotlin
// 准备通话环境
val prepareConfig = PrepareConfig()
// highlight-start
// 设置新的频道号
prepareConfig.roomId = <#需要呼叫的频道 ID#>
// 此处省略其他属性的设置。请确保同步设置其他属性
// highlight-end

api.prepareForCall(prepareConfig: prepareConfig) { err ->
    // 成功即可以开始进行呼叫
}
```

### 16. 离开并释放资源 [#16-离开并释放资源]

如果要离开业务场景，还需要清理通话环境，释放相关资源。

```kotlin
// 清除 CallAPI 缓存
callApi.deinitialize {
}

// 销毁 RTC 实例
RtcEngineEx.destroy()

// 登出 环信
signalClient.logout()
  
// 其他业务逻辑
``` 

## 开发注意事项 [#开发注意事项]

为确保通话安全，你需要在每次发起呼叫前，都调用 `prepareForCall` 方法更新 `roomId`，以保证每次通话使用不同的 RTC 频道，进而确保通话的私密性。详见 [15. 观众端更新频道号](/zh-CN/solutions/one-to-one-live/custom-signaling/build/implement-core-features/showroom-to-one-to-one)。

## 参考文档 [#参考文档]

在开发过程中，你可以参考如下文档，获取更多 API 调用详情：

- [CallAPI 参考](/zh-CN/api-reference/private-room)
- [RTC API 参考](/zh-CN/api-reference/private-room)
- [环信即时通讯云文档](https://doc.easemob.com/product/introduction.html)

如果希望在项目中实现更高阶的功能，可以参考[优化自定义信令方案集成逻辑](/zh-CN/solutions/one-to-one-live/custom-signaling/build/customize-and-extend/integration-guideline)。
<_PlatformProcessedMarker close="true" />

<_PlatformProcessedMarker groupMode="structured" canonicalPlatform="android" platform="ios" />
主播在直播过程中，用户可以付费发起 1v1 视频通话。在通话接通后，主播的原直播间不关闭但不推流，主播转场到 1v1 与付费用户进行视频通话；当 1v1 视频通话结束后，主播转场回原直播间继续直播的场景玩法。

本文以环信为例，介绍如何使用 [`CallAPI`](https://github.com/AgoraIO-Community/CallAPI) 和自定义的三方信令管理类 [`CallEasemobSignalClient`](https://github.com/AgoraIO-Community/CallAPI/blob/main/iOS/Example/CallAPI/SignalClient/CallEasemobSignalClient.swift) 实现秀场转 1v1 场景。

## 技术架构 [#技术架构-1]

声网 1v1 私密房三方信令方案使用如下模块搭建：

- `CallAPI`：对 1v1 呼叫中 RTC 相关实现的封装，以及状态和业务的管理。
- `CallEasemobSignalClient`：基于环信开发的信令管理类，用于处理信令的连接、登录、呼叫等操作。

## 业务流程 [#业务流程-1]

1v1 私密房常见的业务流程如下图所示，你可以参考该流程实现相关业务。

:::warning[注意]
下图仅体现使用声网 `CallAPI`
  的流程。你还需要根据场景需求，自行添加业务相关流程，如加入、离开可通话列表等。
:::

![showroom to one-to-one call diagram](https://web-cdn.agora.io/docs-portal/img/one-to-one-live/easemob-showroom-call-diagram.png)

## 前提条件 [#前提条件-1]

开始前，请确保满足如下前提条件：

- [Git](https://git-scm.com/downloads)。
- [CocoaPods](https://guides.cocoapods.org/using/getting-started.html#getting-started)。
- [Xcode](https://apps.apple.com/cn/app/xcode/id497799835?mt=12) 12.0 及以上。
- 有效的苹果开发者账号。
- 参考[开通一对一直播服务](/zh-CN/solutions/one-to-one-live/custom-signaling/build/setup-and-access/enable-service)在控制台创建项目，获取 App ID 和 App 证书。
- 参考[集成 CallAPI 和三方信令](/zh-CN/solutions/one-to-one-live/custom-signaling/get-started/integrate-third-party)将 `CallAPI` 和 `CallEasemobSignalClient` 集成到项目中。

## 实现步骤 [#实现步骤-1]

本节介绍如何基于 [`CallAPI`](https://github.com/AgoraIO-Community/CallAPI) 和自定义的三方信令管理类 [`CallEasemobSignalClient`](https://github.com/AgoraIO-Community/CallAPI/blob/main/iOS/Example/CallAPI/SignalClient/CallEasemobSignalClient.swift) 来快速实现一个秀场转 1v1 场景。

完整的 API 调用时序如下图所示。其中：

- **主播**指代以主播角色（`broadcaster`）加入直播间的客户端 App。
- `CallEasemobSignalClient` 指代基于环信即时通讯 SDK 开发的信令管理类。
- `RtcEngine` 指代声网 RTC SDK 的 API。
- `CallAPI` 指代声网 1v1 场景化 API。
- **业务服务器**指代**你**的 App 业务后台，需要自行部署实现。
- **观众**指代以观众角色（`audience`）加入直播间的客户端 App。

:::info[信息]
出于篇幅考虑，下图省略了观众在**初始化**和**离开并释放资源**中的 API
  调用时序。这部分时序可以直接参考主播。
:::

![](https://web-cdn.agora.io/docs-portal/img/one-to-one-live/showroom-1v1-scene-custom.svg)

### 1. 初始化 RTC 引擎 [#1-初始化-rtc-引擎-1]

调用 RTC SDK 的 [`sharedEngineWithConfig`](/zh-CN/api-reference/rtc/ios/initialize#api_irtcengine_initialize) 方法初始化 `RtcEngine`。

```swift
let config = AgoraRtcEngineConfig()
config.appId = <#AppId#>
config.channelProfile = .liveBroadcasting
config.audioScenario = .gameStreaming
config.areaCode = .global

let rtcEngine = AgoraRtcEngineKit.sharedEngine(with: config,
                                               delegate: nil)
```

### 2. 创建环信信令对象 [#2-创建环信信令对象-1]

基于 `CallEasemobSignalClient` 类初始化一个环信的信令对象 `signalClient`，用于管理信令服务的登录、注销、Token 续期、收发消息、网络状态变化通知等。你需要在初始化时填入环信的 App Key。

```swift
let signalClient = CallEasemobSignalClient(appKey: <#环信AppKey#>, userId: <#UserId#>)
```

### 3. 建立并实现信令回调机制 [#3-建立并实现信令回调机制-1]

通过 `ICallEasemobSignalClientListener` 注册并监听信令相关的状态和事件回调。

```swift
signalClient.delegate = self

extension EMPure1v1RoomViewController: ICallEasemobSignalClientListener {
    func onConnected() {
        // 连接成功
    }

    func onDisconnected() {
        // 连接断开
    }
}
```

### 4. 初始化 CallAPI 并绑定信令 [#4-初始化-callapi-并绑定信令-1]

调用 `CallAPI` 的 [`initialize`](/zh-CN/api-reference/private-room) 方法初始化 `CallAPI`。你需要在该方法中设置 `appId`、`userId`、`rtcEngine` 和 `signalClient` 参数，其中 `signalClient` 就是在第一步中初始化的 `CallEasemobSignalClient` 对象。

```swift
let config = CallConfig()
config.appId = <#AppId#>
config.userId =  <#UserId#>
config.rtcEngine = rtcEngine
config.signalClient = signalClient

let api = CallApiImpl()
api.initialize(config: config)
```

### 5. 添加并监听 CallAPI 的回调 [#5-添加并监听-callapi-的回调-1]

调用 [`addListener`](/zh-CN/api-reference/private-room) 方法注册 `CallAPI` 回调事件。然后实现 `CallAPI` 的回调 `CallApiListenerProtocol`，并监听 [`onCallStateChanged`](/zh-CN/api-reference/private-room) 回调。`CallAPI` 在运行过程中，会通过该回调报告当前的通话状态。你需要跟踪报告的状态实现不同的后续处理逻辑。

```swift
callApi.addListener(listener: self)

extension Pure1v1UserListViewController: CallApiListenerProtocol {
    func onCallStateChanged(with state: CallStateType,
                            stateReason: CallStateReason,
                            eventReason: String,
                            eventInfo: [String : Any]) {
        // 省略其他逻辑
    }
}
```

### 6. 处理环信信令登录 [#6-处理环信信令登录-1]

调用 `CallAPI` 进行呼叫等操作时，需要保证信令可用。因此在准备通话前，需要调用 `login` 方法登录信令服务。

```swift
signalClient.login() {[weak self] err in
    guard let self = self else {return}
    if let err = err {
        // 错误处理
        return
    }
    // 登录成功，可以进行 CallAPI 的通话准备了
}
```

### 7. 准备通话环境 [#7-准备通话环境-1]

调用 [`prepareForCall`](/zh-CN/api-reference/private-room) 准备通话环境。

:::warning[注意]
- 请确保将该方法中的 `rtcToken` 设置为通配频道名的 Token，即在生成 Token 时将
  `channelName` 设为通配符或空字符串。详见[使用通配
  Token](/zh-CN/realtime-media/rtc/build/security-and-auth/wildcard-token)。

  - 请确保用于生成 `rtcToken` 的 `uid` 和调用
  [`initialize`](/zh-CN/api-reference/private-room) 方法初始化
  CallAPI 中传入的 `userId` 一致。
:::

```swift
// 准备通话环境
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. 主播进房间并发流 [#8-主播进房间并发流-1]

调用 RTC API 让主播加入直播房间，并开始发布音、视频流。

:::warning[注意]
`joinChannel` 中的 `token` 需要设为通配 Token，详见[使用通配
  Token](/zh-CN/realtime-media/rtc/build/security-and-auth/wildcard-token)。你可以使用在
  `prepareForCall` 中设置的 RTC Token。
:::

```swift
extension BroadcasterViewController {
    private func joinRTCChannel() {
        rtcEngine?.setClientRole(.broadcaster)
        rtcEngine?.enableLocalVideo(true)
        rtcEngine?.enableLocalAudio(true)
        let mediaOptions = AgoraRtcChannelMediaOptions()
        mediaOptions.publishCameraTrack = true
        mediaOptions.publishMicrophoneTrack = true
        // 以主播角色加入 RTC 频道并推流
        rtcEngine?.joinChannel(byToken: broadcasterToken,
                               channelId: channelId,
                               uid: uid,
                               mediaOptions: mediaOptions,
                               joinSuccess: {[weak self] channelId, uid, elapsed in
        })

        // 设置画布
    }

    override func viewDidLoad() {
        // 此处省略其他逻辑

        joinRTCChannel()
    }
}
```

### 9. 观众进房间并收流 [#9-观众进房间并收流-1]

调用 RTC API 让观众加入直播房间，并开始接收音、视频流。

:::warning[注意]
`joinChannel` 中的 `token` 需要设为通配 Token，详见[使用通配
  Token](/zh-CN/realtime-media/rtc/build/security-and-auth/wildcard-token)。你可以使用在
  `prepareForCall` 中设置的 RTC Token。
:::

```swift
extension BroadcasterViewController {
    private func joinRTCChannel() {
    
        // 如果是观众
        rtcEngine?.setClientRole(.audience)
        let mediaOptions = AgoraRtcChannelMediaOptions()
        mediaOptions.autoSubscribeAudio = true
        mediaOptions.autoSubscribeVideo = true
        // 以观众角色加入直播频道并订阅音视频流
        rtcEngine?.joinChannel(byToken: rtcToken,
                               channelId: channelId,
                               uid: uid,
                               mediaOptions: mediaOptions,
                               joinSuccess: {[weak self] channelId, uid, elapsed in
        })

        // 设置画布
    }

    override func viewDidLoad() {
        // 此处省略其他逻辑

        joinRTCChannel()
    }
}
```

### 10. 观众发起呼叫 [#10-观众发起呼叫-1]

通过 `CallAPI` 的 `call` 方法，呼叫主播。

:::warning[注意]
为了保证通话安全性，建议每次呼叫前调用 `prepareForCall` 更新 `roomId`。详见 [15. 观众更新频道号](/zh-CN/solutions/one-to-one-live/custom-signaling/build/implement-core-features/showroom-to-one-to-one)。
:::

<Tabs defaultValue="video" groupId="make-call" persist>
<TabsList>
  <TabsTrigger value="video">发起视频呼叫</TabsTrigger>
  <TabsTrigger value="audio">发起音频呼叫</TabsTrigger>
</TabsList>

<TabsContent value="video">

如下示例代码调用 [`call [1/2]`](/zh-CN/api-reference/private-room/ios/custom-signaling/api/call-api#call) 来发起视频呼叫。

  ```swift
  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
            })
        }
    }
  ```

</TabsContent>

<TabsContent value="audio">

如下示例代码调用 [`call [2/2]`](/zh-CN/api-reference/private-room/ios/custom-signaling/api/call-api#call2)，将 `callType` 设置为 `audio`，可发起音频呼叫。

  ```swift
  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
          })
      }
  }
  ```

</TabsContent>
</Tabs>

:::warning[注意]
呼叫返回失败时，可以判断当前状态，如果仍然处于呼叫中（`callState == .calling`），此时发起取消呼叫 [`cancelCall`](/zh-CN/api-reference/private-room) 可避免呼叫等待过长。
:::

### 11. 监听到呼叫事件并处理 [#11-监听到呼叫事件并处理-1]

观众发起呼叫，主播和观众都需要监听呼叫状态，并根据当前的状态实现后续逻辑。具体实现步骤如下：

1. 通过 `CallAPI` 的回调 `onCallStateChanged` 能收到呼叫的状态
2. 根据发起呼叫的用户 ID，判断当前用户是主叫还是被叫，然后引导主播选择接受、拒绝或取消呼叫操作
3. 主播在收到呼叫时，需要先停止向直播房间发布音、视频流把主播频道的音视频推流关闭

```swift
private func _publishMedia(_ publish: Bool) {
    guard isBroadcaster else { return }
    let mediaOptions = AgoraRtcChannelMediaOptions()
    mediaOptions.publishCameraTrack = publish
    mediaOptions.publishMicrophoneTrack = publish
    rtcEngine?.updateChannel(with: mediaOptions)
}

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)" {
            // 被叫(即主播)

            // 主播关闭直播频道推流
            _publishMedia(false)

            // 不询问直接接受呼叫
            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
                })
            }
        } else if currentUid == "\(fromUserId)" {
            // 主叫(观众)
            

            // 获取用户信息，供弹出框展示
            let user = roomList.first {$0.uid == "\(toUserId)"}
            let dialog = CallerDialog.show(user: user)
            dialog?.cancelClosure = {[weak self] in
                self?.callApi.cancelCall(completion: { err in
                })
            }
        }
    default:
        break
    }
}
```

:::warning[注意]
接受返回失败时，可以发起拒绝请求，避免对端没收到接受请求导致的异常。
:::

### 12. 收到通话成功 [#12-收到通话成功-1]

主播点击接受后，主叫和主播都会收到状态更新回调，且状态为通话成功。你可以在该回调中展示通话页面。

```swift
func onCallStateChanged(with state: CallStateType,
                        stateReason: CallStateReason,
                        eventReason: String,
                        eventInfo: [String : Any]) {
    switch state {
    case .connected:
        
        // 显示通话页面
        navigationController?.pushViewController(callVC, animated: false)
        break
    default:
        break
    }
}
```

### 13. 观众挂断 [#13-观众挂断-1]

观众通过调用 `CallAPI` 的 [`hangup`](/zh-CN/api-reference/private-room) 方法来结束和主播的通话。

```swift
func _hangupAction() {
    callApi?.hangup(remoteUserId: UInt(targetUser?.uid ?? "") ?? 0, reason: nil, completion: { err in
    })
    ...
}
```

### 14. 收到挂断消息 [#14-收到挂断消息-1]

观众结束呼叫后，`CallAPI` 会通过回调将当前的呼叫状态同步给主叫和被叫端。相应的步骤和实现逻辑如下：

1. 点击挂断后，主叫和被叫都会通过 `CallApiListenerProtocol` 的 `onCallStateChanged` 回调监听到状态变化。
2. 呼叫状态会变更为空闲 `(state: prepared)`，变更原因为本地挂断 `(stateReason: localHangup)` 或远端挂断 `(stateReason: remoteHangup)`。
3. 你可以根据接收到的状态移除对应的通话页面。
4. 主播回到直播页面恢复发布音视频流。

```swift
extension BroadcasterViewController {
    func onCallStateChanged(with state: CallStateType,
                            stateReason: CallStateReason,
                            eventReason: String,
                            eventInfo: [String : Any]) {
        switch state {
        case .prepared, .idle, .failed:
            // 主播恢复直播频道推流
            _publishMedia(true)
            switch stateReason {
            case .localHangup, .remoteHangup:
                // 其他业务流程

                ...
            default:
                break
            }
        default:
            break
        }
    }
}
```

如果由于网络等问题，通话一方（用户 A）发起挂断的信令消息未能成功发送到另一方（用户 B），会导致用户 B 的通话页面无法结束。为避免该问题，我们建议你采用客户端 + 服务端配合的方式，关注并处理通话异常情况。具体操作步骤如下：

1. **客户端**：如果用户 B 收到 `onCallEventChanged` 回调，且 `event` 为 `remoteLeave`，表示用户 A 已离开通话频道。此时你可以调整用户界面，例如使用缺省的图像填充用户 A 的视图，并给出用户 A 暂时离开的提示。

2. **服务端**：通过 RTC 频道事件回调 [`103`](/zh-CN/api-reference/rtc/restful/webhook/events#103-broadcaster-join-channel) 和 [`104`](/zh-CN/api-reference/rtc/restful/webhook/events#104-broadcaster-leave-channel) 识别待观察用户。
   - 收到用户 A 离开频道事件 `104` 后，记录事件中的 `uid`、`channelName` 及离开的时间戳 `ts` 到缓存中，并将该用户列为待观察用户。设置用户的超时离开时间，如 30 秒，然后每 5 秒或 10 秒检索待观察用户列表。如果用户 A 离开的时间超过 30 秒，则调用[封禁用户权限 RESTful API](/zh-CN/api-reference/api-ref/rtc/create-ban-rule) 将用户 A 从频道中踢出。
   - 收到用户 A 加入频道事件 `103` 后，识别事件中的 `channelName` 和 `uid`。如果缓存中该 `uid` 在当前频道中有记录，且未申请下麦，则将该 `uid` 从待观察用户列表中移除。

3. **客户端**：如果用户 B 收到 `onCallError` 回调，且 `errorEvent` 为 `rtcOccurError`，`errorType` 为 `rtc`，`errorCode` 为 `123`，表示用户 A 被踢出 RTC 频道。此时用户 B 可以调用 `CallAPI` 的 `hangup` 方法结束当前通话并离开通话页面。

### 15. 观众更新频道号 [#15-观众更新频道号]

调用 [`prepareForCall`](/zh-CN/api-reference/private-room) 更新频道号。

在秀场转 1v1 场景中，观众（即主叫）发起呼叫后，主播（即被叫）会获取到观众在准备通话环境中设置的 `roomId`，从而加入对应的 RTC 频道进行通话。通话结束后，如果观众不更新 `roomId` 就发起新的呼叫，可能会有一定的安全风险。

以用户 A、B、C 为例，观众 A 向主播 B 发起呼叫后，B 就获取了 A 的 RTC 频道名（`roomId`）。呼叫结束后，如果 A 不更新 `roomId` 就向主播 C 发起呼叫，主播 B 可以通过技术手段使用之前的 `roomId` 和通配 Token 加入观众 A 的频道进行盗流。

因此为确保通话安全，我们建议在每次发起呼叫前，都调用 `prepareForCall` 方法更新 `roomId`，以保证每次通话使用不同的 RTC 频道，进而确保通话的私密性。

:::warning[注意]
由于 `PrepareConfig` 为全量更新，因此调用 `prepareForCall`
  更新频道号时，请确保 `PrepareConfig`
  中的其他属性均已设值，否则会调用失败。你可以将之前的 `PrepareConfig`
  中的设置保存起来，修改 `roomId` 后进行传参。
:::

```swift
// 准备通话环境
let prepareConfig = PrepareConfig()

// highlight-start
// 设置新的频道号
prepareConfig.roomId = <#需要呼叫的频道 ID#>
// 请确保同步设置其他属性
// highlight-end

callApi.prepareForCall(prepareConfig: prepareConfig) { err in
    // 成功即可以开始进行呼叫
}
```

### 16. 离开并释放资源 [#16-离开并释放资源-1]

如果要离开业务场景，还需要清理通话环境，释放相关资源。

```swift
// 清除 CallAPI 缓存
callApi.deinitialize {
}

// 销毁 RTC 实例
AgoraRtcEngineKit.destroy()

//登出 环信
signalClient.logout()

// 其它业务逻辑
```

## 开发注意事项 [#开发注意事项-1]

为确保通话安全，你需要在每次发起呼叫前，都调用 `prepareForCall` 方法更新 `roomId`，以保证每次通话使用不同的 RTC 频道，进而确保通话的私密性。详见 [15. 观众更新频道号](/zh-CN/solutions/one-to-one-live/custom-signaling/build/implement-core-features/showroom-to-one-to-one)。

## 参考文档 [#参考文档-1]

在开发过程中，你可以参考如下文档，获取更多 API 调用详情：

- [`CallAPI` 参考](/zh-CN/api-reference/private-room)
- [RTC API 参考](/zh-CN/api-reference/private-room)
- [环信即时通讯云文档](https://doc.easemob.com/product/introduction.html)

如果希望在项目中实现更高阶的功能，可以参考[优化自定义信令方案集成逻辑](/zh-CN/solutions/one-to-one-live/custom-signaling/build/customize-and-extend/integration-guideline)。
<_PlatformProcessedMarker close="true" />
</_PlatformTabsGroup>
