基于声网 Agora RTM + RTC SDK 实现 iOS 语音聊天室(进阶封装)

一、二次封装------你需要一层「业务服务层」

基础 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 不出域
相关推荐
2501_9160088938 分钟前
Windows 上写第一个 SwiftUI App,KXBuild 空工程起步,跑到 iPhone 真机
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
2501_915918411 小时前
iOS 上架注意事项有哪些?Appuploader 的四个阶段自查清单
android·ios·小程序·https·uni-app·iphone·webview
黑化旺仔21 小时前
【OC】Runtime:类元数据、加载初始化与消息派发
开发语言·macos·ios·objective-c
songgeb1 天前
iOS App支持设置RTL语言的实践
ios·swift·ui kit
软泡芙1 天前
【iOS】swift启动屏显示黑屏
ios
用户69371750013841 天前
当你向领导申请 AI 编程工具预算时,如果领导追问“能提效多少,有量化数据吗”,你该怎么回答?
android·后端·ios
Layer1 天前
一些脱离 Mac 依赖的 Agent 操作 iPhone 实现
ios·ai编程·测试
用户69371750013841 天前
当我说 Claude 搭配 GLM-5.3-Flash 太好用了,同事却提醒我:你可能正在失去对代码的掌控
android·后端·ios
茶底世界之下1 天前
一键裁方会丢掉什么?用 Awaking 给园林照片做两种画幅
ios
Marco_Marco1351 天前
定位SDK接入实战:Android/iOS/鸿蒙三端怎么选、怎么接
android·ios·harmonyos