一、前言与业务背景
1. 业务场景说明
本文落地企业级海外语音聊天室(语音直播房) ,面向中东、北非(MENA)市场,核心能力覆盖:
- 基础能力:房间生命周期管理、多人实时语音连麦、麦位全生命周期管理(上麦 / 下麦 / 控麦 / 禁麦 / 锁麦)
- 互动能力:公屏文字聊天、自定义二进制信令、房间成员状态实时同步、系统通知广播
- 环境特征:弱网高延迟(200-600ms)、高丢包(5%-20%)、网络抖动大,要求 RTC 实时音频 + RTM 实时信令双引擎保障
- 合规与适配:阿拉伯语 RTL 自动布局、iOS 后台音频保活、沙特 / 阿联酋本地数据合规、宗教内容审核适配
2. 技术选型整体结论
| 模块 | 技术方案 | 选型原因 |
|---|---|---|
| 实时语音连麦 | Agora RTC 4.x | MENA 区域节点覆盖完善、抗 30% 丢包仍可通话、内置 AEC 回声消除 / ANS 降噪、行业市占率 75% |
| 即时消息 / 房间信令 | Agora RTM 2.x | 与 RTC 同账号同房间体系、信令延迟 <100ms、专为实时互动设计、优于传统第三方 IM |
| 整体架构 | VIPER 架构 | 彻底解耦视图、业务逻辑、数据、网络,适配音视频复杂状态机,便于多人协作与长期迭代 |
| 分层思想 | 四层分层架构 | 基础层 → 能力层 → 业务层 → 视图层,严格遵循依赖倒置原则,可插拔可复用 |
| 设计模式 | 单例、代理、状态模式、工厂模式、观察者模式、中介者模式 | 解决音视频多状态流转、SDK 回调泛滥、模块间通信复杂问题 |
| 语言特性 | Swift Concurrency | 用 async/await 替代回调地狱,@MainActor 保证 UI 线程安全,类型安全避免运行时崩溃 |
3. 核心设计目标
- 极致解耦:SDK 底层能力、业务逻辑、UI 视图完全隔离,更换 RTC/RTM SDK 仅需修改能力层,上层业务零改动
- 状态可控:统一管理房间、麦位、用户角色三大状态机,彻底解决音视频场景常见的状态错乱问题
- 可扩展性:新增礼物、排行榜、付费连麦、PK 玩法等功能无需重构核心架构
- 海外适配:从架构底层支持弱网优化、RTL 布局、后台保活、数据合规
- 易维护测试:业务逻辑收敛在 Interactor,支持单元测试,问题定位精准
二、整体架构总览
2.1 四层分层架构(自上而下)
严格遵循依赖倒置原则:上层依赖抽象,不依赖具体实现;下层不感知上层存在。
┌─────────────────────────────────────────────────────┐
│ 视图层 (View Layer) - VIPER View │
│ 页面、控件、动画、RTL 布局、用户交互、UI 刷新 │
├─────────────────────────────────────────────────────┤
│ 业务层 (Business Layer) - VIPER Presenter/Interactor│
│ 业务规则、房间逻辑、麦位管理、权限控制、状态流转 │
├─────────────────────────────────────────────────────┤
│ 能力层 (Capability Layer) - 基础能力封装 │
│ Agora RTC 封装、Agora RTM 封装、网络、缓存、工具类 │
├─────────────────────────────────────────────────────┤
│ 基础层 (Base Layer) - 公共底层 │
│ 系统权限、音频会话管理、全局常量、扩展、日志、基类 │
└─────────────────────────────────────────────────────┘
各层职责详解
-
**基础层(Base)**全局通用底层,无任何业务侵入。
- 系统权限:麦克风、网络、后台模式统一校验
- 音频会话管理:全局唯一
AVAudioSession配置,解决音频抢占、混音问题 - Swift 扩展:
UIView、String、Date等通用扩展 - 日志系统、线程管理、全局常量、基础基类
-
**能力层(Capability)**核心价值:对 Agora 原生 SDK 做二次封装,屏蔽 API 差异、版本变更、回调复杂度,对外提供统一抽象接口。拆分为两个完全独立的能力模块:
AgoraRTCService:语音采集、播放、角色切换、静音、耳返、音质配置、网络质量检测AgoraRTMService:RTM 登录、频道管理、文本消息、二进制信令、成员状态监听设计原则:纯能力、无业务逻辑,只做 SDK 代理转发、参数封装、异常捕获、日志上报。
-
**业务层(Business)**基于 VIPER 架构划分,承载语音聊天室完整业务规则:
- 房间创建 / 销毁、用户进出房间逻辑
- 麦位状态机管理:空闲 / 占用 / 禁麦 / 锁定
- 上麦申请、房主审批、管理员控麦、全员禁言业务规则
- RTM 自定义信令解析与指令分发
- 房间数据模型、成员列表、聊天消息管理
-
**视图层(View)**纯 UI 层,不持有任何业务逻辑、不直接调用 SDK、不访问能力层。
- 聊天室主页面、麦位视图、公屏聊天视图、成员列表、弹窗
- 阿拉伯语 RTL 自动布局适配、动画效果、弱网状态提示
- 所有用户交互通过代理回调至 Presenter,由 Presenter 驱动业务
2.2 VIPER 架构在 Swift 中的标准落地
VIPER 是单向数据流架构,完美适配音视频这种多状态、多异步回调的复杂页面。针对语音聊天室房间页,拆分为 5 大角色,全部用 Swift 协议定义接口:
| 角色 | 协议定义 | 核心职责 |
|---|---|---|
| View | VoiceChatViewProtocol | 展示 UI、接收用户交互、响应 Presenter 指令刷新界面 |
| Presenter | VoiceChatPresenterProtocol | 视图与业务的中间人,逻辑调度、状态中转、驱动 View 刷新 |
| Interactor | VoiceChatInteractorProtocol | 纯业务逻辑、数据处理、调用能力层接口、状态机管理 |
| Entity | 无协议(纯数据模型) | 房间、用户、麦位、消息、信令数据模型,遵循 Codable |
| Router | VoiceChatRouterProtocol | 页面跳转、弹窗路由、模块间通信 |
核心数据流规则:
View ↔ Presenter → Interactor → 能力层(RTC/RTM)
↑ ↓
└────────────────┘
- 所有 SDK 回调、网络回调最终收敛到 Interactor
- Interactor 处理完业务逻辑后通知 Presenter
- Presenter 组装数据后指令 View 刷新 UI
- 严禁 View 直接访问 Interactor 或能力层
2.3 设计模式结合 Swift 特性应用
-
单例模式
AgoraRTCService、AgoraRTMService、AudioSessionManager使用 Swift 静态单例,保证全局唯一:class AgoraRTCService { static let shared = AgoraRTCService() private init() {} // 禁止外部初始化 } -
**代理模式(Protocol-Oriented)**所有模块间通信基于 Swift 协议,接口清晰、可测试性强,支持默认实现。
-
**状态模式(重点)**用 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 } -
工厂模式统一创建消息、信令模型,自动解析 JSON,避免重复代码:
struct ChatMessageFactory { static func makeMessage(from data: Data) -> ChatMessage? { try? JSONDecoder().decode(ChatMessage.self, from: data) } } -
观察者模式用
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) {
// 跳转到用户资料页
}
}
四、核心业务流程全链路解析
以 「用户进入房间 → 申请上麦 → 房主审批 → 成功上麦 → 下麦退出」 完整流程为例,串联全架构:
-
进入房间
- Router 创建 VIPER 模块并跳转
- View 点击进入房间 → 回调 Presenter
- Presenter 调用 Interactor 的
joinRoom方法 - Interactor 配置音频会话、校验权限
- 并行执行 RTM 登录和 RTC 进房
- 加入 RTM 频道,拉取房间和麦位数据
- Interactor 通知 Presenter → View 刷新 UI
-
申请上麦
- View 点击麦位 → 回调 Presenter
- Presenter 调用 Interactor 的
requestMic方法 - Interactor 校验房间状态和麦位状态
- 通过 RTM 发送二进制上麦申请信令
- 房主端 RTM 收到信令 → 弹窗确认
- 房主同意后发送
approveMic信令
-
成功上麦
- 申请人 RTM 收到审批通过信令
- Interactor 调用 RTC 切换为主播角色
- 更新本地用户角色和麦位状态
- 通知 Presenter → View 刷新麦位 UI
-
下麦退出
- View 点击下麦按钮 → 回调 Presenter
- Presenter 调用 Interactor 的
leaveMic方法 - Interactor 调用 RTC 切换为观众角色
- 发送下麦信令广播全房间
- 更新麦位状态 → View 刷新 UI
五、MENA 海外场景专项优化(Swift 版)
5.1 弱网专项优化
-
RTC 配置优化
// 开启抗丢包模式 rtcEngine?.setParameters("{"che.audio.force_agc": true}") rtcEngine?.setParameters("{"che.audio.enable_ns": true}") // 弱网自动降码率 rtcEngine?.setAudioProfile(.speechLowQuality, scenario: .chatRoomEntertainment) -
RTM 消息重发机制用 Swift
Task实现消息超时重发,最多重试 3 次。 -
网络状态机管理用 Swift 枚举定义网络状态,弱网下限制高耗时操作:
enum NetworkState { case excellent case good case poor case bad case disconnected }
5.2 阿拉伯语 RTL 布局适配
-
基础层封装全局 RTL 检测工具:
extension UIApplication { static var isRTL: Bool { UIView.userInterfaceLayoutDirection(for: .unspecified) == .rightToLeft } } -
所有控件使用 Auto Layout 相对布局,禁止固定
left/right,使用leading/trailing。 -
聊天消息气泡自动镜像:
if UIApplication.isRTL { bubbleView.transform = CGAffineTransform(scaleX: -1, y: 1) contentLabel.transform = CGAffineTransform(scaleX: -1, y: 1) }
5.3 iOS 后台保活优化
-
Info.plist 开启
audio后台模式:<key>UIBackgroundModes</key> <array> <string>audio</string> </array> -
退后台时保持 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 本套架构核心优势
- 极致解耦底层 SDK 与上层业务完全隔离,更换音视频方案仅需修改能力层,业务代码零改动。
- 类型安全全程使用 Swift 强类型,枚举定义状态,避免运行时崩溃。
- 异步友好用 Swift Concurrency 的 async/await 替代回调地狱,代码可读性和可维护性大幅提升。
- 状态可控三大状态机(房间、麦位、网络)统一管理,彻底解决音视频场景常见的状态错乱问题。
- 可测试性强业务逻辑收敛在 Interactor,基于协议可轻松编写单元测试。
- 海外适配完善从架构底层支持弱网、RTL、后台保活、数据合规,完全匹配 MENA 市场需求。
6.2 适用场景
- 海外语音直播、语聊房、多人连麦聊天室
- 基于 Agora RTC + RTM 的 iOS 社交类项目
- 复杂音视频交互页面架构重构
- 企业级 Swift 项目模块化开发
6.3 可扩展方向
- 新增礼物动效、弹幕、排行榜模块
- 接入 AI 内容审核、敏感词过滤
- 增加付费连麦、PK 玩法、多人互动游戏
- 集成 Firebase 推送、Crashlytics 崩溃监控
- 支持多语言、多主题切换
七、问题解惑
- 为什么选择 VIPER 而不是 MVVM 做语音聊天室?
MVVM 侧重数据绑定,适合简单的列表展示页面。语音聊天室存在大量异步 SDK 回调、多状态流转、复杂业务规则,MVVM 容易造成 ViewModel 臃肿。VIPER 职责拆分更细,视图、业务、数据、路由完全隔离,状态管理更清晰,便于多人协作和长期迭代。同时 Swift 的协议特性让 VIPER 的实现更加优雅和类型安全。
- Swift Concurrency 在音视频开发中有什么优势?
用 async/await 替代传统的嵌套回调,代码结构更清晰,可读性更强。@MainActor 保证 UI 更新在主线程,避免线程安全问题。Task 可以方便地管理异步操作的生命周期,支持取消和超时。这些特性大幅降低了音视频这种多异步场景的开发复杂度。
- 如何解决 Swift 中 VIPER 的循环引用问题?
所有代理属性都使用
weak var声明,特别是 View 和 Presenter 之间、Presenter 和 Interactor 之间。Router 持有 ViewController 的弱引用。退出页面时主动断开所有代理,销毁强引用对象。
- 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