基于 Agora RTC + RTM + VIPER 架构的 iOS 海外语音聊天室模块化设计

2 阅读18分钟

一、前言与业务背景

1. 业务场景说明

本文落地企业级海外语音聊天室(语音直播房) ,面向中东、北非(MENA)市场,核心能力覆盖:

  • 基础能力:房间生命周期管理、多人实时语音连麦、麦位全生命周期管理(上麦 / 下麦 / 控麦 / 禁麦 / 锁麦)
  • 互动能力:公屏文字聊天、自定义二进制信令、房间成员状态实时同步、系统通知广播
  • 环境特征:弱网高延迟(200-600ms)、高丢包(5%-20%)、网络抖动大,要求 RTC 实时音频 + RTM 实时信令双引擎保障
  • 合规与适配:阿拉伯语 RTL 自动布局、iOS 后台音频保活、沙特 / 阿联酋本地数据合规、宗教内容审核适配

2. 技术选型整体结论

模块技术方案选型原因
实时语音连麦Agora RTC 4.xMENA 区域节点覆盖完善、抗 30% 丢包仍可通话、内置 AEC 回声消除 / ANS 降噪、行业市占率 75%
即时消息 / 房间信令Agora RTM 2.x与 RTC 同账号同房间体系、信令延迟 <100ms、专为实时互动设计、优于传统第三方 IM
整体架构VIPER 架构彻底解耦视图、业务逻辑、数据、网络,适配音视频复杂状态机,便于多人协作与长期迭代
分层思想四层分层架构基础层 → 能力层 → 业务层 → 视图层,严格遵循依赖倒置原则,可插拔可复用
设计模式单例、代理、状态模式、工厂模式、观察者模式、中介者模式解决音视频多状态流转、SDK 回调泛滥、模块间通信复杂问题
语言特性Swift Concurrency用 async/await 替代回调地狱,@MainActor 保证 UI 线程安全,类型安全避免运行时崩溃

3. 核心设计目标

  1. 极致解耦:SDK 底层能力、业务逻辑、UI 视图完全隔离,更换 RTC/RTM SDK 仅需修改能力层,上层业务零改动
  2. 状态可控:统一管理房间、麦位、用户角色三大状态机,彻底解决音视频场景常见的状态错乱问题
  3. 可扩展性:新增礼物、排行榜、付费连麦、PK 玩法等功能无需重构核心架构
  4. 海外适配:从架构底层支持弱网优化、RTL 布局、后台保活、数据合规
  5. 易维护测试:业务逻辑收敛在 Interactor,支持单元测试,问题定位精准

二、整体架构总览

2.1 四层分层架构(自上而下)

严格遵循依赖倒置原则:上层依赖抽象,不依赖具体实现;下层不感知上层存在。

┌─────────────────────────────────────────────────────┐
│  视图层 (View Layer) - VIPER View                    │
│  页面、控件、动画、RTL 布局、用户交互、UI 刷新        │
├─────────────────────────────────────────────────────┤
│  业务层 (Business Layer) - VIPER Presenter/Interactor│
│  业务规则、房间逻辑、麦位管理、权限控制、状态流转     │
├─────────────────────────────────────────────────────┤
│  能力层 (Capability Layer) - 基础能力封装            │
│  Agora RTC 封装、Agora RTM 封装、网络、缓存、工具类  │
├─────────────────────────────────────────────────────┤
│  基础层 (Base Layer) - 公共底层                      │
│  系统权限、音频会话管理、全局常量、扩展、日志、基类  │
└─────────────────────────────────────────────────────┘

各层职责详解

  1. **基础层(Base)**全局通用底层,无任何业务侵入。

    • 系统权限:麦克风、网络、后台模式统一校验
    • 音频会话管理:全局唯一 AVAudioSession 配置,解决音频抢占、混音问题
    • Swift 扩展:UIViewStringDate 等通用扩展
    • 日志系统、线程管理、全局常量、基础基类
  2. **能力层(Capability)**核心价值:对 Agora 原生 SDK 做二次封装,屏蔽 API 差异、版本变更、回调复杂度,对外提供统一抽象接口。拆分为两个完全独立的能力模块:

    • AgoraRTCService:语音采集、播放、角色切换、静音、耳返、音质配置、网络质量检测
    • AgoraRTMService:RTM 登录、频道管理、文本消息、二进制信令、成员状态监听设计原则:纯能力、无业务逻辑,只做 SDK 代理转发、参数封装、异常捕获、日志上报。
  3. **业务层(Business)**基于 VIPER 架构划分,承载语音聊天室完整业务规则:

    • 房间创建 / 销毁、用户进出房间逻辑
    • 麦位状态机管理:空闲 / 占用 / 禁麦 / 锁定
    • 上麦申请、房主审批、管理员控麦、全员禁言业务规则
    • RTM 自定义信令解析与指令分发
    • 房间数据模型、成员列表、聊天消息管理
  4. **视图层(View)**纯 UI 层,不持有任何业务逻辑、不直接调用 SDK、不访问能力层

    • 聊天室主页面、麦位视图、公屏聊天视图、成员列表、弹窗
    • 阿拉伯语 RTL 自动布局适配、动画效果、弱网状态提示
    • 所有用户交互通过代理回调至 Presenter,由 Presenter 驱动业务

2.2 VIPER 架构在 Swift 中的标准落地

VIPER 是单向数据流架构,完美适配音视频这种多状态、多异步回调的复杂页面。针对语音聊天室房间页,拆分为 5 大角色,全部用 Swift 协议定义接口:

角色协议定义核心职责
ViewVoiceChatViewProtocol展示 UI、接收用户交互、响应 Presenter 指令刷新界面
PresenterVoiceChatPresenterProtocol视图与业务的中间人,逻辑调度、状态中转、驱动 View 刷新
InteractorVoiceChatInteractorProtocol纯业务逻辑、数据处理、调用能力层接口、状态机管理
Entity无协议(纯数据模型)房间、用户、麦位、消息、信令数据模型,遵循 Codable
RouterVoiceChatRouterProtocol页面跳转、弹窗路由、模块间通信

核心数据流规则

View ↔ Presenter → Interactor → 能力层(RTC/RTM)
          ↑                ↓
          └────────────────┘
  • 所有 SDK 回调、网络回调最终收敛到 Interactor
  • Interactor 处理完业务逻辑后通知 Presenter
  • Presenter 组装数据后指令 View 刷新 UI
  • 严禁 View 直接访问 Interactor 或能力层

2.3 设计模式结合 Swift 特性应用

  1. 单例模式AgoraRTCServiceAgoraRTMServiceAudioSessionManager 使用 Swift 静态单例,保证全局唯一:

    class AgoraRTCService {
        static let shared = AgoraRTCService()
        private init() {} // 禁止外部初始化
    }
    
  2. **代理模式(Protocol-Oriented)**所有模块间通信基于 Swift 协议,接口清晰、可测试性强,支持默认实现。

  3. **状态模式(重点)**用 Swift 枚举定义所有状态,带关联值传递数据,编译时保证类型安全:

    enum RoomState {
        case idle
        case joining
        case connected
        case reconnecting
        case disconnected(Error)
    }
    
    enum MicSeatState {
        case empty
        case occupied(userId: String, muted: Bool)
        case locked
    }
    
  4. 工厂模式统一创建消息、信令模型,自动解析 JSON,避免重复代码:

    struct ChatMessageFactory {
        static func makeMessage(from data: Data) -> ChatMessage? {
            try? JSONDecoder().decode(ChatMessage.self, from: data)
        }
    }
    
  5. 观察者模式NotificationCenter 或 Combine 框架广播全局事件:网络变化、前后台切换、音频中断。


三、模块详细设计与 Swift 代码实现

3.1 基础层(Base)核心实现

3.1.1 全局音频会话管理

语音聊天室的核心基础,统一配置 AVAudioSession,防止第三方应用抢占音频焦点:

import AVFoundation

final class AudioSessionManager {
    static let shared = AudioSessionManager()
    private init() {}
    
    func configureForVoiceChat() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(
            .playAndRecord,
            mode: .voiceChat, // 优先保障人声清晰度、低延迟
            options: [.mixWithOthers, .allowBluetooth, .allowBluetoothA2DP]
        )
        try session.setActive(true)
    }
    
    func deactivate() throws {
        try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
    }
}

3.1.2 系统权限管理

封装麦克风权限请求,进入房间前统一预校验:

import AVFoundation

final class PermissionManager {
    static func requestMicrophonePermission() async -> Bool {
        await AVAudioSession.sharedInstance().requestRecordPermission()
    }
}

3.2 能力层:Agora RTC/RTM 二次封装(核心解耦层)

设计思想:门面模式 + 单例 + 协议代理,彻底屏蔽 Agora 原生 SDK 细节。上层业务完全不知道底层是 Agora,未来切换其他音视频 SDK 仅需改写当前层。

3.2.1 AgoraRTCService 语音引擎封装

import AgoraRtcKit

// 对外代理协议,屏蔽原生 Agora 枚举
protocol AgoraRTCServiceDelegate: AnyObject {
    func rtcService(_ service: AgoraRTCService, didJoinChannel channel: String, uid: String)
    func rtcService(_ service: AgoraRTCService, didLeaveChannelWithStats stats: AgoraChannelStats)
    func rtcService(_ service: AgoraRTCService, userJoined uid: String)
    func rtcService(_ service: AgoraRTCService, userOffline uid: String)
    func rtcService(_ service: AgoraRTCService, networkQualityChanged quality: AgoraNetworkQuality)
}

final class AgoraRTCService: NSObject {
    static let shared = AgoraRTCService()
    weak var delegate: AgoraRTCServiceDelegate?
    
    private var rtcEngine: AgoraRtcEngineKit?
    private var currentRoomId: String?
    
    private override init() {
        super.init()
    }
    
    // 初始化引擎
    func initialize(appId: String) {
        let config = AgoraRtcEngineConfig()
        config.appId = appId
        config.areaCode = .GLOB // 全球节点,MENA 自动就近接入
        rtcEngine = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self)
        
        // 语音房专用配置
        rtcEngine?.setAudioProfile(.speechStandard, scenario: .chatRoomEntertainment)
        rtcEngine?.enableAudioVolumeIndication(200, smooth: 3, reportVad: true)
        rtcEngine?.enableLocalAudio(true)
    }
    
    // 进入语音房间(async/await 封装)
    func joinRoom(roomId: String, userId: String, token: String) async throws {
        currentRoomId = roomId
        return try await withCheckedThrowingContinuation { continuation in
            let result = rtcEngine?.joinChannel(
                byToken: token,
                channelId: roomId,
                uid: userId,
                mediaOptions: AgoraRtcChannelMediaOptions()
            ) { _, uid, elapsed in
                continuation.resume()
            }
            
            if result != 0 {
                continuation.resume(throwing: NSError(domain: "AgoraRTC", code: result!))
            }
        }
    }
    
    // 退出房间
    func leaveRoom() async {
        rtcEngine?.leaveChannel()
        currentRoomId = nil
    }
    
    // 切换为主播(上麦)
    func switchToAnchor() {
        let options = AgoraRtcChannelMediaOptions()
        options.clientRoleType = .broadcaster
        options.publishMicrophoneTrack = true
        rtcEngine?.updateChannel(with: options)
    }
    
    // 切换为观众(下麦)
    func switchToAudience() {
        let options = AgoraRtcChannelMediaOptions()
        options.clientRoleType = .audience
        options.publishMicrophoneTrack = false
        rtcEngine?.updateChannel(with: options)
    }
    
    // 静音本地麦克风
    func muteLocalAudio(_ mute: Bool) {
        rtcEngine?.muteLocalAudioStream(mute)
    }
    
    // 销毁引擎
    func destroy() {
        AgoraRtcEngineKit.destroy()
        rtcEngine = nil
    }
}

// MARK: - AgoraRtcEngineDelegate
extension AgoraRTCService: AgoraRtcEngineDelegate {
    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, uid: String, elapsed: Int) {
        delegate?.rtcService(self, didJoinChannel: channel, uid: uid)
    }
    
    func rtcEngine(_ engine: AgoraRtcEngineKit, didLeaveChannelWith stats: AgoraChannelStats) {
        delegate?.rtcService(self, didLeaveChannelWithStats: stats)
    }
    
    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: String, elapsed: Int) {
        delegate?.rtcService(self, userJoined: uid)
    }
    
    func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: String, reason: AgoraUserOfflineReason) {
        delegate?.rtcService(self, userOffline: uid)
    }
    
    func rtcEngine(_ engine: AgoraRtcEngineKit, networkQuality uid: String, txQuality: AgoraNetworkQuality, rxQuality: AgoraNetworkQuality) {
        delegate?.rtcService(self, networkQualityChanged: txQuality)
    }
}

3.2.2 AgoraRTMService 信令 & 消息引擎封装

import AgoraRtmKit

protocol AgoraRTMServiceDelegate: AnyObject {
    func rtmService(_ service: AgoraRTMService, didReceiveMessage message: RTMMessage, from userId: String)
    func rtmService(_ service: AgoraRTMService, memberJoined userId: String)
    func rtmService(_ service: AgoraRTMService, memberLeft userId: String)
}

final class AgoraRTMService: NSObject {
    static let shared = AgoraRTMService()
    weak var delegate: AgoraRTMServiceDelegate?
    
    private var rtmClient: AgoraRtmClientKit?
    private var currentChannel: AgoraRtmChannel?
    private var currentUserId: String?
    
    private override init() {
        super.init()
    }
    
    // 初始化 RTM
    func initialize(appId: String) throws {
        let config = AgoraRtmClientConfig(appId: appId, userId: "")
        rtmClient = try AgoraRtmClientKit(config: config, delegate: self)
    }
    
    // 登录 RTM
    func login(userId: String, token: String) async throws {
        currentUserId = userId
        let response = try await rtmClient?.login(byToken: token)
        if response?.errorCode != .ok {
            throw NSError(domain: "AgoraRTM", code: response?.errorCode.rawValue ?? -1)
        }
    }
    
    // 加入频道(与 RTC 房间 ID 一致)
    func joinChannel(channelId: String) async throws {
        let options = AgoraRtmJoinChannelOptions()
        options.autoSubscribeAudio = false
        options.autoSubscribeVideo = false
        
        let result = try await rtmClient?.joinChannel(
            channelName: channelId,
            token: nil,
            options: options
        )
        
        if result?.errorCode != .ok {
            throw NSError(domain: "AgoraRTM", code: result?.errorCode.rawValue ?? -1)
        }
        
        currentChannel = result?.channel
    }
    
    // 发送文本消息(公屏聊天)
    func sendTextMessage(_ text: String) async throws {
        let message = AgoraRtmMessage(text: text)
        let options = AgoraRtmSendMessageOptions()
        options.enableHistoricalMessaging = true
        
        let result = try await currentChannel?.sendMessage(message, options: options)
        if result?.errorCode != .ok {
            throw NSError(domain: "AgoraRTM", code: result?.errorCode.rawValue ?? -1)
        }
    }
    
    // 发送二进制信令(上麦/下麦/禁言)
    func sendBinarySignal(_ data: Data) async throws {
        let message = AgoraRtmMessage(rawData: data)
        let options = AgoraRtmSendMessageOptions()
        options.enableHistoricalMessaging = false
        
        let result = try await currentChannel?.sendMessage(message, options: options)
        if result?.errorCode != .ok {
            throw NSError(domain: "AgoraRTM", code: result?.errorCode.rawValue ?? -1)
        }
    }
    
    // 退出频道
    func leaveChannel() async throws {
        try await currentChannel?.leave()
        currentChannel = nil
    }
    
    // 登出
    func logout() async throws {
        try await rtmClient?.logout()
        currentUserId = nil
    }
}

// MARK: - AgoraRtmClientDelegate
extension AgoraRTMService: AgoraRtmClientDelegate {
    func rtmClient(_ client: AgoraRtmClientKit, didReceiveMessage event: AgoraRtmMessageEvent) {
        let message = RTMMessage(
            userId: event.publisherUserId,
            text: event.message.text,
            rawData: event.message.rawData,
            timestamp: Date()
        )
        delegate?.rtmService(self, didReceiveMessage: message, from: event.publisherUserId)
    }
    
    func rtmClient(_ client: AgoraRtmClientKit, didReceivePresenceEvent event: AgoraRtmPresenceEvent) {
        switch event.type {
        case .remoteJoinChannel:
            delegate?.rtmService(self, memberJoined: event.publisherUserId)
        case .remoteLeaveChannel:
            delegate?.rtmService(self, memberLeft: event.publisherUserId)
        default: break
        }
    }
}

// 统一消息模型
struct RTMMessage {
    let userId: String
    let text: String?
    let rawData: Data?
    let timestamp: Date
}

3.3 业务层:VIPER 完整实现(Swift 版)

3.3.1 Entity 数据模型(纯数据,Codable)

// 房间模型
struct VoiceRoom: Codable {
    let roomId: String
    let title: String
    let ownerId: String
    let onlineCount: Int
    var state: RoomState
}

// 用户模型
struct VoiceUser: Codable {
    let userId: String
    let nickname: String
    let avatarURL: String
    var role: UserRole // .audience / .anchor / .admin
    var isMuted: Bool
}

enum UserRole: String, Codable {
    case audience
    case anchor
    case admin
}

// 麦位模型
struct MicSeat: Codable {
    let index: Int
    var state: MicSeatState
}

// 聊天消息模型
struct ChatMessage: Codable {
    let messageId: String
    let senderId: String
    let senderName: String
    let content: String
    let type: MessageType
    let timestamp: TimeInterval
}

enum MessageType: String, Codable {
    case text
    case system
    case gift
}

// 自定义信令模型
struct RTMSignal: Codable {
    let action: SignalAction
    let senderId: String
    let targetId: String?
    let micIndex: Int?
    let data: [String: String]?
}

enum SignalAction: String, Codable {
    case requestMic
    case approveMic
    case rejectMic
    case leaveMic
    case muteMic
    case kickUser
    case roomNotice
}

3.3.2 Interactor 业务逻辑核心(大脑)

protocol VoiceChatInteractorProtocol: AnyObject {
    func joinRoom(roomId: String, userId: String, rtcToken: String, rtmToken: String) async throws
    func leaveRoom() async throws
    func requestMic(index: Int) async throws
    func leaveMic() async throws
    func sendTextMessage(_ text: String) async throws
}

final class VoiceChatInteractor: VoiceChatInteractorProtocol {
    weak var presenter: VoiceChatPresenterProtocol?
    
    private let rtcService = AgoraRTCService.shared
    private let rtmService = AgoraRTMService.shared
    private let audioSession = AudioSessionManager.shared
    
    private var currentRoom: VoiceRoom?
    private var currentUser: VoiceUser?
    private var micSeats: [MicSeat] = []
    private var roomState: RoomState = .idle
    
    init() {
        rtcService.delegate = self
        rtmService.delegate = self
    }
    
    func joinRoom(roomId: String, userId: String, rtcToken: String, rtmToken: String) async throws {
        guard roomState == .idle else { return }
        roomState = .joining
        
        // 1. 配置音频会话
        try audioSession.configureForVoiceChat()
        
        // 2. 校验麦克风权限
        let hasPermission = await PermissionManager.requestMicrophonePermission()
        guard hasPermission else {
            throw NSError(domain: "VoiceChat", code: -1, userInfo: [NSLocalizedDescriptionKey: "麦克风权限未开启"])
        }
        
        // 3. 并行登录 RTM 和进入 RTC 房间
        async let rtmLogin: () = rtmService.login(userId: userId, token: rtmToken)
        async let rtcJoin: () = rtcService.joinRoom(roomId: roomId, userId: userId, token: rtcToken)
        
        try await rtmLogin
        try await rtcJoin
        
        // 4. 加入 RTM 频道
        try await rtmService.joinChannel(channelId: roomId)
        
        // 5. 拉取房间和麦位数据(业务服务器接口)
        let room = try await fetchRoomInfo(roomId: roomId)
        let seats = try await fetchMicSeats(roomId: roomId)
        
        currentRoom = room
        micSeats = seats
        roomState = .connected
        
        // 通知 Presenter 刷新 UI
        await presenter?.interactorDidJoinRoom(room: room, micSeats: seats)
    }
    
    func leaveRoom() async throws {
        roomState = .disconnected(NSError())
        
        // 并行退出 RTC 和 RTM
        async let rtcLeave: () = rtcService.leaveRoom()
        async let rtmLeave: () = rtmService.leaveChannel()
        async let rtmLogout: () = rtmService.logout()
        
        await rtcLeave
        try await rtmLeave
        try await rtmLogout
        
        try audioSession.deactivate()
        
        await presenter?.interactorDidLeaveRoom()
    }
    
    func requestMic(index: Int) async throws {
        guard roomState == .connected, micSeats[index].state == .empty else { return }
        
        let signal = RTMSignal(
            action: .requestMic,
            senderId: currentUser!.userId,
            targetId: currentRoom!.ownerId,
            micIndex: index,
            data: nil
        )
        
        let data = try JSONEncoder().encode(signal)
        try await rtmService.sendBinarySignal(data)
    }
    
    func leaveMic() async throws {
        guard currentUser?.role == .anchor else { return }
        
        // 1. 本地切换为观众
        rtcService.switchToAudience()
        currentUser?.role = .audience
        
        // 2. 发送下麦信令
        let signal = RTMSignal(
            action: .leaveMic,
            senderId: currentUser!.userId,
            targetId: nil,
            micIndex: nil,
            data: nil
        )
        
        let data = try JSONEncoder().encode(signal)
        try await rtmService.sendBinarySignal(data)
        
        // 3. 更新麦位状态
        if let index = micSeats.firstIndex(where: { $0.state == .occupied(currentUser!.userId, _) }) {
            micSeats[index].state = .empty
            await presenter?.interactorDidUpdateMicSeats(micSeats)
        }
    }
    
    func sendTextMessage(_ text: String) async throws {
        try await rtmService.sendTextMessage(text)
    }
    
    // MARK: - 私有方法
    private func fetchRoomInfo(roomId: String) async throws -> VoiceRoom {
        // 调用业务服务器接口获取房间信息
        VoiceRoom(roomId: roomId, title: "MENA 语音房", ownerId: "admin", onlineCount: 1, state: .connected)
    }
    
    private func fetchMicSeats(roomId: String) async throws -> [MicSeat] {
        // 调用业务服务器接口获取麦位列表
        (0..<8).map { MicSeat(index: $0, state: .empty) }
    }
    
    private func handleRTMSignal(_ signal: RTMSignal) async {
        switch signal.action {
        case .approveMic:
            // 房主同意上麦
            rtcService.switchToAnchor()
            currentUser?.role = .anchor
            if let index = signal.micIndex {
                micSeats[index].state = .occupied(userId: currentUser!.userId, muted: false)
                await presenter?.interactorDidUpdateMicSeats(micSeats)
            }
        case .muteMic:
            // 被管理员禁麦
            rtcService.muteLocalAudio(true)
            currentUser?.isMuted = true
            await presenter?.interactorDidMuteUser(userId: signal.targetId!)
        default: break
        }
    }
}

// MARK: - AgoraRTCServiceDelegate
extension VoiceChatInteractor: AgoraRTCServiceDelegate {
    func rtcService(_ service: AgoraRTCService, didJoinChannel channel: String, uid: String) {}
    
    func rtcService(_ service: AgoraRTCService, didLeaveChannelWithStats stats: AgoraChannelStats) {}
    
    func rtcService(_ service: AgoraRTCService, userJoined uid: String) {
        Task { @MainActor in
            await presenter?.interactorDidUserJoin(userId: uid)
        }
    }
    
    func rtcService(_ service: AgoraRTCService, userOffline uid: String) {
        Task { @MainActor in
            await presenter?.interactorDidUserLeave(userId: uid)
        }
    }
    
    func rtcService(_ service: AgoraRTCService, networkQualityChanged quality: AgoraNetworkQuality) {
        Task { @MainActor in
            await presenter?.interactorDidNetworkQualityChanged(quality: quality)
        }
    }
}

// MARK: - AgoraRTMServiceDelegate
extension VoiceChatInteractor: AgoraRTMServiceDelegate {
    func rtmService(_ service: AgoraRTMService, didReceiveMessage message: RTMMessage, from userId: String) {
        Task {
            if let data = message.rawData, let signal = try? JSONDecoder().decode(RTMSignal.self, from: data) {
                await handleRTMSignal(signal)
            } else if let text = message.text {
                let chatMessage = ChatMessage(
                    messageId: UUID().uuidString,
                    senderId: userId,
                    senderName: userId,
                    content: text,
                    type: .text,
                    timestamp: message.timestamp.timeIntervalSince1970
                )
                await MainActor.run {
                    presenter?.interactorDidReceiveMessage(chatMessage)
                }
            }
        }
    }
    
    func rtmService(_ service: AgoraRTMService, memberJoined userId: String) {}
    
    func rtmService(_ service: AgoraRTMService, memberLeft userId: String) {}
}

3.3.3 Presenter 调度层

protocol VoiceChatPresenterProtocol: AnyObject {
    func viewDidLoad()
    func didTapJoinRoom(roomId: String, userId: String, rtcToken: String, rtmToken: String)
    func didTapLeaveRoom()
    func didTapRequestMic(index: Int)
    func didTapLeaveMic()
    func didSendMessage(_ text: String)
    
    // Interactor 回调
    func interactorDidJoinRoom(room: VoiceRoom, micSeats: [MicSeat]) async
    func interactorDidLeaveRoom() async
    func interactorDidUpdateMicSeats(_ seats: [MicSeat]) async
    func interactorDidReceiveMessage(_ message: ChatMessage)
    func interactorDidUserJoin(userId: String) async
    func interactorDidUserLeave(userId: String) async
    func interactorDidNetworkQualityChanged(quality: AgoraNetworkQuality) async
    func interactorDidMuteUser(userId: String) async
}

final class VoiceChatPresenter: VoiceChatPresenterProtocol {
    weak var view: VoiceChatViewProtocol?
    let interactor: VoiceChatInteractorProtocol
    let router: VoiceChatRouterProtocol
    
    init(interactor: VoiceChatInteractorProtocol, router: VoiceChatRouterProtocol) {
        self.interactor = interactor
        self.router = router
    }
    
    func viewDidLoad() {
        view?.setupUI()
    }
    
    func didTapJoinRoom(roomId: String, userId: String, rtcToken: String, rtmToken: String) {
        view?.showLoading()
        Task {
            do {
                try await interactor.joinRoom(
                    roomId: roomId,
                    userId: userId,
                    rtcToken: rtcToken,
                    rtmToken: rtmToken
                )
                await MainActor.run {
                    view?.hideLoading()
                }
            } catch {
                await MainActor.run {
                    view?.hideLoading()
                    view?.showError(message: error.localizedDescription)
                }
            }
        }
    }
    
    func didTapLeaveRoom() {
        Task {
            do {
                try await interactor.leaveRoom()
                await MainActor.run {
                    router.dismissVoiceChat()
                }
            } catch {
                await MainActor.run {
                    view?.showError(message: error.localizedDescription)
                }
            }
        }
    }
    
    func didTapRequestMic(index: Int) {
        Task {
            do {
                try await interactor.requestMic(index: index)
            } catch {
                await MainActor.run {
                    view?.showError(message: "上麦申请失败")
                }
            }
        }
    }
    
    func didTapLeaveMic() {
        Task {
            do {
                try await interactor.leaveMic()
            } catch {
                await MainActor.run {
                    view?.showError(message: "下麦失败")
                }
            }
        }
    }
    
    func didSendMessage(_ text: String) {
        Task {
            do {
                try await interactor.sendTextMessage(text)
            } catch {
                await MainActor.run {
                    view?.showError(message: "消息发送失败")
                }
            }
        }
    }
    
    // MARK: - Interactor 回调
    @MainActor
    func interactorDidJoinRoom(room: VoiceRoom, micSeats: [MicSeat]) async {
        view?.updateRoomInfo(room)
        view?.updateMicSeats(micSeats)
    }
    
    @MainActor
    func interactorDidLeaveRoom() async {}
    
    @MainActor
    func interactorDidUpdateMicSeats(_ seats: [MicSeat]) async {
        view?.updateMicSeats(seats)
    }
    
    func interactorDidReceiveMessage(_ message: ChatMessage) {
        view?.addChatMessage(message)
    }
    
    @MainActor
    func interactorDidUserJoin(userId: String) async {
        view?.addUser(userId: userId)
    }
    
    @MainActor
    func interactorDidUserLeave(userId: String) async {
        view?.removeUser(userId: userId)
    }
    
    @MainActor
    func interactorDidNetworkQualityChanged(quality: AgoraNetworkQuality) async {
        view?.updateNetworkQuality(quality)
    }
    
    @MainActor
    func interactorDidMuteUser(userId: String) async {
        view?.showMuteTip(userId: userId)
    }
}

3.3.4 View 视图层

import UIKit

protocol VoiceChatViewProtocol: AnyObject {
    func setupUI()
    func showLoading()
    func hideLoading()
    func showError(message: String)
    func updateRoomInfo(_ room: VoiceRoom)
    func updateMicSeats(_ seats: [MicSeat])
    func addChatMessage(_ message: ChatMessage)
    func addUser(userId: String)
    func removeUser(userId: String)
    func updateNetworkQuality(_ quality: AgoraNetworkQuality)
    func showMuteTip(userId: String)
}

final class VoiceChatViewController: UIViewController, VoiceChatViewProtocol {
    var presenter: VoiceChatPresenterProtocol!
    
    private let micSeatsView = MicSeatsView()
    private let chatTableView = UITableView()
    private let messageInputView = MessageInputView()
    private let networkIndicator = UILabel()
    
    private var messages: [ChatMessage] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        presenter.viewDidLoad()
    }
    
    func setupUI() {
        view.backgroundColor = .white
        title = "语音聊天室"
        
        // 麦位视图
        micSeatsView.translatesAutoresizingMaskIntoConstraints = false
        micSeatsView.delegate = self
        view.addSubview(micSeatsView)
        
        // 聊天列表
        chatTableView.translatesAutoresizingMaskIntoConstraints = false
        chatTableView.register(ChatMessageCell.self, forCellReuseIdentifier: "ChatMessageCell")
        chatTableView.dataSource = self
        chatTableView.separatorStyle = .none
        view.addSubview(chatTableView)
        
        // 输入框
        messageInputView.translatesAutoresizingMaskIntoConstraints = false
        messageInputView.delegate = self
        view.addSubview(messageInputView)
        
        // 网络状态
        networkIndicator.translatesAutoresizingMaskIntoConstraints = false
        networkIndicator.font = .systemFont(ofSize: 12)
        networkIndicator.textColor = .gray
        view.addSubview(networkIndicator)
        
        // 布局
        NSLayoutConstraint.activate([
            micSeatsView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            micSeatsView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            micSeatsView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            micSeatsView.heightAnchor.constraint(equalToConstant: 200),
            
            networkIndicator.topAnchor.constraint(equalTo: micSeatsView.bottomAnchor, constant: 8),
            networkIndicator.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            
            chatTableView.topAnchor.constraint(equalTo: networkIndicator.bottomAnchor, constant: 8),
            chatTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            chatTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            chatTableView.bottomAnchor.constraint(equalTo: messageInputView.topAnchor),
            
            messageInputView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            messageInputView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            messageInputView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
            messageInputView.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
    
    func showLoading() {
        // 显示加载动画
    }
    
    func hideLoading() {
        // 隐藏加载动画
    }
    
    func showError(message: String) {
        let alert = UIAlertController(title: "错误", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "确定", style: .default))
        present(alert, animated: true)
    }
    
    func updateRoomInfo(_ room: VoiceRoom) {
        title = room.title
    }
    
    func updateMicSeats(_ seats: [MicSeat]) {
        micSeatsView.updateSeats(seats)
    }
    
    func addChatMessage(_ message: ChatMessage) {
        messages.append(message)
        chatTableView.reloadData()
        chatTableView.scrollToRow(at: IndexPath(row: messages.count-1, section: 0), at: .bottom, animated: true)
    }
    
    func addUser(userId: String) {
        // 更新成员列表
    }
    
    func removeUser(userId: String) {
        // 更新成员列表
    }
    
    func updateNetworkQuality(_ quality: AgoraNetworkQuality) {
        switch quality {
        case .excellent, .good:
            networkIndicator.text = "网络良好"
            networkIndicator.textColor = .green
        case .poor, .bad:
            networkIndicator.text = "网络较差"
            networkIndicator.textColor = .orange
        case .down:
            networkIndicator.text = "网络断开"
            networkIndicator.textColor = .red
        default: break
        }
    }
    
    func showMuteTip(userId: String) {
        let alert = UIAlertController(title: "提示", message: "你已被管理员禁麦", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "确定", style: .default))
        present(alert, animated: true)
    }
}

// MARK: - MicSeatsViewDelegate
extension VoiceChatViewController: MicSeatsViewDelegate {
    func micSeatsView(_ view: MicSeatsView, didTapSeatAt index: Int) {
        presenter.didTapRequestMic(index: index)
    }
}

// MARK: - MessageInputViewDelegate
extension VoiceChatViewController: MessageInputViewDelegate {
    func messageInputView(_ view: MessageInputView, didSendMessage text: String) {
        presenter.didSendMessage(text)
    }
}

// MARK: - UITableViewDataSource
extension VoiceChatViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        messages.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ChatMessageCell", for: indexPath) as! ChatMessageCell
        cell.configure(with: messages[indexPath.row])
        return cell
    }
}

3.3.5 Router 路由层

protocol VoiceChatRouterProtocol: AnyObject {
    static func createModule(roomId: String, userId: String, rtcToken: String, rtmToken: String) -> UIViewController
    func dismissVoiceChat()
    func showUserProfile(userId: String)
}

final class VoiceChatRouter: VoiceChatRouterProtocol {
    weak var viewController: UIViewController?
    
    static func createModule(roomId: String, userId: String, rtcToken: String, rtmToken: String) -> UIViewController {
        let view = VoiceChatViewController()
        let interactor = VoiceChatInteractor()
        let router = VoiceChatRouter()
        let presenter = VoiceChatPresenter(interactor: interactor, router: router)
        
        view.presenter = presenter
        presenter.view = view
        router.viewController = view
        
        return view
    }
    
    func dismissVoiceChat() {
        viewController?.dismiss(animated: true)
    }
    
    func showUserProfile(userId: String) {
        // 跳转到用户资料页
    }
}

四、核心业务流程全链路解析

「用户进入房间 → 申请上麦 → 房主审批 → 成功上麦 → 下麦退出」 完整流程为例,串联全架构:

  1. 进入房间

    • Router 创建 VIPER 模块并跳转
    • View 点击进入房间 → 回调 Presenter
    • Presenter 调用 Interactor 的 joinRoom 方法
    • Interactor 配置音频会话、校验权限
    • 并行执行 RTM 登录和 RTC 进房
    • 加入 RTM 频道,拉取房间和麦位数据
    • Interactor 通知 Presenter → View 刷新 UI
  2. 申请上麦

    • View 点击麦位 → 回调 Presenter
    • Presenter 调用 Interactor 的 requestMic 方法
    • Interactor 校验房间状态和麦位状态
    • 通过 RTM 发送二进制上麦申请信令
    • 房主端 RTM 收到信令 → 弹窗确认
    • 房主同意后发送 approveMic 信令
  3. 成功上麦

    • 申请人 RTM 收到审批通过信令
    • Interactor 调用 RTC 切换为主播角色
    • 更新本地用户角色和麦位状态
    • 通知 Presenter → View 刷新麦位 UI
  4. 下麦退出

    • View 点击下麦按钮 → 回调 Presenter
    • Presenter 调用 Interactor 的 leaveMic 方法
    • Interactor 调用 RTC 切换为观众角色
    • 发送下麦信令广播全房间
    • 更新麦位状态 → View 刷新 UI

五、MENA 海外场景专项优化(Swift 版)

5.1 弱网专项优化

  1. RTC 配置优化

    // 开启抗丢包模式
    rtcEngine?.setParameters("{"che.audio.force_agc": true}")
    rtcEngine?.setParameters("{"che.audio.enable_ns": true}")
    // 弱网自动降码率
    rtcEngine?.setAudioProfile(.speechLowQuality, scenario: .chatRoomEntertainment)
    
  2. RTM 消息重发机制用 Swift Task 实现消息超时重发,最多重试 3 次。

  3. 网络状态机管理用 Swift 枚举定义网络状态,弱网下限制高耗时操作:

    enum NetworkState {
        case excellent
        case good
        case poor
        case bad
        case disconnected
    }
    

5.2 阿拉伯语 RTL 布局适配

  1. 基础层封装全局 RTL 检测工具:

    extension UIApplication {
        static var isRTL: Bool {
            UIView.userInterfaceLayoutDirection(for: .unspecified) == .rightToLeft
        }
    }
    
  2. 所有控件使用 Auto Layout 相对布局,禁止固定 left/right,使用 leading/trailing

  3. 聊天消息气泡自动镜像:

    if UIApplication.isRTL {
        bubbleView.transform = CGAffineTransform(scaleX: -1, y: 1)
        contentLabel.transform = CGAffineTransform(scaleX: -1, y: 1)
    }
    

5.3 iOS 后台保活优化

  1. Info.plist 开启 audio 后台模式:

    <key>UIBackgroundModes</key>
    <array>
        <string>audio</string>
    </array>
    
  2. 退后台时保持 RTC 音频会话活跃:

    NotificationCenter.default.addObserver(
        forName: UIApplication.didEnterBackgroundNotification,
        object: nil,
        queue: .main
    ) { _ in
        try? AudioSessionManager.shared.configureForVoiceChat()
    }
    

5.4 内存管理优化

  • 所有代理使用 weak var 避免循环引用
  • 退出房间时主动销毁 RTC/RTM 引擎
  • 大图片、礼物动效使用 NSCache 缓存
  • 避免在 deinit 中执行异步操作

六、架构优势与总结

6.1 本套架构核心优势

  1. 极致解耦底层 SDK 与上层业务完全隔离,更换音视频方案仅需修改能力层,业务代码零改动。
  2. 类型安全全程使用 Swift 强类型,枚举定义状态,避免运行时崩溃。
  3. 异步友好用 Swift Concurrency 的 async/await 替代回调地狱,代码可读性和可维护性大幅提升。
  4. 状态可控三大状态机(房间、麦位、网络)统一管理,彻底解决音视频场景常见的状态错乱问题。
  5. 可测试性强业务逻辑收敛在 Interactor,基于协议可轻松编写单元测试。
  6. 海外适配完善从架构底层支持弱网、RTL、后台保活、数据合规,完全匹配 MENA 市场需求。

6.2 适用场景

  • 海外语音直播、语聊房、多人连麦聊天室
  • 基于 Agora RTC + RTM 的 iOS 社交类项目
  • 复杂音视频交互页面架构重构
  • 企业级 Swift 项目模块化开发

6.3 可扩展方向

  1. 新增礼物动效、弹幕、排行榜模块
  2. 接入 AI 内容审核、敏感词过滤
  3. 增加付费连麦、PK 玩法、多人互动游戏
  4. 集成 Firebase 推送、Crashlytics 崩溃监控
  5. 支持多语言、多主题切换

七、问题解惑

  1. 为什么选择 VIPER 而不是 MVVM 做语音聊天室?

MVVM 侧重数据绑定,适合简单的列表展示页面。语音聊天室存在大量异步 SDK 回调、多状态流转、复杂业务规则,MVVM 容易造成 ViewModel 臃肿。VIPER 职责拆分更细,视图、业务、数据、路由完全隔离,状态管理更清晰,便于多人协作和长期迭代。同时 Swift 的协议特性让 VIPER 的实现更加优雅和类型安全。

  1. Swift Concurrency 在音视频开发中有什么优势?

用 async/await 替代传统的嵌套回调,代码结构更清晰,可读性更强。@MainActor 保证 UI 更新在主线程,避免线程安全问题。Task 可以方便地管理异步操作的生命周期,支持取消和超时。这些特性大幅降低了音视频这种多异步场景的开发复杂度。

  1. 如何解决 Swift 中 VIPER 的循环引用问题?

所有代理属性都使用 weak var 声明,特别是 View 和 Presenter 之间、Presenter 和 Interactor 之间。Router 持有 ViewController 的弱引用。退出页面时主动断开所有代理,销毁强引用对象。

  1. Agora RTC 和 RTM 为什么要做二次封装?

一是屏蔽原生 SDK 的版本差异和 API 变化,降低上层业务的维护成本;二是解耦,未来替换其他音视频 SDK 仅需修改能力层,上层业务无需改动;三是统一异常捕获、日志上报、弱网策略,做通用能力加固。

附录:项目目录结构(参考)

VoiceChatModule/
├── Base/
│   ├── AudioSessionManager.swift
│   ├── PermissionManager.swift
│   ├── Extensions/
│   └── Constants.swift
├── Capability/
│   ├── AgoraRTCService.swift
│   └── AgoraRTMService.swift
├── Business/
│   ├── Entity/
│   │   ├── VoiceRoom.swift
│   │   ├── VoiceUser.swift
│   │   ├── MicSeat.swift
│   │   ├── ChatMessage.swift
│   │   └── RTMSignal.swift
│   ├── Interactor/
│   │   └── VoiceChatInteractor.swift
│   ├── Presenter/
│   │   └── VoiceChatPresenter.swift
│   └── Router/
│       └── VoiceChatRouter.swift
└── View/
    ├── VoiceChatViewController.swift
    ├── Views/
    │   ├── MicSeatsView.swift
    │   ├── ChatMessageCell.swift
    │   └── MessageInputView.swift
    └── Protocols/
        ├── VoiceChatViewProtocol.swift
        ├── VoiceChatPresenterProtocol.swift
        ├── VoiceChatInteractorProtocol.swift
        └── VoiceChatRouterProtocol.swift