一、二次封装------你需要一层「业务服务层」
基础 Demo 里业务代码直接调 SDK,遇到真实场景立刻暴露三个硬伤:
| 痛点 | 后果 |
|---|---|
| 客户端自己决定谁能上麦/踢人 | 越权(改包/多开直接绕过) |
| 麦位状态存在内存里 | 断线重连、切后台、崩溃 → 麦位乱套 |
| 礼物/统计/在线人数无权威源 | 刷礼物重复计数、在线数飘忽 |
✅ 正确的分层
swift
纯文本
纯文本
┌──────────────────────────────────────────────┐
│ UI Layer │ MicSeatCell / GiftBannerView
├──────────────────────────────────────────────┤
│ VoiceRoomViewModel │ @Published 驱动 UI,单一状态源
│ (麦位状态机 · 礼物队列 · 权限判断 · 房间上下文) │
├──────────────────┬───────────────────────────┤
│ VoiceRTCService │ VoiceRTMService │ SDK 二次封装(统一回调、错误转换)
│ (engine 生命周期) │ (信令收发·频道属性同步) │
├──────────────────┴───────────────────────────┤
│ App Server(权威源) │ 踢人鉴权、麦位锁、房间存活、礼物扣费
└──────────────────────────────────────────────┘
声网官方在 agora-ent-scenarios示例中也采用了类似思想:通过 ChatRoomServiceProtocol/ VoiceRoomSubscribeDelegate把房间交互抽象成协议,业务层只依赖协议而非 SDK 细节。
二、RTC 二次封装:只暴露「业务语义」,不暴露 SDK 细节
1. 定义业务能力协议(关键一步)
swift
swift
swift
// MARK: - 业务协议
protocol VoiceRTCServiceProtocol: AnyObject {
var isJoined: Bool { get }
var localUid: UInt { get }
func setup(appId: String)
func join(roomId: String,
token: String?,
role: VoiceRoomRole,
completion: @escaping (Error?) -> Void)
func leave()
// 角色切换(观众 ↔ 主播)
func switchRole(_ role: VoiceRoomRole)
// 本地静音(不发流,但仍占麦位)
func setLocalMuted(_ muted: Bool)
// 音量指示(用于麦位 UI 呼吸灯)
func enableAudioVolumeIndication(interval: Int, smooth: Int)
// 回调桥接
func addDelegate(_ delegate: VoiceRTCEventDelegate)
func removeDelegate(_ delegate: VoiceRTCEventDelegate)
}
enum VoiceRoomRole {
case broadcaster // 主播(发流)
case audience // 观众(只收流)
}
这样做的好处:换 SDK / 升级 API / 做模拟器 Debug 模式 时,只需要换一个
Impl,ViewController 一行不改。
2. 实现封装(AgoraRtcEngineKit 适配器)
swift
swift
swift
import AgoraRtcKit
final class AgoraRTCService: NSObject, VoiceRTCServiceProtocol {
// MARK: - State
private(set) weak var engine: AgoraRtcEngineKit!
private var _appId: String = ""
private(set) var localUid: UInt = 0
private(set) var isJoined: Bool = false
private var delegates = NSHashTable<AnyObject>.weakObjects()
// MARK: - Setup
func setup(appId: String) {
_appId = appId
let cfg = AgoraRtcEngineConfig()
cfg.appId = appId
cfg.channelProfile = .liveBroadcasting
let eng = AgoraRtcEngineKit.sharedEngine(with: cfg, delegate: self)
eng.disableVideo()
eng.enableAudio()
// 语聊房场景
eng.setAudioProfile(.default, scenario: .chatroom)
self.engine = eng
}
// MARK: - Join
func join(roomId: String,
token: String?,
role: VoiceRoomRole,
completion: @escaping (Error?) -> Void) {
let opts = AgoraRtcChannelMediaOptions()
opts.clientRoleType = (role == .broadcaster) ? .broadcaster : .audience
opts.publishMicrophoneTrack = (role == .broadcaster)
opts.publishCameraTrack = false
opts.autoSubscribeAudio = true
opts.autoSubscribeVideo = false
engine.joinChannel(
byToken: token,
channelId: roomId,
uid: 0,
mediaOptions: opts
) { [weak self] _, uid, _ in
self?.localUid = uid
self?.isJoined = true
completion(nil)
}
}
func leave() {
engine.leaveChannel()
isJoined = false
}
// MARK: - 角色切换(上麦 / 下麦的核心)
func switchRole(_ role: VoiceRoomRole) {
switch role {
case .broadcaster:
engine.setClientRole(.broadcaster)
engine.muteLocalAudioStream(false)
case .audience:
engine.muteLocalAudioStream(true)
engine.setClientRole(.audience)
}
}
func setLocalMuted(_ muted: Bool) {
engine.muteLocalAudioStream(muted)
}
func enableAudioVolumeIndication(interval: Int = 300, smooth: Int = 3) {
engine.enableAudioVolumeIndication(interval, smooth: smooth, reportVad: true)
}
// MARK: - Delegate Multicast
func addDelegate(_ delegate: VoiceRTCEventDelegate) {
delegates.add(delegate)
}
func removeDelegate(_ delegate: VoiceRTCEventDelegate) {
delegates.remove(delegate)
}
}
// MARK: - SDK Callback → 业务事件
extension AgoraRTCService: AgoraRtcEngineDelegate {
func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
broadcast { $0.rtcDidUserJoin?(uid) }
}
func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) {
broadcast { $0.rtcDidUserLeave?(uid, reason) }
}
// ⚠️ Token 即将过期 → 通知 VM 去服务端换新 token
func rtcEngine(_ engine: AgoraRtcEngineKit, tokenPrivilegeWillExpire token: String) {
broadcast { $0.rtcTokenWillExpire?(token) }
}
// ⚠️ 被服务端踢出(ban)
// iOS SDK 上报为 connectionChangedToState → AgoraConnectionChangedBannedByServer
func rtcEngine(_ engine: AgoraRtcEngineKit,
connectionChangedTo state: AgoraConnectionState,
reason: AgoraConnectionChangedReason) {
if reason == .bannedByServer {
broadcast { $0.rtcDidKickedByServer?() }
}
}
func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
broadcast { $0.rtcDidError?(errorCode) }
}
private func broadcast(_ invoker: (VoiceRTCEventDelegate) -> Void) {
delegates.allObjects.compactMap { $0 as? VoiceRTCEventDelegate }.forEach(invoker)
}
}
3. 事件桥接协议
kotlin
swift
swift
protocol VoiceRTCEventDelegate: AnyObject {
var rtcDidUserJoin: ((UInt) -> Void)? { get set }
var rtcDidUserLeave: ((UInt, AgoraUserOfflineReason) -> Void)? { get set }
var rtcTokenWillExpire: ((String) -> Void)? { get set }
var rtcDidKickedByServer: (() -> Void)? { get set }
var rtcDidError: ((AgoraErrorCode) -> Void)? { get set }
}
三、服务端踢人权限------双轨制(业务信令 + Agora 兜底 Ban API)
这是最容易做错的部分。很多人第一反应是"SDK 有没有 kick API 给我调"------答案是:
- 客户端没有安全的 kick 能力(UID 可伪造、请求可重放)
- 正确做法是:业务服务器做鉴权 → 下发信令 → 客户端 leaveChannel + UI 跳转
- 声网额外提供了一个 RESTful Ban/Privilege 接口作为兜底武器
方案 A(主力):业务信令踢人 ------ 安全、可控、可审计
时序
arduino
纯文本
纯文本
房主点"踢人" → App Server 校验(是不是房主? 目标在不在房?)
↓ 校验通过
① 更新 DB: 标记 target 为 "kicked"
② 记录操作日志(审计)
③ 通过 RTM Channel Message / 长连接推送 发信令给 target
↓
客户端收到 {type:"kicked", by:"owner_xxx", reason:"违反规则"}
↓
执行:rtcService.leave()
跳回房间列表
弹出 toast
RTM 信令结构
json
json
json
{
"cmd": "kick_user",
"roomId": "room_1001",
"targetUid": 778899,
"operatorUid": 10001,
"reason": "violation",
"ts": 1710000000,
"sig": "<HMAC-SHA256 防伪造>"
}
✅
sig字段是关键 :用服务端私钥对 payload 签名,客户端验签后才执行,防止恶意客户端伪造kick_user信令搞事。
iOS 端处理
swift
swift
swift
// RTM 回调中
func handleSignal(_ json: [String: Any]) {
guard json["cmd"] as? String == "kick_user",
verifySignature(json), // ← 验签
let targetUid = json["targetUid"] as? UInt,
targetUid == viewModel.localUid else { return }
// 1. 停音频
rtcService.setLocalMuted(true)
rtcService.switchRole(.audience)
rtcService.leave()
// 2. UI 回到房间列表
DispatchQueue.main.async {
Toast.show("你已被房主请出房间")
Router.popToRoomList()
}
}
方案 B(兜底):声网 RESTful Ban API ------ 踢出 + 阻止重入
声网提供 POST https://api.agora.io/dev/v1/kicking-rule,可直接让指定 UID 无法 join_channel 或被强制离线。
| 参数 | 值 | 含义 |
|---|---|---|
appid |
你的 App ID | --- |
cname |
"room_1001" |
频道名(填 → 针对该房) |
uid |
778899 |
被踢用户 UID |
privileges |
["join_channel"] |
禁止加入频道 = 踢出+拦重入 |
time |
10 |
封禁分钟数(短封 = 软踢;长封 = 封号) |
bash
bash
bash
POST https://api.agora.io/dev/v1/kicking-rule
Headers:
Authorization: Basic base64(CustomerID:CustomerSecret)
Content-Type: application/json
Body:
{
"appid": "YOUR_APP_ID",
"cname": "room_1001",
"uid": 778899,
"privileges": ["join_channel"],
"time": 10
}
成功响应带回 "id"(rule ID),你必须保存这个 ID,用来后续 DELETE 解封。
客户端侧对应的回调(被 Ban 时触发):
swift
swift
swift
// AgoraConnectionChangedBannedByServer
func rtcEngine(_ engine: AgoraRtcEngineKit,
connectionChangedTo state: AgoraConnectionState,
reason: AgoraConnectionChangedReason) {
if reason == .bannedByServer {
// 清理本地状态 → 回房间列表
print("⛔ 被服务端 Ban 踢出")
}
}
✅ 最佳实践组合拳
| 层次 | 用什么 |
|---|---|
| 常规踢人(99%) | 业务信令(快、灵活、可带原因/审计) |
| 恶意用户对抗 | 信令 + Ban API 双写(先信令通知,再调 API 堵门) |
| 超时解散房间 | Ban API 按 cname清场(time 设短,别长期封 cname) |
声网文档也明确提醒:不要把主业务流程绑死在 Ban API 调用成功与否上,应作为 fallback。
四、麦位队列 UI ------ 状态机 + 快照同步
声网官方语聊房文档中,麦位管理覆盖:上麦、下麦、静音、锁麦、换麦 ,并建议通过 subscribeEvent监听房间回调事件来驱动 UI。
1. 麦位领域模型(状态机灵魂)
swift
swift
swift
enum MicSeatState: Equatable {
case empty
case locked(uid: UInt?) // 锁麦(可能上面还有人需要先踢下)
case occupied(uid: UInt,
isMuted: Bool,
isSpeaking: Bool,
userInfo: VoiceUserInfo?)
}
struct MicSeat: Identifiable {
let index: Int // 0~N-1(0=房主位)
var state: MicSeatState
var id: Int { index }
/// 当前占用者 UID(如有)
var occupantUid: UInt? {
switch state {
case .occupied(let uid, _, _, _): return uid
case .locked(let uid?): return uid
default: return nil
}
}
}
2. ViewModel ------ 唯一状态源
less
swift
swift
final class VoiceRoomViewModel {
@Published var seats: [MicSeat] = Self.initialSeats()
@Published var textMessages: [ChatMessage] = []
@Published var kickedOut: Bool = false
let localUid: UInt
let roomId: String
private let rtc: VoiceRTCServiceProtocol
private let rtm: VoiceRTMService
static func initialSeats(count: Int = 8) -> [MicSeat] {
(0..<count).map { MicSeat(index: $0, state: .empty) }
}
// MARK: - 处理来自 RTM 的麦位快照(服务器权威)
func applySeatSnapshot(_ list: [SeatDTO]) {
seats = list.enumerated().map { offset, dto in
switch dto.status {
case .empty:
return MicSeat(index: offset, state: .empty)
case .locked:
return MicSeat(index: offset, state: .locked(uid: nil))
case .occupied:
let user = VoiceUserInfo(uid: dto.uid, name: dto.name, avatar: dto.avatar)
return MicSeat(index: offset, state: .occupied(uid: dto.uid,
isMuted: dto.muted,
isSpeaking: false,
userInfo: user))
}
}
}
// MARK: - 本地用户请求上麦
func requestMicOn(targetSeat index: Int? = nil) {
// 先请求业务服务器,服务器决定给哪个麦位
AppAPI.requestMicOn(roomId: roomId, preferredIndex: index) { [weak self] result in
switch result {
case .success(let assignedIndex):
// 服务器批准 → 切 RTC 角色
self?.rtc.switchRole(.broadcaster)
// RTM 广播麦位变更(或等服务器推送的快照更新)
case .failure(let err):
Toast.show(err.localizedDescription)
}
}
}
// MARK: - 房主锁麦
func lockSeat(_ index: Int) {
AppAPI.lockSeat(roomId: roomId, index: index) { _ in }
}
// MARK: - 踢人下麦(房主操作)
func kickMicOff(_ index: Int) {
AppAPI.kickSeat(roomId: roomId, index: index) { [weak self] _ in
// 服务器会发 RTM 信令给被踢方
}
}
}
3. 麦位 UI(SwiftUI 示例,UIKit 同理)
scss
swift
swift
struct MicSeatGridView: View {
@ObservedObject var vm: VoiceRoomViewModel
let columns = Array(repeating: GridItem(.flexible(), spacing: 12),
count: 4)
var body: some View {
LazyVGrid(columns: columns, spacing: 12) {
ForEach($vm.seats) { $seat in
MicSeatCell(seat: seat) {
handleTap(seat)
}
}
}
}
private func handleTap(_ seat: MicSeat) {
switch seat.state {
case .empty:
vm.requestMicOn(targetSeat: seat.index)
case .occupied(let uid, _, _, _):
if uid == vm.localUid {
// 自己 → 下麦
vm.requestMicOff()
} else if vm.isOwner {
// 房主 → 弹出操作菜单(踢人/锁麦/静音)
showHostActionMenu(for: seat)
}
case .locked:
if vm.isOwner { vm.unlockSeat(seat.index) }
}
}
}
struct MicSeatCell: View {
let seat: MicSeat
let onTap: () -> Void
var body: some View {
VStack(spacing: 4) {
ZStack {
Circle()
.fill(bgColor)
.frame(width: 72, height: 72)
.overlay(Circle().strokeBorder(strokeColor, lineWidth: 2))
// 说话呼吸灯
if case .occupied(_, _, let speaking, _) = seat.state, speaking {
Circle()
.strokeBorder(Color.green.opacity(0.6), lineWidth: 3)
.frame(width: 80, height: 80)
}
iconView
}
Text(labelText)
.font(.caption2)
.foregroundColor(.secondary)
}
.onTapGesture { onTap() }
}
var bgColor: Color { /* empty=灰 locked=暗红 occupied=绿 */ ... }
var strokeColor: Color { /* gold for owner seat */ ... }
var iconView: some View { /* 头像/锁🔒/➕ */ ... }
var labelText: String { /* 昵称 or "空麦位" */ ... }
}
呼吸灯的 speaking标志来自 RTC 的音量回调:
swift
swift
swift
// AgoraRTCService 中
func rtcEngine(_ engine: AgoraRtcEngineKit,
reportAudioVolumeIndication speakers: [AgoraRtcAudioVolumeInfo],
totalVolume: Int) {
let speakingUids = Set(speakers.filter { $0.volume > 15 }.map(.uid))
// → 通知 VM → 更新对应 seat.isSpeaking → SwiftUI 自动刷新
}
五、送礼物系统 ------ 可靠收发 + 动画队列
礼物不是 RTC 的事,是 RTM/业务信令 + 本地动画队列的事。
1. 礼物消息协议
rust
swift
swift
struct GiftMessage: Codable {
let cmd = "gift"
let fromUid: UInt
let fromName: String
let giftId: Int // 1=玫瑰 2=跑车 3=火箭 ...
let combo: Int // 连击数(服务端可合并累加)
let transactionId: String // UUID,防重复消费
}
2. 发送(走 RTM Channel Message)
less
swift
swift
func sendGift(_ giftId: Int) {
let msg = GiftMessage(
fromUid: localUid,
fromName: myName,
giftId: giftId,
combo: 1,
transactionId: UUID().uuidString
)
rtmService.publish(json: msg) // 内部转 JSON → AgoraRtmMessage
}
3. 接收端 ------ 去重 + 进队列
swift
swift
swift
func onGiftReceived(_ gift: GiftMessage) {
// ① 去重(transactionId 记入 NSCache / Set 滚动窗口)
guard !dedupCache.hasSeen(gift.transactionId) else { return }
// ② 追加动画队列(别在回调里直接播,会撞车)
GiftPlayQueue.shared.enqueue(
GiftPlayItem(giftId: gift.giftId,
sender: gift.fromName,
combo: gift.combo)
)
// ③ 公屏消息(可选)
viewModel.textMessages.append(
.system("(gift.fromName) 送出了 (giftLabel(gift.giftId))"))
}
4. 动画队列(串行消费,支持连击合并)
swift
swift
swift
final class GiftPlayQueue {
static let shared = GiftPlayQueue()
private let serialQ = DispatchQueue(label: "gift.queue")
private var heap: [GiftPlayItem] = []
private var playing = false
func enqueue(_ item: GiftPlayItem) {
serialQ.async {
heap.append(item)
if !self.playing { self.dequeue() }
}
}
private func dequeue() {
guard !heap.isEmpty else { playing = false; return }
playing = true
let item = heap.removeFirst()
// 合并同 giftId 的待播项(连击叠加)
let comboExtra = heap.filter { $0.giftId == item.giftId }.count
heap.removeAll { $0.giftId == item.giftId }
let finalCombo = item.combo + comboExtra
DispatchQueue.main.async {
GiftAnimator.play(giftId: item.giftId,
sender: item.sender,
combo: finalCombo) { [weak self] in
self?.serialQ.async { self?.dequeue() }
}
}
}
}
动画渲染层推荐:Lottie (json 动效)+ CAEmitterLayer(粒子如花瓣/金币),不要放在 RTC 线程做任何 UI 事。
六、Token 安全闭环(生产必备)
arduino
纯文本
纯文本
App 启动 → 业务 Server 鉴权(用户登录态)
↓
Server 用 App Certificate 签发 RTC Token + RTM Token
↓
返回给客户端 → 传入 joinChannel / rtmClient.login
↓
SDK 触发 tokenPrivilegeWillExpire → VM 调 Server 换新 → renewToken
- App Certificate 绝对不要打包进客户端
- Token 过期前 30s 提前续期,避免正在说话时断流
- RTM Token 和 RTC Token 可以分别签发,也可以共用同一套 uid 映射
七、完整文件结构建议(可直接照此建目录)
less
纯文本
纯文本
VoiceRoom/
├── Services/
│ ├── VoiceRTCService.swift // RTC 二次封装
│ ├── VoiceRTMService.swift // RTM 二次封装
│ └── VoiceRoomAPI.swift // App Server REST 接口
├── Model/
│ ├── VoiceUserInfo.swift
│ ├── MicSeat.swift
│ ├── SeatDTO.swift
│ └── GiftMessage.swift
├── ViewModels/
│ └── VoiceRoomViewModel.swift // 单一状态源 @Published
├── Views/
│ ├── MicSeatGridView.swift
│ ├── MicSeatCell.swift
│ ├── GiftBannerView.swift
│ └── ChatBarView.swift
└── Controllers/
└── VoiceRoomVC.swift // 绑定 VM ↔ View
八、最后:一张表总结「谁该做什么」
| 能力 | 放哪里 | 为什么 |
|---|---|---|
| 踢人决策/审计 | App Server | 防越权 |
| 踢人通知下发 | RTM 信令(签名) | 快、可带原因 |
| 恶意用户堵门 | 声网 Ban API(RESTful) | 服务端级强制离线 |
| 麦位占用状态 | Server 快照 / KV | 断线重连不丢状态 |
| 礼物扣费 | App Server 事务 | 防刷 |
| 礼物动画 | 纯客户端队列 | 与音频流解耦 |
| Token 签发 | App Server | Certificate 不出域 |