阅读视图

发现新文章,点击刷新页面。

用一套View代码,同时支持RTL和LTR布局混合排版

用一套 View,同时支持「全局 RTL、全局 LTR、局部 RTL、局部 LTR、混合排版」核心就是:

  • semanticContentAttribute 控制 “流向”
  • 布局只写 leading/trailing,永远不写 left/right
  • 文本用 natural 对齐
  • 图标用 directional asset / 自动翻转
  • 混合文字用 Unicode 控制符 修正

下面从原理 → 架构 → 代码 → 坑点,一次说清,项目可以使用。

一、核心原理:每个 View 都有自己的 “流向”

iOS 9+ 每个 UIView 都有:

var semanticContentAttribute: UISemanticContentAttribute
  • .unspecified:继承父视图
  • .forceLeftToRight:强制 LTR
  • .forceRightToLeft:强制 RTL

**结论:**你可以让 页面整体 RTL,但某个子 View(如手机号、URL、时间轴)强制 LTR;也可以反过来。完全做到同一屏 RTL/LTR 共存。

二、整体架构:三层流向控制(一套 View 走天下)

1. 全局层:App 整体方向

// 语言切换时调用
func setAppLayoutDirection(isRTL: Bool) {
    let attr: UISemanticContentAttribute = 
        isRTL ? .forceRightToLeft : .forceLeftToRight
    
    // 全局默认
    UIView.appearance().semanticContentAttribute = attr
    UINavigationBar.appearance().semanticContentAttribute = attr
    UITabBar.appearance().semanticContentAttribute = attr
}

此时所有控件 默认跟随全局 RTL/LTR

2. 页面层:VC 统一流向(可选)

override func viewDidLoad() {
    super.viewDidLoad()
    view.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
}

页面内所有子 View 默认继承页面流向

3. 局部层:单个 View 强制方向(关键!共存核心)

// 手机号、验证码、URL、时间轴、播放器控制 → 强制 LTR
phoneLabel.semanticContentAttribute = .forceLeftToRight
urlLabel.semanticContentAttribute = .forceLeftToRight

// 纯阿拉伯语文案 → 跟随全局(或强制 RTL)
arabicLabel.semanticContentAttribute = .unspecified

这就是 “一套 View 共存” 的核心:局部覆盖全局。


三、布局:永远只用 leading/trailing(AutoLayout 自动镜像)

❌ 错误(写死方向,不能共存)

label.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true

✅ 正确(自动适配 RTL/LTR)

label.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
  • LTR:leading = 左,trailing = 右
  • RTL:leading = 右,trailing = 左系统自动切换,一套约束走天下。 Apple Developer

间距 / 内边距适配

// 统一写法,自动交换 left/right
let inset: UIEdgeInsets = isRTL 
    ? UIEdgeInsets(top: 8, left: 16, right: 8, bottom: 8) 
    : UIEdgeInsets(top: 8, left: 8, right: 16, bottom: 8)

或用扩展:

extension UIEdgeInsets {
    func rtlFlipped() -> UIEdgeInsets {
        UIEdgeInsets(top: self.top, left: self.right, right: self.left, bottom: self.bottom)
    }
}

四、文本:natural 对齐 + 混合文字修正

1. 对齐方式

label.textAlignment = .natural
  • LTR:左对齐
  • RTL:右对齐自动适配,不用改代码。

2. 混合文字(阿语 + 英文 / 数字)

系统默认会自动分段,但遇到 @#、链接、手机号时会错乱。用 Unicode 控制符 强制 LTR:

// \u200E = LEFT-TO-RIGHT MARK(强制 LTR)
let text = "مرحبا \u{200E}@username \u{200E}13800138000"
label.text = text

这样 @username 和手机号永远 LTR,阿语部分 RTL,完美共存

五、图标:Directional Asset(自动镜像)

1. 箭头类图标(返回、前进、左右箭头)

在 Asset Catalog 中勾选 Directional

  • LTR:显示原图
  • RTL:自动水平翻转不用代码,一套图片走天下。 Apple Developer

2. 非方向图标(相机、搜索、设置)

不勾选 Directional,永远不翻转

六、自定义控件适配(一套代码,双向渲染)

示例:自定义按钮(带图标 + 文字)

class RTLButton: UIButton {
    override func layoutSubviews() {
        super.layoutSubviews()
        // 系统已根据 semanticContentAttribute 自动翻转 leading/trailing
        // 只需确保图标和文字用 natural 对齐 + directional 图标
    }
    
    override var semanticContentAttribute: UISemanticContentAttribute {
        didSet {
            // 流向变化时刷新布局
            setNeedsLayout()
        }
    }
}

关键点:自定义控件不要硬编码 frame 的 x 坐标,全部用 AutoLayout + leading/trailing。

七、手势与动画:自动反转 + 局部覆盖

1. 手势方向

let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swipeHandler))
swipe.direction = isRTL ? .left : .right
  • LTR:右滑 → 前进
  • RTL:左滑 → 前进

2. 页面切换动画

系统导航栈 自动反转 push/pop 方向

  • LTR:push 从右进,pop 从左出
  • RTL:push 从左进,pop 从右出无需手动写动画代码。

八、常见坑

  1. 用了 left/right 约束 → RTL 不翻转,布局错乱
  2. 自定义控件硬编码 frame → 镜像后位置错误
  3. 图标没设为 Directional → 箭头方向反,用户困惑
  4. 混合文字没加 \u200E → 英文 / 数字倒序
  5. 第三方库用了 left/right → 全局错乱(如旧版 Masonry)

九、一句话总结

通过 semanticContentAttribute 实现全局与局部流向控制,布局全程使用 leading/trailing,文本设为 natural 对齐,箭头类图标采用 Directional Asset,混合文字用 Unicode 控制符修正,手势动画按 RTL 动态反转,从而用一套 View、一套代码完美支持 RTL/LTR 全局与局部共存。

基于声网 Agora RTM + RTC SDK 实现 iOS 语音聊天室 —— 常见问题汇总 & 解决方案手册

目录

  1. 【无声/单边无声】—— 占问题的 60%+
  2. 【Token 相关报错】—— 进房失败、中途断连
  3. 【RTC 错误码速查】—— -102 / -121 / -17 / -7 / -3
  4. 【RTM 登录/发消息报错】—— NOT_LOGIN / TOKEN_EXPIRED / TIMEOUT / REJECTED
  5. 【RTM 断线重连状态机】—— 你到底该不该手动 re-login
  6. 【麦克风权限 & iOS 隐私弹窗】—— 真机静默失败的高危坑
  7. 【音频路由错乱】—— 插耳机/连蓝牙后声音跑到听筒
  8. 【后台/锁屏后没声音】—— iOS 系统限制
  9. 【iOS 14 本地网络弹窗】—— RTM SDK 经典惊喜
  10. 【内存/生命周期】—— sharedEngine重复初始化、没 leaveChannel 就 rejoin
  11. 【调试利器】—— 开日志 + Agora Analytics

一、无声 / 单边无声(最常见)

症状分类

表现 典型指向
本地能说话,远端听不到 本地采集没起来(权限/路由/mute)
远端能说话,本地听不到 远端没发流 或 本地没 sub(role/options 配错)
互相都听不到 channel 不一致 / Token 不对 / App ID 不匹配
刚进房有声音,几秒后没了 Token 过期 / 被 mute / AVAudioSession 被别的模块改了

排查清单(按顺序做)

✅ Step 1:先确认两人真的在同一个 channel

swift
swift
// 你收到的回调
func rtcEngine(_ engine: AgoraRtcEngineKit,
               didJoinChannel channel: String,
               withUid uid: UInt,
               elapsed: Int) {
    print("✅ joined channel = (channel), uid = (uid)")
}

很多"无声"本质是:两个人进了不同 channel(前后带空格、大小写不一致、拼接参数写错)。channel name 必须完全一致。


✅ Step 2:确认没被 mute / 音量设 0

语聊房最常见的"手滑式无声":

swift
swift
// ⚠️ 这些调用都会直接导致没声音
engine.muteLocalAudioStream(true)           // 本地不发流
engine.muteAllRemoteAudioStreams(true)       // 不听任何人
engine.adjustRecordingSignalVolume(0)       // 采集音量 0
engine.adjustPlaybackSignalVolume(0)        // 播放音量 0

自检代码(调试时打出来):

swift
swift
print("recording vol =", engine.recordingSignalVolume())
print("playback vol  =", engine.playbackSignalVolume())

✅ Step 3:确认 AgoraRtcChannelMediaOptions配对了

这是 4.x 最容易配错的一步

swift
swift
let opts = AgoraRtcChannelMediaOptions()

// 主播(上麦)
opts.clientRoleType         = .broadcaster
opts.publishMicrophoneTrack  = true    // ← 必须是 true,否则远端听不到你
opts.publishCameraTrack      = false
opts.autoSubscribeAudio      = true

// 观众(听别人)
opts.clientRoleType         = .audience
opts.publishMicrophoneTrack = false
opts.autoSubscribeAudio     = true     // ← 必须是 true,否则你听不到别人
opts.autoSubscribeVideo     = false

如果你用老 API(joinChannelByToken不带 mediaOptions),或者 publishMicrophoneTrack忘了设,didJoinedOfUid会触发但音频 tracks 没发布 → 表现为"能看到人进来但没声音"。


✅ Step 4:Info.plist麦克风权限(真机必查)

xml
xml
<key>NSMicrophoneUsageDescription</key>
<string>语音聊天室需要访问麦克风</string>

iOS 10+ 如果这行缺失,系统会 直接拒绝授权且不弹窗,回调立刻返回 denied,表现为"进房成功但始终无声"。

另外在代码中主动检查:

swift
swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    AVAudioSession.sharedInstance().requestRecordPermission { granted in
        if !granted {
            // 弹引导去设置的 alert
        }
    }
}

✅ Step 5:检查是否被其他 App 抢占麦克风

官方枚举明确列出了 iOS 特有错误:

错误 含义 解法
AgoraAudioLocalErrorDeviceNoPermission (2) 没麦克风权限 引导去 设置→隐私→麦克风
AgoraAudioLocalErrorDeviceBusy (3) 麦克风被其他 App 占用(微信通话/Siri/录音中等) 提示用户关闭其它录音 App;空闲约 5s 后自动恢复,或 rejoin
AgoraAudioLocalErrorInterrupted (8) 被来电 / Siri / 闹钟中断 中止干扰源后可恢复

你还可以通过回调监听:

swift
swift
func rtcEngine(_ engine: AgoraRtcEngineKit,
               localAudioStateChanged state: AgoraAudioLocalState,
               error: AgoraAudioLocalError) {
    print("audio state=(state.rawValue), error=(error.rawValue)")
}

二、Token 相关报错(进不去 / 中途掉了)

典型报错码

错误码 常在哪里看到 含义
AgoraErrorCodeInvalidAppId (101/-3) join 失败 App ID 填错 / 项目没启用对应服务
AgoraErrorCodeInvalidToken (110) join 返回非 0 Token 跟 App ID / channel / uid 不匹配
AgoraErrorCodeTokenExpired (109) 中途突然掉 Token 生存期到了

✅ 正确姿势:监听两个回调 + renew

swift
swift
// 1) Token 即将过期 —— 提前 30s 通知你换新
func rtcEngine(_ engine: AgoraRtcEngineKit,
               tokenPrivilegeWillExpire token: String) {
    print("⚠️ Token will expire, renewing...")
    fetchNewTokenFromServer { newToken in
        engine.renewToken(newToken)
    }
}

// 2) Token 已过期(极端:网络抖动导致来不及 renew)
func rtcEngineRequestToken(_ engine: AgoraRtcEngineKit) {
    print("❌ Token expired, rejoin required")
    fetchNewTokenFromServer { newToken in
        engine.leaveChannel(nil)
        // 重新 joinChannel(byToken:newToken:...)
    }
}

官方建议两种更新路径:

  • 优先:调 renewToken(_:)(不断连热更新)
  • 兜底leaveChannel→ 拿新 Token → joinChannel重新加入

⚠️ 常见错误:Token 算错了 uid 不匹配(服务端你把 uid 当 "123",SDK 里传 123/0混用)→ 表现为 INVALID_TOKEN


三、RTC 经典错误码速查

错误码 符号 触发场景 怎么修
-102 AgoraErrorCodeJoinChannelRejected / invalid channel name channelId 含非法字符 / nil / 超长 限制 channelId 正则 [a-zA-Z0-9_-]{1,64}
-121 invalid uid uid=0 混用没问题,但你如果手动指定 uid,不能是 0 或负数 用 0(SDK 分配)或服务端分配 1~UINT32_MAX-1
-17 AgoraErrorCodeJoinChannelRejected 已经在频道里又调了一次 joinChannel 先 leaveChannel或判断 connectionChangedToState:reason:的状态
-7 not initialized sharedEngine还没走完就用它 保证 setupEngine 在 join 之前
-8 invalid state 典型:调 startEchoTest后没 stopEchoTest就 join 清理测试流程

四、RTM 登录/发消息报错速查

错误码 含义 修法
-10001 NOT_INITIALIZED SDK 没 init 就调 API 先 AgoraRtmClientKit(config:)
-10002 NOT_LOGIN 没 login 就发消息/进频道 等 login 成功回调再 joinChannel
-10003 INVALID_APP_ID App ID 错 或 没开通 RTM 服务 控制台确认项目启用 RTM
-10005 INVALID_TOKEN Token 格式错 / 跟 userId 不匹配 确认 RTM Token 的服务端生成参数
-10009 TOKEN_EXPIRED RTM Token 过期 换新 RTM Token → loginByToken:
-10011 LOGIN_TIMEOUT 12s 内没连上 查网络 / 代理 / 防火墙白名单
-10012 LOGIN_REJECTED userId 被封禁 / App ID 没开 RTM 控制台查封禁
-10013 LOGIN_ABORTED 同 userId 在其他端登录挤掉 做"互踢"策略或允许多端共存(不同 uid)

五、RTM 断线重连 —— 你到底要不要手动 re-login?

官方状态机逻辑(重要)

RTM 2.x 的连接状态迁移:

纯文本
纯文本
IDLE
 ↓ login
CONNECTING → CONNECTED  ✅
       ↑
    断网 4s+
       ↓
  RECONNECTING  ← SDK 自动重试(你别管)
       ↓
  ┌─ 30s 内恢复 → CONNECTED(在线状态不变)
  └─ 超过 30s 仍未恢复 → 你被从在线列表移除 → 之后 recovery 成功也要重新 sync 状态
       ↓ 极端:2min 都无法恢复
   FAILED(不会自动重试,你要手动 login)

✅ 正确写法

swift
swift
func rtmClient(_ client: AgoraRtmClientKit,
               didReceiveLinkStateEvent event: AgoraRtmLinkStateEvent) {

    let cur  = event.currentState
    let code = event.reasonCode

    switch cur {
    case .connected:
        print("✅ RTM connected")
        // 重新 join 消息频道(如果需要)
    case .reconnecting:
        print("🔄 RTM reconnecting, reason=(code)")
    case .disconnected:
        print("⚠️ RTM disconnected")
    case .failed:
        // ⚠️ SDK 不会自动重连了
        print("❌ RTM FAILED reason=(code)")
        // 你的业务决定是否 retry login
        // 但要防雪崩:加退避(exponential backoff)
    default: break
    }
}

关键原则:RECONNECTING阶段不要主动 logout+login,让 SDK 自己跑;只有到 FAILED才介入。


六、麦克风权限 & 真机"静默失败"坑

这个坑的特征:模拟器好像能跑、真机进去不弹授权框、也不崩、就是没声音

根因:iOS 10+ 强制检查 Info.plist的 NSMicrophoneUsageDescription

完整合规检查清单

xml
xml
<!-- Info.plist -->
<key>NSMicrophoneUsageDescription</key>
<string>语音聊天室需要通过麦克风进行实时语音交流</string>

并在首次进房前触发一次:

swift
swift
AVAudioSession.sharedInstance().requestRecordPermission { ok in
    DispatchQueue.main.async {
        if ok { self.joinChannel() }
        else { /* 弹设置引导 */ }
    }
}

Apple 审核层面要注意:

  • 描述字符串不能空着也不能写废话("用于App功能"会被拒)
  • 多语言包要对应上

七、音频路由错乱(插耳机/连蓝牙后声音跑听筒)

语聊房默认路由策略:

SDK 场景 默认路由
Audio SDK + 通信场景 听筒(earpiece) ← 很多人以为这是 bug
Live Broadcasting 扬声器(speaker)

✅ 语音聊天室推荐配置

swift
swift
// 1) 进房前:设默认走扬声器(绝大多数语聊房期望的行为)
engine.setDefaultAudioRouteToSpeakerphone(true)

// 2) 进房后:动态切换
engine.setEnableSpeakerphone(true)   // 扬声器
engine.setEnableSpeakerphone(false)  // 回落听筒(少见)

⚠️ setDefaultAudioRouteToSpeakerphone必须在 join 之前调,setEnableSpeakerphone在 join 之后调,调反了不生效。

如果用户的蓝牙耳机优先级更高,iOS 的音频路由优先级是:用户物理行为(插拔耳机/蓝牙)> 你的 setEnableSpeakerphone。这是系统设计,不要跟它对抗——能做的是监听路由变化后同步 UI 状态。


八、后台/锁屏后没声音(iOS 系统限制)

iOS 12.4+ 系统限制:App 切后台,系统自动停采集

✅ 要在 Xcode 里加 Background Modes:

纯文本
纯文本
Signing & Capabilities → + Capability → Background Modes
☑️ Audio, AirPlay, and Picture in Picture
☑️ Background processing

同时确保:

  • 用户在前台已 join 成功
  • 没调过 disableAudio()disableLocalAudio()
  • SDK 的 localAudioStateChanged回调报告过 AgoraAudioLocalStateRecording

语聊房的现实取舍:加了 Audio Background Mode 后 App 可以在后台维持音频采集,但 Apple 审核可能追问你的后台必要性(如果只是"听"不需要采集,观众角色可以不申请)。


九、iOS 14 的"查找本地网络设备"弹窗

RTM SDK 早期版本在 iOS 14 触发本地网络权限弹窗。

解决方案(二选一)

  1. 升 RTM SDK ≥ 1.4.1(官方修了,弹窗不再出现,服务不受影响)
  2. 或在 Info.plist加描述占位(不推荐但可应急)
xml
xml
<key>NSLocalNetworkUsageDescription</key>
<string>语音聊天室需要本地网络以连接服务</string>

十、生命周期坑:重复 init / 没 leaveChannel 就 rejoin

❌ 典型翻车代码

swift
swift
// 用户快速点两次"进入房间"
func onTapEnter() {
    setupEngine()        // 又创了一次 sharedEngine
    joinChannel(...)     // 上一次还在频道里 → -17 或被覆盖
}

✅ 正确模式

swift
swift
final class VoiceRTCService {

    private(set) var engine: AgoraRtcEngineKit!
    private var hasJoined = false

    func ensureEngine(appId: String) {
        if engine == nil {
            let cfg = AgoraRtcEngineConfig()
            cfg.appId = appId
            cfg.channelProfile = .liveBroadcasting
            engine = .sharedEngine(with: cfg, delegate: self)
            engine.disableVideo()
            engine.enableAudio()
        }
    }

    func joinChannel(...) {
        guard !hasJoined else {
            print("⚠️ already in channel, skip or leave first")
            return
        }
        hasJoined = true
        engine.joinChannel(...)
    }

    func leaveChannel() {
        guard hasJoined else { return }
        engine.leaveChannel { _ in
            self.hasJoined = false
        }
    }

    deinit { /* 页面销毁时:leaveChannel 后 destroy */ }
}

十一、调试利器:开日志 + Console 控制台

1) 开 RTC 日志

swift
swift
let cfg = AgoraRtcEngineConfig()
cfg.appId = appId
cfg.channelProfile = .liveBroadcasting
cfg.logConfig.level = .info   // .debug 更详细
cfg.logConfig.filePath = NSTemporaryDirectory() + "agora_rtc.log"

2) RTM 2.x 开日志

swift
swift
let logCfg = AgoraRtmLogConfig()
logCfg.level = .info
let cfg = AgoraRtmClientConfig(appId: appId, userId: uid)
cfg.logConfig = logCfg

3) Agora Console(analytics)

官方无声排查流程建议你把 频道名 + 出问题的 uid + 时间段 记下来,用控制台里的 Agora Analytics 看每个用户的进房/发流/收流状态,比盲猜效率高 10 倍。


🧰 速查急救表(贴在你工位上那种)

现象 先看哪 一句定位
进房成功但没声音 mediaOptions.publishMicrophoneTrack 是不是 false / 有没有 mute
两人像在不同房间 channelId 字符串 前后空格、大小写、拼接 bug
join 返回 -102 channelId 合法性 非法字符 / nil
join 返回 -121 uid 你传了 0 但服务端又期望固定 uid
突然掉线 tokenPrivilegeWillExpire Token 到期没 renew
RTM 发消息报 -10002 login 状态机 没等 login 成功就 joinChannel
锁屏后没声音 Background Modes 没勾 Audio/AirPlay
真机无声模拟器好使 Info.plist 缺 NSMicrophoneUsageDescription
iOS 14 本地网络弹窗 RTM 版本 升 RTM ≥ 1.4.1

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

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

基础 Demo 里业务代码直接调 SDK,遇到真实场景立刻暴露三个硬伤:

痛点 后果
客户端自己决定谁能上麦/踢人 越权(改包/多开直接绕过)
麦位状态存在内存里 断线重连、切后台、崩溃 → 麦位乱套
礼物/统计/在线人数无权威源 刷礼物重复计数、在线数飘忽

✅ 正确的分层

纯文本
纯文本
┌──────────────────────────────────────────────┐
                  UI Layer                        MicSeatCell / GiftBannerView
├──────────────────────────────────────────────┤
             VoiceRoomViewModel                  @Published 驱动 UI,单一状态源
  (麦位状态机 · 礼物队列 · 权限判断 · 房间上下文)  
├──────────────────┬───────────────────────────┤
  VoiceRTCService     VoiceRTMService           SDK 二次封装(统一回调错误转换)
  (engine 生命周期)    (信令收发·频道属性同步)    
├──────────────────┴───────────────────────────┤
          App Server(权威源)                    踢人鉴权麦位锁房间存活礼物扣费
└──────────────────────────────────────────────┘

声网官方在 agora-ent-scenarios示例中也采用了类似思想:通过 ChatRoomServiceProtocolVoiceRoomSubscribeDelegate把房间交互抽象成协议,业务层只依赖协议而非 SDK 细节


二、RTC 二次封装:只暴露「业务语义」,不暴露 SDK 细节

1. 定义业务能力协议(关键一步)

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
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. 事件桥接协议

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(主力):业务信令踢人 —— 安全、可控、可审计

时序

纯文本
纯文本
房主点"踢人" → App Server 校验(是不是房主? 目标在不在房?)
                    ↓ 校验通过
            ① 更新 DB: 标记 target 为 "kicked"
            ② 记录操作日志(审计)
            ③ 通过 RTM Channel Message / 长连接推送 发信令给 target
                    ↓
            客户端收到 {type:"kicked", by:"owner_xxx", reason:"违反规则"}
                    ↓
            执行:rtcService.leave()
                 跳回房间列表
                 弹出 toast

RTM 信令结构

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
// 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
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
// 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
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 —— 唯一状态源

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 同理)

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
// 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. 礼物消息协议

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)

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
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
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 安全闭环(生产必备)

纯文本
纯文本
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 映射

七、完整文件结构建议(可直接照此建目录)

纯文本
纯文本
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 不出域

基于声网 Agora RTM + RTC SDK 实现 iOS 语音聊天室——从零到可跑的指南

一、为什么语音聊天室需要 RTM + RTC 两套 SDK

完整的语音聊天室至少需要解决两类问题:

负责什么 用哪个 SDK
音频流传输层 麦克风采集 → 编码 → 网络传输 → 远端播放 RTC SDKAgoraRtcKit
信令/消息层 用户上下麦通知、聊天室文字消息、在线人数同步、房主踢人、送礼等 RTM SDKAgoraRtmKitShengwangRtm

RTC 管声音,RTM 管"谁在说话/谁上了麦/大家聊了什么"。 两者配合,才是完整方案。

二、前置准备

1. 声网控制台操作

  1. 前往 声网控制台创建项目,拿到 App ID
  2. 如果需要正式环境 Token,还需获取 App 证书,在服务端签发 Token
  3. 测试阶段可以直接在控制台生成 临时 Token(有效期 24 小时)
  4. 如果使用 RTM 的高级特性(如 Storage / Lock),需在控制台为项目启用 RTM 功能

2. 环境要求

项目 最低版本
iOS Deployment Target iOS 11.0+ (建议 13.0+)
Xcode 26.0+(苹果提审需要)
语言 Swift(本文示例用 Swift)
真机 必须有,模拟器无法采集麦克风

别忘了在 Info.plist中添加麦克风权限描述:

<key>NSMicrophoneUsageDescription</key>
<string>语音聊天室需要访问麦克风</string>

三、SDK 集成(CocoaPods)

Podfile

platform :ios, '13.0'
target 'VoiceChatRoom' do
  use_frameworks!

  # RTC —— 纯语音场景用 AgoraAudio_iOS 体积更小
  pod 'AgoraAudio_iOS', '~> 4.3.0'

  # RTM —— 2.2.7+ 新包名
  pod 'ShengwangRtm', '~> 2.2.8'
end

注意:RTC 有多个子 pod。如果你的语聊房不带视频,选 AgoraAudio_iOS即可,包体积比全量 AgoraRtcEngineKit小很多。如果同时集成了 2.2.0+ 的 RTM 和 4.3.0+ 的 RTC,注意看官方 FAQ 里的链接冲突处理。

终端执行:

pod install --repo-update
open VoiceChatRoom.xcworkspace

四、架构设计与角色模型

一个标准语音聊天室的角色划分:

┌──────────┐
│   Room   │  channelId = 房间ID
│ Owner    │← 房主(固定 0 号麦位 / 第一个主播)
│ Mic #1   │← 主播(clientRole = broadcaster, publishMicrophone = YES)
│ Mic #2   │
│ ...      │
│ Audience │← 观众(clientRole = audience,   autoSubscribeAudio = YES)
└──────────┘

关键设计原则

  • RTC Channel = 音频房间,所有人 join 同一个 channelId,靠 clientRoleType区分能不能发流
  • RTM Channel = 消息频道(聊天室消息 + 信令广播),用于文字聊天、上麦申请/通知
  • 房主和主播 → broadcaster;观众 → audience;观众想说话时必须先切角色 → 上麦

五、RTC 层:音频引擎初始化 & 加入频道

1. 创建引擎

import AgoraRtcKit

class VoiceChatManager: NSObject {

    private(set) var engine: AgoraRtcEngineKit!
    private let appId = "YOUR_APP_ID"

    func setupEngine() {
        let config = AgoraRtcEngineConfig()
        config.appId = appId
        // 语聊房推荐 LIVE_BROADCASTING
        config.channelProfile = .liveBroadcasting

        engine = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self)

        // ✅ 只启用音频,禁用视频(省资源)
        engine.disableVideo()
        engine.enableAudio()

        // 推荐:开启耳返时需要的话
        // engine.enableInEarMonitoring(true)

        // 音频场景调为语聊(AGORA_AUDIO_SCENARIO_CHATROOM 的封装)
        engine.setAudioProfile(.default, scenario: .chatroom)
    }
}

2. 加入频道(区分角色)

extension VoiceChatManager {

    /// 加入语音房
    /// - Parameters:
    ///   - channel: 房间ID / 频道名
    ///   - token:  临时Token 或 服务端签发Token
    ///   - asBroadcaster: 是否以主播身份(YES=上麦 / NO=观众)
    func joinChannel(
        channel: String,
        token: String?,
        asBroadcaster: Bool
    ) {
        let options = AgoraRtcChannelMediaOptions()

        options.clientRoleType = asBroadcaster ? .broadcaster : .audience
        options.publishMicrophoneTrack = asBroadcaster
        options.publishCameraTrack = false          // 纯语音
        options.autoSubscribeAudio = true           // 自动拉取远端音频
        options.autoSubscribeVideo = false

        engine.joinChannel(
            byToken: token,
            channelId: channel,
            uid: 0,           // 0 = SDK 随机分配
            mediaOptions: options
        ) { channel, uid, elapsed in
            print("✅ joinChannel success: (channel), uid=(uid)")
        }
    }

    /// 离开频道 & 销毁
    func leaveChannel() {
        engine.leaveChannel { stats in
            print("left channel, duration=(stats.duration)")
        }
        // 如果整个会话结束:
        // AgoraRtcEngineKit.destroy()
    }
}

3. RTC 回调监听(谁上了麦 / 谁下了麦)

swift
swift
extension VoiceChatManager: AgoraRtcEngineDelegate {

    // 远端用户加入(开始发流时也会触发)
    func rtcEngine(_ engine: AgoraRtcEngineKit,
                   didJoinedOfUid uid: UInt,
                   elapsed: Int) {
        print("🎙️ remote user joined: (uid)")
        NotificationCenter.default.post(
            name: .voiceChatUserDidJoin,
            object: nil,
            userInfo: ["uid": uid]
        )
    }

    // 远端用户离开
    func rtcEngine(_ engine: AgoraRtcEngineKit,
                   didOfflineOfUid uid: UInt,
                   reason: AgoraUserOfflineReason) {
        print("🎙️ remote user offline: (uid), reason=(reason.rawValue)")
        NotificationCenter.default.post(
            name: .voiceChatUserDidLeave,
            object: nil,
            userInfo: ["uid": uid]
        )
    }

    // 错误 & 警告
    func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
        print("❌ RTC error: (errorCode.rawValue)")
    }
}

// MARK: - Notifications
extension Notification.Name {
    static let voiceChatUserDidJoin  = Notification.Name("voiceChatUserDidJoin")
    static let voiceChatUserDidLeave = Notification.Name("voiceChatUserDidLeave")
}

这里 didJoinedOfUiddidOfflineOfUid就是你维护"麦位列表"的数据来源。观众端靠这些回调知道"现在谁在说话"。


六、RTM 层:实时消息(聊天文字 + 信令)

语聊房的 RTM 通常做两件事:

  1. 聊天室文字消息(广播给频道内所有人)
  2. 自定义信令——上麦申请、房主批准、踢人通知等(可用 JSON 透传)

1. 初始化 RTM & 登录

import ShengwangRtm   // 或 import AgoraRtmKit,看你 pod 用的哪个名字

class RTMManager: NSObject {

    private(set) var rtmClient: AgoraRtmClientKit?
    private let appId = "YOUR_APP_ID"
    private var currentUserId: String = ""

    // 消息频道引用(用于发广播消息)
    private var messageChannel: AgoraRtmChannel?

    func login(userId: String, token: String? = nil, completion: @escaping (Error?) -> Void) {
        self.currentUserId = userId

        let cfg = AgoraRtmClientConfig(appId: appId, userId: userId)
        // 日志级别按需开
        cfg.logLevel = .info

        var err: NSError?
        rtmClient = AgoraRtmClientKit(config: cfg, error: &err)
        if let e = err {
            completion(e)
            return
        }

        // 设置消息回调
        rtmClient?.addDelegate(self)

        // 登录 RTM(测试阶段 token 可为 nil 或用临时 token)
        rtmClient?.login(byToken: token) { [weak self] resp, errInfo in
            if let errInfo = errInfo, errInfo.errorCode != .ok {
                completion(NSError(domain: "RTM", code: Int(errInfo.errorCode.rawValue),
                                    userInfo: [NSLocalizedDescriptionKey: errInfo.reason ?? ""]))
                return
            }
            print("✅ RTM login success")
            completion(nil)
        }
    }

    func logout() {
        messageChannel?.release()
        messageChannel = nil
        rtmClient?.logout(nil)
    }
}

新版 RTM 2.x(ShengwangRtm)的入口类是 AgoraRtmClientKit,通过 AgoraRtmClientConfig(appId:userId:)初始化,再调 loginByToken:登录。

2. 加入 RTM 消息频道 & 收发消息

extension RTMManager {

    /// 加入 RTM 频道(通常与 RTC channelId 同名)
    func joinMessageChannel(_ channelId: String) {
        let chanCfg = AgoraRtmChannelConfig()
        messageChannel = rtmClient?.createChannel(channelId, config: chanCfg)

        messageChannel?.join { resp, errInfo in
            if errInfo?.errorCode == .ok {
                print("✅ RTM channel joined: (channelId)")
            }
        }
    }

    /// 发送聊天文字消息
    func sendChat(text: String) {
        let msg = AgoraRtmMessage(text)
        messageChannel?.send(msg) { resp, errInfo in
            if errInfo?.errorCode == .ok {
                print("📨 chat sent")
            }
        }
    }

    /// 发送自定义信令(JSON)
    func sendSignal(type: String, payload: [String: Any]) {
        var dict: [String: Any] = ["type": type]
        dict["data"] = payload
        guard let data = try? JSONSerialization.data(withJSONObject: dict),
              let text = String(data: data, encoding: .utf8) else { return }
        let msg = AgoraRtmMessage(text)
        msg.messageType = .custom  // 标记为非普通聊天
        messageChannel?.send(msg, completion: nil)
    }
}

3. 监听 RTM 消息回调

extension RTMManager: AgoraRtmClientDelegate {

    // RTM 频道消息回调
    func rtmChannel(_ channel: AgoraRtmChannel,
                    messageReceived message: AgoraRtmMessage,
                    from sender: String) {
        DispatchQueue.main.async {
            print("💬 [(sender)]: (message.stringData ?? "")")

            // 尝试解析信令 JSON
            if let data = message.stringData?.data(using: .utf8),
               let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let type = json["type"] as? String {

                NotificationCenter.default.post(
                    name: .voiceChatSignalReceived,
                    object: nil,
                    userInfo: ["type": type, "from": sender, "payload": json["data"] ?? [:]]
                )
            } else {
                // 纯聊天文字
                NotificationCenter.default.post(
                    name: .voiceChatTextMessageReceived,
                    object: nil,
                    userInfo: ["from": sender, "text": message.stringData ?? ""]
                )
            }
        }
    }

    // RTM 连接状态变化
    func rtmClient(_ client: AgoraRtmClientKit,
                   connectionStateChanged state: AgoraRtmClientConnectionState,
                   reason: AgoraRtmClientConnectionChangeReason) {
        print("RTM state: (state.rawValue), reason: (reason.rawValue)")
    }
}

extension Notification.Name {
    static let voiceChatTextMessageReceived = Notification.Name("voiceChatTextMessageReceived")
    static let voiceChatSignalReceived       = Notification.Name("voiceChatSignalReceived")
}

七、核心交互:上麦 / 下麦(角色切换)

这是语聊房最关键的 UX 动作——观众申请上麦 → 切角色 → 开始发流

extension VoiceChatManager {

    /// 观众 → 上麦(切换为 broadcaster,打开麦克风发布)
    func requestMicOn(completion: ((Bool) -> Void)? = nil) {
        // Step 1: 切角色
        engine.setClientRole(.broadcaster)

        // Step 2: 确保麦克风发布打开
        engine.muteLocalAudioStream(false)

        // 通知远端(通过 RTM 信令)
        // rtm.sendSignal(type: "mic_on", payload: ["uid": myUid])
        completion?(true)
    }

    /// 主播 → 下麦(切回 audience,停止发流)
    func micOff() {
        engine.muteLocalAudioStream(true)
        engine.setClientRole(.audience)

        // rtm.sendSignal(type: "mic_off", payload: ["uid": myUid])
    }

    /// 本地静音(不发流但不下麦)
    func toggleMuteLocal(_ muted: Bool) {
        engine.muteLocalAudioStream(muted)
    }
}

⚠️ setClientRole(.audience)vs muteLocalAudioStream(true)的区别:前者是角色切换(不再作为"发言者"出现在远端列表),后者只是静音但仍在发空流/保连接。语聊房一般用角色切换更干净。


八、一个简单的 ViewController 串起来

swift
swift
class VoiceRoomVC: UIViewController {

    private let rtc = VoiceChatManager()
    private let rtm = RTMManager()
    private let roomId = "room_1001"
    private let token: String? = nil   // 临时 token

    override func viewDidLoad() {
        super.viewDidLoad()
        rtc.setupEngine()

        // 1. RTM 登录
        let userId = "user_(Int.random(in: 1000...9999))"
        rtm.login(userId: userId) { err in
            guard err == nil else { print("RTM login fail"); return }
            // 2. 加入 RTM 消息频道
            self.rtm.joinMessageChannel(self.roomId)
            // 3. 加入 RTC(观众身份先进房收听)
            self.rtc.joinChannel(channel: self.roomId, token: self.token, asBroadcaster: false)
        }
    }

    // IBAction: 点击"上麦"
    @IBAction func onTapMicOn() {
        rtc.requestMicOn()
    }

    @IBAction func onTapSendMessage() {
        rtm.sendChat(text: "Hello 语聊房 👋")
    }

    deinit {
        rtc.leaveChannel()
        rtm.logout()
    }
}

九、常见踩坑 Checklist ✅

问题 原因 & 解法
进房没声音 忘记调 enableAudio()/ 忘了 autoSubscribeAudio = true/ Info.plist 没加麦克风权限
模拟器能跑但真机无声 真机必须授麦克风权限,且第一次进房前系统弹框要允许
RTM 和 RTC pod 冲突(duplicate symbols) 确认 RTM ≥ 2.2.0 与 RTC ≥ 4.3.0 时的官方 FAQ 处理方式,或用 ShengwangRtm新包名
Token 过期后音频断了 监听 rtcEngine(_:tokenPrivilegeWillExpire:)回调,去后端换新 token 后调 renewToken:
上麦后远端听不到 确认 publishMicrophoneTrack = truesetClientRole(.broadcaster),且没被 muteLocalAudioStream(true)静音着
语聊房耗电/发热 disableVideo()setAudioProfile(.default, scenario: .chatroom)、退出时 leaveChannel+ 适时 destroy

十、总结 & 下一步

至此你已经有了一个最小可跑的语音聊天室骨架

RTC ──→ 音频流传输(谁发声 / 谁静音 / 谁进出)

RTM ──→ 文字消息 + 信令(上麦申请 / 麦位状态 / 系统通知)

下一步可以做的事

  1. 服务端房间管理(创建房间、持久化麦位状态、踢人鉴权)—— 别让客户端自己当"房主权威"
  2. Token 安全方案——生产环境务必用 App 证书在服务端签发 RTC + RTM Token
  3. 麦位队列 UI——把 didJoinedOfUiddidOfflineOfUid映射到固定麦位 Grid
  4. 声音增强——AI 降噪(setAudioProfile高级参数)、耳返、音量指示(enableAudioVolumeIndication
  5. RTM Storage / Lock——用 RTM 的分布式锁做"同一时刻只有一个人操作麦位"的轻量原子控制

MVVM 本质解构 + RxSwift 与 Combine 深度对决与选型指南

作为 iOS 开发演进的核心架构,MVVM彻底解决了原生 MVC 的 Massive View Controller 顽疾;而响应式编程是 MVVM 落地的灵魂 —— 脱离响应式的 MVVM 只是伪架构。本文从资深开发工程化视角,深度拆解 MVVM 的底层设计逻辑,全方位对比 RxSwift 与 Combine 两大 iOS 响应式框架,结合实战、踩坑与选型策略,为中大型 iOS 项目的架构设计提供专业参考。

一、深刻理解 MVVM:不止是分层,是 iOS UI 开发的范式升级

绝大多数 iOS 开发者对 MVVM 的理解停留在「View-ViewModel-Model」三层结构,这是表层认知。从资深开发和工程化角度,MVVM 的核心是UI 与业务逻辑的彻底解耦数据驱动 UI的编程范式升级。

1.1 原生 MVC 的致命困境

iOS 官方推荐的 MVC 架构,在实际工程中会快速腐化:

  • ViewController 身兼数职:UI 渲染、用户交互、网络请求、数据解析、业务逻辑、状态管理;
  • 千行 VC 是常态,不可测试、难复用、难维护
  • View 与 Model 强耦合,UI 修改会牵连业务逻辑,业务逻辑变动会破坏 UI 渲染。

这是 iOS 原生开发的历史痛点,也是 MVVM 诞生的核心原因。

1.2 MVVM 的核心本质(资深开发必掌握)

MVVM 的设计目标不是「分层」,而是让 UI 层彻底被动化,让业务逻辑彻底纯净化

核心角色职责(严格边界)

表格

角色 核心职责 禁忌
View(ViewController/UIView) 仅负责:转发用户交互事件、响应数据渲染 UI 不写任何业务逻辑、不直接操作 Model、不持有网络 / 数据库对象
ViewModel 核心中间层:持有 Model、处理业务逻辑(校验 / 网络 / 数据转换)、暴露可观察数据流 不导入 UIKit、不持有任何 UI 对象、完全脱离 iOS 平台,可独立单元测试
Model 纯数据结构(实体类 / 结构体) 不包含任何业务逻辑、不与 UI/ViewModel 耦合

MVVM 的灵魂:双向绑定

View 与 ViewModel 之间不直接调用方法,而是通过可观察数据流实现自动绑定:

  1. ViewModel 数据变化 → 自动驱动 View 更新 UI;
  2. View 用户交互(点击 / 输入)→ 自动触发 ViewModel 业务逻辑。

这是 MVVM 的核心价值,也是原生 iOS 无法高效实现的能力 ——KVO/Notification/Delegate 代码冗余、易泄漏、难以维护,必须依赖响应式编程框架落地。

1.3 MVVM 黄金法则(工程化落地准则)

  1. View 只做「UI 转发 + 渲染」,无任何业务逻辑;
  2. ViewModel 无 UIKit 依赖,100% 可单元测试;
  3. 所有通信通过响应式数据流,禁止反向引用;
  4. 单一职责:复杂 ViewModel 拆分 UseCase/Service,拒绝臃肿。

二、响应式编程:MVVM 的唯一高效落地方案

MVVM 的核心是「绑定」,而响应式编程(RP) 是实现绑定的最优解:

  • 一切异步事件(UI 点击、网络请求、数据变化、定时器)抽象为可观察的数据流
  • 声明式语法处理数据流,实现自动化绑定;
  • 彻底告别代理、通知、闭包嵌套的异步噩梦。

iOS 生态中,只有两个选择:

  1. RxSwift:跨平台响应式标准 ReactiveX 的 iOS 实现,成熟稳定;
  2. Combine:苹果原生官方响应式框架,iOS13 + 内置,未来主流。

三、RxSwift 深度解析:成熟的响应式事实标准

3.1 核心定位

RxSwift 是ReactiveX的 iOS 移植版本(跨平台响应式规范,Java/RxJS 通用),是 iOS 响应式编程的「事实标准」,历经多年迭代,生态极致完善。

3.2 核心抽象

  • Observable:数据流生产者(发送数据 / 错误 / 完成);
  • Observer:数据流消费者;
  • Disposable:资源回收器(避免内存泄漏);
  • Operator:操作符(map/filter/flatMap/zip),数据流处理核心;
  • Scheduler:线程调度器(主线程 / 后台线程切换)。

3.3 iOS 生态矩阵

  • RxCocoa:UIKit 全扩展(UIButton.rx.tap/UITextField.rx.text);
  • RxDataSources:UITableView/CollectionView 极简数据绑定;
  • RxAlamofire:网络请求响应式封装;
  • 几乎所有主流第三方库都提供 Rx 扩展。

3.4 优劣势

优势

  • 全版本兼容:iOS8+,覆盖所有存量项目;
  • 生态天花板:社区成熟,无实现不了的场景;
  • 操作符丰富:复杂数据流开箱即用;
  • 文档 / 社区完善,问题秒解。

劣势

  • 学习成本极高:冷 / 热 Observable、背压等概念抽象;
  • 第三方依赖:增加包体积;
  • 非官方维护,未来迭代放缓。

四、Combine 深度解析:苹果原生的响应式未来

4.1 核心定位

苹果在 iOS13 推出的原生响应式框架,深度集成 SwiftUI、UIKit、Swift Concurrency(async/await),是苹果生态的未来标准

4.2 核心抽象(与 RxSwift 无缝映射)

表格

RxSwift Combine 功能一致
Observable Publisher 数据流生产者
Observer Subscriber 数据流消费者
Disposable Cancellable 资源销毁
BehaviorSubject CurrentValueSubject 带缓存值
PublishSubject PassthroughSubject 无缓存值

4.3 原生杀手锏

  • @Published:属性包装器,一行代码生成可观察数据流,ViewModel 绑定极简;
  • 原生集成 GCD/Operation,线程调度零成本;
  • 无缝衔接 Swift Concurrency,现代 Swift 编程体验拉满。

4.4 优劣势

优势

  • 官方原生:无第三方依赖,系统级优化;
  • 轻量无体积:内置系统,无需引入库;
  • 语法极简:贴合 Swift 语法,学习成本低;
  • 未来兼容:随 Swift/SwiftUI 迭代,长期维护。

劣势

  • 版本硬限制:iOS13 以下完全不支持
  • 生态贫瘠:第三方库远少于 RxSwift;
  • 操作符精简:复杂场景需自定义。

五、RxSwift vs Combine:全方位深度对比(资深开发核心参考)

5.1 基础能力对比

表格

维度 RxSwift Combine
兼容性 iOS8+,全平台覆盖 iOS13+,低版本无支持
依赖方式 第三方库(CocoaPods/SPM) 系统内置,无依赖
语法风格 标准 ReactiveX 链式调用 Swift 原生语法,极简简洁
核心简化 无属性包装器,需手动创建 Subject @Published 一行实现绑定
生态完善度 极致完善(UI / 网络 / 列表全覆盖) 原生生态完善,第三方薄弱
背压支持 需额外处理 原生内置支持
错误处理 灵活,无强类型约束 强类型泛型约束,更安全
测试工具 RxTest/RxBlocking,功能强大 原生 XCTest,简洁轻量化
学习成本 高(ReactiveX 抽象概念) 低(Swift 原生,易上手)

5.2 性能与内存

  • Combine:系统级优化,内存占用更低,线程调度更高效;
  • RxSwift:社区优化多年,性能稳定,资源回收严格可控;
  • 内存管理:两者均需手动管理订阅(DisposeBag/Set),否则泄漏。

5.3 工程化适配

  • 存量旧项目 → RxSwift(兼容低版本);
  • 全新 SwiftUI 项目 → Combine(原生最佳搭配);
  • 团队新手 → Combine(学习成本低);
  • 复杂数据流 / 列表 → RxSwift(生态完善)。

六、实战对比:MVVM + 登录页面(两种实现)

用最经典的登录场景,直观感受两种方案的编码差异。

核心需求

  • 账号 / 密码输入 → 实时校验按钮是否可点击;
  • 点击登录 → 触发网络请求 → 响应结果;
  • 严格遵循 MVVM:ViewModel 无 UIKit,View 仅绑定。

方案 1:MVVM + RxSwift

swift

// ViewModel (无UIKit依赖)
import RxSwift
import RxCocoa

class LoginViewModel {
    // 输入:账号、密码
    let account = BehaviorSubject<String>(value: "")
    let password = BehaviorSubject<String>(value: "")
    // 输出:登录按钮可点击、登录结果
    let isLoginEnabled = Observable<Bool>
    let loginResult = PublishSubject<Bool>()
    
    private let disposeBag = DisposeBag()
    
    init() {
        // 数据流绑定:实时校验输入
        isLoginEnabled = Observable.combineLatest(account, password)
            .map { account, pwd in
                return account.count >= 6 && pwd.count >= 6
            }
        
        // 业务逻辑:登录方法
        func login() {
            // 模拟网络请求
            Observable.just(true)
                .delay(.seconds(1), scheduler: ConcurrentDispatchQueueScheduler(qos: .default))
                .observe(on: MainScheduler.instance)
                .subscribe(onNext: { [weak self] result in
                    self?.loginResult.onNext(result)
                })
                .disposed(by: disposeBag)
        }
    }
}

// View (ViewController)
import UIKit
import RxSwift
import RxCocoa

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // 1. UI输入 → ViewModel
        accountTF.rx.text.orEmpty.bind(to: vm.account).disposed(by: disposeBag)
        passwordTF.rx.text.orEmpty.bind(to: vm.password).disposed(by: disposeBag)
        
        // 2. ViewModel状态 → UI渲染
        vm.isLoginEnabled.bind(to: loginBtn.rx.isEnabled).disposed(by: disposeBag)
        
        // 3. UI交互 → ViewModel逻辑
        loginBtn.rx.tap.subscribe(onNext: { [weak self] in
            self?.vm.login()
        }).disposed(by: disposeBag)
        
        // 4. 业务结果 → UI响应
        vm.loginResult.subscribe(onNext: { success in
            print("登录结果:(success)")
        }).disposed(by: disposeBag)
    }
}

方案 2:MVVM + Combine

swift

// ViewModel (无UIKit依赖)
import Combine

class LoginViewModel {
    // 输入:@Published 极简声明
    @Published var account = ""
    @Published var password = ""
    // 输出
    @Published var isLoginEnabled = false
    let loginResult = PassthroughSubject<Bool, Never>()
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // 实时校验
        $account.combineLatest($password)
            .map { account, pwd in
                account.count >= 6 && pwd.count >= 6
            }
            .assign(to: &$isLoginEnabled)
    }
    
    func login() {
        // 模拟网络请求 + 异步
        Future<Bool, Never> { promise in
            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                promise(.success(true))
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] success in
            self?.loginResult.send(success)
        }
        .store(in: &cancellables)
    }
}

// View (ViewController)
import UIKit
import Combine

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // UI输入 → ViewModel
        accountTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$account)
        
        passwordTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$password)
        
        // ViewModel → UI
        vm.$isLoginEnabled
            .assign(to: .isEnabled, on: loginBtn)
            .store(in: &cancellables)
        
        // 点击事件
        loginBtn.publisher(for: .touchUpInside)
            .sink { [weak self] in
                self?.vm.login()
            }
            .store(in: &cancellables)
        
        // 登录结果
        vm.loginResult
            .sink { success in
                print("登录结果:(success)")
            }
            .store(in: &cancellables)
    }
}

七、资深开发选型决策树

无需盲目追新,工程化落地是第一准则:

  1. 项目最低支持 < iOS13 → 唯一选择:RxSwift
  2. 全新项目 ≥iOS13 / SwiftUI 项目 → 首选:Combine
  3. 存量项目逐步升级 → 混合方案:旧页面保留 RxSwift,新页面用 Combine;
  4. 团队无响应式基础 → 优先:Combine(学习成本低,原生规范);
  5. 重度复杂数据流(电商 / 金融) → 优先:RxSwift(生态完善);
  6. 长期维护、追求苹果原生标准 → 必选:Combine

八、工程化避坑指南(资深实战经验)

8.1 MVVM 通用误区

  1. ❌ ViewModel 持有 UIKit 对象 → 破坏可测试性,严格禁止;
  2. ❌ ViewModel 过度臃肿 → 拆分 UseCase/Service,单一职责;
  3. ❌ 为了绑定而绑定 → 简单 UI 用原生,复杂数据流用响应式。

8.2 RxSwift 避坑

  • 内存泄漏:必须DisposeBag管理订阅;
  • 冷 / 热 Observable 误用:网络请求用Single,事件用PublishSubject
  • UI 更新必须切MainScheduler

8.3 Combine 避坑

  • 订阅销毁:必须Set<AnyCancellable>存储,否则订阅立即失效;
  • iOS13 存在 APIbug,建议最低支持 iOS14;
  • 缺少操作符时,用async/await补充。

九、总结

  1. MVVM 的核心:不是三层结构,而是数据驱动 UI+UI 与业务彻底解耦,响应式编程是其唯一高效落地方式;
  2. RxSwift:成熟稳定、生态完善、全版本兼容,是存量项目的最优解
  3. Combine:苹果原生、轻量简洁、未来主流,是新项目的标准答案
  4. 资深 iOS 开发的核心能力:不迷信框架,根据项目场景选型,落地可维护、可测试的工程化架构

iOS 开发已进入SwiftUI+Combine+async/await的原生现代化时代,MVVM 作为核心架构,将长期主导中大型项目的设计。


关键点回顾

  1. MVVM 核心:解耦 + 数据驱动,无响应式则无落地价值;
  2. RxSwift:存量项目、低版本兼容、生态为王;
  3. Combine:新项目、原生未来、简洁轻量;
  4. 选型看系统版本+项目阶段+团队成本,不盲目追新。

iOS RunLoop 原理深度解析与Swift高级用法

RunLoop是iOS开发的底层核心,贯穿应用全生命周期,支撑UI响应、定时器、网络回调、线程保活等所有异步操作,更是解决卡顿、死锁、内存泄漏的关键。本文以Swift视角,系统精简RunLoop的核心原理、组件机制、工作流程及高级实战,摒弃冗余,直击本质,助力开发者快速吃透底层逻辑并落地实践。

一、核心认知:RunLoop 的本质与关键误区

1.1 纠正常见误区

❌ 错误认知:RunLoop是用户态空转轮询(do-while死循环),持续占用CPU; ✅ 正确结论:RunLoop是苹果基于Mach内核封装的线程级事件调度管理器,核心靠内核阻塞调用实现“无事件休眠、有事件唤醒”,99%时间线程休眠,CPU占用接近0。 补充对比(精简版):普通死循环CPU占用接近100%,线程sleep无法响应即时事件,而RunLoop可在休眠时被即时事件唤醒,兼顾资源释放与响应速度。

1.2 核心定义与价值

本质:单线程事件调度中枢,核心职责3点:

  1. 统一接管线程所有事件(UI、定时器、网络回调等);
  2. 无事件时通过mach_msg阻塞休眠,释放CPU;
  3. 事件触发时唤醒线程,按优先级调度处理,通过Mode隔离事件避免干扰。

1.3 线程与RunLoop的绑定关系

  • 一一对应:一个线程对应一个RunLoop,生命周期完全绑定;
  • 懒加载:线程默认无RunLoop,调用RunLoop.current/CFRunLoopGetCurrent()时自动创建;
  • 主线程:系统自动创建并启动,贯穿APP生命周期;
  • 子线程:需手动管理(启动/停止),无事件源则启动后立即退出。

1.4 Swift常用的两套API体系

框架 API类型 线程安全 Swift用法 核心场景
Foundation RunLoop ❌ 非线程安全 RunLoop.current/main 上层业务开发(便捷)
Core Foundation CFRunLoopRef ✅ 线程安全 CFRunLoopGetCurrent() 底层开发(卡顿监控、线程保活)

二、底层拆解:RunLoop 核心组件(精简版)

核心结构:1个RunLoop + N个Mode + 3类组件(Source、Timer、Observer),核心规则:一次RunLoop仅运行在一个Mode下,切换Mode需退出并重新进入。

2.1 Mode:事件隔离容器(核心)

作用:隔离不同类型事件,避免干扰(如滑动与定时器不冲突),Swift常用Mode:

  • RunLoop.Mode.default:默认模式,APP空闲时运行(普通UI、默认定时器);
  • RunLoop.Mode.tracking:界面跟踪模式,滑动ScrollView/TableView时自动切换;
  • RunLoop.Mode.common:通用模式集合,事件可在多个Mode生效(推荐用于滑动时需触发的定时器)。

2.2 Source:事件输入源(唤醒RunLoop)

分两类,核心区别的是“是否具备内核唤醒能力”,补充Swift核心处理逻辑:

  • Source0:用户态事件(UI点击、手势、performSelector:onThread:),无内核唤醒能力,需手动调用CFRunLoopSourceSignal标记待处理,再调用CFRunLoopWakeUp唤醒RunLoop;
  • Source1:内核态事件(屏幕触摸、网络回调、跨线程Mach Port消息),基于Mach Port通信,内核检测到事件后自动唤醒RunLoop,无需手动操作。

补充:屏幕触摸完整流程(精简):手指触摸 → 内核包装为Mach消息 → Source1接收 → 唤醒RunLoop → 分发到Source0 → 处理手势/UI响应。

  • Source0:用户态事件(UI点击、手势),无内核唤醒能力,需手动标记待处理并唤醒RunLoop;
  • Source1:内核态事件(屏幕触摸、网络回调),基于Mach Port,可自动唤醒RunLoop。

2.3 Timer:定时触发源

依赖Mode机制,仅在绑定的Mode下触发,Swift实战选型(精简):

  • Timer:精度低(受RunLoop阻塞影响),适合普通定时(倒计时、轮播);
  • CADisplayLink:与屏幕刷新率同步(60fps),适合自定义动画;
  • GCD定时器:内核级精度最高,不依赖RunLoop,适合高精度场景(秒杀倒计时)。

2.4 Observer:状态监控者

监控RunLoop生命周期状态(entry/afterWaiting等),核心用于卡顿检测、性能监控,Swift中通过CFRunLoopObserver实现。

三、深度剖析:RunLoop 工作机制

核心流程:事件处理 → 阻塞休眠 → 唤醒处理 → 循环往复,核心依赖mach_msg函数实现阻塞与唤醒,结合CFRunLoop源码核心逻辑(精简伪代码):

// 核心循环逻辑(精简版)
void __CFRunLoopRun() {
    // 1. 通知进入RunLoop
    __CFRunLoopDoObservers(entry);
    while (1) {
        // 2. 处理Timer和Source0
        __CFRunLoopDoTimers();
        __CFRunLoopDoSources0();
        // 3. 检查Source1,有则处理,无则休眠
        if (!__CFRunLoopServiceMachPort()) {
            __CFRunLoopDoObservers(beforeWaiting);
            mach_msg(...);// 阻塞休眠
            __CFRunLoopDoObservers(afterWaiting);
        }
        // 4. 处理唤醒事件(Timer/Source1等)
        __CFRunLoopHandleMsg();
        // 5. 满足条件则退出
        if (shouldExit) break;
    }
    __CFRunLoopDoObservers(exit);
}

流程拆解:

  1. 进入RunLoop,通知Observer(entry状态);
  2. 处理当前Mode下到期的Timer、待处理的Source0;
  3. 检查Source1,有则直接处理,无则调用mach_msg阻塞休眠(释放CPU);
  4. 被事件(Source1/Timer/手动唤醒)唤醒,处理对应事件;
  5. 满足退出条件则终止,否则重复循环。

关键:mach_msg是内核级阻塞调用,无事件时线程挂起,有事件时内核自动唤醒,这是RunLoop与死循环的本质区别。

四、Swift 实操:基础用法

4.1 获取RunLoop实例

// 当前线程RunLoop(懒加载)
let currentRunloop = RunLoop.current
// 主线程RunLoop(系统自动创建)
let mainRunloop = RunLoop.main
// 线程安全的CFRunLoop
let cfRunloop = CFRunLoopGetCurrent()

4.2 子线程RunLoop启动(重点)

// 子线程保活示例
DispatchQueue.global().async {
    let runloop = RunLoop.current
    // 必须添加事件源(否则启动后立即退出)
    runloop.add(NSMachPort(), forMode: .default)
    // 无限运行(需手动停止)
    runloop.run()
}

// 停止RunLoop(需在对应线程调用)
DispatchQueue.global().async {
    CFRunLoopStop(CFRunLoopGetCurrent())
}

4.3 Timer避坑用法(推荐)

// 手动添加到common模式,滑动时仍触发
let timer = Timer(timeInterval: 1, repeats: true) { _ in
    print("定时执行,滑动不暂停")
}
RunLoop.current.add(timer, forMode: .common)
timer.fire() // 立即触发一次

五、高级实战:RunLoop 核心落地场景

5.1 主线程卡顿检测(核心应用)

原理:监控beforeSources和afterWaiting状态,计算耗时超过阈值(300ms)判定为卡顿,捕获堆栈用于排查。

import UIKit
import QuartzCore

class卡顿Monitor {
    static let shared = 卡顿Monitor()
    private let threshold: TimeInterval = 0.3 // 300ms阈值(可调整)
    private var startTimestamp: CFTimeInterval = 0
    private let lock = NSLock() // 保证线程安全
    private var observer: CFRunLoopObserver?
    
    private init() {} // 单例,禁止外部初始化
    
    func startMonitoring() {
        guard observer == nil else { return }
        let mainRunloop = CFRunLoopGetMain() // 监控主线程RunLoop
        // 上下文传递,将self绑定到Observer回调中
        let context = CFRunLoopObserverContext(
            version: 0,
            info: Unmanaged.passUnretained(self).toOpaque(),
            retain: nil,
            release: nil,
            copyDescription: nil
        )
        // 监控beforeSources(即将处理事件)和afterWaiting(唤醒后)状态
        observer = CFRunLoopObserverCreate(
            nil,
            CFRunLoopActivity.beforeSources.rawValue | CFRunLoopActivity.afterWaiting.rawValue,
            true, // 重复监控
            0, // 优先级(0最低)
            { _, activity, info in
                // 从上下文取出self
                guard let info = info else { return }
                let monitor = Unmanaged<卡顿Monitor>.fromOpaque(info).takeUnretainedValue()
                monitor.handleRunLoopActivity(activity)
            },
            &context
        )
        // 添加到common模式,确保滑动时也能监控
        if let observer = observer {
            CFRunLoopAddObserver(mainRunloop, observer, CFRunLoopMode.commonModes)
        }
    }
    
    func stopMonitoring() {
        guard let observer = observer else { return }
        CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, CFRunLoopMode.commonModes)
        self.observer = nil // 释放,避免内存泄漏
    }
    
    private func handleRunLoopActivity(_ activity: CFRunLoopActivity) {
        lock.lock()
        defer { lock.unlock() } // 确保锁一定会释放
        
        switch activity {
        case .beforeSources:
            // 记录事件处理开始时间戳
            startTimestamp = CACurrentMediaTime()
        case .afterWaiting:
            // 计算事件处理耗时
            let elapsed = CACurrentMediaTime() - startTimestamp
            if elapsed > threshold {
                print("⚠️ 主线程卡顿警告,耗时:(String(format: "%.2f", elapsed*1000))ms")
                let stack = getCurrentStack()
                print("卡顿堆栈信息:\n(stack)")
                // 实际开发中可在此处添加日志上报(友盟、Bugly等)
            }
        default:
            break
        }
    }
    
    // 优化版堆栈捕获:过滤系统堆栈,保留业务堆栈,更易排查
    private func getCurrentStack() -> String {
        var callStack = Thread.callStackSymbols
        // 过滤前2条(当前函数、Observer回调)和后3条(系统底层函数)
        callStack.removeFirst(2)
        if callStack.count > 8 {
            callStack = Array(callStack.prefix(8))
        }
        // 格式化堆栈,添加序号,更易阅读
        return callStack.enumerated().map { "($0.offset + 1). ($0.element)" }.joined(separator: "\n")
    }
}

// 使用方式(AppDelegate或SceneDelegate中)
// func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//     卡顿Monitor.shared.startMonitoring()
//     return true
// }

5.2 其他高频场景

  • 线程保活:通过子线程RunLoop+Source/Port,实现后台任务长期运行(如后台下载);
  • 延迟执行:利用RunLoop.perform(afterDelay:),比GCD更轻量且可取消;
  • 避免滑动卡顿:将耗时任务(如复杂计算)移出主线程,或切换到合适Mode。

5.3 常见坑点总结

  • 坑点1:Timer滑动失效 → 解决方案:将Timer添加到common模式,而非default模式;
  • 坑点2:子线程RunLoop启动后立即退出 → 解决方案:必须添加事件源(Source/Port/Timer),否则无事件可处理会直接退出;
  • 坑点3:手动停止RunLoop无效 → 解决方案:停止RunLoop必须在对应线程调用,不可跨线程停止;
  • 坑点4:Observer内存泄漏 → 解决方案:停止监控时,必须移除Observer并置为nil,避免循环引用;
  • 坑点5:混淆RunLoop与GCD定时器 → 解决方案:高精度定时用GCD定时器,普通定时用RunLoop的Timer(更轻量)。

六、核心总结

  1. RunLoop核心:线程的事件调度中枢,靠mach_msg实现“休眠-唤醒”,不占用多余CPU;

  2. 组件核心:Mode隔离事件,Source提供事件,Timer定时,Observer监控;

  3. Swift选型:上层用RunLoop(便捷),底层用CFRunLoopRef(线程安全);

  4. 实战价值:解决卡顿、线程保活、定时器失效等底层问题,是iOS高级开发必备技能;补充:吃透RunLoop,能快速定位APP性能瓶颈,避免因底层认知不足导致的隐蔽bug。

iOS 线程常驻(RunLoop 保活)实战:原理、优劣、避坑与双语言实现

作为 iOS 资深开发,线程常驻是底层线程开发的高阶技能,核心用于高频轻量任务、音视频数据流、长连接等极致性能场景。它的本质是通过 RunLoop 保活子线程,让线程执行完任务后不销毁,一直等待新任务。

本文将从核心原理、优劣分析、生产级高级写法、避免方案四个维度深度拆解,并提供 Objective-C + Swift 双语言完整示例。


一、核心原理:线程常驻的底层逻辑

1. 默认线程生命周期

iOS 普通子线程(NSThread/pthread)执行流程:创建线程 → 执行任务 → 任务完成 → 线程自动销毁缺点:频繁创建 / 销毁线程会产生巨大性能开销。

2. 线程常驻核心机制

RunLoop 保活:给子线程绑定一个无限循环的 RunLoop,添加空输入源防止 RunLoop 立即退出,让线程进入休眠状态(不消耗 CPU),实现永久存活。

  • 关键 API:CFRunLoopAddSource(添加保活源)、CFRunLoopRun(启动循环)、CFRunLoopStop(停止循环)
  • 核心:RunLoop 不退出 → 线程不销毁

3. 适用边界

仅用于高频、轻量、低延迟任务(日志上报、埋点、音视频编解码、长连接心跳);普通业务绝对禁止使用。


二、线程常驻的 优势 VS 劣势(资深视角)

✅ 核心优势

  1. 极致性能:避免线程频繁创建 / 销毁(线程是操作系统重量级资源,创建耗时≈100ms)
  2. 低延迟响应:任务直达常驻线程,无线程创建耗时
  3. 资源可控:专用线程处理特定任务,不与业务线程竞争
  4. 长连接保活:网络长连接、音视频流必须用常驻线程保证链路不中断

❌ 致命劣势

  1. 内存泄漏风险:忘记停止 RunLoop → 线程永久驻留内存,无法释放
  2. 系统资源浪费:常驻线程会占用系统线程池配额,过多会导致 APP 卡顿
  3. 维护成本极高:手动管理 RunLoop、线程安全、生命周期,极易出现死锁 / 野指针
  4. 违背系统设计:GCD/NSOperation 已自动实现线程复用,手动常驻是兜底方案

三、线程常驻 高级写法(生产级封装)

基础版仅用于理解原理,工程中必须用高级封装版:单例复用、线程安全任务队列、优雅退出、无内存泄漏。线程常驻仅支持 NSThread(pthread),GCD 无法手动实现常驻(系统自动管理线程)。

方案 1:Objective-C 高级常驻线程

objectivec

#import <Foundation/Foundation.h>

@interface ResidentThread : NSObject
/// 单例全局常驻线程
+ (instancetype)sharedThread;
/// 异步执行任务
- (void)executeTask:(dispatch_block_t)task;
/// 优雅退出线程(必须调用,防止内存泄漏)
- (void)stopThread;
@end

// ====================== 实现 ======================
#import "ResidentThread.h"

@interface ResidentThread ()
@property (nonatomic, strong) NSThread *residentThread; // 常驻线程
@property (nonatomic, assign) BOOL isStopped;            // 退出标记
@property (nonatomic, strong) NSLock *lock;               // 线程安全锁
@property (nonatomic, strong) NSMutableArray *taskArray; // 任务队列
@end

@implementation ResidentThread

+ (instancetype)sharedThread {
    static ResidentThread *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _isStopped = NO;
        _lock = [[NSLock alloc] init];
        _taskArray = [NSMutableArray array];
        // 创建常驻线程
        __weak typeof(self) weakSelf = self;
        self.residentThread = [[NSThread alloc] initWithTarget:weakSelf selector:@selector(runLoopAction) object:nil];
        self.residentThread.name = @"com.app.resident.thread";
        [self.residentThread start];
    }
    return self;
}

/// RunLoop 保活核心方法
- (void)runLoopAction {
    @autoreleasepool {
        // 1. 添加空输入源,防止RunLoop立即退出
        CFRunLoopSourceContext context = {0};
        CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
        CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
        CFRelease(source);
        
        // 2. 启动RunLoop循环(休眠状态,不消耗CPU)
        while (!self.isStopped) {
            // 执行队列中的任务
            [self.lock lock];
            if (self.taskArray.count > 0) {
                dispatch_block_t task = self.taskArray.firstObject;
                [self.taskArray removeObjectAtIndex:0];
                task();
            }
            [self.lock unlock];
            
            // RunLoop 运行1秒,循环检测
            CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, NO);
        }
        
        // 3. 停止RunLoop,线程销毁
        CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
        NSLog(@"常驻线程已销毁");
    }
}

/// 异步添加任务
- (void)executeTask:(dispatch_block_t)task {
    if (!task || self.isStopped) return;
    [self.lock lock];
    [self.taskArray addObject:task];
    [self.lock unlock];
}

/// 优雅退出
- (void)stopThread {
    if (self.isStopped) return;
    self.isStopped = YES;
    // 停止RunLoop
    CFRunLoopStop(CFRunLoopGetCurrent());
    self.residentThread = nil;
}

@end

方案 2:Swift 高级常驻线程

swift

import Foundation

final class ResidentThread {
    // 单例
    static let shared = ResidentThread()
    private init() {
        self.setupThread()
    }
    
    // MARK: - 私有属性
    private var thread: Thread!
    private var isStopped = false
    private let lock = NSLock()
    private var taskArray = [() -> Void]()
    
    // MARK: - 初始化常驻线程
    private func setupThread() {
        thread = Thread(target: self, selector: #selector(runLoopAction), object: nil)
        thread.name = "com.app.resident.thread.swift"
        thread.start()
    }
    
    // MARK: - RunLoop 保活核心
    @objc private func runLoopAction() {
        autoreleasepool {
            // 1. 添加空源,防止RunLoop退出
            let context = CFRunLoopSourceContext()
            let source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, context)
            CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .defaultMode)
            
            // 2. 循环执行任务
            while !isStopped {
                lock.lock()
                if !taskArray.isEmpty {
                    let task = taskArray.removeFirst()
                    task()
                }
                lock.unlock()
                
                // RunLoop 休眠1秒,低功耗
                CFRunLoopRunInMode(.defaultMode, 1.0, false)
            }
            
            // 3. 清理资源
            CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .defaultMode)
            print("Swift 常驻线程已销毁")
        }
    }
    
    // MARK: - 公开API
    /// 执行任务
    func execute(task: @escaping () -> Void) {
        guard !isStopped else { return }
        lock.lock()
        taskArray.append(task)
        lock.unlock()
    }
    
    /// 优雅退出
    func stop() {
        guard !isStopped else { return }
        isStopped = true
        CFRunLoopStop(CFRunLoopGetCurrent())
    }
}

双语言使用示例

objectivec

// OC 使用
- (void)testResidentThread {
    // 执行任务
    [[ResidentThread sharedThread] executeTask:^{
        NSLog(@"OC 常驻线程执行任务:%@", [NSThread currentThread]);
    }];
    
    // 页面销毁/模块销毁时,必须调用退出!
    // [[ResidentThread sharedThread] stopThread];
}

swift

// Swift 使用
func testResidentThread() {
    // 执行任务
    ResidentThread.shared.execute {
        print("Swift 常驻线程执行任务:Thread.current)")
    }
    
    // 必须在合适时机退出
    // ResidentThread.shared.stop()
}

四、如何避免线程常驻?(最优工程实践)

99% 的业务场景,完全不需要手动实现线程常驻!苹果的 GCD / NSOperation 已经内置了线程池复用机制,系统自动管理线程生命周期,比手动常驻更安全、更高效。

替代方案 1:GCD 串行队列(系统自动复用线程)

GCD 会复用空闲线程,不会频繁创建 / 销毁,完美替代手动常驻线程。

objectivec

// OC:GCD 复用线程(推荐)
dispatch_queue_t serialQueue = dispatch_queue_create("com.app.gcd.serial", DISPATCH_QUEUE_SERIAL);
- (void)gcdTask {
    dispatch_async(serialQueue, ^{
        NSLog(@"GCD 复用线程:%@", [NSThread currentThread]);
    });
}

swift

// Swift:GCD 复用线程
private let serialQueue = DispatchQueue(label: "com.app.gcd.serial.swift")
func gcdTask() {
    serialQueue.async {
        print("GCD 复用线程:Thread.current)")
    }
}

替代方案 2:NSOperationQueue(可控并发)

swift

// Swift 操作队列
private let operationQueue = OperationQueue()
init() {
    operationQueue.maxConcurrentOperationCount = 1 // 串行复用
}
func operationTask() {
    let op = BlockOperation {
        print("NSOperation 复用线程")
    }
    operationQueue.addOperation(op)
}

避免线程常驻的核心原则

  1. 普通业务 → 用 GCD:系统自动线程复用,零维护成本
  2. 复杂任务 → 用 NSOperation:支持依赖 / 取消,自动管理线程
  3. 绝对禁止:无理由创建手动常驻线程
  4. 必须用常驻:仅音视频、长连接、低延迟心跳等极致场景

五、关键避坑指南

  1. 必须优雅退出:页面 / 模块销毁时,一定要调用 stopThread 停止 RunLoop,否则内存泄漏
  2. 禁止多开:整个 APP 最多创建 1~2 个 常驻线程,过多会耗尽系统线程资源
  3. 线程安全:任务队列必须加锁,防止多线程读写崩溃
  4. 禁止 UI 操作:常驻线程是子线程,绝对不能更新 UI
  5. 低功耗设计:RunLoop 使用 RunInMode 定时休眠,不要无限循环消耗 CPU

总结

  1. 核心原理:线程常驻 = RunLoop 保活,是底层性能优化方案
  2. 高级写法:生产级必须封装单例 + 线程安全队列 + 优雅退出
  3. 优劣:性能极致但风险极高,仅用于特殊场景
  4. 最优解优先用 GCD/NSOperation,系统自动线程复用,避免手动常驻
  5. 生命周期:常驻线程必须手动退出,否则永久泄漏
❌