644 lines
17 KiB
Markdown
644 lines
17 KiB
Markdown
SellyCloudSDK
|
||
|
||
SellyRTC Android SDK 接入文档
|
||
|
||
本文档介绍如何在 Android 中使用 SellyRTC 快速集成一对一或多人音视频通话功能,包括:
|
||
- 基本接入
|
||
- 音视频控制
|
||
- 数据处理(如美颜)
|
||
- 事件回调
|
||
- 通话统计
|
||
- Token 生成与更新机制
|
||
|
||
---
|
||
|
||
## 目录
|
||
1. 准备工作
|
||
2. 快速开始
|
||
- 创建引擎
|
||
- 设置本地/远端画面
|
||
- 配置视频参数
|
||
- 加入频道
|
||
- 结束通话
|
||
3. 常用功能
|
||
- 开关本地音视频
|
||
- 切换摄像头
|
||
- 静音远端音视频
|
||
- 音频输出控制(扬声器 / 听筒)
|
||
- 发送自定义消息
|
||
- 美颜开关
|
||
4. 视频帧处理(美颜等)
|
||
5. 事件回调 (InteractiveRtcEngineEventHandler)
|
||
6. 通话统计
|
||
7. Token 过期机制
|
||
8. 常见问题
|
||
|
||
---
|
||
|
||
# 1. 准备工作
|
||
|
||
## 1.1 集成 SellyCloudSDK
|
||
|
||
或如果目前是通过本地 AAR 集成(demo 方式):
|
||
|
||
```gradle
|
||
dependencies {
|
||
implementation files("libs/sellycloudsdk-release.aar")
|
||
}
|
||
```
|
||
|
||
> 注意:如果你的业务侧还依赖 WebRTC、ijkplayer、美颜等第三方库,请保持与 SDK Demo 中的依赖版本一致。
|
||
|
||
## 1.2 必要权限
|
||
|
||
在 `AndroidManifest.xml` 中声明音视频必需权限:
|
||
|
||
```xml
|
||
<uses-permission android:name="android.permission.CAMERA" />
|
||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||
```
|
||
|
||
在 Android 6.0+ 设备上,运行时还需要动态申请权限,示例见后文(Demo 中的 `requiredPermissions` + `ActivityResultContracts.RequestMultiplePermissions` 已经实现)。
|
||
|
||
## 1.3 获取 AppId / Secret / Token
|
||
|
||
从 SellyCloud 控制台获取:
|
||
|
||
- `signaling_app_id`
|
||
- `signaling_secret`(用于服务端生成 Token)
|
||
- 或直接配置一个测试用的 `signaling_token`
|
||
|
||
在 Demo 中,这些值通常配置在 `res/values/strings.xml`:
|
||
|
||
```xml
|
||
<string name="signaling_app_id">your-app-id</string>
|
||
<string name="signaling_secret">your-secret</string>
|
||
<string name="signaling_token"></string> <!-- 可选:直接写死 token -->
|
||
```
|
||
|
||
> 生产环境建议:
|
||
> 不要在 App 里写 secret,而是在你们自己的业务服务器上生成 Token,App 只向服务器请求 Token。
|
||
|
||
---
|
||
|
||
# 2. 快速开始
|
||
|
||
以下示例基于 Demo 中的 `InteractiveLiveActivity`,展示最小接入流程。
|
||
|
||
## 2.1 创建引擎 InteractiveRtcEngine
|
||
|
||
在 `Activity` 中创建并配置 RTC 引擎:
|
||
|
||
```kotlin
|
||
private var rtcEngine: InteractiveRtcEngine? = null
|
||
private var beautyRenderer: FURenderer? = null
|
||
private var fuFrameInterceptor: FuVideoFrameInterceptor? = null
|
||
@Volatile private var isFrontCamera = true
|
||
@Volatile private var beautyEnabled: Boolean = true
|
||
|
||
private fun initRtcEngine() {
|
||
val appId = getString(R.string.signaling_app_id)
|
||
val token = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
|
||
|
||
// 可选:初始化美颜
|
||
beautyRenderer = FURenderer(this).also { it.setup() }
|
||
fuFrameInterceptor = beautyRenderer?.let {
|
||
FuVideoFrameInterceptor(it).apply {
|
||
setFrontCamera(isFrontCamera)
|
||
setEnabled(beautyEnabled)
|
||
}
|
||
}
|
||
|
||
rtcEngine = InteractiveRtcEngine.create(
|
||
InteractiveRtcEngineConfig(
|
||
context = applicationContext,
|
||
appId = appId,
|
||
defaultToken = token
|
||
)
|
||
).apply {
|
||
// 设置回调
|
||
setEventHandler(rtcEventHandler)
|
||
|
||
// 角色:主播/观众,Demo 里默认主播(BROADCASTER)
|
||
setClientRole(InteractiveRtcEngine.ClientRole.BROADCASTER)
|
||
|
||
// 配置视频参数(可选,见下一节)
|
||
setVideoEncoderConfiguration(
|
||
InteractiveVideoEncoderConfig(
|
||
width = 640,
|
||
height = 480,
|
||
fps = 20,
|
||
minBitrateKbps = 150,
|
||
maxBitrateKbps = 350
|
||
)
|
||
)
|
||
|
||
// 默认走扬声器
|
||
setDefaultAudioRoutetoSpeakerphone(true)
|
||
|
||
// 视频采集前拦截(用于美颜等)
|
||
setCaptureVideoFrameInterceptor { frame ->
|
||
if (!beautyEnabled) return@setCaptureVideoFrameInterceptor frame
|
||
fuFrameInterceptor?.process(frame) ?: frame
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
生命周期注意:
|
||
在 `onDestroy` 中记得 `leaveChannel()` 并销毁引擎,避免内存泄漏:
|
||
|
||
```kotlin
|
||
override fun onDestroy() {
|
||
super.onDestroy()
|
||
rtcEngine?.setCaptureVideoFrameInterceptor(null)
|
||
leaveChannel()
|
||
InteractiveRtcEngine.destroy(rtcEngine)
|
||
rtcEngine = null
|
||
// 释放 renderer / 美颜资源...
|
||
}
|
||
```
|
||
|
||
## 2.2 设置本地 & 远端画面
|
||
|
||
SellyRTC 使用 `InteractiveVideoCanvas + SurfaceViewRenderer` 来承载视频画面。
|
||
|
||
### 初始化本地与远端渲染 View
|
||
|
||
```kotlin
|
||
private var localRenderer: SurfaceViewRenderer? = null
|
||
private val remoteRendererMap = mutableMapOf<String, SurfaceViewRenderer>()
|
||
|
||
private fun createRenderer(): SurfaceViewRenderer =
|
||
SurfaceViewRenderer(this).apply {
|
||
setZOrderMediaOverlay(false)
|
||
}
|
||
|
||
private fun setupVideoSlots() {
|
||
// 本地 slot
|
||
if (localRenderer == null) {
|
||
localRenderer = createRenderer()
|
||
}
|
||
localRenderer?.let { renderer ->
|
||
// Demo 中使用自定义的 VideoReportLayout 来承载
|
||
binding.flLocal.attachRenderer(renderer)
|
||
}
|
||
|
||
// 远端 slot 见 Demo 中的 remoteSlots / ensureRemoteRenderer
|
||
}
|
||
```
|
||
|
||
### 绑定本地视频
|
||
|
||
在加入频道前/时,设置本地视频 canvas:
|
||
|
||
```kotlin
|
||
val renderer = localRenderer ?: createRenderer().also { localRenderer = it }
|
||
rtcEngine?.setupLocalVideo(InteractiveVideoCanvas(renderer, localUserId))
|
||
```
|
||
|
||
### 绑定远端视频
|
||
|
||
在 `onUserJoined` 或业务逻辑中,为某个 `userId` 分配一个远端窗口:
|
||
|
||
```kotlin
|
||
private fun ensureRemoteRenderer(userId: String): SurfaceViewRenderer {
|
||
return remoteRendererMap[userId] ?: createRenderer().also { renderer ->
|
||
remoteRendererMap[userId] = renderer
|
||
rtcEngine?.setupRemoteVideo(InteractiveVideoCanvas(renderer, userId))
|
||
}
|
||
}
|
||
```
|
||
|
||
> 多人会议:为不同的 `userId` 分配不同的 View / slot,即可实现多路画面显示。
|
||
|
||
## 2.3 配置视频参数(可选)
|
||
|
||
视频编码参数需要在加入频道前配置:
|
||
|
||
```kotlin
|
||
rtcEngine?.setVideoEncoderConfiguration(
|
||
InteractiveVideoEncoderConfig(
|
||
width = 640,
|
||
height = 480,
|
||
fps = 20,
|
||
minBitrateKbps = 150,
|
||
maxBitrateKbps = 350
|
||
)
|
||
)
|
||
// 不设置则使用 SDK 默认配置
|
||
```
|
||
|
||
## 2.4 加入频道 / 发起通话
|
||
|
||
### 1)准备 CallType 等入会参数
|
||
|
||
```kotlin
|
||
val options = InteractiveChannelMediaOptions(
|
||
callType = if (isP2P) CallType.ONE_TO_ONE else CallType.GROUP
|
||
)
|
||
```
|
||
|
||
其中:
|
||
|
||
- `CallType.ONE_TO_ONE`:一对一视频通话
|
||
- `CallType.GROUP`:多人会议 / 互动直播
|
||
|
||
### 2)生成 Token
|
||
|
||
Demo 中的策略(简化):
|
||
|
||
```kotlin
|
||
private val defaultTokenTtlSeconds = InteractiveCallConfig.DEFAULT_TOKEN_TTL_SECONDS
|
||
|
||
private fun buildToken(appId: String, callId: String, userId: String): TokenBundle? {
|
||
val manualToken = getString(R.string.signaling_token).takeIf { it.isNotBlank() }
|
||
if (manualToken != null) {
|
||
return TokenBundle(
|
||
token = manualToken,
|
||
expiresAtSec = parseExprTime(manualToken),
|
||
secret = null
|
||
)
|
||
}
|
||
|
||
val secret = getString(R.string.signaling_secret)
|
||
if (secret.isBlank()) {
|
||
Toast.makeText(
|
||
this,
|
||
"请在 strings.xml 配置 signaling_secret 用于生成 token,或直接填写 signaling_token",
|
||
Toast.LENGTH_LONG
|
||
).show()
|
||
return null
|
||
}
|
||
|
||
return try {
|
||
val generated = TokenGenerator.generate(
|
||
appId = appId,
|
||
userId = userId,
|
||
callId = callId,
|
||
secret = secret,
|
||
ttlSeconds = defaultTokenTtlSeconds
|
||
)
|
||
TokenBundle(
|
||
token = generated.token,
|
||
expiresAtSec = generated.expiresAtSec,
|
||
secret = secret
|
||
)
|
||
} catch (t: Throwable) {
|
||
Toast.makeText(this, "生成 token 失败: ${t.message}", Toast.LENGTH_LONG).show()
|
||
null
|
||
}
|
||
}
|
||
```
|
||
|
||
> 生产环境建议:
|
||
> 将 `TokenGenerator` 放在你的业务服务器,客户端只请求业务服务器获取 Token。
|
||
|
||
### 3)调用 joinChannel
|
||
|
||
```kotlin
|
||
rtcEngine?.joinChannel(
|
||
token = request.token,
|
||
callId = request.callId,
|
||
userId = request.userId,
|
||
options = request.options, // CallType 等
|
||
tokenSecret = request.tokenSecret, // 可为空
|
||
tokenExpiresAtSec = request.tokenExpiresAtSec,
|
||
tokenTtlSeconds = request.tokenTtlSeconds
|
||
)
|
||
```
|
||
|
||
成功后,会回调:
|
||
|
||
```kotlin
|
||
override fun onJoinChannelSuccess(channel: String, userId: String, code: Int) {
|
||
// 已成功加入频道,可更新 UI 状态
|
||
}
|
||
```
|
||
|
||
## 2.5 结束通话
|
||
|
||
业务结束通话时调用:
|
||
|
||
```kotlin
|
||
private fun leaveChannel() {
|
||
rtcEngine?.leaveChannel()
|
||
resetUiAfterLeave() // 清 UI、清理 renderer 等
|
||
}
|
||
```
|
||
|
||
SDK 会通过:
|
||
|
||
```kotlin
|
||
override fun onLeaveChannel(durationSeconds: Int) {
|
||
// 通话结束时长(秒)
|
||
}
|
||
```
|
||
|
||
通知已经离开频道。
|
||
|
||
---
|
||
|
||
# 3. 常用功能
|
||
|
||
以下示例同样来自 Demo,可直接复用。
|
||
|
||
## 3.1 开/关本地视频
|
||
|
||
```kotlin
|
||
private var isLocalVideoEnabled = true
|
||
private var isLocalPreviewEnabled = true
|
||
|
||
binding.btnToggleCamera.setOnClickListener {
|
||
isLocalVideoEnabled = !isLocalVideoEnabled
|
||
rtcEngine?.enableLocalVideo(isLocalVideoEnabled)
|
||
isLocalPreviewEnabled = isLocalVideoEnabled
|
||
updateControlButtons()
|
||
}
|
||
```
|
||
|
||
## 3.2 开/关本地音频采集
|
||
|
||
```kotlin
|
||
private var isLocalAudioEnabled = true
|
||
|
||
binding.btnToggleMic.setOnClickListener {
|
||
isLocalAudioEnabled = !isLocalAudioEnabled
|
||
rtcEngine?.enableLocalAudio(isLocalAudioEnabled)
|
||
updateControlButtons()
|
||
}
|
||
```
|
||
|
||
## 3.3 切换前后摄像头
|
||
|
||
```kotlin
|
||
binding.btnSwitchCamera.setOnClickListener {
|
||
isFrontCamera = !isFrontCamera
|
||
fuFrameInterceptor?.setFrontCamera(isFrontCamera)
|
||
rtcEngine?.switchCamera()
|
||
}
|
||
```
|
||
|
||
## 3.4 静音远端音视频
|
||
|
||
按用户静音远端音频 / 视频:
|
||
|
||
```kotlin
|
||
private fun muteRemoteUserAudio(targetUserId: String, muted: Boolean) {
|
||
rtcEngine?.muteRemoteAudioStream(targetUserId, muted)
|
||
}
|
||
|
||
private fun muteRemoteUserVideo(targetUserId: String, muted: Boolean) {
|
||
rtcEngine?.muteRemoteVideoStream(targetUserId, muted)
|
||
}
|
||
```
|
||
|
||
## 3.5 控制音频输出(扬声器 / 听筒)
|
||
|
||
```kotlin
|
||
private var isSpeakerOn = true
|
||
|
||
binding.btnToggleAudioRoute.setOnClickListener {
|
||
isSpeakerOn = !isSpeakerOn
|
||
rtcEngine?.setDefaultAudioRoutetoSpeakerphone(isSpeakerOn)
|
||
updateControlButtons()
|
||
}
|
||
```
|
||
|
||
## 3.6 发送自定义消息
|
||
|
||
```kotlin
|
||
binding.btnSendMessage.setOnClickListener {
|
||
val text = binding.etMessage.text?.toString()?.trim().orEmpty()
|
||
if (text.isEmpty()) {
|
||
Toast.makeText(this, "请输入消息内容", Toast.LENGTH_SHORT).show()
|
||
} else if (currentCallId == null) {
|
||
Toast.makeText(this, "请先加入频道", Toast.LENGTH_SHORT).show()
|
||
} else {
|
||
rtcEngine?.sendMessage(text) { error ->
|
||
runOnUiThread {
|
||
if (error != null) {
|
||
Toast.makeText(this, "发送失败: ${error.message}", Toast.LENGTH_SHORT).show()
|
||
} else {
|
||
Toast.makeText(this, "已发送", Toast.LENGTH_SHORT).show()
|
||
binding.etMessage.text?.clear()
|
||
binding.tvMessageLog.text = "我: $text"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
收到消息的回调见后文 `onMessageReceived`。
|
||
|
||
## 3.7 美颜开关
|
||
|
||
```kotlin
|
||
binding.btnToggleBeauty.setOnClickListener {
|
||
beautyEnabled = !beautyEnabled
|
||
fuFrameInterceptor?.setEnabled(beautyEnabled)
|
||
updateControlButtons()
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# 4. 视频帧处理(美颜等)
|
||
|
||
SellyRTC 提供视频采集前拦截接口,可以在推流前做美颜、滤镜等处理。
|
||
|
||
在创建引擎后设置:
|
||
|
||
```kotlin
|
||
rtcEngine?.setCaptureVideoFrameInterceptor { frame ->
|
||
if (!beautyEnabled) return@setCaptureVideoFrameInterceptor frame
|
||
fuFrameInterceptor?.process(frame) ?: frame
|
||
}
|
||
```
|
||
|
||
其中 `FuVideoFrameInterceptor` 内部使用 `FURenderer` 做实际美颜处理。
|
||
|
||
> 你也可以替换为自己的处理逻辑:
|
||
> - 对 `frame` 做 GPU 或 CPU 处理
|
||
> - 返回处理后的帧给 SDK 继续编码和发送
|
||
|
||
---
|
||
|
||
# 5. 事件回调 (InteractiveRtcEngineEventHandler)
|
||
|
||
实现 `InteractiveRtcEngineEventHandler`,监听通话过程中发生的事件:
|
||
|
||
```kotlin
|
||
private val rtcEventHandler = object : InteractiveRtcEngineEventHandler {
|
||
|
||
override fun onJoinChannelSuccess(channel: String, userId: String, code: Int) { ... }
|
||
|
||
override fun onLeaveChannel(durationSeconds: Int) { ... }
|
||
|
||
override fun onUserJoined(userId: String, code: Int) { ... }
|
||
|
||
override fun onUserLeave(userId: String, code: Int) { ... }
|
||
|
||
override fun onConnectionStateChanged(
|
||
state: InteractiveConnectionState,
|
||
reason: Int,
|
||
userId: String?
|
||
) { ... }
|
||
|
||
override fun onError(code: String, message: String) { ... }
|
||
|
||
override fun onLocalVideoStats(stats: InteractiveStreamStats) { ... }
|
||
|
||
override fun onRemoteVideoStats(stats: InteractiveStreamStats) { ... }
|
||
|
||
override fun onMessageReceived(message: String, userId: String?) { ... }
|
||
|
||
override fun onTokenWillExpire(token: String?, expiresAt: Long) { ... }
|
||
|
||
override fun onTokenExpired(token: String?, expiresAt: Long) { ... }
|
||
|
||
override fun onDuration(durationSeconds: Long) { ... }
|
||
|
||
override fun onRemoteVideoEnabled(enabled: Boolean, userId: String?) { ... }
|
||
|
||
override fun onRemoteAudioEnabled(enabled: Boolean, userId: String?) { ... }
|
||
|
||
override fun onStreamStateChanged(
|
||
peerId: String,
|
||
state: RemoteState,
|
||
code: Int,
|
||
message: String?
|
||
) { ... }
|
||
}
|
||
```
|
||
|
||
**常见事件说明:**
|
||
|
||
- `onConnectionStateChanged`:连接状态变化(Disconnected / Connecting / Connected / Reconnecting / Failed)
|
||
- `onUserJoined` / `onUserLeave`:远端用户加入/离开频道
|
||
- `onRemoteVideoEnabled` / `onRemoteAudioEnabled`:远端用户开关音视频
|
||
- `onMessageReceived`:收到自定义消息
|
||
- `onDuration`:通话时长更新(秒)
|
||
- `onError`:错误回调(建议弹窗 + 打日志)
|
||
|
||
---
|
||
|
||
# 6. 通话统计信息
|
||
|
||
## 6.1 单路流统计:InteractiveStreamStats
|
||
|
||
在本地/远端视频统计回调中获取:
|
||
|
||
```kotlin
|
||
override fun onLocalVideoStats(stats: InteractiveStreamStats) {
|
||
// stats.width / height / fps / videoBitrateKbps / audioBitrateKbps / rttMs 等
|
||
}
|
||
|
||
override fun onRemoteVideoStats(stats: InteractiveStreamStats) {
|
||
// 针对某个 userId 的码率、分辨率、丢包、RTT 等
|
||
}
|
||
```
|
||
|
||
你可以将这些信息显示在 UI 上,Demo 中的 `buildStatsLabel` 已经示范了如何构造:
|
||
|
||
```kotlin
|
||
private fun buildStatsLabel(header: String, stats: InteractiveStreamStats?): String {
|
||
// Res: WxH, FPS, Codec, Video/Audio Kbps, RTT 等
|
||
}
|
||
```
|
||
|
||
## 6.2 通话结束时长:onLeaveChannel
|
||
|
||
在 `onLeaveChannel` 中可以拿到本次通话时长(秒),无论是主动离开还是断网/失败结束,只要曾加入成功都会回调:
|
||
|
||
```kotlin
|
||
override fun onLeaveChannel(durationSeconds: Int) {
|
||
Log.d(TAG, "onLeaveChannel duration=${durationSeconds}s")
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# 7. Token 过期机制
|
||
|
||
SDK 在 Token 生命周期内会通过事件提醒你续期:
|
||
|
||
## 7.1 Token 即将过期
|
||
|
||
```kotlin
|
||
override fun onTokenWillExpire(token: String?, expiresAt: Long) {
|
||
Toast.makeText(
|
||
this@InteractiveLiveActivity,
|
||
"Token 即将过期,请及时续期",
|
||
Toast.LENGTH_LONG
|
||
).show()
|
||
// 1. 通知业务服务器刷新 Token
|
||
// 2. 拿到新 Token 后调用 rtcEngine?.renewToken(newToken)(具体接口以实际 SDK 为准)
|
||
}
|
||
```
|
||
|
||
## 7.2 Token 已过期
|
||
|
||
```kotlin
|
||
override fun onTokenExpired(token: String?, expiresAt: Long) {
|
||
Toast.makeText(
|
||
this@InteractiveLiveActivity,
|
||
"Token 已过期,断线后将无法重连",
|
||
Toast.LENGTH_LONG
|
||
).show()
|
||
}
|
||
```
|
||
|
||
> 说明:
|
||
> - Token 过期后,**当前通话不会立刻中断**,但网络异常时自动重连会失败。
|
||
> - 请务必在 `onTokenWillExpire` 阶段就完成续期。
|
||
|
||
---
|
||
|
||
# 8. 常见问题 (FAQ)
|
||
|
||
## Q1:多人远端画面如何渲染?
|
||
|
||
为每一个远端用户(`userId`)分配一个 `SurfaceViewRenderer`,并调用:
|
||
|
||
```kotlin
|
||
val canvas = InteractiveVideoCanvas(renderer, userId)
|
||
rtcEngine?.setupRemoteVideo(canvas)
|
||
```
|
||
|
||
在布局层面,你可以将多个 `renderer` 放到不同的容器中(网格布局 / 自定义九宫格等),参考 Demo 中的 `remoteSlots`。
|
||
|
||
---
|
||
|
||
## Q2:远端画面不显示怎么办?
|
||
|
||
排查方向:
|
||
|
||
1. 是否收到了 `onUserJoined` 回调?
|
||
2. 有没有为该 `userId` 调用 `setupRemoteVideo` 并绑定到一个可见的 View?
|
||
3. View 是否被其他控件覆盖?
|
||
4. 远端用户是否已开启视频(可监听 `onRemoteVideoEnabled` 回调)?
|
||
|
||
---
|
||
|
||
## Q3:如何实现画中画 / 小窗布局?
|
||
|
||
这是布局层面的工作,与 SDK 解耦:
|
||
|
||
- 将远端大画面放在父容器(如 `FrameLayout`)中
|
||
- 再将本地小窗 View 作为子 View 添加在右下角,并设置合适的 `layoutParams`
|
||
- SDK 会把视频渲染到对应的 View 上,你只需要控制 View 的大小和位置即可
|
||
|
||
---
|
||
|
||
## Q4:如何在后台保持通话?
|
||
|
||
Demo 中使用了一个前台 Service:
|
||
|
||
```kotlin
|
||
InteractiveForegroundService.start(this)
|
||
// 离开频道后记得 stop
|
||
InteractiveForegroundService.stop(this)
|
||
```
|