普通视图

发现新文章,点击刷新页面。
昨天以前iOS

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 服务器。


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;
    }
}

微信Matrix 卡顿监控原理梳理与图解

作者 sweet丶
2026年5月22日 17:50

如果你想建立公司自己的APM监控平台,卡顿检测这项来说,微信Matrix是一个很好的参考;如果想进一步监控卡顿的实际时长,当卡顿时长达到8秒时升级为卡死级别(ANR)上报,可以在这个基础上增加判断逻辑。

下文是根据源码梳理的图解实现原理

WCBlockMonitorMgr 是 微信Matrix 中用于监控 iOS 主线程卡顿(ANR / Hang)的核心模块。其设计思路是:通过 RunLoop Observer 感知主线程状态变化,再通过独立的后台线程周期性检查主线程是否“卡死”超过阈值,若卡顿则收集堆栈、CPU 等信息并生成 dump 文件上报。同时它还集成了 CPU 高负载检测、耗电堆栈采集、内存监控等功能。

下面从五个方面详细讲解实现原理,并辅以图解。


一、核心监控流程概览

+----------------+       +-------------------+       +-------------------+
| 主线程 RunLoop | --->  | RunLoop Observer   | ---> | 全局状态变量更新   |
| (UI/事件处理)   |       | (开始/结束回调)    |       | g_bRun, g_tvRun   |
+----------------+       +-------------------+       +-------------------+
                                                              |
                                                              v
+----------------+       +-------------------+       +-------------------+
| 监控线程        | <--- | 定期检查             | ---> | 计算卡顿时长 diff  |
| (独立 thread)  |       | (每轮 50ms 堆栈采样)|       | diff > 阈值?      |
+----------------+       +-------------------+       +-------------------+
                                                              |
                                    +-------------------------+
                                    |                         |
                                    v                         v
                          +-------------------+    +-------------------+
                          | 卡顿触发           |    | 无卡顿,继续采样   |
                          | 收集堆栈/CPU/快照  |    | 退火算法调整间隔   |
                          | 生成 dump 文件     |    +-------------------+
                          +-------------------+

关键参数(单位均为微秒,但代码中常用 BM_MicroFormat_Second 等宏):

  • g_RunLoopTimeOut:卡顿判定阈值,默认 2 秒(可动态调整)。
  • g_CheckPeriodTime:无论是否卡顿,监控线程的单次检查周期,通常为 g_RunLoopTimeOut / 2
  • g_PerStackInterval:连续采样堆栈的间隔,固定 50ms(代码中 BM_MicroFormat_FrameMillSecond)。

二、RunLoop 观测与状态记录

RunLoop 在每次循环的不同阶段(Entry、BeforeTimers、BeforeSources、AfterWaiting、BeforeWaiting、Exit)会触发 Observer 回调。WCBlockMonitorMgr 添加了两个 Observer

  • beginObserver(优先级 LONG_MIN):在 RunLoop 开始处理事件时(Entry、BeforeTimers、BeforeSources、AfterWaiting)将 g_bRun 置为 YES,并记录开始时间 g_tvRun
  • endObserver(优先级 LONG_MAX):在 RunLoop 即将休眠(BeforeWaiting)或 退出(Exit)时将 g_bRun 置为 NO

另外还针对启动阶段(UIInitializationRunLoopMode)添加了单独的 Observer,用于处理启动卡顿。

状态图:

    [RunLoop 启动] 
          │
          ▼
   kCFRunLoopEntry  ──▶ g_bRun = YES; 记录开始时间
          │
          ▼
   BeforeTimers / BeforeSources / AfterWaiting
          │
          ▼
    (主线程忙碌处理事件)
          │
          ▼
   BeforeWaiting (即将休眠) ──▶ g_bRun = NO; 可选敏感检测
          │
          ▼
    (休眠等待事件)
          │
          ▼
   唤醒后回到 AfterWaiting,重复

监控线程读取 g_bRung_tvRun,若当前 g_bRun == YES 且距离 g_tvRun 的时长超过 g_RunLoopTimeOut,则认为主线程发生了卡顿。

注意:g_bRun 在 Observer 中更新,是跨线程共享的原子变量(实际未加锁,但因访问简单且时序不苛刻,可接受)。


三、监控线程的工作机制

监控线程在 threadProc 中无限循环(内部有休眠),每次循环执行以下步骤:

  1. 检查卡顿(调用 check 方法):

    • 计算当前时间与 g_tvRun 的差值 diff
    • g_bRun == YESdiff > g_RunLoopTimeOut,则判定卡顿。
    • 同时还会检查 CPU 使用率是否超过阈值(g_CPUUsagePercent,默认 1000 即忽略,实际可配置)。
    • 返回对应的 EDumpType(如 MainThreadBlockBackgroundMainThreadBlockCPUBlock 等)。
  2. 卡顿处理

    • 调用 needFilter 进行退火过滤:对比当前卡顿的堆栈与上次卡顿堆栈是否相同,若相同则放大检查间隔(退火算法),避免重复上报相同的堆栈。
    • 若未过滤,则收集主线程堆栈(来自循环队列 WCMainThreadHandler 中保存的多次采样结果)或 CPU 高负载堆栈。
    • 调用 dumpFileWithType 生成 dump 文件(同时可选择挂起所有线程并生成快照)。
    • 通过 delegate 回调通知上层。
  3. 记录当前堆栈recordCurrentStack):

    • 无论是否卡顿,每一轮都会以 g_CheckPeriodTime 为周期,内部按 g_PerStackInterval 多次获取主线程调用栈,存入 WCMainThreadHandler 的循环队列中。
    • 这样在卡顿发生时,可以拿到卡顿期间的多组堆栈,而非仅某一瞬间的堆栈。
  4. 重置状态:若无卡顿,调用 resetStatus 恢复监控参数。

监控线程时序图:

监控线程                    主线程 (RunLoop)
   │                            │
   ├─ 等待 g_CheckPeriodTime ──→│ (忙碌)
   │                            │
   ├─ 检查卡顿 ←────────────────┤ (g_bRun = YES; )
   │   (diff > 阈值)             │
   ├─ 卡顿触发                   │
   │   ├─ 收集堆栈 (队列中已有)   │
   │   ├─ 生成 dump              │
   │   └─ 回调                   │
   │                            │
   ├─ 继续下一轮监控             │
   │                            │

四、堆栈采集与退火算法

1. 堆栈采集

  • 通过 WCGetMainThreadUtil 获取主线程调用栈(内部使用 kscrashstacktracethread_get_state)。
  • 也是在上述监控线程中进行,每 g_PerStackInterval(50ms)采集一次,每次最多保存 g_StackMaxCount 个地址。采集后休眠。
  • 这些地址被存入 WCMainThreadHandler 的循环队列,队列长度由 g_MainThreadCount = g_CheckPeriodTime / g_PerStackInterval 决定(例如 500ms / 50ms = 10 个槽位)。

2. 退火算法(needFilter

  • 将当前卡顿时的堆栈与上一次卡顿的堆栈逐帧比较。
  • 若完全相同(表明可能是同样的代码循环卡顿),则:
    • 将检查间隔 m_nIntervalTime 扩大为 m_nLastTimeInterval + m_nIntervalTime(退火)。
    • 返回 EFilterType_Annealing,跳过本次上报。
  • 若不同,则恢复 m_nIntervalTime = g_CheckPeriodTime,并更新保存的堆栈副本。
  • 同时还会检查今日已上报次数是否超过限额(getDumpDailyLimit)。

算法示意图:

首次卡顿 Stack A
   │
   ▼
第二次卡顿 Stack A' ──→ 比对相同 ──→ 退火: 等待间隔加倍 (例如 1s → 2s)
   │                                    │
   │                                    ▼
   │                               再次卡顿仍相同 → 间隔继续扩大
   │                                    │
   │                                    ▼
   │                               跳过上报,避免日志泛滥
   │
   ▼
第三次卡顿 Stack B (不同) ──→ 重置间隔,正常上报

五、辅助功能与扩展

  • CPU 监控:通过 WCCPUHandler 累计平均 CPU 使用率,若持续高则触发 CPUBlock 类型 dump。
  • 耗电堆栈WCPowerConsumeStackCollector 在 CPU 长时间高于阈值时,采集消耗 CPU 的热点堆栈树并异步保存。
  • 内存监控:定期打印 footprint,超过阈值(如 400MB)回调 delegate。
  • RunLoop 敏感检测:当 g_bSensitiveRunloopHangDetection 开启时,在每次 BeforeWaiting 前立即检查主线程当前已执行时长,若超过 250ms 则异步回调(类似苹果 HangTracer)。
  • 动态阈值调整:提供 lowerRunloopThreshold / recoverRunloopThreshold 方法,可动态降低卡顿阈值(如进入页面后降低到 400ms),退出后恢复。

六、完整流程图(文字版)

+-----------------------------+
|  App 启动                    |
+-----------------------------+
              │
              ▼
+-----------------------------+
|  添加 RunLoop Observers      |
|  (begin/end, 两种 mode)      |
+-----------------------------+
              │
              ▼
+-----------------------------+
|  启动监控线程 (threadProc)    |
+-----------------------------+
              │
     ┌────────┴────────┐
     │                 │
     ▼                 ▼
+------------+   +------------+
| 主线程     |   | 监控线程    |
| RunLoop    |   | 循环:       |
| 触发回调   |   | 1. 检查卡顿 |
| 更新       |   | 2. 若卡顿   |
| g_bRun/    |   |   过滤重复  |
| g_tvRun    |   |   生成dump  |
+------------+   | 3. 采样堆栈 |
                 | 4. 休眠     |
                 +------------+
                       │
                       ▼
                  +----------+
                  | 卡顿上报  |
                  | (delegate)|
                  +----------+

状态转换细节(卡顿检测部分):

       [RunLoop 开始处理]
              │
              ▼
      (g_bRun=YES, 记录开始)
              │
              │ 主线程长时间不进入休眠
              │ (例如死循环、大量IO)
              ▼
      监控线程计算 diff > 阈值
              │
              ├─ 是 ──→ 判定卡顿
              │         │
              │         ├─ 堆栈是否重复?
              │         │    ├─ 是 → 退火,不上报
              │         │    └─ 否 → 生成 dump
              │         │
              │         └─ 回调通知
              │
              └─ 否 ──→ 继续监控

七、关键代码片段注释(对应原理)

原理部分 代码位置(函数/行)
添加 RunLoop Observer addRunLoopObserver,使用 CFRunLoopAddObserver
状态变量更新 myRunLoopBeginCallback / myRunLoopEndCallback
监控线程循环 threadProc 中的 while(YES)
卡顿判定 check 方法中计算 diff > g_RunLoopTimeOut
堆栈采集 recordCurrentStack 内循环调用 WCGetMainThreadUtil
退火过滤 needFilter 方法
动态阈值调整 setRunloopThreshold:

通过以上机制,WCBlockMonitorMgr 能够在不显著影响性能的前提下,高效准确地捕获主线程卡顿,并提供丰富的辅助信息用于定位问题。

iOS应用启动过程深度分析与优化实践

作者 sweet丶
2026年5月18日 13:17

APP启动缓慢可能会导致用户流失、负面评价甚至卸载. iOS系统经过多年的演进,已经形成了一套复杂而精密的启动机制。本文将从内核加载到首帧渲染,,深入剖析iOS应用启动的全过程。

本文将以启动过程、监控、优化三部分来讲解过往实践总结。

一、iOS启动过程

如果你了解或做过启动优化,你会发现iOS中其实有很多的启动场景,那么具体有哪些场景?区别是什么?

1. 启动场景-冷启动作为优化目标

让我们先来了解iOS的启动场景:

  • 冷启动(Cold Launch):系统不存在 App进程,也没有进程缓存信息,系统创建进程启动。
  • 热启动(Warm Launch):App 最近被关闭,进程已销毁,但部分数据仍被系统缓存。
  • 回前台(Resume/Foreground):用户回到桌面,App 未被关闭,只是切到了后台(挂起状态),进程和数据都还在内存中。
  • 后台启动(Background Launch):系统因后台任务(如 Background Fetch、推送静默通知、VoIP 唤醒)唤醒或创建进程 → 执行有限时间代码(30 秒) → 系统可能再次挂起或终止进程。用户完全无感知
  • 预热启动(Prewarming Launch):系统为了加快启动速度,会智能预测提前帮你启动APP到后台(执行didFinishLaunching但不初始化UI),这样你点开时系统从暂停点继续执行,跳过了耗时的初始化过程,实现了“秒开”。
类型 进程状态 是否需要创建进程 是否需要加载资源 速度 典型场景
冷启动 不存在 ✅ 是 ✅ 全部 最慢 重启手机后首次打开App
热启动 刚被杀死,缓存还在 ✅ 是 大部分 较快 手动杀掉后台,再立即打开
回前台 存活(挂起状态) ❌ 否 ❌ 否 最快 切到后台几秒后又切回来
后台启动 不存在、挂起 不一定 不一定 瞬间(后台) 后台刷新、推送唤醒
预热启动 系统预创建 ⚠️ 提前创建 ⚠️ 部分 极快 iOS 15+ 预测你要打开该 App

综上,冷启动涉及启动全过程,我们应该以冷启动速度作为目标。

2. 冷启动全过程图解

iOS-Launch-Process.png

阶段一: 2.1 虚拟内存分配-128TB巨大内存

系统通过“虚拟内存分配”给进程创造一个独立、连续、安全的内存世界。试想如果没有虚拟内存,所有进程(你的 App、微信、系统设置)都直接操作物理内存,那会是怎样的混乱和复杂度?

系统创建进程后会为进程分配地址空间,具体的大小根据CPU架构决定,当前iOS设备实际为128TB,这对移动设备已足够。

2.2 dyld通过mmap加载MachO

dyld 不是"创建", 而是系统预置的二进制文件(/usr/lib/dyld),内核将其映射到新进程的地址空间。 dyld 加载库时使用了mmap(内存映射技术),而不是"整个文件加载"。

  • 加载主二进制machO并解析依赖库
  • Debug下额外加载调试动态库
  • 加载依赖系统动态库(共享缓存)
  • 递归加载依赖APP内动态库

动态库的加载顺序由依赖关系决定,而非简单的字母排序:

  1. 依赖关系优先:被依赖的库先于依赖者加载
  2. 同一层级顺序:无依赖关系的库按Mach-O中的出现顺序

调试环境差异:在Xcode调试时,会注入调试库(如libBacktraceRecording.dylib、libMainThreadChecker.dylib),这些库会在主程序之前加载。

2.3 Rebase与ASLR处理

由于iOS库加载使用ASLR(地址空间布局随机化),所有库中指向自己的指针地址需要重新计算:

// Rebase过程
new_address = original_address + aslr_slide

// ASLR偏移是启动时确定的随机值
// 需要遍历所有指针并加上这个偏移

2.4 符号绑定(Bind)

符号绑定是将符号引用解析为实际地址的过程,库中引用外部动态库的符号需要绑定到真实的内存地址。分为三个阶段:

graph TD
    A[符号绑定] --> B[延迟绑定 Lazy Binding]
    A --> C[非延迟绑定 Non-Lazy Binding]
    A --> D[弱符号绑定 Weak Binding]
    
    B --> E[第一次使用时绑定<br/>减少启动时间]
    C --> F[启动时立即绑定<br/>确保符号可用]
    D --> G[运行时决定是否绑定<br/>符号可能不存在]
    
    E --> H[通过PLT/GOT完成]
    F --> I[直接修改__DATA段]
    G --> J[不影响启动]

绑定过程技术细节

  1. 解析Mach-O中的LC_DYLD_INFO命令
  2. 遍历需要绑定的符号列表
  3. 在符号表中查找对应符号
  4. 将实际地址写入__DATA段的相应位置

2.5 ObjC运行时初始化

  • 注册所有ObjC类和方法
  • 建立方法列表和协议映射表
  • 初始化Category
  • 准备消息发送机制

2.6 +load方法执行

所有OC类的+load方法在这个阶段执行,执行顺序:

  1. 所有类的+load方法(先父类后子类,没有继承关系按编译顺序)
  2. Category的+load方法(编译顺序)

(注意:Swift中没有load和initiallize方法)

2.7 C++静态初始化阶段

这是启动过程中常被忽视但影响重大的阶段。C++静态初始化的两个阶段

  1. 静态存储期初始化:零初始化和常量初始化(编译期确定)
  2. 动态初始化:调用构造函数、执行复杂表达式(运行时)

C++构造函数

无论是 C++ 全局对象的构造函数,还是用 __attribute__((constructor)) 修饰的 C 函数,编译器都会将它们放到 Mach-O 文件的同一个段中

  • 段名__DATA,__mod_init_func
  • 内容:一个函数指针数组,指向所有需要在 main() 之前执行的初始化函数。

当 dyld 完成动态链接后,会执行:

// dyld 伪代码
void doModInitFunctions(const Image* image) {
    void** funcs = image->getSegmentData("__DATA", "__mod_init_func");
    int count = image->getSegmentSize("__DATA", "__mod_init_func") / sizeof(void*);
    for (int i = 0; i < count; i++) {
        funcs[i]();  // 逐个调用所有初始化函数
    }
}
// dyld 根本不区分这个函数指针是来自 C++ 构造函数还是 `__attribute__((constructor))`,
// 它只是无差别地调用。

调用顺序:+load 先于 constructor

在任何一个单一的镜像文件(Image,指可执行文件或动态库)的加载过程中,dyld 严格按照以下顺序执行初始化

由于 runtime 的初始化在 dyld 处理 __mod_init_func 段之前,所以 +load 自然先执行。

性能影响:全局C++对象的构造函数可能执行昂贵操作(文件I/O、网络请求、复杂计算),显著拖慢启动。

阶段二:2.8 main()函数

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, 
                               NSStringFromClass([AppDelegate class]));
    }
}

2.9 UIApplicationMain启动流程

  1. 创建UIApplication单例实例
  2. 创建AppDelegate实例
  3. 建立应用运行环境
  4. 调用application:didFinishLaunchingWithOptions:

2.10 首帧渲染关键路径

1. 加载Main.storyboard或根视图控制器
2. 执行viewDidLoad方法
3. 执行viewWillAppear方法
4. 布局视图(viewWillLayoutSubviews)
5. 渲染视图(drawRect:)
6. 执行viewDidAppear方法/applicationDidBecomeActive

二、系统做的优化

1. iOS13-启动闭包(Launch Closure)

iOS 13 引入了新一代的动态链接器 dyld3,并引入了“启动闭包”的概念。在系统更新、App 更新或重启后首次启动时,系统会创建一个包含预加载和预绑定信息的闭包。这大大减少了后续启动时动态库加载、符号绑定和初始化的工作量,是启动速度提升的关键技术基础。‌

启动闭包内容构成‌:依赖动态库列表(depends)、绑定和重基地址(fixup: bind & rebase)、初始化调用顺序(initializer-order)以及 Objective-C 优化信息(optimizeObjc)等。

核心机制:Out-of-Process 预计算

dyld3 最大的架构变化,是把加载过程分为了“Out-of-Process(进程外) ”和“In-Process(进程内) ”两部分

  1. 进程外 (Out-of-Process) 预计算:这是一个独立的系统进程,它会在App安装、更新或系统重启后自动运行一次

    • 它会解析 App 的可执行文件(Mach-O)及其所有依赖动态库 参考
    • 将这一过程产生的所有关键信息打包成一个“启动闭包”文件,保存在 tmp/com.apple.dyld 目录下。
    • 这些信息包括:依赖库列表、需要修复(rebase/bind)的地址、初始化顺序(initializer-order),以及优化过的 Objective-C 类/方法元数据等。
  2. 进程内 (In-Process) 快速执行:当用户下次点击 App 图标时,dyld3 不再重新解析和计算。

    • 它直接读取之前生成好的“启动闭包”文件参考①参考②
    • 由于所有解析工作都已经完成,dyld3 只需要根据闭包内的指令,快速地将镜像加载和初始化即可。 对启动闭包进一步优化时间可参考# 得物 iOS 启动优化

2. iOS15-链式修复(Chained Fixup)

iOS 15 引入的链式修复机制,是 dyld 在动态链接领域的又一次重大优化。它彻底改变了 App 启动时修正地址的传统方式,将独立的操作表重组为按物理页组织的链表,以空间换时间,将随机 I/O 转化为顺序遍历,大幅提升了启动速度。

A.传统方案的瓶颈?

App 启动时的 fixups 主要包含两步:Rebase(基址修复)Bind(符号绑定)

传统方案下,这两个操作是独立的表,dyld 必须先全量处理完所有 Rebase,再全量处理所有 Bind。这意味着 dyld 要在内存里来来回回跑两遍,导致大量的内存页被重复访问和标记为“脏”,性能开销很大。

B.链式修复是如何工作的?

链式修复的核心思想是:把分散在多个表中的修正信息,按内存物理页(16KB)为单位,串成一条条链表。 这种设计让 dyld 在遍历内存时,访问是顺序的,完美契合了 CPU 的缓存预取机制,将随机的磁盘 I/O 开销降到了最低。

  1. 新结构取代旧命令
    在 iOS 15 的二进制文件中,旧的 LC_DYLD_INFO_ONLY 加载命令被替换成了 LC_DYLD_CHAINED_FIXUPS(链式修正信息)和 LC_DYLD_EXPORTS_TRIE(导出符号信息)。

  2. 每个指针即是“节点”
    现在,需要进行修正的地址本身被设计成了一个精巧的“链表节点”。例如,一个普通的 64 位指针,不再只存一个地址,其内部被拆分为不同的位域,用来存储:

    • target (36位) :指向最终需要修正到的目标地址偏移。
    • next (12位)本页内下一个需要修正的指针地址,偏移范围正好是 0~16KB(一个页面大小)。
    • bind (1位) :标识这是一个 Rebase 操作还是 Bind 操作。
  3. 处理流程:一“镜”到底
    dyld 不再需要遍历全局大表。现在,它会:

    1. 跳转到每个内存页的起始修正点。
    2. 读取该指针,根据 bind 位判断是 Rebase 还是 Bind,并立即执行。
    3. 根据 next 字段,直接定位到本页内的下一个需要修正的地址。
    4. 重复步骤 2,直到 next 字段为 0,表示本页修正结束。

3. iOS15-预热启动(Prewarming Launch)

系统为了加快启动速度,会智能预测提前帮你启动APP到后台(执行didFinishLaunching但不初始化UI),这样你点开时系统从暂停点继续执行,跳过了耗时的初始化过程,实现了“秒开”。一般siri建议、Spotlight、小组件、系统预测时会启动(可能几分钟后被杀)。

// 判断是否预热启动
ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"

三、内存分页与启动优化

4.1 内存分页机制

在iOS启动过程中,方法在内存中的布局和分页由编译器和链接器协同决定

  1. Mach-O段和节结构:__TEXT段存放代码,__DATA段存放数据
  2. 对齐约束:每个Section有特定的对齐要求(如__text为16字节)
  3. 链接器布局算法:决定方法在内存中的最终位置
  4. 页面大小边界:iOS使用16KB页面大小

4.1 为什么方法同页重要?

物理内存映射机制:一个虚拟内存页对应一个物理页,按需加载;在方法调用时其代码未载入内存会产生一次Page Fault

**Page Fault的昂贵成本**:
// 一次Page Fault包含:
1. 陷入内核(上下文切换)≈ 1000 cycles
2. 查找页表项 ≈ 200 cycles  
3. 分配物理页 ≈ 500 cycles
4. 磁盘I/O(最贵!)≈ 10,000-100,000 cycles
5. 建立映射 ≈ 300 cycles
6. 返回用户态 ≈ 1000 cycles
// 总计:≈ 13,000-103,000 cycles
// 对比:L1缓存命中 ≈ 4 cycles

如果将启动关键方法同页化(二进制重排),那么就可以减少Page Fault次数降低 I/O 开销,从而提升启动速度。二进制重排的具体操作可以查看参考文章。

4.3 静态库与动态库的分页差异

静态库:

  • 代码在链接时被拆散并合并到主Mach-O中
  • 与主程序代码共享内存区域和分页行为
  • 链接器可以进行全局优化布局
  • 启动相关代码可集中到同一页

动态库依赖的静态库:

  • 静态库代码被合入到依赖它的动态库
  • 与动态库自身代码共享相同的内存映射区域
  • 所有代码(动态库+静态库)统一分页加载

多个自定义动态库的问题:

  • 每个动态库有独立的内存区域
  • 方法天然分散在不同页
  • 导致更多Page Fault和缓存未命中
  • 40个动态库可能导致40+次额外Page Fault

四、启动耗时统计与监控

方式一:自定义监控

主要关注指标TTID、TTFD。

TTID: 起点=进程创建时间;结束点=ViewDidAPPear.

TTFD: 起点=进程创建时间;结束点=首页初始化请求完成并刷新完整页面.

为什么可以这样定?先来看下启动过程的生命周期函数调用过程:

1. 系统加载动态库和静态初始化器‌
2. 执行 main() 函数‌,调用 UIApplicationMain
3. AppDelegate 调用‌:
  - application:willFinishLaunchingWithOptions:
  - application:didFinishLaunchingWithOptions:
4. (iOS 13+SceneDelegate 调用‌:
5.scene:willConnectToSession:options:
- 创建 UIWindow 和根视图控制器‌
- 初始 UIViewController 调用‌:
6.  - viewDidLoad()  viewWillAppear:  viewDidAppear:
7. applicationDidBecomeActive:(或 SceneDelegate 的 sceneDidBecomeActive:)
// 注意:viewDidAppeare方法和didBecomeActive方法的顺序实际打印出来是不一定的。
// AAAA:自己工程里面纯代码未使用scene,是先didAppeare再didBecomeActive
["DidFinishLaunching", "viewDidLoad", "viewWillAppear", "viewDidAppear", "DidBecomeActive"]
// BBBB:新建工程里确是先didBecomeActive
📍 AppDelegate.init
📍 AppDelegate.didFinishLaunching
📍 ViewController.init(coder:)
SceneDelegate.willConnectTo
📍 ViewController.loadView
📍 ViewController.viewDidLoad
📍 ViewController.viewWillAppear
SceneDelegate.sceneWillEnterForeground(未调用AppDelegate对应方法)
SceneDelegate.sceneDidBecomeActive(未调用AppDelegate对应方法)
📍 ViewController.viewDidAppear

由此我们可以把启动监控起点定在:进程创建的时间;终点定在:applicationDidBecomeActive或者viewDidAppear。

// 启动的起点可以获取进程的创建时间
func logProcessStartTime() {
    let pid = getpid()
    var info = kinfo_proc()
    var size = MemoryLayout<kinfo_proc>.size
    var mib = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
    
    if sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) == 0 {
        let startTime = TimeInterval(info.kp_proc.p_starttime.tv_sec) + 
                        TimeInterval(info.kp_proc.p_starttime.tv_usec) / 1_000_000.0
        print("Process started at: \(Date(timeIntervalSince1970: startTime))")
    }
}

方式二:Apple的MetricKit:

import MetricKit
// iOS 13以上
class MetricsManager: MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            if let launchMetrics = payload.applicationLaunchMetrics {
                let duration = launchMetrics.timeToFirstDraw
                // 处理启动耗时数据
            }
        }
    }
}

五、启动优化实战策略

1. Pre-main阶段优化

去除无用代码

去除无用代码:无用类、方法、协议等;无用图片、资源文件等.

减少动态库数量:

  • 合并小功能库
  • 使用静态库替代动态库
  • 相同功能库改为使用同一个
  • 去除未动态库
  • Debug下的功能库改为Debug才引入
  • 如有必要可以采取懒加载(dlopen的方式在用到时手动加载)

控制相关方法调用

  • +load方法:避免在+load中执行耗时操作,将初始化延迟到首次使用时

  • 优化C++静态初始化:消除或延迟全局C++对象构造,避免执行耗时的初始化操作,将部分功能延迟到首次调用时。

  • fishhook尽量不放到启动过程。

二进制重排

先收集启动期间的方法调用顺序放到文件中,然后设置到工程的Order file编译设置中。

收集调用方法可以使用LLVM插桩的方式:简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 __sanitizer_cov_trace_pc_ 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。

相关方法已经有人做成了开源库,现在很简单就能收集到,具体文章在文末。

监控Page Fault:

// 监控Page Fault数量
task_vm_info_data_t vm_info;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vm_info, &count);
NSLog(@"Page Faults: %llu", vm_info.faults);

2. main函数后阶段优化

  • main函数后用启动任务管理器来管理任务优先级和多线程调度,将不影响效果的功能后移到viewDidAppear之后执行。
  • 优化webview useragent、keychain获取、Userdefault、定位权限、WIFI信息等xpc跨进程通信的动作
  • 优化广告加载、bundle中image获取、一键登录预取号逻辑

六、调试方法

6.1 动态库加载顺序分析

// 打印当前加载的所有库
+ (void)load {
    uint32_t count = _dyld_image_count();
    NSLog(@"=== 库加载顺序验证 ===");
    for (uint32_t i = 0; i < count; i++) {
        const char *name = _dyld_get_image_name(i);
        NSLog(@"%2d: %s", i, name);
    }
}

6.2 启动闭包调试

# 调试启动闭包
DYLD_PRINT_CLOSURES=1           # 打印闭包使用情况
DYLD_DISABLE_CLOSURES=1         # 禁用闭包(强制传统启动)
DYLD_FORCE_CLOSURE_REBUILD=1    # 强制重建闭包
DYLD_PRINT_STATISTICS=1         # 包含闭包节省的时间

6.3 编译顺序查看

可以通过查看LinkMap的查看:## 通过LinkMap来了解Mach-O

6.4 性能分析工具

  1. Instruments - System Trace:分析系统调用和Page Fault
  2. Instruments - Time Profiler:分析CPU时间分布
  3. Instruments - App Launch Template:专门分析启动性能
  4. Xcode Organizer:查看真实用户启动数据

九、总结与最佳实践

在货拉拉出行乘客端中,我们做完优化之后,3秒内完成启动设备数从85% -》99%

启动优化数据.png

结语

iOS应用启动过程是一个复杂而精密的系统工程,涉及操作系统内核、动态链接器、编译器和运行时等多个层面的协同工作。通过深入理解启动过程的每个阶段,我们可以有针对性地进行优化,显著提升应用启动速度,改善用户体验。

作为开发者,我们需要保持学习的态度,持续探索和实践,为用户打造更快、更好的应用体验。


参考资料

  1. Apple Developer Documentation - Reducing Your App's Launch Time
  2. WWDC 2019 - Optimizing App Launch
  3. WWDC 2020 - Explore app launch performance
  4. dyld源代码分析

抖音 iOS 启动优化实战

网易云启动速度-开荒篇

iOS基于二进制重排启动优化

二进制重排工具AppOrderFiles库地址 iOS15 动态链接 fixup chain 原理详解

AI编程对话式定位解决bug

作者 sweet丶
2026年4月11日 10:15

本文是想记录一个使用编译器打开工程后,通过对话式聊天直接定位bug,并提供有效解决方案,效果快速、省事令人满意!

一、传统方式下

先来看一个Crash日志的堆栈信息:

Termination Reason:<RBSTerminateContext| domain:10 code:0x8BADF00D 
explanation:scene-create watchdog transgression: application<com.xxx.aaa>:
34689 exhausted real (wall clock) time allowance of 3.43 seconds

// 
Thread 0 Crashed:
0      libsystem_pthread.dylib       _pthread_mutex_lock$VARIANT$armv81 + 120
1      libc++.1.dylib                std::__1::mutex::lock() + 12
2      libicucore.A.dylib            icu::Locale::getDefault() + 32
3      libicucore.A.dylib            icu::Locale::init(char const*, signed char) + 1400
4      libicucore.A.dylib            _ures_getLocaleByType + 436
5      libicucore.A.dylib            icu::DecimalFormatSymbols::initialize(icu::Locale const&, UErrorCode&, signed char, icu::NumberingSystem const*) + 256
6      libicucore.A.dylib            icu::DecimalFormatSymbols::DecimalFormatSymbols(icu::Locale const&, icu::NumberingSystem const&, UErrorCode&) + 236
7      libicucore.A.dylib            icu::number::LocalizedNumberFormatter::getDecimalFormatSymbols() const + 4608
8      libicucore.A.dylib            icu::number::LocalizedNumberFormatter::getDecimalFormatSymbols() const + 1632
9      libicucore.A.dylib            icu::number::LocalizedNumberFormatter::formatImpl(icu::number::impl::UFormattedNumberData*, UErrorCode&) const + 128
10     libicucore.A.dylib            icu::SimpleDateFormat::zeroPaddingNumber(icu::NumberFormat const*, icu::UnicodeString&, int, int, int) const + 524
11     libicucore.A.dylib            icu::SimpleDateFormat::subFormat(icu::UnicodeString&, char16_t, int, UDisplayContext, int, char16_t, icu::FieldPositionHandler&, icu::Calendar&, UErrorCode&) const + 904
12     libicucore.A.dylib            icu::SimpleDateFormat::_format(icu::Calendar&, icu::UnicodeString&, icu::FieldPositionHandler&, UErrorCode&) const + 688
13     libicucore.A.dylib            icu::SimpleDateFormat::format(icu::Calendar&, icu::UnicodeString&, icu::FieldPosition&) const + 80
14     libicucore.A.dylib            icu::DateFormat::format(double, icu::UnicodeString&, icu::FieldPosition&) const + 124
15     libicucore.A.dylib            _udat_format + 356
16     CoreFoundation                ___cficu_udat_format + 64
17     CoreFoundation                _CFDateFormatterCreateStringWithAbsoluteTime + 180
18     Foundation                    -[NSDateFormatter stringForObjectValue:] + 160
19     MyAPP                        -[HAMLaunchMonitor startUUID] + 136
...    MYApp                               其它调用函数

传统解决步骤:

  1. 查看LaunchMonitor中startUUID方法里面内容,看代码上有没明显bug;
  2. 查看调用NSDateFormatter的string...方法的方式是否有问题;
  3. 后面是系统堆栈,所以得出结论是系统库发生了长时间等锁;
  4. 查看崩溃说明是0x8BADF00D,确定是被系统WatchDog检测到杀死了进程导致的崩溃。
  5. 解决方式:少量偶现且逻辑不好修改,出临时性修复方案~
  6. 问题可能没有根本解决,只是概率降低了。

可以看到我们人工查看这个问题有几个瓶颈:

  1. 需要手动找到文件查看分析函数调用链,速度慢。
  2. 对于顶部的系统函数,我们一般是缺乏了解的,不知道里面发生了什么导致出了问题。
  3. 弄清楚了上面内容后,我们的解决方法也是基于自己认知的,存在偏差。

而这几个瓶颈,交给AI后那都不是事儿。

二、AI时代下Bug修复

复制堆栈信息交给AI工具,AI直接给出了根本原因及有效的方案:

Crash 类型

这是一个 Watchdog 超时 Crash0x8BADF00D = "ate bad food"),发生在 scene-create(应用启动/场景创建) 阶段。系统允许的时间为 3.43 秒,但应用在该时间内未能完成启动。

根因分析

从调用栈来看,崩溃发生在 [-[HAMLaunchMonitor startUUID]] 方法中:

- (NSString *)startUUID { 
    NSDateFormatter *formatter = [NSDateFormatter new];
    formatter.dateFormat = @"yyMMddHHmmssSSS";
    formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; 
    NSString* time = [formatter stringFromDate:[NSDate date]]; 
    // ... 
} 

主线程被阻塞在 pthread_mutex_lock,具体路径是:

  1. startUUID 调用 NSDateFormatter stringForObjectValue: 进行日期格式化
  2. 底层 ICU 库执行 SimpleDateFormat::format 进行实际格式化
  3. ICU 在格式化数字时需要获取 DecimalFormatSymbols
  4. 这触发了 icu::Locale::getDefault() 来获取系统默认 locale
  5. getDefault() 内部需要获取一个 全局互斥锁(std::mutex
  6. 该锁被其他线程持有,导致主线程死等(pthread_mutex_lock 卡住)

核心问题

锁竞争(Lock Contention) —— ICU 库内部有一个全局的 std::mutex 保护 Locale::getDefault()。在应用启动阶段,如果有多个线程同时使用 NSDateFormatterNSNumberFormatter 或其他依赖 ICU 的 API,就会争抢这把锁。主线程被阻塞等锁,累计时间超过 watchdog 限制,系统杀掉了应用。

加剧因素

  1. 启动阶段调用 startUUID:该方法在 [commonParameters] 中被调用,而 commonParameters 在 [appDidFinishLaunch] 流程中执行——正处于 watchdog 监控最严格的时间窗口。
  2. 每次调用都创建新的 NSDateFormatterNSDateFormatter 的初始化本身就是重量级操作,且会触发 ICU locale 的初始化。
  3. ICU 内部的全局锁:这是系统库的已知问题,在 iOS 启动早期阶段锁竞争尤为严重,因为各种系统框架也在并发初始化。

建议修复方案

  1. 缓存 NSDateFormatter(最直接有效):将 NSDateFormatter 创建为静态变量或单例属性,避免每次调用 startUUID 都重新创建:
- (NSString *)startUUID {
 static NSDateFormatter *formatter = nil;
  static dispatch_once_t onceToken; 
  dispatch_once(&onceToken, ^{
   formatter = [NSDateFormatter new]; 
   formatter.dateFormat = @"yyMMddHHmmssSSS"; 
   formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; 
  }); 
   NSString *time = [formatter stringFromDate:[NSDate date]]; 
   NSString *startUuid = [NSString stringWithFormat:@"%@2000000%@", time, [self getRandomNumber]]; 
   return startUuid; 
 } 
  1. 延迟执行:将 startUUID 和上报逻辑延迟到首帧渲染之后,脱离 watchdog 监控的关键时间窗口。
  2. 替代方案:如果仅需要生成唯一标识,可以完全避免 NSDateFormatter,改用时间戳数字拼接: 这完全绕开了 ICU/NSDateFormatter,不存在锁竞争问题。
- (NSString *)startUUID { 
NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; 
long long ms = (long long)(now * 1000); 
NSString *startUuid = [NSString stringWithFormat:@"%lld2000000%@", ms, [self getRandomNumber]]; 
return startUuid;
 } 

推荐方案 3,因为它从根本上消除了 ICU 锁竞争的风险,同时性能开销最小。

三、感想

AI时代,整体APP行业的性能将得到大幅提升

优秀大模型解决Bug的能力很强,且修复速度和质量都发生了颠覆性的变化,意味着如果有规划+APP开发人员有心,大部分问题都能解决;AI编程工具下,目前AI写复杂功能可能还有点问题,但如果是指定AI去写一个特定功能bug率可能会比资深工程师还要好;借助AI来深入了解底层知识也很方便,对于提升工程师认知也有帮助,进一步提升了性能。

AI时代,Bug的解决方式会发生变化

现在的热修复功能集成到APP后,往往需要编写修复后的脚本语言文件,下发到APP,APP动态运行时交换方法实现解决。

AI时代的方式可能是:
-》Crash发生后,自动分析原因,出解决方案,发出通知;
-》人工收到通知后,选择一个方案;
-》自动生成对应的脚本文件,自动下发到对应的APP版本。
-》APP再次打开时,Bug已经自动修复。

Swift 并发编程深度解析:从 async/await 到智能调度

作者 sweet丶
2026年2月11日 14:23

深入理解 Swift 5.5+ 的现代并发模型,掌握如何编写安全高效的多线程代码


引言:为什么需要新的并发模型?

在传统 iOS/macOS 开发中,我们使用 GCD(Grand Central Dispatch)或 OperationQueue 来处理并发任务。然而,这些技术存在一些痛点:

  1. 回调地狱:多层嵌套的回调难以阅读和维护
  2. 手动内存管理:容易忘记 weak self 导致内存泄漏
  3. 线程爆炸:过度创建线程消耗系统资源
  4. 数据竞争:共享状态需要手动加锁,容易出错

Swift 5.5 引入的 async/await 和结构化并发解决了这些问题,提供了更安全、更简洁的并发编程方式,iOS 13以上是支持的。


第一部分:async/await 基础语法

1.1 异步函数声明

// 传统回调方式
func fetchUser(completion: @escaping (Result<User, Error>) -> Void)

// 异步函数方式
func fetchUser() async throws -> User

1.2 异步函数调用

// 使用 await 调用异步函数
do {
    let user = try await fetchUser()
    print("用户: \(user.name)")
} catch {
    print("错误: \(error)")
}

第二部分:async let 与结构化并发

2.1 并发启动多个任务

// 同时启动多个异步任务
func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()          // 立即开始
    async let orders = fetchOrders()      // 立即开始
    async let messages = fetchMessages()  // 立即开始
    
    // 等待所有任务完成
    return try await Dashboard(
        user: user,
        orders: orders,
        messages: messages
    )
}

2.2 与顺序执行的对比

// 并发执行(总耗时 ≈ 最慢的任务)
async let a = taskA()  // 0-1秒
async let b = taskB()  // 0-2秒
let results = await (a, b)  // 总耗时: 2秒

// 顺序执行(总耗时 = 所有任务时间之和)
let a = await taskA()  // 0-1秒
let b = await taskB()  // 1-3秒(等A完成后才开始)
// 总耗时: 3秒

2.3 重要概念澄清

Q: async let user = fetchUser() 立即返回什么? A: 它不立即返回数据,而是返回一个异步任务句柄。实际数据在 await 时获取。

Q: 多个 async let 相当于 GCD 的异步任务吗? A: 相似但有重要区别。async let 是结构化并发的一部分,任务生命周期自动管理,支持取消和错误传播。


第三部分:数据安全与线程调度

3.1 数据竞争的解决方案

方案一:使用 Actor(银行柜台模型)

actor UserCache {
    private var storage: [String: User] = [:]
    
    func getUser(id: String) -> User? {
        return storage[id]
    }
    
    func setUser(_ user: User, for id: String) {
        storage[id] = user
    }
}

// 使用时自动序列化访问
let cache = UserCache()
let user = await cache.getUser(id: "123")  // 自动排队等待

原理:编译器强制同一时间只有一个任务能访问 Actor 内部状态,通过消息传递模型确保安全。

方案二:使用值语义(发复印件模型)

struct UserProfile {
    let user: User
    var settings: Settings
    // 结构体是值类型,复制安全
}

func processProfile(profile: UserProfile) async {
    // 每个任务获取独立的副本
    async let task1 = {
        var copy = profile
        copy.settings.theme = .dark
        return copy
    }()
    
    async let task2 = {
        var copy = profile
        copy.settings.fontSize = 16
        return copy
    }()
    
    let results = await (task1, task2)  // 独立修改,互不影响
}

原理:通过复制而非共享,从根本上消除数据竞争的可能性。

3.2 智能线程调度

Q: async let 任务在哪个线程执行? A: Swift 并发运行时智能决定,基于以下因素:

  1. 当前线程负载 - 太忙就调度到其他线程
  2. 任务类型 - I/O密集型 vs CPU密集型
  3. 优先级 - 高优先级任务可能更快执行
  4. 硬件资源 - CPU核心数、当前负载
  5. 执行器约束 - 如 @MainActor 强制主线程

智能调度的具体表现:

@MainActor
func updateUIWithData() async {
    // 从主线程调用,但会自动优化
    async let data = fetchHeavyData()  // 运行时:这个会阻塞 → 调度到后台线程
    
    let processed = await process(data)  // 可能在后台线程继续处理
    
    // 更新UI时自动回到主线程
    self.label.text = processed.title
}

3.3 什么时候需要显式控制线程?

// 1. UI操作必须主线程
@MainActor
func updateUI() {
    // 编译时确保在主线程
}

// 2. CPU密集型长时间计算
func processImage(_ image: UIImage) async -> UIImage {
    // 明确指定在独立线程执行
    return await Task.detached {
        return image.applyFilters()  // 耗时的图像处理
    }.value
}

// 3. 不应该干预的案例
// ❌ 不要这样:破坏了智能调度
Task {
    DispatchQueue.global().async {
        await someAsyncWork()
    }
}

// ✅ 应该这样:信任运行时
Task {
    await someAsyncWork()  // 让系统决定最佳执行方式
}

第四部分:实际应用模式

4.1 网络请求组合

class UserService {
    func loadFullProfile(userId: String) async throws -> FullProfile {
        // 并发获取所有数据
        async let userInfo = fetchUserInfo(userId)
        async let posts = fetchUserPosts(userId)
        async let friends = fetchUserFriends(userId)
        async let preferences = fetchUserPreferences(userId)
        
        // 等待所有结果
        return try await FullProfile(
            info: userInfo,
            posts: posts,
            friends: friends,
            preferences: preferences
        )
    }
    
    // 对比传统回调方式
    func loadFullProfileOld(userId: String, 
                           completion: @escaping (Result<FullProfile, Error>) -> Void) {
        fetchUserInfo(userId) { result1 in
            switch result1 {
            case .success(let userInfo):
                self.fetchUserPosts(userId) { result2 in
                    switch result2 {
                    case .success(let posts):
                        // 更多嵌套...
                    case .failure(let error):
                        completion(.failure(error))
                    }
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

4.2 限制并发数量

func downloadMultipleFiles(urls: [URL], maxConcurrent: Int = 4) async throws -> [Data] {
    // 使用 TaskGroup 控制并发数
    return try await withThrowingTaskGroup(of: Data.self) { group in
        var results: [Data] = []
        results.reserveCapacity(urls.count)
        
        // 分批处理,限制并发数
        for index in urls.indices {
            if group.taskCount >= maxConcurrent {
                // 等待一个任务完成再添加新的
                if let result = try await group.next() {
                    results.append(result)
                }
            }
            
            group.addTask {
                return try await downloadFile(from: urls[index])
            }
        }
        
        // 收集剩余结果
        for try await result in group {
            results.append(result)
        }
        
        return results
    }
}

第五部分:与系统框架的集成

5.1 iOS 13+ 的系统 API 更新

// iOS 13+ 提供了异步版本的 openURL
func openSettings() async -> Bool {
    guard let url = URL(string: UIApplication.openSettingsURLString) else {
        return false
    }
    
    return await UIApplication.shared.open(url)
}

// 使用示例
Task {
    let success = await openSettings()
    print("设置应用打开\(success ? "成功" : "失败")")
}

// 为什么使用Task?
// ❌ 错误:不能在同步函数中直接使用 await
func buttonTapped() {
    let success = await openSettings()  // 编译错误!
    print("结果: \(success)")
}

// ✅ 正确:需要 Task 包装
func buttonTapped() {
    Task {  // 创建异步执行环境
        let success = await openSettings()
        print("结果: \(success)")
    }
}

5.2 适配旧版本系统

// 为 iOS 13+ 提供兼容方案
func openURL(_ url: URL) async -> Bool {
    if #available(iOS 13.0, *) {
        return await UIApplication.shared.open(url)
    } else {
        // 使用 continuation 桥接到 async/await
        return await withCheckedContinuation { continuation in
            UIApplication.shared.open(url) { success in
                continuation.resume(returning: success)
            }
        }
    }
}

第六部分:最佳实践总结

6.1 代码组织原则

  1. 优先使用 async/await 替代回调
  2. 合理使用 async let 进行并发,但注意数量控制
  3. 使用 Actor 保护共享状态,避免手动锁
  4. 尽量使用值类型,减少共享可变状态

6.2 架构设计建议

// 推荐的层次结构:
// UI层 (@MainActor) - 处理用户交互和界面更新
// 业务层 (混合) - 协调数据流,处理业务逻辑
// 数据层 (async/await) - 网络请求、数据库操作
// 工具层 (值类型) - 纯函数计算、数据处理

@MainActor
class ViewController: UIViewController {
    private let viewModel: UserViewModel
    
    func loadData() async {
        await viewModel.loadUserData()
        updateUI()
    }
}

actor UserViewModel {
    private let repository: UserRepository
    
    func loadUserData() async {
        let user = await repository.fetchUser()
        // 处理业务逻辑
    }
}

class UserRepository {
    func fetchUser() async throws -> User {
        // 数据层操作
        return try await apiClient.fetchUser()
    }
}

第七部分:内部原理机制

Swift的async/await基于协程实现: 技术关系:

// 1个线程上可以运行多个协程
Thread A: [协程1运行] → [协程2运行] → [协程1恢复] → [协程3运行]
                ↑           ↑           ↑           ↑
           遇到await挂起 遇到await挂起 结果返回恢复 遇到await挂起

// 协程在挂起时释放线程,让其他协程使用

// 传统线程 vs 协程

// 线程:操作系统调度,上下文切换成本高
Thread 1: [运行] → [阻塞等待I/O] → [运行]
Thread 2: [等待] → [运行] → [等待]

// 协程:用户态调度,轻量级
协程 A: [运行] → [挂起] → [运行]
协程 B:     [运行] → [挂起]
// 在同一线程上交替执行,没有线程切换开销

结合实际代码说明:

// 规则1:一个协程必须在一个线程上运行
// 规则2:协程只能在特定点挂起(await处)
// 规则3:挂起的协程不占用线程

// 示例:
func fetchMultipleResources() async {
    // 开始:在主线程运行(如果从@MainActor调用)
    
    let data1 = await fetchData()  // 挂起点1
    // 挂起:释放主线程,其他协程可用
    
    // 恢复:可能在任意线程(不一定是主线程)
    process(data1)  // 在某个后台线程执行
    
    let data2 = await fetchData()  // 挂起点2
    // 再次挂起...
    
    // 最后如果需要更新UI,要确保在主线程
    await MainActor.run {
        updateUI(data1, data2)
    }
}

结语

Swift 的现代并发模型代表了并发编程的范式转变:

  1. 从手动调度到智能调度 - 信任运行时做出最优决策
  2. 从回调地狱到线性代码 - 使用 async/await 简化异步流程
  3. 从容易出错到内存安全 - 通过 Actor 和值语义避免数据竞争
  4. 从复杂管理到结构化 - 自动处理任务生命周期和取消

虽然学习曲线比 GCD 更陡峭,但一旦掌握,你将能编写出更安全、更简洁、更高效的并发代码。


进一步学习资源:

❌
❌