普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月30日首页

【图像处理】Core Image 与 GPU 渲染管线——让滤镜飞起来

作者 FIRE_Lee
2026年5月30日 16:49

CPU 是一位精英工程师,一次专心做一件事; GPU 是一支万人工厂,每条流水线同时处理一块像素。 选对工具,差距可以是 10 倍。


一、GPU vs CPU:图像处理的架构差异

处理一张 4000×3000(1200 万像素)的照片,每个像素需要独立计算。

架构 核心数 时钟频率 每秒操作数(粗估) 典型图像处理耗时
CPU(Apple M2,4 性能核) 4 3.5 GHz ~14 GFLOPS(浮点) 高斯模糊 ~120ms
GPU(Apple M2,10 核) 10 个着色器簇(每簇 128 核) 1.4 GHz ~3.6 TFLOPS 高斯模糊 ~3ms

差距来自什么?

CPU 处理像素(串行):
  Pixel[0] → 计算 → Pixel[1] → 计算 → Pixel[2] → ...
  → 1200 万次顺序操作

GPU 处理像素(并行):
  Pixel[0..N]   → 同时计算(一个 GPU 核处理一个像素)
  → 理论上 1280 个核同时工作,实际受限于带宽

CPU 的优势在于复杂逻辑、分支预测、大缓存;GPU 的优势在于数量庞大的简单核心,适合对每个像素做相同操作(SIMT:单指令多线程)。

图像滤镜恰好是 GPU 的主场:对每个像素独立执行同一段着色器程序。


二、Core Image 处理流水线

Apple 的 Core Image 框架把 GPU 渲染封装成了一套声明式 API:

原始图像数据
    ↓
CIImage(图像描述符,惰性)
    ↓
CIFilter(滤镜图,节点图)
    ↓  ← CIContext 触发渲染
CGImage / MTLTexture / UIImage

关键概念:CIImage 不是像素数据

CIImage 是一个描述,不是内存中的像素数组。它只记录"这是一张什么图,来自哪里,怎么处理"。

// 这行代码不做任何像素计算:
let ciImage = CIImage(cgImage: myCGImage)

// 这行也只是在图上挂一个滤镜节点:
let blurred = ciImage.applyingFilter("CIGaussianBlur", parameters: ["inputRadius": 10])

// 只有这行才真正触发 GPU 计算:
let result = context.createCGImage(blurred, from: blurred.extent)

这种惰性求值(Lazy Evaluation)设计让 Core Image 可以把多个滤镜合并成一个 GPU Pass,极大减少显存读写。


三、CIContext 的创建成本

CIContext 是连接 CPU/Swift 代码和 GPU 的桥梁,它负责:

  • 编译 Metal 着色器
  • 管理 GPU 纹理缓存
  • 协调 GPU 命令队列

问题:创建 CIContext 非常昂贵。

// 实测(iPhone 14,Debug build):
let t0 = CACurrentMediaTime()
let ctx = CIContext()                    // Metal 默认
let t1 = CACurrentMediaTime()
print((t1 - t0) * 1000)                 // 输出:约 18ms ~ 32ms

在处理每张图片时创建新的 CIContext常见错误

// 错误做法:每次调用都创建新 context,消耗 18-32ms
func applyBlur(to image: CIImage) -> CGImage {
    let context = CIContext()            // 每次都创建!
    return context.createCGImage(image, from: image.extent)!
}

解决方案:复用 CIContext,用对象池管理。


四、CIContextPool 设计

MLImageToolkit 中的 CIContextPool 采用单例 + Metal 优先,CPU 回退的策略:

final class CIContextPool {

    static let shared = CIContextPool()
    private init() {}

    // Metal 上下文(GPU 渲染)
    private lazy var metalContext: CIContext? = {
        guard let device = MTLCreateSystemDefaultDevice() else { return nil }
        return CIContext(mtlDevice: device, options: [
            .workingColorSpace: CGColorSpaceCreateDeviceRGB(),
            .outputColorSpace:  CGColorSpaceCreateDeviceRGB()
        ])
    }()

    // CPU 软件渲染(测试 / Metal 不可用时的回退)
    private lazy var cpuContext: CIContext = {
        CIContext(options: [
            .useSoftwareRenderer: true,
            .workingColorSpace: CGColorSpaceCreateDeviceRGB()
        ])
    }()

    /// 获取当前最优上下文
    func context(preferCPU: Bool = false) -> CIContext {
        if preferCPU { return cpuContext }
        return metalContext ?? cpuContext
    }
}

设计要点

特性 说明
lazy var 延迟初始化,不在 app 启动时占用 18ms
Metal 优先 MTLCreateSystemDefaultDevice() 失败时自动降级
CPU 回退 模拟器、单元测试环境无 GPU,需要 CPU 渲染
单例共享 整个 app 生命周期只创建一次,完全消除重复创建成本

五、MLBitmap ↔ CIImage 的转换

5.1 坐标系差异

这是 Core Image 最容易踩的坑:

UIKit / MLBitmap 坐标系:
  (0,0) ─────────────→ x
    │
    │
    ↓ y
  原点在左上角,y 轴向下

CIImage 坐标系:
  原点在左下角,y 轴向上(类 OpenGL)
    ↑ y
    │
    │
  (0,0) ─────────────→ x

在 iOS 上直接把 UIImage 转成 CIImage 再渲染回来,图像会上下翻转。必须手动处理。

5.2 MLBitmap → CIImage

extension MLBitmap {

    func toCIImage() -> CIImage {
        // 1. 把 MLBitmap 的原始字节包装成 Data(零拷贝)
        let data = Data(bytes: pixels, count: pixels.count)

        // 2. 用 RGBA 格式描述内存布局
        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)

        // 3. 创建 CGDataProvider(仍是惰性,不复制数据)
        guard let provider = CGDataProvider(data: data as CFData),
              let cgImage = CGImage(
                width: width,
                height: height,
                bitsPerComponent: 8,
                bitsPerPixel: 32,
                bytesPerRow: width * 4,
                space: CGColorSpaceCreateDeviceRGB(),
                bitmapInfo: bitmapInfo,
                provider: provider,
                decode: nil,
                shouldInterpolate: false,
                intent: .defaultIntent
              )
        else {
            fatalError("MLBitmap → CIImage 转换失败")
        }

        // 4. CIImage 原点在左下,需要翻转 y 轴
        return CIImage(cgImage: cgImage)
            .transformed(by: CGAffineTransform(scaleX: 1, y: -1)
                .concatenating(CGAffineTransform(translationX: 0, y: CGFloat(height))))
    }
}

为什么需要那个 transform?

CIImage(cgImage:) 会把 CGImage 的第一行(上方)映射到 CIImage 坐标的 y=0(下方),导致图像倒置。我们施加一个 y 轴翻转 + 向上平移 height 的变换,把图像重新正过来。

5.3 CIImage → MLBitmap(render 阶段)

func ciImageToBitmap(_ ciImage: CIImage, width: Int, height: Int) -> MLBitmap {

    // 关键:render bounds 固定为 (0, 0, width, height)
    // 不能用 ciImage.extent,因为经过 transform 后 extent 可能有偏移
    let renderRect = CGRect(x: 0, y: 0, width: width, height: height)

    let context = CIContextPool.shared.context()
    guard let cgImage = context.createCGImage(ciImage, from: renderRect) else {
        fatalError("CIImage → CGImage 渲染失败")
    }

    // 从 CGImage 读回像素
    var bitmap = MLBitmap(width: width, height: height)
    let bmpContext = CGContext(
        data: &bitmap.pixels,
        width: width,
        height: height,
        bitsPerComponent: 8,
        bytesPerRow: width * 4,
        space: CGColorSpaceCreateDeviceRGB(),
        bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
    )
    bmpContext?.draw(cgImage, in: renderRect)
    return bitmap
}

为什么 render bounds 固定为 (0, 0, w, h)

经过坐标系翻转变换后,ciImage.extent 的原点可能变成 (0, -height),直接传入 createCGImage 会渲染出全黑或裁剪错误。固定 (0, 0, w, h) 确保始终渲染图像的有效区域。


六、CIFilterBridge 协议设计

为了让自定义滤镜和系统 CIFilter 无缝集成,MLImageToolkit 定义了 CIFilterBridge 协议:

/// 所有基于 Core Image 的滤镜都实现此协议
protocol CIFilterBridge {
    /// 将自身应用到 CIImage,返回处理后的 CIImage(仍是惰性)
    func apply(to image: CIImage, extent: CGRect) -> CIImage
}

/// 工厂协议:创建对应的系统 CIFilter(按需懒创建)
protocol CIFilterFactory: CIFilterBridge {
    var filterName: String { get }
    var parameters: [String: Any] { get }
}

extension CIFilterFactory {
    func apply(to image: CIImage, extent: CGRect) -> CIImage {
        image.applyingFilter(filterName, parameters: parameters)
    }
}

具体滤镜只需声明名称和参数:

struct CIBoxBlurBridge: CIFilterFactory {
    let radius: Float
    var filterName: String { "CIBoxBlur" }
    var parameters: [String: Any] { ["inputRadius": radius] }
}

struct CIColorControlsBridge: CIFilterFactory {
    let brightness: Float
    let contrast:   Float
    let saturation: Float
    var filterName: String { "CIColorControls" }
    var parameters: [String: Any] {
        [
            "inputBrightness": brightness,
            "inputContrast":   contrast,
            "inputSaturation": saturation
        ]
    }
}

工厂模式的好处:新增一个 Core Image 滤镜只需 5 行代码,无需关心 CIContext、坐标系转换、渲染时机等底层细节。


七、惰性求值与 GPU 合并渲染

Core Image 的惰性求值在多滤镜链时效果最显著:

// 三个滤镜叠加
let input: CIImage = bitmap.toCIImage()
let step1 = CIBoxBlurBridge(radius: 5).apply(to: input, extent: input.extent)
let step2 = CIColorControlsBridge(brightness: 0.1, contrast: 1.2, saturation: 0.9)
              .apply(to: step1, extent: step1.extent)
let step3 = step2.applyingFilter("CIVignette", parameters: ["inputIntensity": 0.5])

// 上面三步都没有发生任何 GPU 运算
// 只有这一行触发渲染,Core Image 自动把三个滤镜合并成一个 GPU Pass:
let result = CIContextPool.shared.context().createCGImage(step3, from: renderRect)
没有惰性求值(假如每步都立即渲染):
  GPU Pass 1:blur    → 显存写入 12M 像素
  GPU Pass 2color   → 显存读取 + 写入 12M 像素
  GPU Pass 3:vignette→ 显存读取 + 写入 12M 像素
  总显存带宽:72M 像素次读写

使用惰性求值(实际情况):
  GPU Pass 1:blur + color + vignette 合并 → 显存写入 12M 像素
  总显存带宽:12M 像素次读写(节省 83%

八、用 CPUWrapped 在测试中注入 CPU 上下文

单元测试环境(模拟器、CI 服务器)通常没有可用的 Metal GPU。为了不跳过 GPU 路径的测试,MLImageToolkit 使用依赖注入模式:

// 生产代码:从 Pool 获取最优 context
struct GPUGaussianBlur: ImageFilter {
    let radius: Float
    let contextProvider: () -> CIContext   // 依赖注入点

    init(radius: Float, contextProvider: @escaping () -> CIContext = {
        CIContextPool.shared.context()
    }) {
        self.radius = radius
        self.contextProvider = contextProvider
    }

    func apply(to bitmap: MLBitmap) -> MLBitmap {
        let ciImage = bitmap.toCIImage()
        let blurred = ciImage.applyingFilter("CIGaussianBlur",
                                             parameters: ["inputRadius": radius])
        let ctx = contextProvider()   // 测试时注入 CPU context
        return ciImageToBitmap(blurred, context: ctx,
                               width: bitmap.width, height: bitmap.height)
    }
}

// 测试代码:注入 CPU 软件渲染 context
func testGPUGaussianBlur() {
    let cpuCtx = CIContext(options: [.useSoftwareRenderer: true])
    let filter = GPUGaussianBlur(radius: 5) { cpuCtx }
    let result = filter.apply(to: testBitmap)
    XCTAssertEqual(result.width, testBitmap.width)
    // ...
}

依赖注入原则:让调用方提供依赖(CIContext),而不是在内部硬编码获取方式。测试时注入可控的假实现,生产时使用真实的 GPU Context。


九、性能对比

以 4000×3000 图像的高斯模糊(radius=10)为例,在 iPhone 14 上实测:

方案 耗时 备注
Phase 1 CPU 卷积(Swift 循环) ~380ms 纯 Swift,无 SIMD
vImage/Accelerate(SIMD,Day 12) ~28ms CPU SIMD 加速
Core Image(Metal GPU) ~4ms GPU 并行,含首次 kernel 编译
Core Image(复用 Context,非首次) ~3ms 热路径,kernel 已编译

关键结论:GPU 处理比 Phase 1 的 CPU 循环快约 100 倍;复用 CIContext 与每次创建相比节省 18–32ms。


十、小结

概念 核心内容
GPU vs CPU GPU 靠核心数量(1000+)胜出,适合大量独立计算
CIImage 惰性 构建滤镜图不触发计算,render 时才执行,可合并多个 Pass
CIContext 成本 创建耗时 5–30ms,必须复用,用 Pool 管理
CIContextPool Metal 优先,CPU 回退,单例生命周期
坐标系差异 CIImage 原点在左下,y 向上;需要 transform 翻转
render bounds 固定为 (0, 0, w, h),避免 extent 偏移导致黑图
CIFilterBridge 工厂协议统一封装 CIFilter,5 行新增一个滤镜
依赖注入 CPUWrapped context 注入,让 GPU 路径在 CI 中也可测试

思考题

  1. 如果一个 CIFilter 链中某个节点依赖前一步的输出才能确定参数(比如自适应对比度:先扫描直方图,再决定拉伸系数),Core Image 的惰性求值还能把它们合并成一个 GPU Pass 吗?为什么?

  2. CIContextPool 使用 lazy var 保证线程安全吗?如果两个线程同时首次调用 metalContext,会发生什么?应该怎么修复?

  3. 在把 MLBitmap 转成 CIImage 时,代码中施加了一个 scaleX:1, y:-1 的变换。如果把这个变换去掉,改为在 ciImageToBitmap 里用 CGContext.draw 时翻转坐标系,两种方案哪个性能更好?为什么?

答案:1. 不能合并。Core Image 的合并渲染要求滤镜图是纯函数(输入确定输出),若需要先渲染再读回 CPU 数据再决定下一步参数,就必须分两次 render。这种场景需要手动拆成两次 context.createCGImage 调用。2. 不安全。Swift 的 lazy var 在多线程并发首次访问时会有数据竞争。修复方式:用 DispatchQueueNSLock 保护初始化,或者用 static let 替代(Swift 的 static let 保证线程安全的一次性初始化)。3. 在 toCIImage 里用 transform 更好。transform 会被 Core Image 合并进 GPU 着色器,无额外开销。而在 CGContext 里翻转需要在 CPU 端重新绘制一次,是额外的栅格化操作。

♥️喜欢我的内容,欢迎大家点赞、转发、关注。

♥️本人专注于技术+投资+认知三位一体的内容分享。

往期推荐:

一图了解Sobel边缘检测原理

一图了解图像处理中的高斯模糊

为什么卷积核通常必须是奇数?

一图了解卷积中的边界处理

一图了解几种常用卷积核

一图了解卷积的核心原理

一张图带你了解——卷积到底是什么?

一图了解饱和度:控制色彩鲜艳程度的关键

一图了解OCR的处理流程及相关图像处理技术

一图了解二值化与阈值,从灰度到黑白的决策

一张图了解图像处理中的亮度、对比度与实现

颜色科学与灰度化

从"图片"到"内存"——你真正理解图像处理的第一天

iPhone相册背后的图像处理知识(下)

iPhone相册背后的图像处理知识(中)

iPhone相册背后的图像处理知识(上)

一张图了解图像处理的本质

图像到底是什么

图像处理技术概要图

AI时代,软件工程师必备概念全景图

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

作者 择势
2026年5月29日 19:15

用一套 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 语音聊天室 —— 常见问题汇总 & 解决方案手册

作者 择势
2026年5月29日 17:24

目录

  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 语音聊天室(进阶封装)

作者 择势
2026年5月29日 17:02

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

基础 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 语音聊天室——从零到可跑的指南

作者 择势
2026年5月29日 16:00

一、为什么语音聊天室需要 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 的分布式锁做"同一时刻只有一个人操作麦位"的轻量原子控制

【图像处理】框架设计——协议、值类型与工程化思维

作者 FIRE_Lee
2026年5月28日 20:08

同样是实现"灰度化"功能, 一个函数、一个类的方法、一个协议的实现,结果一样,设计完全不同。 这一天我们来聊聊这个框架的设计决策背后的思考, 以及什么样的代码算是"工业级"的。


一、从需求到设计的思维过程

需求:实现灰度化、亮度、对比度、阈值四个图像滤镜,并支持链式调用。

方案 A:函数式

func applyGrayscale(_ bitmap: MLBitmap) -> MLBitmap { ... }
func applyBrightness(_ bitmap: MLBitmap, adjustment: Int) -> MLBitmap { ... }

// 使用:
let result = applyBrightness(applyGrayscale(bitmap), adjustment: 30)
// 问题:嵌套调用难以阅读,顺序从里到外读

方案 B:命令式方法

extension MLBitmap {
    mutating func applyGrayscale() { ... }
    mutating func applyBrightness(adjustment: Int) { ... }
}

// 使用:
bitmap.applyGrayscale()
bitmap.applyBrightness(adjustment: 30)
// 问题:修改原始数据,无法保留中间结果,难以测试

方案 C:协议 + 链式(本框架选择)

protocol ImageFilter {
    func apply(to bitmap: MLBitmap) -> MLBitmap
}

// 使用:
let result = bitmap
    .applying(GrayscaleFilter())
    .applying(BrightnessFilter(adjustment: 30))
// 清晰、可组合、可测试

二、ImageFilter 协议的设计哲学

协议定向编程(POP)

Swift 的核心设计理念之一:用协议而非继承来定义行为

public protocol ImageFilter {
    func apply(to bitmap: MLBitmap) -> MLBitmap
}

这个协议只定义一件事:把一个 bitmap 转换成另一个 bitmap。极度简单,但这种简单性是强大的。

为什么不用继承(class hierarchy)?

// 面向对象风格(不好):
class ImageFilter {
    func apply(to bitmap: MLBitmap) -> MLBitmap {
        fatalError("Subclass must override")
    }
}
class GrayscaleFilter: ImageFilter {
    override func apply(to bitmap: MLBitmap) -> MLBitmap { ... }
}

继承的问题:

  • 强制使用 class(引用类型),增加内存管理复杂度
  • 强耦合:子类依赖父类实现
  • 扩展困难:第三方代码无法"继承扩展"

协议的优势:

  • struct 实现,值类型语义
  • 第三方可以轻松实现自己的 Filter
  • 组合优于继承

纯函数(Pure Function)

func apply(to bitmap: MLBitmap) -> MLBitmap

纯函数的定义

  • 相同输入 → 永远产生相同输出(确定性)
  • 没有副作用(不修改外部状态)

纯函数的好处:

  • 易于测试:无需 mock,直接传入测试数据
  • 易于并行:多个 Filter 可以并行处理不同图像,没有竞争条件
  • 易于组合:输出直接作为下一个的输入

三、值类型(Struct)与 Copy-on-Write

为什么 MLBitmap 是 struct?

public struct MLBitmap {
    public var pixels: [UInt8]
    ...
}

值类型的语义

var bitmap1 = MLBitmap(width: 10, height: 10, filling: .white)
var bitmap2 = bitmap1     // 看起来像复制

bitmap2[0, 0] = .red      // 修改 bitmap2

// bitmap1[0, 0] 仍然是 .white!
// 两者完全独立

如果 MLBitmap 是 class

class MLBitmap {
    var pixels: [UInt8]
}

var bitmap2 = bitmap1     // 实际上是引用复制
bitmap2.pixels[0] = 255  // bitmap1.pixels[0] 也变了!

图像处理中,每个 Filter 应该生成新图像,不影响原图。值类型的语义天然满足这个需求。

Copy-on-Write(写时复制)

Swift 的数组([UInt8])实现了 CoW:

var pixels1 = [UInt8](repeating: 0, count: 1000)
var pixels2 = pixels1    // 此时不复制,只是共享引用

pixels2[0] = 255         // 第一次写入时,才真正复制
// pixels1 不受影响

这使得 var result = bitmap 的操作几乎没有成本——只有当你真正写入 result 时,内存才会复制。

性能影响

// 三次 Filter 链式调用
let result = bitmap
    .applying(GrayscaleFilter())   // 第 1 次 CoW 触发,复制 bitmap
    .applying(BrightnessFilter())  // 第 2 次 CoW 触发,复制中间结果
    .applying(ThresholdFilter())   // 第 3 次 CoW 触发,复制中间结果

// 共有 3 次内存复制
// 对 100×100 图:3 × 40 KB = 120 KB,几乎不可感知
// 对 4K 图:3 × 32 MB = 96 MB,链式调用时峰值内存较高

工业级优化:Fusion(把多个 Filter 的计算合并到一次遍历)。


四、some ImageFilter vs any ImageFilter

// MLBitmap 的链式调用方法
func applying(_ filter: some ImageFilter) -> MLBitmap {
    filter.apply(to: self)
}

some Protocol(Opaque Type)

  • 调用时类型固定,编译器知道具体类型
  • 零运行时开销(不需要 existential box)
  • 适合:每次调用类型确定的场景

any Protocol(Existential Type)

  • 类型在运行时动态决定
  • 有运行时开销(existential box + vtable 查找)
  • 适合:把不同类型的 Filter 放入同一个数组
// 需要存放不同 Filter 的数组时,用 any:
let pipeline: [any ImageFilter] = [
    GrayscaleFilter(),
    BrightnessFilter(adjustment: 30),
    ThresholdFilter()
]
let result = pipeline.reduce(bitmap) { $1.apply(to: $0) }

五、Precondition vs Guard vs Throw:三种防御方式

框架代码中有三种处理错误的方式,选择哪种取决于错误性质

precondition:编程错误(Bug)

// 调用方传了不合法的参数,这是 bug,应该在开发阶段崩溃暴露
precondition(width > 0 && height > 0, "Width and height must be positive")
precondition(factor.isFinite, "factor must be finite")
precondition(values.count % 2 == 1, "Kernel height must be odd")

适用:不变量被违反,是调用方的编程错误。在 Debug 下崩溃(暴露 bug),在 Release 下行为未定义(Swift 优化掉 precondition 检查)。

guard + throw:运行时错误(预期可能发生)

// 图像可能真的很大,这不是 bug,而是正常运行时的条件
guard width <= maxDimension && height <= maxDimension else {
    throw LoadError.dimensionTooLarge(width: width, height: height)
}

适用:外部资源(文件大小、内存限制、网络状态)不可控,调用方需要处理这些情况。

return nil / 默认值:可恢复的退化

// CGDataProvider 创建失败,返回 nil,调用方检查
guard let provider = CGDataProvider(data: data as CFData) else { return nil }

适用:失败是轻量级的,调用方可以通过 optional 判断处理。

选择原则

  • "这种情况不应该发生,发生了说明有 bug" → precondition
  • "这种情况可能发生,调用方必须处理" → throw
  • "这种情况可能发生,调用方可以忽略" → return nil

六、@inline(__always)@discardableResult

@inline(__always)

@inline(__always)
func index(x: Int, y: Int) -> Int {
    (y * width + x) * Self.bytesPerPixel
}

这个函数在像素遍历的内层循环中被调用,100×100 图调用 10,000 次,4K 图调用 800 万次。普通函数调用有开销(压栈/出栈、跳转)。@inline(__always) 让编译器把函数体直接嵌入调用处,消除调用开销。

权衡:内联会增加代码体积(每个调用处都展开一份代码),但对热路径的小函数是合理的。

@discardableResult

@discardableResult
public static func process(_ bitmap: MLBitmap, to url: URL, scenario: ExportScenario) -> ExportResult

Swift 默认情况下,如果你忽略一个有返回值的函数的返回值,编译器会给出警告。@discardableResult 表示"忽略返回值是可以接受的"。

适用场景:返回值提供额外信息(如成功/失败详情),但调用方也可能只关心副作用(文件是否写出),而不在乎详细的返回值。


七、单一可信来源(SSOT)原则

// ❌ 错误:同样的常量在两个地方定义
// ImageLoader.swift
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue

// ImageExporter.swift  
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
// 问题:如果只改了一处,另一处不同步,导致颜色错乱,且没有编译器提示

// ✅ 正确:单一定义,双端引用
// MLBitmap.swift(单一可信来源)
public static let bitmapInfo: CGBitmapInfo = CGBitmapInfo(rawValue:
    CGImageAlphaInfo.premultipliedLast.rawValue |
    CGBitmapInfo.byteOrder32Big.rawValue
)

// ImageLoader.swift
let bitmapInfo = MLBitmap.bitmapInfo.rawValue  // 引用

// ImageExporter.swift
let bitmapInfo = MLBitmap.bitmapInfo            // 引用

SSOT 原则(Single Source of Truth):每个知识(常量、配置、逻辑)只在一个地方定义,其他地方引用。


八、测试驱动的工程化

每一个重要功能都有对应的测试:

testBitmapMemoryLayout()          ← 验证内存布局公式
testCoordinateOriginIsTopLeft()   ← 验证坐标系约定(最容易出错的地方)
testGrayscaleLuminanceFormula()   ← 验证 BT.709 公式精度
testBrightnessClampMax()          ← 验证溢出截断(不是回绕)
testContrastAnchorPoint()         ← 验证 128 锚点不变性
testSobelDetectsVerticalEdge()    ← 验证边缘检测有效性
testAutoFormatTransparentImage()  ← 验证透明度检测
testResampleReducesOversized()    ← 验证等比缩放

测试的价值

  • 文档化了预期行为(代码即文档)
  • 重构时有安全网(改代码不怕破坏已有功能)
  • 发现设计缺陷(如 testCoordinateOriginIsTopLeft 暴露了坐标系 bug)

测试的粒度

好的测试只测一件事

// ❌ 测试太多,失败时不知道哪里出了问题
func testGrayscale() {
    // 测试白色、黑色、亮度公式、Alpha 保护……全放在一起
}

// ✅ 每个测试一个断言
func testGrayscaleWhiteStaysWhite() { ... }
func testGrayscaleBlackStaysBlack() { ... }
func testGrayscaleLuminanceFormula() { ... }
func testGrayscaleAlphaUnchanged() { ... }

九、代码注释的层次

本框架的注释分为三层:

Layer 1:文件头注释(解释"为什么这个文件存在")

// ImageProcessor.swift
// 工业级图像预处理管线
//
// 职责:在导出/上传前,根据场景策略对图像进行:
//   1. 尺寸重采样(Resample)
//   2. 格式选择(Format Selection)
//   3. 质量决策(Quality Decision)

Layer 2:函数注释(解释"这个函数做什么,参数是什么")

/// 将 UIImage 解码为 MLBitmap(RGBA8888 / sRGB)。
///
/// 流程:UIImage → CGImage → CGContext(重新绘制)→ [UInt8]
/// 通过重新绘制确保颜色空间统一(Display P3 / sRGB 均归一化为 sRGB)
///
/// - Throws: `LoadError`(尺寸超限 / 内存超限 / CGImage 缺失)
public static func load(from image: UIImage) throws -> MLBitmap

Layer 3:关键步骤注释(解释"为什么这么做,不是这么做会怎样")

// ⚠️ 不要加 translateBy/scaleBy flip:
// flip 会把 CGImage row 0 翻到 buffer 末尾,
// 反而使 bitmap[0,0] 变成视觉「左下角」。
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))

原则:注释解释"为什么",而不是重复"做什么"(代码本身已经说明做什么了)。


十、阶段一学习完整架构回顾

MLImageCore
│
├── Core/
│   └── MLBitmap.swift          # 核心数据结构(struct + CoW)
│
├── Filters/
│   ├── ImageFilter.swift       # 协议定义(POP)
│   ├── GrayscaleFilter.swift   # BT.709 灰度化
│   ├── BrightnessFilter.swift  # 线性亮度调整
│   ├── ThresholdFilter.swift   # 二值化
│   └── ContrastFilter.swift    # 对比度调整
│
├── Algorithms/
│   ├── Convolution.swift       # 2D 卷积引擎(通用)
│   ├── GaussianBlur.swift      # 可分离高斯模糊
│   └── SobelEdge.swift         # Sobel 边缘检测
│
└── IO/
    ├── ImageLoader.swift       # UIImage → MLBitmap(颜色空间归一化)
    ├── ImageExporter.swift     # MLBitmap → UIImage / 文件(回退链)
    └── ImageProcessor.swift   # 工业级管线(重采样 + 格式决策 + 体积控制)

每一层都遵循单一职责原则(SRP)

  • ImageLoader:只负责加载和格式归一化
  • ImageExporter:只负责编码和写文件
  • ImageProcessor:只负责决策和调度(不操作像素)
  • Convolution:只是纯数学引擎,不知道 Filter 业务

十一、工业级 vs 学习级代码

维度 学习级 工业级
错误处理 强制解包 !,打印错误 结构化 Error,throw/Result
日志 print() os.log,带级别和类别
内存 随意分配,不考虑峰值 预估峰值,设置上限,提前拦截
边界 "应该不会发生" precondition + guard + throw
API 功能正确即可 命名清晰,访问控制合理,文档完善
测试 手动跑一下 单元测试覆盖核心路径
可扩展性 直接改代码 协议 + 预设 + 自定义接口
常量管理 魔法数字散落各处 SSOT,单一定义

工业级代码的核心追求:在 3 个月后,由另一个人来维护这段代码时,他能快速理解、安全修改


十二、小结与展望

Phase 1 建立了:

  • 图像处理的基础数据结构和坐标系约定
  • CPU 层的完整算法实现(灰度、亮度、对比度、二值化、卷积、高斯模糊、Sobel)
  • 工业级的 IO 管线(颜色空间归一化、格式决策、体积控制)
  • 良好的代码架构和测试覆盖

Phase 2 目标:从"手写 CPU 算法"升级到"调用 Apple 系统框架加速":

  • Core Image:GPU 渲染管线,CIFilter 包装
  • vImage / Accelerate:SIMD 向量化,更快的卷积
  • 直方图分析:Otsu 自适应阈值,直方图均衡
  • 颜色空间转换:RGB ↔ HSV ↔ Lab

Phase 3 目标:Metal Compute Shader,真正的 GPU 并行:

  • 数千个像素同时计算
  • 实时滤镜(30fps 视频处理)
  • Custom Compute Kernel(自定义 GPU 程序)

思考题

  1. 如果要把 MLBitmap 从 struct 改为 class,需要修改哪些地方?会带来哪些新的问题?
  2. 设计一个 CompositFilter,它包含一个 [any ImageFilter] 数组,调用 apply 时依次执行所有 Filter。写出这个类型的定义,并说明它是 struct 还是 class,理由是什么?
  3. 在 iOS 开发中,如果你的图像处理需要在后台线程运行(避免主线程阻塞),现有的 ImageFilter 协议设计需要做什么改动?(提示:Swift Concurrency 的 Sendable

答案:2. 应该是 struct(值类型),因为它只是 Filter 序列的组合,没有共享状态;定义:struct CompositFilter: ImageFilter { let filters: [any ImageFilter]; func apply(to bitmap: MLBitmap) -> MLBitmap { filters.reduce(bitmap) { $1.apply(to: $0) } } };3. 需要让所有 Filter 标注 Sendablestruct GrayscaleFilter: ImageFilter, Sendable {}),以及让 MLBitmap 也标注 Sendable,这样才能在不同 actor 之间安全传递。

♥️喜欢我的内容,欢迎大家点赞、转发、关注。

♥️本人专注于技术+投资+认知三位一体的内容分享。

往期推荐:

一图了解图像处理中的高斯模糊

为什么卷积核通常必须是奇数?

一图了解卷积中的边界处理

一图了解几种常用卷积核

一图了解卷积的核心原理

一张图带你了解——卷积到底是什么?

一图了解饱和度:控制色彩鲜艳程度的关键

一图了解OCR的处理流程及相关图像处理技术

一图了解二值化与阈值,从灰度到黑白的决策

一张图了解图像处理中的亮度、对比度与实现

颜色科学与灰度化

从"图片"到"内存"——你真正理解图像处理的第一天

iPhone相册背后的图像处理知识(下)

iPhone相册背后的图像处理知识(中)

iPhone相册背后的图像处理知识(上)

一张图了解图像处理的本质

图像到底是什么

图像处理技术概要图

AI时代,软件工程师必备概念全景图

iOS 横竖屏实践(UIKit)

作者 iOS日常
2026年5月28日 18:02

present 场景

关注点

present 页面时,重点是这两个属性:

override var supportedInterfaceOrientations: UIInterfaceOrientationMask
override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation
  • supportedInterfaceOrientations:页面允许方向
  • preferredInterfaceOrientationForPresentation:模态展示时首选方向

必须满足的规则

preferredInterfaceOrientationForPresentation 必须包含在 supportedInterfaceOrientations 中。

错误示例:

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    .landscape
}

override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
    .portrait
}

iOS 16 及以下会崩溃:

preferredInterfaceOrientationForPresentation 'portrait' must match a supported interface orientation: 'landscapeLeft, landscapeRight'!

iOS 17 为警告,不崩溃,但仍属于非法配置。

与应用级方向的关系

present 页面还必须与应用级可用方向有交集。
若应用只允许竖屏,而被 present 页面只支持横屏,会出现:

Supported orientations has no common orientation with the application...

推荐写法

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    .landscape
}

override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
    .landscapeRight
}

如果需要在多个页面混用横竖屏,可在 AppDelegate 放开应用级方向:

func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
    .all
}

push 场景

关注点

push 页面只用看:

override var supportedInterfaceOrientations: UIInterfaceOrientationMask

preferredInterfaceOrientationForPresentation 不是 push 的控制项。

与应用级方向的关系

如果应用级只允许竖屏,即使页面声明横屏,也不会崩溃,但横屏不会生效。

iOS 版本行为

iOS 15 及以下

从竖屏页 push 到横屏页,不会自动旋转,需要手动触发:

let value: UIInterfaceOrientation = orientation.contains(.landscapeRight) ? .landscapeRight : .portrait
UIDevice.current.setValue(value.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()

iOS 16 及以上

会自动旋转。
如需显式请求方向更新,可用 iOS 16 新接口:

guard let windowScene = self.view.window?.windowScene else {
    return
}
let preferences = UIWindowScene.GeometryPreferences.iOS(interfaceOrientations: orientation)
windowScene.requestGeometryUpdate(preferences) { _ in }
self.setNeedsUpdateOfSupportedInterfaceOrientations()

wift Part 5 oc -> swift

作者 看谷秀
2026年5月28日 12:45

1. 记号/标记

作用: 打个标记, 方便寻找


// MARK:  类似于OC中的 #pragma mark (红旗任务)

// TODO:  用于标记未完成的任务

// FIXME:  用于标记待修复的问题

#warning("警告!!!")

截屏2026-05-25 12.00.13.png

2 条件编译


// 操作系统:macOS\iOS\tvOS\watchOS\Linux\Android\Windows\FreeBSD
#if os(macOS) || os(iOS)
// CPU架构:i386\x86_64\arm\arm64
#elseif arch(x86_64) || arch(arm64)
// swift版本
#elseif swift(<5) && swift(>=3)
// 模拟器
#elseif targetEnvironment(simulator)
// 可以导入某模块
#elseif canImport(Foundation)
#else
#endif

查看当前模式 product -> scheme -> edit scheme -> (run) Build Configiuration -> Debug / Release

// 代码区分 debug模式 或 release模式

#if DEBUG 
  print("debug!!!!") 
#else 
  print("release!!!!") 
#endif
//统版本检测 (available 可获得的)

if #available(iOS 11.0, tvOS 11.0, *){
// ios 11.0 以上, tvOS 11.0 以上
}

3 程序入口配置 根控制器配置

// 1 通过 AppDelegate 配置根控制器

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow? // 新建

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        
        // 新建
        window = UIWindow(frame: UIScreen.main.bounds)
            window?.rootViewController = ViewController()
            window?.makeKeyAndVisible()
        return true
    }
}
// 2 通过 SceneDelegate 配置根控制器

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?  //新建
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        // 替换成你的控制器
        let yourVC = TabBarViewController()
        window?.rootViewController = yourVC
        window?.makeKeyAndVisible()
    }
}

4 Swift 调用 OC

swift 通过桥接文件 调用oc文件 {targetName}-Bridging-Header.h

  • 桥接文件并暴露oc文件, swift通过桥接文件访问oc文件

  • 桥接文件会在第一次创建oc文件同时创建

// 例如 
learn-swift1-Bridging-Header.h  //桥接文件

learn-swift1 // 项目名称

// 默认 swift项目首次新建oc文件, 会自动创建一个桥接文件 

// 支持自行创建桥接文件

截屏2026-05-25 12.39.32.png

// 1 创建oc文件

#import "OCViewController.h"

@implementation OCViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"oc文件";
}
@end
// 2 桥接文件中暴露oc文件 

// 测试oc控制器
#import "OCViewController.h"
// 3 swift 调用oc文件

import UIKit  
class TestViewController: UIViewController { 
    override func viewDidLoad() {
        super.viewDidLoad() 
    }
    
    // 使用 oc 文件
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {   
        self.navigationController?.pushViewController(OCViewController(), animated: true)
    }
}

5 OC 调用 swift

oc 通过桥接文件 调用 swift 文件 {targetName}-Swift.h

  • 桥接文件是个隐藏文件, 非醒目在项目内
  • 桥接文件自动创建(bulid setting查看)

ps: 所以 oc 调用swift 仅需要引入头文件即可

截屏2026-05-25 12.58.11.png

// 文件名 learn-swift1
#import "learn-swift1-Swift.h" // ❌ 按道理是这个  但是找不到

#import "learn_swift1-Swift.h" // 这个能找到  

// 所以 带横线的会转成 "-" -> "_"

5.1 @objcMembers & @objc

objcMembers: 全类都开放给 oc 文件使用

@objc: 某属性, 方法暴露给 oc 文件使用

// swift 文件 暴露 属性方法 给oc

@objcMembers class TestViewController: UIViewController { 

    @objc var price: Double
    var band: String
    
    init(price: Double, band: String) {
        self.price = price
        self.band = band
        super.init(nibName: nil, bundle: nil)  // ViewController 的初始化必须确保父类也完成初始化
    } 
    
    required init?(coder: NSCoder) { 
        fatalError("init(coder:) has not been implemented")
    }
    
    @objc func run() {
        print(price, band, "run")
    }
    
    static func run() {
        print("Car run")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad() 
    }
}


//  oc 调用 swift

#import "OCViewController.h"
#import "learn_swift1-Swift.h" // 引入头文件

@implementation OCViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"oc文件";
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    TestViewController *con = [[TestViewController alloc] initWithPrice:10 band:@"band"];
    
    [con run]; //  print(price, band, "run")
    
    TestViewController.run; //  print("Car run")
    
    [self.navigationController pushViewController:con animated:true];
}

5.1 @objc (别名)

@objc (TVC) 代表将类 "TestViewController" 起别名为 "TVC" 并开放给oc

// 起别名

@objc (TVC)
@objcMembers class TestViewController: UIViewController { 
    @objc var price: Double
    @objc func run() {
        print(price, band, "run")
    }
    
    static func run() {
        print("Car run")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()    
    }
}

//  注意 @objc (TVC) 代表开发 TVC 给oc
// @objcMembers 必须有, 否则找不到 TVC 内的方法

6 选择器 Selector

swift也有perform 方法 , 由于是oc方法 需要添加 @objcMembers

// oc 的 perform

- (void) jump {
    NSLog(@"OCViewController--挑起来了");
}

[self performSelector:@selector(jump)];
// swift 的 perform

 @objc func test1(v1: Int) {
     print("test1")
 } 
 
self.perform(#selector(test1(v1:)))

7 String 与 NSString关系 及 as 转换

// String 转 NSString   方便操作字符串切割
    let str = "123456789"
    let str1 = str as NSString  // 转oc字符串
    let str3 = str1.substring(with: NSRange(location: 3, length: 2)) // 45
// NSString 转 String
    let str5 : NSString = "0987654321"
    var str6 = str5 as String
    str6.append("-111") // 0987654321-111
// 对比  NSString VS String  截取字符串

// OC截取
let str2 = str1.substring(from: 4) // 56789

// Swift截取
let str4 = str[str.index(str.startIndex, offsetBy: 4) ... str.index(before: str.endIndex)] // 56789

桥接转换表
String 和 NSString 相互转换, 本质是通过桥接进行的

双向箭头: 代表可以互相转换

截屏2026-05-25 14.46.21.png

8 是否继承 NSObject 内存分析

class Person {
    var age = 10
    var weight: Int = 0
}

// 内存情况

// Person对象占用32位

// 最前8个字节存放isa指针(指向元类型)
// 然后8个字节引用计数相关
// 在后8个字节 age 
// 最后8个字节 weight*
class Person: NSObject {
    var age = 10
    var weight: Int = 0
}

// 内存情况

// Person对象占用32位

// 最前8个字节存放isa指针(指向元类型)
// 然后8个字节 age 
// 最后8个字节 weight 
// 最后8个字节内存对齐

结论:

  1. 纯 Swift 类(不继承 NSObject)完全可以正常工作,并且有自己的内存管理机制(通过单独的引用计数区域)。
  2. 这证明了:在 Swift 中,NSObject 不是必须的基类。纯 Swift 类更轻量、更独立,不依赖 Objective-C 运行时。
  3. 继承 NSObject 的类后, 引用计数相关压缩进了前8个字节

9 可选协议

  1. 仅仅能被类遵守的协议
protocol Runnable1: AnyObject {}
protocol Runnable2: class {}  // 这个过期了 AnyObject代替
@objc protocol Runnable3 {} // 除了需要被类swift类遵守, 还将暴露给外部oc类调用

// 协议
protocol Runnable {
    func run()
}

class TestViewController: UIViewController, Runnable {
// ❌ 不实现协议方法 func run() 报错
}
  1. 遵守协议, 不用写实现了
// 协议
protocol Runnable {
    func run()
}

// 扩展给默认实现(空)
extension Runnable {
    func run() {
        print("什么都不做")
    }
}

class TestViewController: UIViewController, Runnable {
    
    override func viewDidLoad() {
        super.viewDidLoad()  
        TestViewController().run()   
    }
}

//  TestViewController 遵守协议, 但是不需要写实现了

加 @objc dynamic 的核心作用:让方法调用走 Objective-C 的消息转发机制(objc_msgSend),而不是 Swift 的静态或虚表调用。

10 dynamic 关键字

class Dog: NSObject {
    @objc dynamic func bark() {
        print("汪汪")
    }
}

let original = #selector(Dog.bark)

// Dog.bark 走oc的消息机制

11 Swift 代码使用 OC 的 KVC / KVO

class TestViewController: UIViewController { 

    @objc dynamic var age: Int = 0
    var observation : NSKeyValueObservation? 
    
    override func viewDidLoad() {
        super.viewDidLoad() 
        
        // 添加观察值 监听age  
        observation = observe(\Self.age, options: [.old,.new]) {
            (person, change) in
            print("---",change.oldValue as Any,change.newValue as Any)
        }
        
        self.age = 30 //--- Optional(0) Optional(30)
        self.age = 12 //--- Optional(30) Optional(12)
    }
}
     
// 注意 \Self.age 类型的属性这么写, 类型.age
// 原方法

observe(_ keyPath: KeyPath<_KeyValueCodingAndObserving, Value>,

options: NSKeyValueObservingOptions, 

changeHandler:(_KeyValueCodingAndobserving, NSKeyValueObservedChange<Value>) -> Void) -> NSKeyValueObservation

// 参数 1 
_ keyPath: KeyPath<_KeyValueCodingAndObserving, Value>

// `_KeyValueCodingAndObserving` 就是“支持 KVC/KVO 的任何类型”(比如 `NSObject` 的子类)。

传一个 类型的值

// swift 属性监听

class TestViewController: UIViewController {
    
    var name : String? {
        willSet {  // 属性即将改变时进行监听
              print("willSet--name:", name ?? "")
              print("willSet--newValue:", newValue ?? "")
         }
         didSet {  // 属性已经改变时进行监听
              print("didSet--name:", name ?? "")
              print("didSet--oldValue:", oldValue ?? "")
         }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad() 
        
        self.name = "why"
        self.name = "yz"    
    }
}


// willSet--name: 

// willSet--newValue: why

// didSet--name: why

// didSet--oldValue: 


// willSet--name: why

// willSet--newValue: yz

// didSet--name: yz

12 关联对象(Associated Object)

// 关联对象

class Person { }

extension Person {
    // 定义全局变量, 只要为了用地址值, Void占一个内存
    private static var AGE_KEY: Void?
    var age: Int {
        get {
            (objc_getAssociatedObject(self, &Self.AGE_KEY) as? Int) ?? 0
        }
        set {
            objc_setAssociatedObject(self, &Self.AGE_KEY, // 关联地址值
                                     newValue, // 传进来的变量
                                     .OBJC_ASSOCIATION_ASSIGN)  // 存储策略
        }
    }
}
var p = Person()
print(p.age) // 0

p.age = 10
print(p.age) // 10

13 多线程

13.1 异步线程

class TestViewController: UIViewController {
    // 创建 DispatchGroup 实例!!!
    let group = DispatchGroup() 
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // 异步并发队列
        let queue1 = DispatchQueue(label: "com.Miss.queue", qos: .default, attributes: .concurrent)
               queue1.async(group: group) { 
                    Thread.sleep(forTimeInterval: 3)
                    print("翻翻书")   
                }
                        
        let queue2 = DispatchQueue(label: "com.Miss.queue", qos: .default, attributes: .concurrent)
                queue2.async(group: group) {
                    print("画画画")
                    Thread.sleep(forTimeInterval: 1)
                }
                        
        let queue3 = DispatchQueue(label: "com.Miss.queue", qos: .default, attributes: .concurrent)
                queue3.async(group: group) {
                    print("看看景")
                    Thread.sleep(forTimeInterval: 2)
                }
                        
        group.notify(queue: DispatchQueue.main) {
                    print("收拾下")
        }        
    }
}

// 画画画
// 看看景
// 翻翻书
// 收拾下

13.2 延迟线程

let item  = DispatchWorkItem {
        print("来来来\(Thread.current)")
}

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3, execute: item) // 主线程

DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 3) {
     print("来来来\(Thread.current)") // 子线程
}


// 来来来 <NSThread: 0x1159192c0>{number = 10, name = (null)}
// 来来来 <_NSMainThread: 0x1046ca130>{number = 1, name = main}

13.3 一次性任务

线程安全, 整个程序只初始化一次

fileprivate let initTask2: Void = { // 静态全局变量
   print("initTask2---------")
}()

class TestViewController: UIViewController {
    static let initTask1: Void = { // 静态局部变量
        print("initTask1---------")
}()

    override func viewDidLoad() {
        super.viewDidLoad()

        test()
        test()
        test()
        print("华丽的分割线")
        
    }
    func test() {
        
        let _ = Self.initTask1 // 初始化一次
        let _ = initTask2 // 初始化一次
    }
}

// initTask1---------
// initTask2---------
// 华丽的分割线

// 注意 Void = () 
// 立即执行闭包 { ... }() 返回值为 () 所以用Void声明

13.4 信号量

// 1 保证同一时间只有 1 个线程读写共享数据(字典、数组、变量),防止多线程崩溃。

// 2 控制线程最大并发数

class Cache {
  private static var data = [String: Any]()
  private static var lock = DispatchSemaphore(value: 2) // 最多两条线程可以访问

  static func set(_ key: String, _ value: Any) {
  lock.wait()
  defer { lock.signal() } // defer: 方法退出前执行
  data[key] = value
  }
}

昨天以前首页

iOS 蓝牙开发深入总结

作者 sweet丶
2026年5月27日 18:35

N年前在一个支付公司做过2.5年的蓝牙开发,当时的项目是通过蓝牙连接mpos机进行刷卡支付。

APP中业务交互逻辑是比较复杂,当时APP的核心难以解决的痛点:逻辑比较混乱,很难从头到尾排查清楚一个完整流程。同时也会出现偶现的不好解决的bug---当时项目的架构是将蓝牙与VC交互放到了基类VC中,机具蓝牙交互则使用了多个单例通过代理和通知监听实现,由于是单例所以会偶现VC未正常释放也会出现UI响应在不该出现的页面。

我经过持续大约半年的时间完成针对蓝牙交互模块重构成了一个组件库,APP中刷卡/查余额/各类交易类型的各个VC中关于蓝牙交互的逻辑也在这基础上完成重构,极大简化了逻辑提升排查效率,也降低了极端异常的功能展示问题。(不影响业务开发的情况下半年)

下面我针对过往的这次做个回顾总结。

一、重构的蓝牙SDK架构

核心诉求:基于6厂商蓝牙SDK封装蓝牙连接交互与业务逻辑完成机具扫描连接、激活绑定、N个刷卡交易场景等。

实现思路:

  • 工厂方法模式封装各厂商SDK提供统一交互接口,统一管理单例类完成各业务发起后内部完成各操作并在所有流程节点回调给业务侧。
  • 蓝牙基础操作抽取在基类,不同业务场景通过策略模式分发管理其发起及蓝牙回调业务逻辑处理。
  • 单例类保存机具、交易等基本信息作为上下文,专门的网络层处理请求事宜。

1. 核心文件

文件 职责
BluetoothManager.h/.m 对外门面。暴露蓝牙扫描、连接、断开、固件更新、刷卡交易等能力;内部编排 CoreBluetooth、机具请求层、网络请求和业务回调。
BLERequestManager.h/.m 机具网络请求/厂商 SDK 适配层。根据设备名称选择不同厂商 SDK,负责打开设备、获取设备信息/SN/流水号、更新 WorkKey/IC 参数、刷卡、二次授权、取消交易。
BLEInfo.h/.m 全局上下文单例。保存登录用户信息、HTTP key、用户机具列表、当前连接机具、最近厂商接口、最近操作状态、刷卡参数。
BLEOperatDelegate.h BluetoothManager中对业务层的回调协议,包含扫描、连接、参数更新、刷卡、签名、交易成功/失败等回调。
BLERequestManagerDelegate.h BLERequestManager 对上层的回调协议,包含打开机具、关闭机具、更新参数、刷卡结果、无键盘机具输密等回调。
SwipAPI.h/.m 刷卡 SDK 静态入口。负责用户注册、网络初始化、连接状态查询、断开、清除数据、App 回前台连接状态校验。

2. 内部架构

flowchart TB
    UI[业务页面 / Delegate<br/>SwipBaseDel 子类] --> BM[BluetoothManager<br/>对外门面]
    BM --> CB[CoreBluetooth<br/>CBCentralManager]
    BM --> RM[BLERequestManager<br/>厂商 SDK 适配]
    BM --> HTTP[SwipHttpTool<br/>AA008 / AA124 / AA006 / AA010 等]
    BM --> INFO[BLEInfo<br/>全局状态 / 当前连接机具 / 交易参数]
    RM --> INFO
    RM --> SDK[厂商 SDK / IRequestPosInteface<br/>新大陆 / 联迪 / 天谕 / 中磁 / 华智融等]
    RM --> SQL[SwipSQLUtils<br/>IC 参数 / 签购单缓存]
    BM --> CALLBACK[BLEOperatDelegate<br/>扫描 / 连接 / 更新 / 刷卡流程节点及结果回调]
    CALLBACK --> UI

3. 业务 Delegate 关系

flowchart TB
    Base[SwipBaseDel<br/>通用扫描/连接/更新/弹窗逻辑]
    Base --> Pay[SwipPaymentDel<br/>收款]
    Base --> Web[SwipForWebNeedDel<br/>H5收款/订单]
    Base --> Recharge[SwipRechargeDel<br/>钱包充值]
    Base --> Order[SwipOrderPayDel<br/>订单支付]
    Base --> Credit[SwipCreditCardRepaymentDel<br/>信用卡还款]
    Base --> Balance[SwipAccountBlanceDel<br/>查余额]

    Pay --> BM[BluetoothManager]
    Web --> BM
    Recharge --> BM
    Order --> BM
    Credit --> BM
    Balance --> BM

4. 业务复杂度举例-连接

代码中的“连接成功”不是蓝牙配对成功,而是以下链路全部完成:

  1. requestOpenDevice 打开设备。
  2. requestDeviceInfo 获取设备信息和终端号。
  3. requestDeviceSN 获取 SN、PN、APP 版本、厂商信息等。
  4. requestDeviceTrace 获取交易流水号。
  5. BluetoothManager 再发 AA008 校验用户和机具关系。
  6. 如需更新 WorkKey / IC 参数,则更新完成后才回调 didAllsetMpos

二、系统蓝牙基本交互回顾

1、基本概念及系统架构

蓝牙是一种短距离无线通信技术,用于设备之间的小数据量、低功耗、近距离传输。在 iOS 开发中,主要指 BLE(蓝牙低功耗) ,通过 CoreBluetooth 框架实现。

1.1 核心角色

概念 解释 类比
Central(中心设备) 主动扫描、连接、读写数据的设备 手机 App(主动发起)
Peripheral(外设) 广播自己的存在,响应中心设备 智能手表、心率带、打印机
Service(服务) 一组功能的集合,一个外设可以有多个 Service 一个“功能模块”
Characteristic(特征) 服务下的具体数据点,是读写操作的最小单位 一个“数据字段”
UUID 标识 Service 和 Characteristic 的唯一 ID 门牌号
广播包 外设定期发送的数据,包含设备名、服务 UUID 等 “我在,来找我吧”

1.2 层次结构

Peripheral(外设)
  └── Service 1(服务)
        ├── Characteristic A(特征-可读)
        ├── Characteristic B(特征-可写)
        └── Characteristic C(特征-可通知)
  └── Service 2(服务)
        └── Characteristic D(特征-可读)
[初始化][扫描][连接][发现服务][发现特征][读写/通知][断开]

1.3 大体API使用情况

扫描scanForPeripherals连接connectPeripheral通信通过 CBPeripheraldiscoverServicesdiscoverCharacteristicswriteValue / setNotifyValue 实现。


2、详细代码与说明

2.1 初始化

import CoreBluetooth

class BluetoothManager: NSObject, CBCentralManagerDelegate {
    
    var centralManager: CBCentralManager!
    var connectedPeripheral: CBPeripheral?
    
    override init() {
        super.init()
        // 初始化中心设备,会触发 centralManagerDidUpdateState
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }
    
    // 蓝牙状态回调(必须实现)
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            print("蓝牙已开启,可以扫描")
        case .poweredOff:
            print("蓝牙已关闭")
        case .unauthorized:
            print("未授权")
        case .unsupported:
            print("设备不支持蓝牙")
        default:
            break
        }
    }
}

关键点

  • queue: nil 表示回调在主线程;传自定义 queue 则会在子线程
  • 必须等 .poweredOn 才能开始扫描

2.2 扫描外设

func startScan() {
    guard centralManager.state == .poweredOn else { return }
    
    // 方式1:扫描所有设备
    centralManager.scanForPeripherals(withServices: nil, options: nil)
    
    // 方式2:只扫描特定服务的设备(推荐,省电)
    let serviceUUIDs = [CBUUID(string: "180D")] // 心率服务
    centralManager.scanForPeripherals(withServices: serviceUUIDs, options: [
        CBCentralManagerScanOptionAllowDuplicatesKey: false  // 不重复上报
    ])
}

// 扫描到设备后的回调
func centralManager(_ central: CBCentralManager, 
                    didDiscover peripheral: CBPeripheral, 
                    advertisementData: [String : Any], 
                    rssi RSSI: NSNumber) {
    
    print("发现设备: \(peripheral.name ?? "无名"), RSSI: \(RSSI)")
    
    // 根据名称或信号强度筛选
    if peripheral.name == "MyDevice" {
        // 保存引用,否则会被释放
        self.connectedPeripheral = peripheral
        // 停止扫描,省电
        centralManager.stopScan()// 实际使用时建议等连接成功再停止。
        // 发起连接
        centralManager.connect(peripheral, options: nil)
    }
}

关键点

  • peripheral 需要强引用保存,否则会释放导致连接失败
  • 扫描到目标后立即 stopScan(),省电
  • RSSI 绝对值越小信号越强(-50 比 -80 强)

2.3 连接外设

// 连接成功
func centralManager(_ central: CBCentralManager, 
                    didConnect peripheral: CBPeripheral) {
    print("连接成功")
    peripheral.delegate = self  // 设置代理,用于后续服务发现
    // 开始发现服务
    peripheral.discoverServices(nil)  // nil = 所有服务
}

// 连接失败
func centralManager(_ central: CBCentralManager, 
                    didFailToConnect peripheral: CBPeripheral, 
                    error: Error?) {
    print("连接失败: \(error?.localizedDescription ?? "")")
    // 重试逻辑
}

// 连接断开
func centralManager(_ central: CBCentralManager, 
                    didDisconnectPeripheral peripheral: CBPeripheral, 
                    error: Error?) {
    print("断开连接")
    // 尝试重连
    centralManager.connect(peripheral, options: nil)
}

关键点

  • 必须设置 peripheral.delegate = self
  • 连接成功后要调用 discoverServices,否则无法通信

2.4 发现服务与特征

// 发现服务
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    guard let services = peripheral.services else { return }
    
    for service in services {
        print("发现服务: \(service.uuid)")
        // 发现服务下的特征
        peripheral.discoverCharacteristics(nil, for: service)
    }
}

// 发现特征
func peripheral(_ peripheral: CBPeripheral, 
                didDiscoverCharacteristicsFor service: CBService, 
                error: Error?) {
    guard let characteristics = service.characteristics else { return }
    
    for characteristic in characteristics {
        print("发现特征: \(characteristic.uuid)")
        
        // 根据 UUID 判断特征类型
        switch characteristic.uuid.uuidString {
        case "FFE1":  // 写入特征(发送数据给设备)
            self.writeCharacteristic = characteristic
        case "FFE2":  // 通知特征(接收设备数据)
            self.notifyCharacteristic = characteristic
            // 开启通知
            peripheral.setNotifyValue(true, for: characteristic)
        default:
            break
        }
    }
}

关键点

  • 必须按层级:先发现服务 → 再发现特征
  • 特征有不同属性:.read.write.notify.indicate
  • 开启通知前,确认特征有 .notify 属性

2.5. 通信:写数据

func sendDataToDevice(data: Data) {
    guard let characteristic = writeCharacteristic else { return }
    
    // 方式1:带响应写入(会回调 didWriteValueFor)
    connectedPeripheral?.writeValue(data, 
                                    for: characteristic, 
                                    type: .withResponse)
    
    // 方式2:无响应写入(不回调,速度快,不保证送达)
    // connectedPeripheral?.writeValue(data, 
    //                                 for: characteristic, 
    //                                 type: .withoutResponse)
}

// 写入成功/失败的回调(只有 .withResponse 才会触发)
func peripheral(_ peripheral: CBPeripheral, 
                didWriteValueFor characteristic: CBCharacteristic, 
                error: Error?) {
    if let error = error {
        print("写入失败: \(error)")
    } else {
        print("写入成功")
    }
}

关键点

  • 数据大小限制:20 字节(MTU 默认 23,减去 3 字节头部)
  • 大于 20 字节需要分包发送

2.6 通信:接收数据(通知)

// 收到设备主动推送的数据
func peripheral(_ peripheral: CBPeripheral, 
                didUpdateValueFor characteristic: CBCharacteristic, 
                error: Error?) {
    guard let data = characteristic.value else { return }
    
    // 解析数据
    let string = String(data: data, encoding: .utf8)
    print("收到: \(string ?? "")")
    
    // 更新 UI(注意切主线程)
    DispatchQueue.main.async {
        self.updateUI(with: data)
    }
}

关键点

  • 必须先调用 setNotifyValue(true, for:) 才能收到通知
  • 数据回调在蓝牙队列,更新 UI 需要切主线程

2.7 断开连接

func disconnect() {
    // 先关闭通知(可选)
    if let characteristic = notifyCharacteristic {
        connectedPeripheral?.setNotifyValue(false, for: characteristic)
    }
    // 断开连接
    if let peripheral = connectedPeripheral {
        centralManager.cancelPeripheralConnection(peripheral)
    }
}

3、完整时序图

App                        系统蓝牙                    外设
 │                            │                         │
 │──scanForPeripherals───────→│                         │
 │                            │←──────广播包────────────│
 │←──didDiscover──────────────│                         │
 │──stopScan─────────────────→│                         │
 │──connect──────────────────→│                         │
 │                            │─────连接请求───────────→│
 │                            │←─────连接响应───────────│
 │←──didConnect───────────────│                         │
 │──discoverServices─────────→│                         │
 │←──didDiscoverServices──────│                         │
 │──discoverCharacteristics──→│                         │
 │←──didDiscoverChars─────────│                         │
 │──setNotifyValue(true)─────→│                         │
 │                            │─────开启通知────────────→│
 │──writeValue───────────────→│                         │
 │                            │─────数据───────────────→│
 │                            │←─────处理结果───────────│
 │←──didWriteValue────────────│                         │
 │                            │←─────主动推送───────────│
 │←──didUpdateValue───────────│                         │

三、蓝牙MTU数据传输

MTU(Maximum Transmission Unit最大传输单元) 分包传输是 BLE 开发中决定传输效率和稳定性的核心底层机制。针对涉及固件升级、实时音频流、文件传输的 IoT 业务场景,掌握 MTU 的原理和 iOS 上的处理策略至关重要。

可以从以下三个递进层面来讲解:

1. 基础层:什么是 MTU?为什么要分包?

  • 定义:MTU 是指链路层单次能够承载的最大有效数据载荷(Payload)。在标准 BLE 4.0/4.1 规范中,ATT_MTU 默认值为 23 字节

  • 数学账:23 字节中,还要扣除 ATT 协议头(3 字节)。

  • 结论iOS App 单次 writeValue 指令,实际能传输给外设(Peripheral)的有效数据只有 20 字节。

  • 分包的根本原因:当你需要下发一个 500KB 的固件文件(.bin)或一张图片时,必须将数据流切分成 N 个 20 字节 的小包,依次发送。如果不分包直接塞给系统,系统会因为数据超长而直接丢弃该包且不报错(静默失败)。

2. 演进层:MTU 协商与扩展(解决分包慢的问题)

如果每次只能发 20 字节,传 500KB 文件需要 25,000 次握手,耗时极长且容易出错。现代 BLE 通过MTU 协商机制来放大单包容量。

  • iOS 端的主动协商: 在连接外设成功后,App 应主动发起 MTU 请求,争取更大的通道。
    // 连接成功后,尝试将 MTU 协商到 512 字节(iPhone 6以上支持更大值)
    [peripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithResponse];
    
  • iOS 协商策略
    • 旧设备(iPhone 4S/5):上限约 158 字节。
    • 较新设备(iPhone 6 及以上,支持 BLE 4.2+):上限可达 512 字节,甚至部分外设支持 158 字节(LE Data Length Extension)。
    • 关键收益:单包容量从 20B 提升至 512B,传输同一文件的交互次数减少了 96%,带宽利用率提升,丢包率显著下降。

3. 实战层:iOS 端分包传输的代码实现与绿联场景题

“MTU 协商成功是 512 字节,但你发 600 字节怎么办?代码怎么写?”

核心逻辑:NSData 切片 + 递归/队列发送。

场景模拟:发送智能摄像头的配置 JSON(假设长度 1200 字节,协商 MTU=512)

// 1. 获取当前连接的最大写入长度(系统已考虑 ATT 头开销)
NSInteger maxLength = [peripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithoutResponse];

// 2. 分包切片逻辑
NSData *totalData = ...; // 1200 字节的配置数据
NSInteger offset = 0;

while (offset < totalData.length) {
    // 计算本次切片的长度(剩余长度 vs 最大允许长度)
    NSInteger chunkSize = MIN(maxLength, totalData.length - offset);
    NSData *chunk = [totalData subdataWithRange:NSMakeRange(offset, chunkSize)];
    
    // 3. 写入外设
    [peripheral writeValue:chunk 
         forCharacteristic:characteristic 
                      type:CBCharacteristicWriteWithoutResponse]; // 无响应模式追求速度
    
    offset += chunkSize;
}

业务场景下的关键注意点

在分包传输时,不能像上面代码那样无脑连续循环发送。因为 BLE 是有空中拥塞控制的。

  • 问题:如果 while 循环一口气把 1200 字节切成 3 个包瞬间推给系统,会导致 缓冲区溢出(Buffer Full),第三包可能被系统直接丢弃,外设只收到了不完整的 JSON 导致解析失败。
  • 正确做法(队列 + 回调确认)
    1. 采用 CBCharacteristicWriteWithResponse 模式(可靠模式)。
    2. 不依赖 while 循环,而是在 didWriteValueForCharacteristic 回调中,判断上一包是否成功写入。
    3. 成功后,再从发送队列中取出下一包继续发送。
    4. 这是一个典型的**排队机(Queue State Machine)**设计。

4. 怎么保证数据完整性

把一个大文件切成有序的小包,通过序列号和校验机制,确保接收方能完整、有序地重组。

4.1 具体措施

1. 包序号 (Sequence Number) —— 防止乱序与重复

每个分片必须携带一个自增的序号,这是重组的基础。

字段 大小 说明
Seq 2 字节 当前包的序号(从 0 开始)
Total 2 字节 总包数(便于接收方预分配缓冲区)
Payload N 字节 实际数据切片

接收方维护一个 expectedSeq,如果收到的包序号不等于期望值:

  • 大于期望值:说明中间有包丢了,立即请求重传缺失的包。
  • 小于期望值:可能是重传的重复包,直接丢弃。

2. 校验和 (Checksum / CRC) —— 防止数据损坏

即使蓝牙底层有 CRC,应用层也必须再加一层校验。因为数据可能在硬件接收后、写入 Flash 前,因内存位翻转而损坏。

  • 简单场景:对整个分片数据做 XOR 异或校验 或 累加和校验
  • 严格场景(固件升级):使用 CRC16 或 CRC32,放在包尾。接收方校验不通过则丢弃该包并请求重传。

3. 确认与重传机制 (ACK & Retransmission) —— 核心

这是保证完整性的核心策略,有两种模式可选:

模式 A:停等协议 (Stop-and-Wait) —— 简单可靠

  • 发送方发一包,等待接收方回复 ACK,收到后再发下一包。
  • 超时未收到 ACK,则重传当前包。
  • 优点:实现简单,适合低速传输。
  • 缺点:效率低,BLE 本身延迟就高,停等会让传输更慢。

模式 B:滑动窗口 / 批量确认 (适用于 BLE 高速传输)

  • 发送方连续发送 N 个包(窗口大小),接收方收到后回复一个 位图 ACK,标识哪些包收到了。
  • 发送方只需重传位图中标记为 0 的包。
  • 优点:充分利用 BLE 连接间隔,大幅提升吞吐量。
  • 场景适配:固件升级时,通常采用 窗口大小为 1 的停等协议,因为固件写入 Flash 本身有延迟,发太快反而容易溢出。

4. 整体完整性校验 (Global Checksum / Hash) —— 收尾验证

所有包传输完毕后,接收方需要对整个完整数据进行一次校验,确保拼接后的文件和发送方完全一致。

  • 常见做法:在传输开始前,先发送一个起始包,里面包含:

    • 文件总大小
    • 整个文件的 MD5 或 SHA-256 哈希值
  • 收尾流程:接收方收完所有包、拼接完成后,计算本地文件的哈希,与起始包中的哈希比对。一致则回复 传输完成,不一致则整包重传

5.2 这种验证跟HTTPS的Hmac校验的区别?

HMAC的标准流程如下:

  1. 准备密钥:双方预先共享一把相同的 Secret Key

  2. 双重哈希

    • 内层哈希Hash( (Key ⊕ 内层填充) + 原始消息 )
    • 外层哈希Hash( (Key ⊕ 外层填充) + 内层哈希结果 )
  3. 传输:发送方将 原始消息 和计算出的 HMAC 值 一起发给接收方。

场景假设: 智能摄像头收到一条 App 发来的指令: "格式化存储卡"

  • 场景 A(无 HMAC) :如果攻击者劫持了 Wi-Fi 数据包,把内容改成  "关闭报警" ,并重新算了一个 MD5 填进去。摄像头收到后一看 MD5 是对的,就执行了。
  • 场景 B(有 HMAC) :App 发出  "格式化存储卡"  时,会配合配对时生成的会话密钥计算一个 HMAC。攻击者改了内容,但没有会话密钥,算不出新的 HMAC。摄像头收到后校验 HMAC 失败,直接丢弃危险指令并报警

四、蓝牙ATT、GATT协议

在我们的蓝牙交互过程中,API的层次结构是下面的,其实这个设计背后的原因是蓝牙ATT、GATT协议.

Peripheral(外设)
  └── Service 1(服务)
        ├── Characteristic A(特征-可读)
        ├── Characteristic B(特征-可写)
        └── Characteristic C(特征-可通知)
  └── Service 2(服务)
        └── Characteristic D(特征-可读)

特征里面包含的属性CBCharacteristicProperties很多是跟ATT协议对照的:

@interface CBCharacteristic : CBAttribute
@property(readonly, nonatomic) CBCharacteristicProperties properties;
...
@end

typedef NS_OPTIONS(NSUInteger, CBCharacteristicProperties) {
    CBCharacteristicPropertyBroadcast = 0x01,
    CBCharacteristicPropertyRead = 0x02,
    CBCharacteristicPropertyWriteWithoutResponse = 0x04,
    CBCharacteristicPropertyWrite = 0x08,
    CBCharacteristicPropertyNotify = 0x10,
    CBCharacteristicPropertyIndicate = 0x20,
    CBCharacteristicPropertyAuthenticatedSignedWrites = 0x40,
    CBCharacteristicPropertyExtendedProperties = 0x80,
    CBCharacteristicPropertyNotifyEncryptionRequired NS_ENUM_AVAILABLE(10_9, 6_0) = 0x100,
    CBCharacteristicPropertyIndicateEncryptionRequired NS_ENUM_AVAILABLE(10_9, 6_0) = 0x200
};

下面来具体了解下各个协议

五、蓝牙ATT

全称属性协议(Attribute Protocol),是低功耗蓝牙(BLE)设备间进行数据交换的基础协议。它好比一个精简的“数据库访问协议”,提供了发现、读取、写入和修改数据的基本操作集。

ATT协议是基于客户端/服务器(C/S)模型设计的,一次通信必然由客户端发起:

  • 客户端 (Client) :通常是智能手机、平板等中心设备。它主动向服务器发送请求或命令,并处理服务器的响应或更新。
  • 服务器 (Server) :通常是传感器、手环等外围设备。它负责存储和组织数据(作为属性),响应客户端的请求,并可根据需要主动发送通知或指示。

1. 数据的基石:属性 (Attribute)

属性是ATT协议的数据基本单元,由以下四个部分组成:

  • 句柄 (Handle) :由服务器分配的16位数字0x0001 - 0xFFFF),用作属性的唯一地址,客户端通过它来访问特定数据。
  • 类型 (Type) :一个UUID(通用唯一标识符),用于说明该属性代表的数据种类(如心率、温度、设备名等)。
  • 值 (Value) :数据本身,是属性的实际内容,可以是心跳值、温度读数、字符串等。
  • 权限 (Permissions) :一组由更高层协议(如GATT)定义的安全规则,用于控制客户端对该属性的访问(例如,是否可读、是否需要加密连接)。

2. 六种通信报文 (PDUs) 详解

ATT协议定义了六种报文(PDU)类型,支撑起所有的数据交互。这六种报文因其确认机制(是否需要回复)不同,应用场景也各异:

报文类型 发送方向 特点
请求 (Request) 客户端 → 服务器 一条必须得到“响应”的报文。
响应 (Response) 服务器 → 客户端 对客户端“请求”的回复。
命令 (Command) 客户端 → 服务器 客户端主动发送,无需服务器回复。
通知 (Notification) 服务器 → 客户端 服务器主动推送数据,无需客户端确认。
指示 (Indication) 服务器 → 客户端 服务器主动推送数据,必须得到客户端的“确认”。
确认 (Confirmation) 客户端 → 服务器 对服务器“指示”的确认

基于这些报文,ATT定义了一套操作集(Opcode),完整列表如下:

功能类别 操作名称及操作码 (Opcode) 功能描述
错误处理 Error Response (0x01) 当请求无效时,服务器返回的错误响应。
MTU交换 Exchange MTU Request (0x02) / Response (0x03) 客户端与服务器交换各自能处理的最大传输单元(MTU)值。
查找信息 Find Information Request (0x04) / Response (0x05) 用于发现指定句柄范围内的属性及其类型。
Find By Type Value Request (0x06) / Response (0x07) 用于查找具有特定类型和值的属性。
读取属性 Read By Type Request (0x08) / Response (0x09) 根据属性类型UUID读取属性值和句柄。
Read Request (0x0A) / Response (0x0B) 根据属性句柄读取具体的属性值。
Read Blob Request (0x0C) / Response (0x0D) 用于分片读取一个很长的属性值。
Read Multiple Request (0x0E) / Response (0x0F) 一次性读取多个已知句柄的属性值。
Read by Group Type Request (0x10) / Response (0x11) 用于读取特定组类型(如主服务)的属性。
写入属性 Write Request (0x12) / Response (0x13) 写入一个属性值,服务器必须回复确认。
Write Command (0x52) 写入一个属性值,服务器无需回复,效率更高。
Signed Write Command (0xD2) 带数字签名的写入命令,用于不需要配对但需验证的场景。
Prepare Write Request (0x16) / Response (0x17) 为可靠写入长属性值做准备,可提交或取消。
Execute Write Request (0x18) / Response (0x19) 执行或取消之前所有Prepare Write请求的最终操作。
队列式写入 Prepare Write Request (0x16) / Response (0x17) 与写入属性操作共用,用于实现长属性值的可靠写入。
Execute Write Request (0x18) / Response (0x19)
服务器推送 Handle Value Notification (0x1B) 服务器主动向客户端发送属性值,无需确认。
Handle Value Indication (0x1D) / Confirmation (0x1E) 服务器主动发送属性值,并需要客户端确认。

3. 工作模型:停止-等待与顺序协议

ATT是一个有状态、顺序性的协议,其请求/响应和指示/确认遵循“停止-等待”工作模型。这意味着在一个事务未完成前,不能开始下一个同类型事务。例如,客户端必须等服务器对其“读请求”做出“读响应”后,才能发起下一个请求。

4. 协议演进:从ATT到EATT

随着应用场景的复杂化,原始的ATT协议因为一次只能处理一个事务,逐渐成为性能瓶颈。因此,蓝牙5.2核心规范引入了增强型属性协议(EATT, Enhanced Attribute Protocol)

EATT的核心改进在于:

六、 GATT协议

GATT(Generic Attribute Profile,通用属性规范) 是基于ATT这套语法写出的“数据组织字典和交互规范”。它规定了数据如何组织成有意义的“服务”,以及应用程序该如何使用 ATT 来访问这些数据。

  • GATT 是建立在 ATT 之上的高层次规范,赋予数据以结构和服务语义。
  • 它用 ServiceCharacteristicDescriptor 三个核心概念将 ATT 数据库组织成功能模块。
  • 客户端通过标准的发现 → 访问 → 使能流程,实现对设备功能的读取、控制和数据订阅。
  • 与 ATT 的“无状态”原子操作不同,GATT 定义了有状态的交互过程(如发现、配置 CCCD),但这状态由上层维护,ATT 本身依然是事务性的。

ATT 管传输,GATT 管组织。理解了 ATT 的六种报文和操作,再结合 GATT 的服务/特征体系,你就掌握了 BLE 数据通信的核心。


1. GATT 与 ATT 的关系

ATT 只定义了“属性”这个基本单元(句柄、UUID、值、权限),以及对这些属性进行读、写、通知的原子操作。但仅凭 ATT 无法知道:

  • 哪些属性代表同一个功能(如心率测量)?
  • 哪个属性是该功能的配置开关?
  • 属性之间有什么逻辑关系?

GATT 在 ATT 之上建立了一个分层的抽象模型,把所有属性归类为 服务(Service)特征(Characteristic),并约定了一套标准的发现与访问流程。


2. GATT 的核心概念

1. 服务(Service)

一个服务代表设备上的一项逻辑功能(如心率服务、电池服务、设备信息服务)。它是一组相关特征和其他服务的容器。每个服务都用一个 UUID 来标识:标准服务由蓝牙技术联盟(SIG)分配 16 位 UUID(如心率服务为 0x180D),厂商自定义服务使用 128 位 UUID。

服务有两种类型:

  • 主要服务(Primary Service):代表设备的主要功能。
  • 次要服务(Secondary Service):由其他服务引用,提供辅助功能。
2. 特征(Characteristic)

特征是服务的核心,它包含一个特征值(Value) 和可选的描述符(Descriptor),以及一组特征属性(Properties)权限

  • 特征值:实际的数据,如心率测量值、电池电量百分比。
  • 特征属性:规定该特征支持哪些 ATT 操作,例如:
    • Read:允许客户端读取
    • Write:允许客户端写入
    • Notify:允许服务器发送通知
    • Indicate:允许服务器发送指示 这些属性直接映射到底层 ATT 报文的权限。
  • 特征声明(Characteristic Declaration):存储在 ATT 表中的一种特殊属性,其值包含了该特征的属性特征值句柄以及特征 UUID。客户端通过读取这些声明来发现特征。
3. 描述符(Descriptor)

描述符提供关于特征值或特征的附加信息。常见的有:

  • CCCD(客户端特征配置描述符):启用/禁用通知或指示。只有在该描述符被客户端正确写入后,服务器才会开始推送数据。
  • CCFD(服务器特征格式描述符):描述特征值的数据格式、单位等。
  • RCD(特征用户描述符):一个可读的字符串,用来描述特征(如“心率测量值”)。

3. GATT 的数据层次结构

一个典型的 GATT 数据库看起来像这样:

Profile(未在表中直接体现,是应用的顶层约定)
└── Service 1 (UUID: Battery Service 0x180F)
│   ├── Characteristic (UUID: Battery Level 0x2A19)
│   │   ├── Value (电池电量,可读、可通知)
│   │   └── Descriptor (CCCD,用于开启通知)
│   └── Characteristic (UUID: ...)
└── Service 2 (UUID: Heart Rate Service 0x180D)
    ├── Characteristic (UUID: Heart Rate Measurement 0x2A37, 可通知)
    │   └── Descriptor (CCCD)
    ├── Characteristic (UUID: Body Sensor Location 0x2A38, 可读)
    └── Characteristic (UUID: Heart Rate Control Point 0x2A39, 可写)

4. GATT 的通用操作流程

GATT 客户端通过与服务器建立连接后,按以下典型步骤进行交互,每一步都对应着底层的 ATT 操作:

  1. 服务发现
    客户端使用 Read by Group Type Request 遍历所有主要服务,获取其 UUID 和句柄范围。

  2. 特征发现
    在某个服务的句柄范围内,使用 Read by Type Request(类型为特征声明 UUID 0x2803)找出所有特征声明,从中提取特征值句柄、属性、特征 UUID。

  3. 特征值访问

    • 如果特征是可读的,客户端用 Read Request 读取特征值句柄。
    • 如果特征是可写的,客户端用 Write RequestWrite Command 向特征值句柄写入数据。
    • 如果特征是可通知/指示的,客户端先通过 Write Request 向对应的 CCCD 描述符写入 0x0001(通知)或 0x0002(指示)来使能推送。
  4. 数据推送
    使能后,当特征值发生变化,服务器会主动发送 Handle Value NotificationHandle Value Indication 给客户端。

  5. 多字节传输处理
    GATT 定义了一个长特征值的概念。当特征值或描述符超过 ATT 单次能承载的 MTU-3 字节时,使用 Read Blob Request 分片读取,或使用 Prepare Write Request + Execute Write Request 可靠地分片写入。


5. GATT 客户端与服务器的角色

在 BLE 生态中,角色通常这样分配:

  • GATT 服务器:存储数据,通常是外围设备(如心率带、温湿度传感器)。
  • GATT 客户端:读取/写入数据,通常是中心设备(如智能手机)。

不过也有例外:一些设备可以同时担任 GATT 客户端和服务器。例如,一个智能手表可以既作为手机通知的 GATT 客户端,也作为心率数据向手机提供的 GATT 服务器。


帮Apple修Bug

作者 kevinli
2026年5月26日 20:17

前言

#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <UIKit/UIKit.h>

void test(void) {
    if (@available(iOS 14.0, *)) {
        UTType *t = [UTType typeWithIdentifier:@"public.image"];
        (void)t;
    }
}

如上所示,如果App的支持版本低于iOS14,那么最终对UniformTypeIdentifiers的依赖应该是weak的。因为UniformTypeIdentifiers的最低支持版本是高于我们的App。但是如果你去编译上述的代码你会发现无论是.o的符号还是最终的动态库依赖都是strong的,就让我们一起来帮Apple修复这个bug。 1.png

2.png

背景

决定一个动态库的强弱依赖是通过它里面的符号来决定的,而决定一个符号的强弱是通过符号的声明和依赖来决定的,也叫做符号决议。在编译、链接的过程中,每一步都会决定最终动态库依赖的强弱。

那么如何定位到上述的问题呢?通过现象来看,我们很明确的知道了符号是强的,因为最后生成的.o文件中对符号的依赖是强引用。那么在编译的过程中是不是强引用或者强依赖了这个库以及对应的符号呢?

我们首先想到的是去查看编译的参数,通过Xcode我们很快定位到了对应的文件编译命令。

3.png

可以看到他是用-framework的方式去编译和依赖的,并不是-weak_framework。那么问题会是这里吗?

此时如果我们调整import的顺序,将代码改成

#import <UIKit/UIKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>

void test(void) {
    if (@available(iOS 14.0, *)) {
        UTType *t = [UTType typeWithIdentifier:@"public.image"];
        (void)t;
    }
}

注意此时是先import UIKit然后import UTI你会发现符号和最终产物对于UniformTypeIdentifiers的依赖都是weak。

4.png 有点奇怪,难道import的顺序会影响符号的强弱?

我们继续把demo简化,调用最原始的xcrun命令来编译。

# 编译
xcrun clang -c test.m -o test.o \
      -target arm64-apple-ios12.0 \
      -isysroot $(xcrun --sdk iphoneos --show-sdk-path)
# 检查符号
nm -m test.o | grep UTType

这次出现的符号却是weak,而不是strong。

5.png 这说明问题不在 -framework 还是 -weak_framework,我们连任何链接参数都没加,符号就已经是对的了。很显然是Xcode在编译的过程中插入的某些参数影响到了符号的强弱,从而影响到了最终的依赖。

探究

打开Xcode查看对应的编译命令,发现Xcode默认会显示指定使用modules。(Xcode16之后默认开启)

6.png 让我们重新写个脚本验证一下

# 编译
xcrun clang -c test.m -o test.o \
      -target arm64-apple-ios12.0 \
      -isysroot $(xcrun --sdk iphoneos --show-sdk-path) \
      -fmodules \
      -fmodules-cache-path=/tmp/modcache
# 检查符号
nm -m test.o | grep UTType

这次我们显示的指定用modules编译。结果如下:

7.png 看起来和module是强相关。 我们把UTI放在最前,同时分别用-fmodules-fno-modules测试结果如下.

8.png 因此我们不难得出结论: 编译参数有-fmodulesimport UTI在最前时,会导致UTI的符号变成强符号,对其动态库的依赖也是强依赖。 那么为什么import的顺序和modules参数会影响到符号的强弱呢?这就要去查看编译的过程,所幸LLVM是开源的。 我本机的环境是 mac OS: 26.4 Xcode 26.0 clang 17.0.0 在wiki 上可以看到对应的clang 版本为19.1.5

编译过程追踪:一步一步定位 Redecl 链的问题

我们已经用实验锁定了 -fmodules + import 顺序两个条件,但这只是表象——根因藏在 Clang 编译流程的深处。下面沿着编译流程一步一步追踪。

Step 1:两条路径

Clang 编译一个 .m 文件,大致经过:词法分析 → 语法分析 → 生成 AST → 语义分析 → 代码生成 → 目标文件。其中语义分析阶段(Sema)负责检查类型、合并声明、处理属性等。

但当 -fmodules 开启后,流程出现了一个关键的分叉:

myblog_output-1.png

不用 modules 时,一切正常。但开启 modules 后,Clang 不再解析头文件,而是从 PCM(预编译模块)中加载序列化后的 AST——问题就出在这条反序列化路径上。

那么 PCM 路径下到底发生了什么?顺着这条分支继续追踪。但在此之前,需要先理解 Clang 中一个关键的数据结构。

Step 2:理解 Redeclaration Chain

在 Objective-C 中,同一个类可以被声明多次。最常见的情况:一个 .h 文件里有 @class Foo 的前向声明,另一个 .h 文件里有完整的 @interface Foo : NSObject ... @end 定义。这两个声明描述的是同一个符号,Clang 需要把它们关联起来——这就是 Redeclaration Chain(重声明链)的作用。

链的结构

Redeclaration Chain 是一个单向链表,通过每个声明节点内部的 PreviousDecl 指针串联:

myblog_output-2.png

当一个新声明出现时,它调用 setPreviousDecl(旧节点) 把自己挂到链的头部,成为"最新"节点。getMostRecentDecl() 返回的就是这个链头。

为什么链头这么重要

Clang 中很多判断只看链头。来看 isWeakImported() 的实现(clang/lib/AST/DeclBase.cpp):

bool Decl::isWeakImported() const {
    bool IsDefinition;
    if (!canBeWeakImported(IsDefinition))
        return false;
    // 只检查 getMostRecentDecl() 的属性——也就是链头!
    for (const auto *A : getMostRecentDecl()->attrs()) {
        if (const auto *AA = dyn_cast<AvailabilityAttr>(A)) {
            // 检查是否匹配当前编译平台...
        }
    }
    return false;
}

isWeakImported() 不遍历整条链,只检查链头节点的属性。 链头有什么,符号就是什么。

现在回到我们的问题。当 UTI 在前、UIKit 在后时,UTType 的 Redecl 链是如何一步步变化的?

Step 3:模块加载与 Redecl 链的构建

myblog_output-3.png

  • 加载 UTI 模块后:链上只有 @interface,它是链头,5 个属性齐全。
  • 加载 UIKit 模块后:@class 通过 setPreviousDecl() 把自己挂在 @interface 前面,成为新的链头,getMostRecentDecl() 现在返回 @class

根据 Step 2 的分析,isWeakImported() 只看链头。现在链头是 @class,属性为空——它必须从旧节点 @interface "继承"属性。这个继承过程由 Clang 的 mergeInheritableAttributes 负责。

那合并过程到底是怎么做的?直接看源码。

Step 4:追踪属性合并——mergeInheritableAttributes

在 PCM 反序列化过程中,每构建一个 Redecl 链节点,Clang 都会调用 mergeInheritableAttributes(D=新节点, Previous=旧节点),把旧节点的可继承属性拷贝到新节点。

对于 UTType,旧节点 @interface 带有 5 个平台的 availability 属性(来自 API_AVAILABLE_BEGIN 宏展开):

myblog_output-4.png

从设计意图上说,所有 5 个属性都应该被拷贝。但实际发生了什么,需要看源码。

Step 5:揭开谜底——getAttr 只取第一个

mergeInheritableAttributes 的源码在 clang/lib/Serialization/ASTReaderDecl.cpp 中。属性合并的核心逻辑只有这几行:

if (!D->hasAttr<AvailabilityAttr>()) {
    if (const auto *AA = Previous->getAttr<AvailabilityAttr>()) {
        NewAttr = AA->clone(Context);
        NewAttr->setInherited(true);
        D->addAttr(NewAttr);
    }
}

注意这一行:Previous->getAttr<AvailabilityAttr>()

getAttr<T>() 的语义是"返回属性列表中第一个类型为 T 的属性",不是遍历全部。UTType 的 5 个平台属性按 API_AVAILABLE_BEGIN 宏展开顺序排列,macOS 排在第一位

所以实际执行结果是:

myblog_output-5.png

根因浮现:合并后,链头 @class 只有一个 macOS availability。而 isWeakImported() 判定时的逻辑是:遍历链头属性,找匹配当前编译平台的 availability。编译目标是 iOS:

  1. 找到 macOS 11.0——平台不匹配,跳过
  2. 没有更多属性了
  3. 返回 false——符号变成 strong

iOS、watchOS、tvOS、macCatalyst 四个平台的 availability 在合并中全部丢失了。

Step 6:回看验证——为什么 UIKit 在前就没事

发现了根因之后,回过头看"import 顺序决定结果"的现象就完全说得通了:

myblog_output-6.png

UIKit 在前时,@class 先加载(旧节点),@interface 后加载(新节点 = 链头)。@interface 本身就携带全部 5 个属性,D->hasAttr<AvailabilityAttr>()true整个合并逻辑被跳过。链头属性完整,isWeakImported() 正确返回 true——符号是 weak ✅。

这反过来也印证了根因:问题不在于"有没有合并",而在于"合并做得不完整"。

修复

理解了根因,修复只需要把 getAttr<T>() 换成 specific_attrs<T>()——前者只返回第一个,后者遍历全部:

// ❌ 修复前:getAttr 只返回第一个
if (const auto *AA = Previous->getAttr<AvailabilityAttr>()) {
    NewAttr = AA->clone(Context);
    D->addAttr(NewAttr);
}

// ✅ 修复后:specific_attrs 遍历全部
for (const auto *AA : Previous->specific_attrs<AvailabilityAttr>()) {
    NewAttr = AA->clone(Context);
    NewAttr->setInherited(true);
    D->addAttr(NewAttr);
}

一个 iffor,效果天差地别:

myblog_output-7.png

至此,链头属性完整,isWeakImported() 能正确匹配到 iOS availability,符号正确标记为 weak。


这个 bug 的影响范围不只是 UTType。任何跨模块的 ObjC 声明,只要涉及多个平台的 availability 属性,在特定的模块加载顺序下都可能出现属性丢失。修复已合入 LLVM main 分支,如果你使用自定义编译的 Clang 或等待 Apple 更新 Xcode 中的 Clang 版本,这个问题将不再出现。

Tauri 应用首次上架 App Store 被驳回了 3 次(iOS)和 12 轮(macOS)的经历

作者 ssshooter
2026年5月26日 17:14

Mind Elixir 是一款基于 Tauri 框架的跨平台思维导图应用,支持 iOS、macOS、Linux、Windows、Android。免费用户最多创建 24 个思维导图,付费用户(订阅/终身许可)可解锁无限数量。付费功能通过官网购买,应用内登录账号解锁。

第一次把 Tauri 应用提交到 App Store,iOS 被驳回了 3 次,macOS 跟 Apple 来回沟通了 12 轮才通过。这篇文章记录整个过程中踩过的坑,希望能帮到同样在做跨平台付费应用的开发者。


iOS:3 次驳回,10 天通过

Round 1:登录体验 + 商业模式审查

4月15日收到驳回,涉及两个条款:

Guideline 4 — Design:应用把用户跳转到外部浏览器登录注册,Apple 认为体验不好。

当时我的登录逻辑是直接调起系统默认浏览器走 OAuth 流程。Apple 的要求是要么在应用内实现登录,要么用 ASWebAuthenticationSession 在应用内嵌浏览器完成。

Guideline 2.1(b) — Information Needed:Apple 看到应用有付费功能,但不确定商业模式,要求回答四个问题:

  1. 用户在哪里购买订阅?
  2. 用户能访问哪些已购内容?
  3. 有哪些付费内容不走 IAP?
  4. 用户如何注册账号?

我回复解释了跨平台架构——订阅通过官网购买,登录后解锁"无限思维导图"。

Round 2:IAP 产品没提交

4月17日再次驳回,条款 Guideline 2.1(b) — App Completeness。

这是个低级错误。我的应用里引用了 Annual Pass、Lifetime Pass 这些订阅产品,但我在 App Store Connect 里没有把这些 IAP 产品一起提交审核。Apple 直接说"我们没法继续审核,因为找不到这些 IAP"。

教训:提交应用审核的同时,必须把关联的 IAP 产品也一起提交,并且要提供审核截图。

Round 3:订阅信息不完整

4月20日第三次驳回,涉及两个条款:

Guideline 3.1.2(c) — Subscriptions:应用内缺少自动续期订阅的必要信息展示。Apple 要求必须展示:

  • 订阅服务标题
  • 订阅时长
  • 价格
  • Terms of Use 链接
  • Privacy Policy 链接

Apple 还建议使用 SubscriptionStoreView,可以一站式搞定这些信息展示。

Guideline 2.1(b) — Information Needed:还是找不到 IAP 产品。这次 Apple 明确说了,要在 sandbox 环境测试通过,并且确保 Paid Apps Agreement 已签署。

最终通过

4月21日第四次提交,4月23日通过。


macOS:12 轮拉锯战

macOS 的审核比 iOS 复杂得多。一方面是 macOS 的审核规则和 iOS 有差异,另一方面我用了 Tauri 框架打包,引入了一些 iOS 不存在的问题。

第一轮:三个条款同时命中

4月15日 / 16日收到驳回,一次性命中三个条款:

Guideline 2.4.5(vii) — Performance:应用包含了可能用于在 Mac App Store 之外更新应用的框架或 API。

这个是我用 Tauri 打包时带进来的。Tauri 默认会集成一些自动更新的依赖,即使我没有在代码里使用,打包后的二进制文件里仍然包含了这些框架。Mac App Store 要求所有更新必须通过 App Store 进行,不能有额外的更新检查机制。

Guideline 3.1.1 — In-App Purchase:Premium 会员可以通过官网购买,但应用内没有提供 IAP 购买选项。

Apple 的规则很明确:跨平台应用可以让用户访问在其他平台购买的内容,但前提是该内容也必须可以通过 IAP 购买(参考 Guideline 3.1.3(b))。不能只允许通过网站购买。

Guideline 4 — Design:和 iOS 一样的登录体验问题。

这个条款后来拉扯了好几轮,说起来挺搞笑的。我在 macOS 版已经用了 ASWebAuthenticationSession,但问题是 macOS 上的 ASWebAuthenticationSession 表现出来就是打开默认浏览器——这是 Apple 自己文档里写的行为,不是我的实现问题。但审核员看到"打开了浏览器",就直接判我没用 ASWebAuthenticationSession,反复驳回。

我后来把 Apple 官方文档里关于 macOS ASWebAuthenticationSession 行为的说明贴了回去,审核员才承认我确实用了,然后换了个理由继续驳回。笑死。

后续拉锯

这之后经历了多轮驳回和重新提交:

日期 动作 主要问题
4/21 Apple 驳回 继续追问细节
4/24 Apple 驳回 新问题出现
4/24 我回复 x2 提供更多信息
4/27 Apple 驳回 IAP 功能 bug
4/27 我回复 说明修复进展
4/29 Apple 驳回 订阅链接问题
4/30 我回复 补充元数据
5/2 Apple 驳回 最后几个问题
5/2 我最终回复 一次性解决所有问题

最终回复中解决的问题

  1. Guideline 2.1(b):修复了一个 bug——"升级"按钮在某些情况下会变灰无法点击
  2. Guideline 3.1.2(c):在 App Store 元数据的 app description 里添加了 Terms of Use 链接(https://desktop.mind-elixir.com/eula
  3. Guideline 4:详细解释了 Tauri 框架下使用 ASWebAuthenticationSession 的技术方案,说明这是 Tauri 应用做 OAuth 的标准做法
  4. Guideline 5.1.1(v):Apple 其实不推荐让用户必须登录后才能购买 IAP,他们认为这会增加购买摩擦。但我解释了跨平台场景下的实际需求——用户登录后购买,可以关联到账号,这样在其他平台(Linux、Windows、Web)也能直接使用,避免重复购买。Apple 最终接受了这个解释。

5月2日最终通过。


踩坑总结

IAP 相关

  • IAP 产品必须和应用一起提交。不要想着先提交应用再补 IAP,这会导致 Round 2 那种情况
  • 订阅页面必须展示完整信息。用 SubscriptionStoreView 可以省很多事
  • 跨平台应用必须同时提供 IAP 购买选项。即使你的主要销售渠道是官网,App Store 里也必须有 IAP

登录相关

  • 不要跳转外部浏览器登录。用 ASWebAuthenticationSession 在应用内完成
  • 支持注册的应用必须支持账号删除(Guideline 5.1.1(v))
  • Apple 不推荐"先登录再购买"。默认情况下,用户应该能直接购买 IAP,不需要先注册或登录。但如果你有合理的跨平台需求(比如购买需要关联账号以同步到其他平台),可以在回复中详细说明理由,Apple 会酌情考虑

macOS 专项

  • 检查打包依赖中是否包含更新框架。Tauri、Electron 等框架可能默认带入自动更新模块,需要在打包时排除
  • Mac App Store 禁止任何应用外更新机制

审核策略

  • 首次提交会收到更详细的反馈。Apple 会恭喜你加入开发者计划,同时可能一次性指出多个问题
  • 回复 Apple 消息时要详细、有条理。我最后一轮回复是一次性解决了四个条款的问题,之后就通过了
  • 准备好被驳回的心态。尤其是跨平台 + 付费模式的组合,审核会更严格
  • 每次回复都要把之前的问题重新说一遍。这是我踩的一个大坑——macOS 审核过程中,Apple 多次提出相同的问题,比如登录体验、IAP 覆盖等,明明我在之前的回复里已经解释过了,下一轮又会重新问。我不确定是每次 review 的审核员不同、上下文没延续,还是他们就希望你每次都确认。总之,不要假设审核员看过你之前的回复,每次提交补充信息时,把之前已回答的问题也一并带上,避免来回拉锯

审核时间线

iOS:
04/13  提交
04/15  ❌ 驳回(登录体验 + 商业模式)
04/17  ❌ 驳回(IAP 未提交)
04/20  ❌ 驳回(订阅信息不完整)
04/21  第四次提交
04/23  ✅ 通过

macOS:
04/15  提交
04/16  ❌ 驳回(更新机制 + IAP + 登录)
04/17 ~ 05/02  12 轮消息往来
05/02  ✅ 通过

整个过程最大的体会:跨平台应用上架 App Store,技术上要处理框架带来的隐式依赖(比如 Tauri 的更新模块),产品上要适配 Apple 的 IAP 生态(即使你的主力销售在官网),体验上要符合 Apple 的设计规范(登录流程不能跳外部浏览器)。三个维度缺一不可。

iOS 流畅度监控FPS的一个方案

作者 sweet丶
2026年5月26日 14:44

在监控画面是否卡顿时,直接使用主线程 RunLoop 的运行耗时来判断并不直观。业界通常使用屏幕刷新率(FPS)来衡量流畅度,下面就来讨论基于 CADisplayLink 的帧率监控方案。

一、CADisplayLink 原理

CADisplayLink 是一个与屏幕刷新率同步的定时器,本质上它是一个特殊的 RunLoop 事件源(CFRunLoopSource),使用时需要添加到 RunLoop 中:

CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(update:)];
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

实现原理:
CADisplayLink 内部封装了一个 CFRunLoopSource,注册到 RunLoop 后,会等待 VSync 信号的到来。每次垂直同步信号产生时,系统会把这个 Source 标记为待处理,RunLoop 被唤醒后分发回调。

NSTimer 相比,CADisplayLink 更精准:

  • Timer 基于时间触发,依赖 RunLoop 分发。如果 RunLoop 正在执行耗时任务,定时器可能被延迟甚至跳过,无法保证与屏幕刷新同步。
  • CADisplayLink 由硬件 VSync 驱动,能保证每帧都被调用(只要回调执行时间不超过一帧)。此外,它还支持 preferredFramesPerSecond 属性,可以指定期望的帧率,内部通过有规律地跳帧来实现。

当然,如果回调本身的执行时间超过一帧的时长,下一次 VSync 到来时上一次回调可能尚未结束,就会导致实际丢帧。

┌─────────────────────────────────────────────────────────────┐
│                      硬件层                                  │
│  Display Controller → 产生 VSync 中断信号                    │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│                     内核层 (iOS)                             │
│  IOKit.framework → 接收 VSync 中断 → 转换为 Mach 消息       │
│  CoreAnimation Server 进程接收并分发                         │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│                     用户层 (App)                             │
│  CADisplayLink → 注册到 RunLoop → 接收回调 → 执行方法       │
└─────────────────────────────────────────────────────────────┘

CADisplayLink 与 Core Animation 共享同一个 VSync 源,因此它的回调时机和屏幕刷新严格对齐。

二、监控帧率、掉帧与冻帧

CADisplayLink 提供了两个关键属性:

  • duration:每帧的理论时间间隔(例如 60 Hz 下约为 0.0167 秒)
  • timestamp:当前回调对应的时间戳

利用这些属性就可以实现帧率、掉帧数和冻帧检测。

1. 帧率(FPS)

每次回调时让计数器 fpsCount++,同时记录第一帧的 timestamp。当累积时长 delta = 当前timestamp - 第一帧timestamp ≥ 1秒 时,计算:

FPS = fpsCount / delta

然后重置计数器,并将“第一帧时间戳”更新为当前帧,继续下一轮统计。这样得到的是一段时间内的平均帧率,更平滑。

2. 掉帧计算

通过计算相邻两次回调的实际时间间隔,可以知道中间丢了多少帧:

实际间隔 duration = 当前帧timestamp - 上一帧timestamp
掉帧数 = (duration - displayLink.duration) / displayLink.duration

为避免单次微小的抖动产生噪声,通常只关注连续掉帧的情况,并可以按严重程度分级统计:

  • drop3:单次掉帧 ≥ 3 帧(轻度卡顿)
  • drop7:单次掉帧 ≥ 7 帧(严重卡顿)

3. 冻帧(Freeze)检测

当两帧之间的时间差 ≥ 700 mskFreezeFrameLimitValue)时,判定为主线程“冻死”,画面完全无响应。

此时需要:

  • 采集当前主线程的调用栈,用于定位卡死位置。
  • 避免重复采集:一次长时间的冻帧可能连续触发多次判定条件,因此一旦抓到调用栈,就暂停冻帧检测(例如标记 isFreezeCaptured = YES),直到帧率恢复后再重新开启。

一个简化版冻帧检测逻辑示例:

- (void)update:(CADisplayLink *)link {
    if (_lastTimestamp == 0) {
        _lastTimestamp = link.timestamp;
        return;
    }
    
    CFTimeInterval interval = link.timestamp - _lastTimestamp;
    _lastTimestamp = link.timestamp;
    
    // 掉帧数
    NSInteger dropped = round((interval - link.duration) / link.duration);
    if (dropped >= 7) {
        // 记录严重掉帧
    }
    
    // 冻帧
    if (interval >= 0.7 && !_isFreezeCaptured) {
        _isFreezeCaptured = YES;
        // 采集主线程堆栈,上报冻帧事件
        [self captureMainThreadStack];
    }
    
    // FPS 统计
    _fpsCount++;
    CFTimeInterval elapsed = link.timestamp - _firstTimestamp;
    if (elapsed >= 1.0) {
        CGFloat fps = _fpsCount / elapsed;
        _fpsCount = 0;
        _firstTimestamp = link.timestamp;
        // 上报 FPS
        
        // 如果之前因为冻帧暂停了,此处可以恢复
        _isFreezeCaptured = NO;
    }
}

StoreKit 知识总结

作者 charmson
2026年5月26日 12:10

一、什么是 StoreKit 配置文件

StoreKit 配置文件(.storekit)是 Apple 提供的本地测试环境配置文件,用于在 Xcode 中模拟 App Store 内购行为,无需连接真实的 App Store 服务器。

支持的产品类型

类型 说明
Consumable 消耗型,如游戏币
Non-Consumable 非消耗型,如解锁功能
Auto-Renewable Subscription 自动续期订阅
Non-Renewing Subscription 非自动续期订阅

创建方式

Xcode → File → New → File → StoreKit Configuration File
Edit Scheme → Run → Options → StoreKit Configuration(指定文件)

二、本地配置 vs 沙盒测试

对比项 StoreKit 配置(本地) 沙盒测试(Sandbox)
需要网络
需要 App Store Connect 配置
速度 更快 较慢
适合阶段 开发早期 上线前验证

三、清除本地购买记录(重置初始态)

方法一:Xcode 菜单(推荐)

Debug → StoreKit → Clear Purchased Products

清除后重新运行 App 即可,无需重启模拟器。

方法二:重置整个模拟器

Xcode → Window → Devices and Simulators
→ 选中模拟器 → Erase All Content and Settings

方法三:代码同步(仅供参考)

#if DEBUG
try await AppStore.sync()
#endif

四、正式上线后的工作原理

.storekit 配置文件只在开发阶段生效,打包上线后自动忽略。

用户点击购买
    ↓
StoreKit 框架(代码不变)
    ↓
请求发往 Apple 生产服务器
    ↓
Apple 处理支付,返回 Transaction
    ↓
App 验证收据 → 解锁内容

三种环境对比

环境 数据来源 购买记录存储
本地开发 StoreKit 配置文件(.storekit) 本地模拟器沙盒
沙盒测试 App Store Connect(测试环境) Apple 服务器(沙盒)
正式上线 App Store Connect(生产环境) Apple 服务器(生产)

关键点

  • .storekit 文件是"假数据源",上线后真实数据全部走 Apple 服务器
  • StoreKit 是框架(API) ,在三种环境下代码本身不变,变的是背后连接的服务器
  • 用户购买记录永久保存在 Apple 账户中,restore purchases 从 Apple 服务器拉取
  • 上线后需要对 Apple 服务器签发的收据进行验证(客户端或服务端)

X未提前通知,突然停用twitter授权登录域名,大量X三方登录异常!

作者 CocoaKier
2026年5月26日 11:23

昨天(5月25日)我们收到不少客诉反馈X授权登录异常,具体表现是:X授权登录过期后(网页登录态没过期不受影响),会弹下面页面“若要使用此应用程序,你必须登录X”,用户输入账号密码登录登录后又会回到此页面,无限循环。

iShot_2026-05-26_11.01.25.png

解决方案
(必须)修改 客户端\前端 X授权域名
https://twitter.com/i/oauth2/authorize => https://x.com/i/oauth2/authorize

(可选)修改服务端鉴权域名
https://api.twitter.com/2/oauth2/token => https://api.x.com/2/oauth2/token
——建议一起改一下,避免夜长梦多。这个我们测过了,换了没影响

请检查 客户端、Web站点、PC端、服务端,是否包含上述域名地址,请全部替换

如果上面地址是客户端写死的,哭吧,要更包,我昨晚已经加班更了4个包了😭

Tips: iOS清理X登录态方法:系统设置 - Safari - 清理所有历史记录(只清twitter.com、x.com也行)


问题原因分析
上面这个页面之前没见过,怀疑是X的程序员最近新加的,加出bug了,callback回调时忘记兼容老的twitter域名了。说不定后面他们发现后会修复,就不用更包了,但我们等不起也赌不起,万一人家懒得修呢?

参考资料:
docs.x.com/fundamental…

Swift-属性包装器

作者 Muen
2026年5月26日 10:21

@propertyWrapper

Swift 提供的一种“属性包装器”机制,用来给属性增加统一的逻辑。

它本质上是:

  • 对属性的“读写行为”进行封装
  • 避免重复代码
  • 增强属性能力(缓存、校验、持久化、线程安全等)

基本用法

场景:希望给标题这个属性,提供自动大写逻辑,每次赋值都需要处理

先定义一个属性包装器。(封装某些处理逻辑)

@propertyWrapper
struct Uppercase {
    private var value: String = ""
    var wrappedValue: String {
        get { value }
        set { value = newValue.uppercased() }
    }
}

然后,在需要的地方,修饰属性。(复用代码)

struct User {

    @Uppercase    // 使用属性包装器 修饰
    var name: String

    ...
}

这样,每次对 name 进行set,都会“继承”自动大写的处理逻辑

其他场景:

  • 限制范围
  • 自动缓存
  • UserDefaults存储
  • 自动日志

带参数的 属性包装器

场景:如,给表示年纪的属性,限制范围。

@propertyWrapper
struct RangeLimit {

    private var value: Int

    let min: Int
    let max: Int

    init(wrappedValue: Int,
         min: Int,
         max: Int) {

        self.min = min
        self.max = max

        self.value = Swift.max(min,
                               Swift.min(max, wrappedValue))
    }

    var wrappedValue: Int {
        get { value }
        set {
            value = Swift.max(min,
                              Swift.min(max, newValue))
        }
    }
}

使用

struct User {

    @RangeLimit(min: 0, max: 100)
    var age: Int = 50
}

这样,即使给 user.age = 150 ,其值会限制在 100以内。

Property Wrapper + 泛型

任何类型,都可修饰

@propertyWrapper
struct Cache<T> {

    private var value: T

    init(wrappedValue: T) {
        self.value = wrappedValue
    }

    var wrappedValue: T {
        get { value }
        set {
            // ....
            value = newValue 
        }
    }
}

常见使用场景

1、封装UserDefault

@propertyWrapper
struct UserDefault<T> {

    let key: String
    let defaultValue: T

    var wrappedValue: T {
        get {
            UserDefaults.standard.object(forKey: key) as? T
            ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue,
                                      forKey: key)
        }
    }
}

struct UserDefaultConfig {

    @UserDefault(key: "token", defaultValue: "")
    static var token: String
    
    ...
}

使用

UserDefaultConfig.token = "abc"

Block Events数据覆盖:一个静默Bug的排查过程

2026年5月26日 09:55

Block Events数据覆盖:一个静默Bug的排查过程

一个不会报错的Bug

SmartInspector 有两条卡顿检测的数据通道:

  1. Perfetto SQL:BlockMonitor 通过 Trace.beginSection("SI$block#MsgClass#250ms") 写入 atrace 切片,Python 端用 SQL 查出来,有精确的纳秒级时间戳
  2. WebSocket:App 端 BlockMonitor 缓存了结构化的 BlockEvent(msgClass、durationMs、stackTrace),CLI 通过 WS 实时拉取

两条通道各有优势。SQL 通道有精确的 ts_ns 时间戳,但 atrace 有 127 字符截断限制,长类名会被截断。WS 通道有完整的调用栈,但没有 Perfetto 的时间戳。

理想情况下,应该把两者的数据合并——用 SQL 的精确时间戳,补上 WS 的完整调用栈。

但实际发生的是:WS 的数据直接覆盖了 SQL 的结果。5 个 SQL 事件 + 3 个 WS 事件,最终只得到 3 个事件,时间戳全是 0。

这就是一个静默 Bug:不报错、不崩溃、不抛异常,只是数据悄悄丢了。

数据是怎么丢的

看修复前的代码:

# collector.py 修复前
if ws_events:
    merged = []
    for ev in ws_events:
        raw_name = f"SI$block#{ev.get('msgClass', 'Unknown')}#{ev.get('durationMs', 0)}ms"
        merged.append({
            "raw_name": raw_name,
            "ts_ns": 0,
            "dur_ms": ev.get("durationMs", 0),
            "stack_trace": ev.get("stackTrace", []),
        })
    summary.block_events = merged  # 直接覆盖!

问题在最后一行 summary.block_events = merged

summary.block_events 此前已经通过 collect_block_events() 从 Perfetto SQL 查出了数据,每条都有精确的 ts_ns 时间戳。但这段代码完全忽略了 SQL 数据,直接用 WS 数据替换。

丢掉了什么:

  • Perfetto SQL 查出的 5 个卡顿事件,每个都有纳秒级时间戳
  • 可以和帧渲染数据做时间轴对齐的能力
  • 事件数量可能不一致(SQL 和 WS 采集窗口不完全对齐)

为什么没被发现:

  • WS 数据有 stack_trace,看起来数据"完整"
  • 时间戳全是 0 不会报错,只是后续分析时做不了时间轴关联
  • 报告里的卡顿事件数量可能比实际少(WS 通道有时拿不到全部事件)

排查过程

这个 Bug 不是被测试发现,是被一次 code review 抓到的。

当时在做 Perfetto 采集优化,重新审视 collector.py 的数据流。看到这段代码时,问题很明显——summary.block_events 先被 SQL 查询填充,然后被 WS 数据无条件覆盖。两行赋值之间没有合并逻辑。

用 git blame 看,这段 WS 拉取逻辑是后来加上去的。最初只有 Perfetto SQL 一条通道,summary.block_events 只有 SQL 数据。后来加了 WS 通道作为补充数据源,但实现时直接替换而不是合并。

这种 Bug 的典型特征:增量开发时,后加的逻辑没有考虑已有数据

修复:合并而非覆盖

修复的思路很清晰——SQL 数据为主(有精确时间戳),WS 数据补充 stack_trace:

def _merge_block_events(
    sql_events: list[dict],
    ws_events: list[dict],
) -> list[dict]:
    """合并两条通道的卡顿事件数据。
    
    SQL 有精确 ts_ns;WS 有完整 stack_trace。
    按 (msg_class, dur_ms) 匹配合并。
    """
    # 按 (msg_class, dur_ms) 索引 WS 事件,保留 stack_trace 最长的
    ws_index: dict[tuple[str, float], dict] = {}
    for ev in ws_events:
        key = (ev["msg_class"], ev["dur_ms"])
        if key not in ws_index or len(ev.get("stack_trace", [])) > len(ws_index[key].get("stack_trace", [])):
            ws_index[key] = ev

    # 也按 dur_ms 索引,做模糊匹配
    ws_by_dur: dict[float, list[dict]] = {}
    for ev in ws_events:
        ws_by_dur.setdefault(ev["dur_ms"], []).append(ev)

    merged = []
    matched_ws_keys: set[tuple[str, float]] = set()

    for sql_ev in sql_events:
        result = dict(sql_ev)

        # 从 SQL raw_name 提取 msgClass
        # 格式: SI$block#MsgClass#250ms
        name = sql_ev.get("raw_name", "")
        parts = name.split("#")
        sql_msg_class = parts[1] if len(parts) >= 3 else ""
        sql_dur_ms = sql_ev.get("dur_ms", 0)

        # 精确匹配
        key = (sql_msg_class, sql_dur_ms)
        ws_match = ws_index.get(key)
        
        # 模糊匹配:按 dur_ms 找未匹配的事件
        if not ws_match and sql_dur_ms in ws_by_dur:
            for candidate in ws_by_dur[sql_dur_ms]:
                cand_key = (candidate["msg_class"], candidate["dur_ms"])
                if cand_key not in matched_ws_keys:
                    ws_match = candidate
                    break

        if ws_match:
            matched_ws_keys.add((ws_match["msg_class"], ws_match["dur_ms"]))
            # WS 有更可靠的 stack_trace,覆盖 SQL 的
            if ws_match.get("stack_trace"):
                result["stack_trace"] = ws_match["stack_trace"]

        merged.append(result)

    # 未匹配的 WS 事件也保留(没有精确 ts_ns,但 stack_trace 有价值)
    for ev in ws_events:
        key = (ev["msg_class"], ev["dur_ms"])
        if key not in matched_ws_keys:
            merged.append({
                "raw_name": f"SI$block#{ev['msg_class']}#{ev['dur_ms']}ms",
                "ts_ns": 0,
                "dur_ms": ev["dur_ms"],
                "stack_trace": ev.get("stack_trace", []),
            })

    return merged

关键设计决策:

  1. SQL 为主:遍历 SQL 事件列表,保证每个 SQL 事件都保留(有精确时间戳)
  2. 精确匹配优先:按 (msg_class, dur_ms) 匹配,避免错误关联
  3. 模糊匹配兜底:atrace 截断类名时,精确匹配可能失败,按 dur_ms 模糊匹配
  4. 不丢弃 WS 独有事件:WS 有但 SQL 没有的卡顿事件也保留(ts_ns=0)

调用处改为一行:

sql_events = summary.block_events or []
ws_list = [{"msg_class": ev.get("msgClass", "Unknown"), ...} for ev in ws_events]
merged = _merge_block_events(sql_events, ws_list)
summary.block_events = merged

同一个 Commit 里的另一个优化

修这个 Bug 时顺便解决了一个性能问题。SQL 通道里,卡顿事件要和 logcat 日志按时间戳关联来获取 stack_trace。原来的实现是 O(n×m) 的双重循环:

# 修复前:O(n*m)
for block in block_slices:
    for log in log_entries:
        dist = abs(log["ts_ns"] - block_ts)
        if dist < best_dist:
            best_dist = dist
            best_match = log

改成 bisect 二分查找,O(n log n + m log m):

# 修复后:O(n log n + m log m)
log_timestamps = sorted([log["ts_ns"] for log in log_entries])
for block in block_slices:
    idx = bisect.bisect_left(log_timestamps, block_ts)
    # 只检查 idx-1 和 idx 两个候选
    for candidate_idx in (idx - 1, idx):
        ...

卡顿事件数量通常不多(一次采集 5-20 个),logcat 条目可能几百条,性能差异不大。但作为通用做法,bisect 比双重循环更正确。

教训

1. 静默覆盖比崩溃更难排查

如果是 null pointer 报错了,立刻就能发现。但数据被覆盖了,程序照常跑,报告照常出——只是数据不完整。这种 Bug 的危险在于:你可能根本不知道数据丢了

2. 增量开发时要审视数据流

加新通道时,要问自己:已有数据怎么办?是合并、替换、还是丢弃?如果选择替换,有没有充分理由?

3. 多数据源的合并策略要显式声明

_merge_block_events 这个函数的文档字符串明确写了:"SQL data has precise ts_ns timestamps; WS data has stack_traces. Merge by matching on msgClass + dur_ms." 以后再有人改这段代码,不会踩同样的坑。

4. Code Review 比测试更适合抓这类 Bug

单元测试很难覆盖这种"数据被覆盖但程序不报错"的场景——你得先知道数据应该是什么样子,才可能写出断言。而 code review 时,看数据流走向,"赋值又赋值"的模式一眼就能发现。


本文基于 SmartInspector 项目的真实开发过程。项目地址:github.com/mufans/AppS…

iOS设计模式-适配器

作者 Muen
2026年5月26日 09:53

思想

本质是  把一个“接口不兼容”的对象,转换成你当前系统期望的接口,从而进行调用。

——将一个类的接口,转换成客户端期望的接口,使原本不兼容的类可以协同工作。

——在两个不兼容的接口之间架一座"桥",让原本无法协作的类可以一起工作,而不必修改原有代码。

可以理解为“中间翻译层”。

总结来说,适配器模式用于解决接口不兼容的问题,通过适配器将不兼容的接口转换为兼容的接口,从而使得不同的类能够一起工作。

三个角色

适配者:具有原始接口的类

抽象接口:客户端期望的接口形式

适配器:封装,持有适配者,转发调用

应用场景

常用于集成/调用 第三方 SDK、适配旧代码、数据模型适配。

“旧代码 / 第三方库 / 不同结构的数据 → 统一成我当前代码能用的样子”

应用1: 第三方 SDK的二次封装

你接入了一个第三方 SDK,它的接口不符合你当前项目的设计规范(比如命名、回调方式不同)。即使规范,原则上也不要直接依赖,直接调用其接口。

class ThirdPartyAudioSDK { 
    // 播放
    func loadAndPlay(pathString, volumeFloat) {
        print("SDK 播放: \(path) 音量: \(volume)") 
    } 
}

一般调用方式,直接依赖SDK类

class VC { 
    func do(){
        let sdk = ThirdPartyAudioSDK()
        sdk.loadAndPlay()
    }
}

采用适配器模式,对SDK进行包装

1、设计抽象协议(接口)

protocol AudioPlayer { 
    func play(fileString) 
}

2、设计适配器类

遵循协议,持有SDK对象。实质:调用转发

class AudioPlayerAdapter: AudioPlayer {
    private let sdk = ThirdPartyAudioSDK()

    func play(file: String) {
        // 转换调用:适配接口差异
        sdk.loadAndPlay(path: "/media/\(file)", volume: 1.0)
    }
}

3、业务调用

class VC {
    func playMusic(player: AudioPlayer) {
        player.play(file: "song.mp3")
    }
}

self.playMusic(playerAudioPlayerAdapter())

这样就实现了解耦,业务代码只依赖 AudioPlayer 协议,不知道内部用的是第三方 SDK。

好处:可灵活更换SDK;不污染业务代码。

应用2:服务器数据 的封装

服务端返回的JSON数据,最好不要直接读取使用。原因:

  • 避免耦合
  • 字段名不规范/适合
  • 不是所有字段都显示在UI上
  • 需要进行数据转换/预处理
{
    "num": 1,
    "is_expire": 0,
    "reward_content": "10",
    "reward_name": "WSpoint_reward",
    "reward_type": 1,
    "sign_state": 3,
    "sign_time": 0,
    "trigger_type": 1
}

解析

struct UserDTO: Decodable {
    let sign_time: String
    let trigger_type: Int
    ....
}

采用适配器模式,对服务端返回的数据进行封装

1、设计抽象协议

涵盖 业务层需要用到的数据,及 UI层可直接展示的数据

如:

protocol UIDataDisplay { 
    var count: Int { get }
    var name: String { get }
    .....
}

2、设计适配器类

class ModelAdater: UIDataDisplay {
    let json: UserDTO
    
    var num: Int
    var type: Int
    ...

    init(json: UserDTO){
        self.num = json.num
        ...
    }
    
    // 实现抽象属性
    var count: Int {
        return json.num
    }
    
    var name: String { 
        return json.firstName + json.secondName
    }
}

3、UI 与 具体数据类型 解耦

class Cell {
    func config(model: UIDataDisplay){
        self.nameLabel.text = model.name
        ...
    }
}

好处

  • 数据集中在Adapter进行处理
  • 解耦 UI 和数据结构
  • 扩展性,后端字段变了 → 只改 Adapter

iOS设计模式-策略模式

作者 Muen
2026年5月26日 09:53

思想

把“算法/行为”从“使用它的类”中抽离出来,让客户端在运行时动态切换策略,而不需要修改 Context 的代码。(遵循开闭原则——对扩展开放,对修改封闭)

三个角色

协议:行为抽象

策略者:定义和实现各自的行为策略

上下文统筹者:针对各场景,切换对应的策略者,执行同一行为。

常用场景

庞大的 if-else / switch 语句,每个case都有自己的处理逻辑。

  • 支付场景(微信、支付宝、苹果、银行卡等)
  • 表单输入验证(手机号、邮箱、身份证、密码格式等)
  • 社交分享平台
  • 排序算法(按价格、销量、距离等)

模板

// 1. 抽象策略(协议)
protocol Strategy {
    func execute(data: [Int]) -> [Int]   // 示例:对数据 进行某种处理
}

// 2. 具体策略 A、B、C...
class ConcreteStrategyA: Strategy { ... }
class ConcreteStrategyB: Strategy { ... }

// 3. 上下文(Context)持有策略引用
class Context {
    private var strategy: Strategy
    
    init(strategy: Strategy) {
        self.strategy = strategy
    }
    
    func setStrategy(_ strategy: Strategy) {
        self.strategy = strategy
    }
    
    func doSomething(with data: [Int]) -> [Int] {
        return strategy.execute(data: data)   // 委托给当前策略 执行
    }
}

支付场景 示例

普通代码

面向过程编程

class OrderPayManager{
    func pay(way: Int){
        if way == .alipay {
            alipay()
        }else if way == .wxPay {
            wxpay()
        }else if {
            ...
        }
    }

    func alipay(){
        AliSDK()
        ...
    }
    
    func wxpay(){
        wxSDK()
        ...
    }
}

策略模式封装

  1. 抽象策略协议
protocol PaymentStrategy {
    func pay(amount: Double, completion: @escaping (Bool, String) -> Void)
}
  1. 具体策略实现
class AlipayStrategy: PaymentStrategy {
    func pay(amount: Double, completion: @escaping (Bool, String) -> Void) {
        print("🔸 支付宝支付 \(amount) 元")
        // 真实项目中这里调用支付宝 SDK
        completion(true, "支付宝支付成功")
    }
}

class WechatPayStrategy: PaymentStrategy {
    func pay(amount: Double, completion: @escaping (Bool, String) -> Void) {
        print("🔸 微信支付 \(amount) 元")
        // 调用微信支付 SDK
        completion(true, "微信支付成功")
    }
}

class ApplePayStrategy: PaymentStrategy {
    func pay(amount: Double, completion: @escaping (Bool, String) -> Void) {
        print("🔸 Apple Pay \(amount) 元")
        // 调用 PKPaymentAuthorizationViewController
        completion(true, "Apple Pay 支付成功")
    }
}
  1. 上下文(订单支付类)
class Order {
    private var paymentStrategy: PaymentStrategy
    
    init(paymentStrategy: PaymentStrategy) {
        self.paymentStrategy = paymentStrategy
    }
    
    // 切换支付方式
    func changePaymentMethod(to strategy: PaymentStrategy) {
        self.paymentStrategy = strategy
    }
    
    // 支付
    func checkout(amount: Double) {
        print("📦 订单金额:\(amount) 元")
        paymentStrategy.pay(amount: amount) { success, message in
            if success {
                print("🎉 \(message)")
            } else {
                print("❌ 支付失败:\(message)")
            }
        }
    }
}
  1. 外界调用
let order = Order(paymentStrategy: AlipayStrategy())

order.checkout(amount: 99.9)
// 输出:支付宝支付...

// 用户中途切换支付方式
order.changePaymentMethod(to: ApplePayStrategy())
order.checkout(amount: 199.0)
// 输出:Apple Pay...

输入验证示例

protocol ValidationStrategy {
    func isValid(_ input: String) -> Bool
    var errorMessage: String { get }
}

class EmailValidation: ValidationStrategy {
    var errorMessage = "请输入正确的邮箱地址"
    func isValid(_ input: String) -> Bool {
        let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: input)
    }
}

class PasswordValidation: ValidationStrategy {
    var errorMessage = "密码至少8位,包含字母和数字"
    func isValid(_ input: String) -> Bool {
        return input.count >= 8 && input.range(of: "[A-Za-z]", options: .regularExpression) != nil
    }
}

// 表单验证器(Context)
class FormValidator {
    private var strategy: ValidationStrategy
    
    init(strategy: ValidationStrategy) {
        self.strategy = strategy
    }
    
    func validate(_ text: String) -> String? {
        return strategy.isValid(text) ? nil : strategy.errorMessage
    }
}

总结

在 Swift 开发中,策略模式几乎是“协议 + 运行时切换” 的最佳实践之一。它让你的代码更加灵活、可测试、可扩展,尤其适合那些“行为/算法经常变化”的场景(支付、验证、排序、AI、日志等)。 如果你在实际项目中遇到“一个类里面有大量 if-else 判断不同行为”的情况,强烈建议立刻重构为策略模式,代码会瞬间清晰很多!

iOS开发设计模式-工厂模式

作者 Muen
2026年5月26日 09:52

工厂模式

核心:

解耦创建逻辑 · 多种子类 · 条件分支创建

思想及适用场景:

主要用于封装对象的创建逻辑,调用方只关心"我要什么",不关心"怎么造"。

当需要根据条件创建不同子类时尤其有用。

示例

// 产品协议
protocol Button {
    func render()
    func tap()
}

// 具体产品
struct IOSButton: Button {
    func render() { print("渲染 iOS 风格圆角按钮") }
    func tap()    { print("iOS 触感反馈") }
}

struct AndroidButton: Button {
    func render() { print("渲染 Material Design 按钮") }
    func tap()    { print("Android Ripple 动效") }
}

// 工厂 —— 根据不同平台,决定创建哪种 Button
enum Platform { case iOS, android }

struct ButtonFactory {
    // 提供公共创建方法,传入不同条件/场景,返回协议对象 (调用方不需要知道具体类型)
    static func makeButton(for platform: Platform) -> Button {
        switch platform {
        case .iOS:     return IOSButton()
        case .android: return AndroidButton()
        }
    }
}

// 使用:调用方不需要知道具体类型
let btn = ButtonFactory.makeButton(for: .iOS)
btn.render()   // 渲染 iOS 风格圆角按钮
btn.tap()      // iOS 触感反馈

进阶:抽象工厂模式

思路:

在工厂模式基础上再抽象一层。普通工厂只生产一种产品,抽象工厂负责生产一族相关产品,保证同一工厂出来的产品风格一致,且调用方完全不依赖具体类。

核心区别一句话:工厂方法解决"造哪个",抽象工厂解决"造哪套"。

示例

  • 抽象产品协议
protocol Button {
    func render()
    func onTap()
}

protocol TextField {
    func render()
    func onInput(_ text: String)
}

// MARK: - iOS 具体产品
struct IOSButton: Button {
    func render()  { print("  🔵 渲染 iOS 圆角按钮") }
    func onTap()   { print("  📳 iOS Haptic 触感反馈") }
}

struct IOSTextField: TextField {
    func render()             { print("  🔵 渲染 iOS 下划线输入框") }
    func onInput(_ text: String) { print("  iOS 输入: \(text)") }
}

// MARK: - Android 具体产品
struct AndroidButton: Button {
    func render()  { print("  🟢 渲染 Material Design 按钮") }
    func onTap()   { print("  💧 Android Ripple 水波动效") }
}

struct AndroidTextField: TextField {
    func render()             { print("  🟢 渲染 Material 边框输入框") }
    func onInput(_ text: String) { print("  Android 输入: \(text)") }
}

  • 抽象工厂协议
protocol UIFactory {
    func makeButton() -> Button
    func makeTextField() -> TextField
}

// MARK: - 具体工厂(每个工厂只生产同一风格的产品族)

struct IOSFactory: UIFactory {
    func makeButton()    -> Button    { IOSButton() }
    func makeTextField() -> TextField { IOSTextField() }
}

struct AndroidFactory: UIFactory {
    func makeButton()    -> Button    { AndroidButton() }
    func makeTextField() -> TextField { AndroidTextField() }
}
  • 调用,客户端(只依赖抽象,不知道任何具体类型)
class LoginVC {
    private let button: Button
    private let textField: TextField

    // 注入工厂,由外部决定平台风格
    init(factory: UIFactory) {
        self.button    = factory.makeButton()
        self.textField = factory.makeTextField()
    }

    func show() {
        print("--- 渲染登录界面 ---")
        textField.render()
        textField.onInput("alice@example.com")
        button.render()
        button.onTap()
    }
}

// MARK: - 运行

print("=== iOS 平台 ===")
LoginVC(factory: IOSFactory()).show()

print("\n=== Android 平台 ===")
LoginVC(factory: AndroidFactory()).show()
  • 扩展

只需 新增 品类 和 工厂。不改 旧代码,不改调用方式(开闭原则),只需传递 新变量

// 新增 macOS 产品
struct MacOSButton: Button {
    func render() { print("  🖥 渲染 macOS NSButton") }
    func onTap()  { print("  🖱 macOS 点击事件") }
}

struct MacOSTextField: TextField {
    func render()                { print("  🖥 渲染 macOS NSTextField") }
    func onInput(_ text: String) { print("  macOS 输入: \(text)") }
}

// 新增工厂
struct MacOSFactory: UIFactory {
    func makeButton()    -> Button    { MacOSButton() }
    func makeTextField() -> TextField { MacOSTextField() }
}

// 使用 —— LoginScreen 代码零修改
LoginScreen(factory: MacOSFactory()).show()

适用场景总结:

当系统需要独立于产品创建方式、且需要保证同一产品族的一致性时——比如跨平台 UI 套件、换肤主题、多数据库驱动(SQLite / CoreData / CloudKit)——抽象工厂是最合适的选择。

❌
❌