Go to file
shou aca9365c5b update 2025-12-17 16:29:12 +08:00
example update 2025-12-17 16:29:12 +08:00
gradle/wrapper Initial commit 2025-12-03 05:02:09 +08:00
.gitignore 屏幕共享美颜更新 2025-12-12 15:11:56 +08:00
KIWI_DECOUPLING_GUIDE.md Initial commit 2025-12-03 05:02:09 +08:00
README.md Initial commit 2025-12-03 05:02:09 +08:00
build.gradle Initial commit 2025-12-03 05:02:09 +08:00
gradle.properties Initial commit 2025-12-03 05:02:09 +08:00
gradlew Initial commit 2025-12-03 05:02:09 +08:00
gradlew.bat Initial commit 2025-12-03 05:02:09 +08:00
settings.gradle 屏幕共享美颜更新 2025-12-12 15:11:56 +08:00

README.md

SellyCloudSDK

SellyRTC Android SDK 接入文档

本文档介绍如何在 Android 中使用 SellyRTC 快速集成一对一或多人音视频通话功能,包括:

  • 基本接入
  • 音视频控制
  • 数据处理(如美颜)
  • 事件回调
  • 通话统计
  • Token 生成与更新机制

目录

  1. 准备工作
  2. 快速开始
    • 创建引擎
    • 设置本地/远端画面
    • 配置视频参数
    • 加入频道
    • 结束通话
  3. 常用功能
    • 开关本地音视频
    • 切换摄像头
    • 静音远端音视频
    • 音频输出控制(扬声器 / 听筒)
    • 发送自定义消息
    • 美颜开关
  4. 视频帧处理(美颜等)
  5. 事件回调 (InteractiveRtcEngineEventHandler)
  6. 通话统计
  7. Token 过期机制
  8. 常见问题

1. 准备工作

1.1 集成 SellyCloudSDK

或如果目前是通过本地 AAR 集成demo 方式):

dependencies {
    implementation files("libs/sellycloudsdk-release.aar")
}

注意:如果你的业务侧还依赖 WebRTC、ijkplayer、美颜等第三方库请保持与 SDK Demo 中的依赖版本一致。

1.2 必要权限

AndroidManifest.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

<string name="signaling_app_id">your-app-id</string>
<string name="signaling_secret">your-secret</string>
<string name="signaling_token"></string> <!-- 可选:直接写死 token -->

生产环境建议:
不要在 App 里写 secret而是在你们自己的业务服务器上生成 TokenApp 只向服务器请求 Token。


2. 快速开始

以下示例基于 Demo 中的 InteractiveLiveActivity,展示最小接入流程。

2.1 创建引擎 InteractiveRtcEngine

Activity 中创建并配置 RTC 引擎:

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() 并销毁引擎,避免内存泄漏:

override fun onDestroy() {
    super.onDestroy()
    rtcEngine?.setCaptureVideoFrameInterceptor(null)
    leaveChannel()
    InteractiveRtcEngine.destroy(rtcEngine)
    rtcEngine = null
    // 释放 renderer / 美颜资源...
}

2.2 设置本地 & 远端画面

SellyRTC 使用 InteractiveVideoCanvas + SurfaceViewRenderer 来承载视频画面。

初始化本地与远端渲染 View

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

val renderer = localRenderer ?: createRenderer().also { localRenderer = it }
rtcEngine?.setupLocalVideo(InteractiveVideoCanvas(renderer, localUserId))

绑定远端视频

onUserJoined 或业务逻辑中,为某个 userId 分配一个远端窗口:

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 配置视频参数(可选)

视频编码参数需要在加入频道前配置:

rtcEngine?.setVideoEncoderConfiguration(
    InteractiveVideoEncoderConfig(
        width = 640,
        height = 480,
        fps = 20,
        minBitrateKbps = 150,
        maxBitrateKbps = 350
    )
)
// 不设置则使用 SDK 默认配置

2.4 加入频道 / 发起通话

1准备 CallType 等入会参数

val options = InteractiveChannelMediaOptions(
    callType = if (isP2P) CallType.ONE_TO_ONE else CallType.GROUP
)

其中:

  • CallType.ONE_TO_ONE:一对一视频通话
  • CallType.GROUP:多人会议 / 互动直播

2生成 Token

Demo 中的策略(简化):

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

rtcEngine?.joinChannel(
    token = request.token,
    callId = request.callId,
    userId = request.userId,
    options = request.options,          // CallType 等
    tokenSecret = request.tokenSecret,  // 可为空
    tokenExpiresAtSec = request.tokenExpiresAtSec,
    tokenTtlSeconds = request.tokenTtlSeconds
)

成功后,会回调:

override fun onJoinChannelSuccess(channel: String, userId: String, code: Int) {
    // 已成功加入频道,可更新 UI 状态
}

2.5 结束通话

业务结束通话时调用:

private fun leaveChannel() {
    rtcEngine?.leaveChannel()
    resetUiAfterLeave() // 清 UI、清理 renderer 等
}

SDK 会通过:

override fun onLeaveChannel(durationSeconds: Int) {
    // 通话结束时长(秒)
}

通知已经离开频道。


3. 常用功能

以下示例同样来自 Demo可直接复用。

3.1 开/关本地视频

private var isLocalVideoEnabled = true
private var isLocalPreviewEnabled = true

binding.btnToggleCamera.setOnClickListener {
    isLocalVideoEnabled = !isLocalVideoEnabled
    rtcEngine?.enableLocalVideo(isLocalVideoEnabled)
    isLocalPreviewEnabled = isLocalVideoEnabled
    updateControlButtons()
}

3.2 开/关本地音频采集

private var isLocalAudioEnabled = true

binding.btnToggleMic.setOnClickListener {
    isLocalAudioEnabled = !isLocalAudioEnabled
    rtcEngine?.enableLocalAudio(isLocalAudioEnabled)
    updateControlButtons()
}

3.3 切换前后摄像头

binding.btnSwitchCamera.setOnClickListener {
    isFrontCamera = !isFrontCamera
    fuFrameInterceptor?.setFrontCamera(isFrontCamera)
    rtcEngine?.switchCamera()
}

3.4 静音远端音视频

按用户静音远端音频 / 视频:

private fun muteRemoteUserAudio(targetUserId: String, muted: Boolean) {
    rtcEngine?.muteRemoteAudioStream(targetUserId, muted)
}

private fun muteRemoteUserVideo(targetUserId: String, muted: Boolean) {
    rtcEngine?.muteRemoteVideoStream(targetUserId, muted)
}

3.5 控制音频输出(扬声器 / 听筒)

private var isSpeakerOn = true

binding.btnToggleAudioRoute.setOnClickListener {
    isSpeakerOn = !isSpeakerOn
    rtcEngine?.setDefaultAudioRoutetoSpeakerphone(isSpeakerOn)
    updateControlButtons()
}

3.6 发送自定义消息

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 美颜开关

binding.btnToggleBeauty.setOnClickListener {
    beautyEnabled = !beautyEnabled
    fuFrameInterceptor?.setEnabled(beautyEnabled)
    updateControlButtons()
}

4. 视频帧处理(美颜等)

SellyRTC 提供视频采集前拦截接口,可以在推流前做美颜、滤镜等处理。

在创建引擎后设置:

rtcEngine?.setCaptureVideoFrameInterceptor { frame ->
    if (!beautyEnabled) return@setCaptureVideoFrameInterceptor frame
    fuFrameInterceptor?.process(frame) ?: frame
}

其中 FuVideoFrameInterceptor 内部使用 FURenderer 做实际美颜处理。

你也可以替换为自己的处理逻辑:

  • frame 做 GPU 或 CPU 处理
  • 返回处理后的帧给 SDK 继续编码和发送

5. 事件回调 (InteractiveRtcEngineEventHandler)

实现 InteractiveRtcEngineEventHandler,监听通话过程中发生的事件:

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

在本地/远端视频统计回调中获取:

override fun onLocalVideoStats(stats: InteractiveStreamStats) {
    // stats.width / height / fps / videoBitrateKbps / audioBitrateKbps / rttMs 等
}

override fun onRemoteVideoStats(stats: InteractiveStreamStats) {
    // 针对某个 userId 的码率、分辨率、丢包、RTT 等
}

你可以将这些信息显示在 UI 上Demo 中的 buildStatsLabel 已经示范了如何构造:

private fun buildStatsLabel(header: String, stats: InteractiveStreamStats?): String {
    // Res: WxH, FPS, Codec, Video/Audio Kbps, RTT 等
}

6.2 通话结束时长onLeaveChannel

onLeaveChannel 中可以拿到本次通话时长(秒),无论是主动离开还是断网/失败结束,只要曾加入成功都会回调:

override fun onLeaveChannel(durationSeconds: Int) {
    Log.d(TAG, "onLeaveChannel duration=${durationSeconds}s")
}

7. Token 过期机制

SDK 在 Token 生命周期内会通过事件提醒你续期:

7.1 Token 即将过期

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 已过期

override fun onTokenExpired(token: String?, expiresAt: Long) {
    Toast.makeText(
        this@InteractiveLiveActivity,
        "Token 已过期,断线后将无法重连",
        Toast.LENGTH_LONG
    ).show()
}

说明:

  • Token 过期后,当前通话不会立刻中断,但网络异常时自动重连会失败。
  • 请务必在 onTokenWillExpire 阶段就完成续期。

8. 常见问题 (FAQ)

Q1多人远端画面如何渲染

为每一个远端用户(userId)分配一个 SurfaceViewRenderer,并调用:

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

InteractiveForegroundService.start(this)
// 离开频道后记得 stop
InteractiveForegroundService.stop(this)