基于声网 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 不出域
相关推荐
择势6 小时前
基于声网 Agora RTM + RTC SDK 实现 iOS 语音聊天室——从零到可跑的指南
ios
白玉cfc6 小时前
【iOS】底层原理:类的加载
ios·objective-c·xcode
光电的一只菜鸡8 小时前
shell脚本开发技巧
开发语言·ios·swift
2501_916007479 小时前
iOS应用性能优化全面指南:从内存管理到工具使用
android·ios·性能优化·小程序·uni-app·iphone·webview
库奇噜啦呼10 小时前
【iOS】源码学习-类的加载
学习·ios·cocoa
ayqy贾杰10 小时前
我同事,40了,他vibe coding了个App
前端·ios·客户端
吠品11 小时前
Ubuntu下grep配合管道用的几个场景
ios·notepad++·iphone
人月神话-Lee12 小时前
【图像处理】框架设计——协议、值类型与工程化思维
图像处理·人工智能·ios·设计模式·架构·ai编程·swift