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 ``` 在 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 your-app-id your-secret ``` > 生产环境建议: > 不要在 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() 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) ```