阅读视图

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

UITableView 在 width=0 时 reloadData 被"空转消费"导致 Cell 显示错乱

深入理解代替单纯记忆

本文中的问题和排查过程由作者完成,文章编写由Cursor完成

一、问题现象

一个 UITableView 在特定时序下出现了诡异的显示错乱:

  • 数据源有 2 条数据 [数据 B, 数据 A]numberOfRowsInSection 返回 2
  • 但 UITableView 显示了 2 条完全相同的数据 A
  • 通过日志发现 cellForRowAtIndexPath 只被调用了 1 次(row=1),row=0 从未被请求

数据源没有问题,UITableView 却跳过了 row=0 的 cell 请求。

二、场景结构

出问题的 VC 架构如下:

ContainerVC(容器,通过 frame 动画实现滑入/滑出)
  └── containerView(承载内容的 view,初始位置在屏幕外)
        └── ListVC.view(子 VC,内含 UITableView)

关键行为:

  • ContainerVC 通过 present 弹出,containerView 初始在屏幕外,然后通过 frame 动画滑入
  • ListVCinit 中注册通知,数据变化时调用 reloadData
  • ContainerVC dismiss 后不会释放,下次打开复用同一个实例

三、复现步骤

  1. 打开 ContainerVCcontainerView 滑入,UITableView 显示 [数据 A],正常
  2. 关闭(dismiss),ContainerVC 及其子 VC 仍然存活
  3. 此时外部数据变化,通知触发 reloadData,数据源变为 [数据 B, 数据 A]
  4. 再次打开 ContainerVC

预期:显示 [数据 B, 数据 A]

实际:显示 [数据 A, 数据 A]

四、排查过程

4.1 排除数据源问题

日志确认 numberOfRowsInSection 返回 2,两条数据标识符不同。数据源正确。

4.2 怀疑 reloadData 在 off-screen 时异常

dismiss 后通知仍在触发 reloadData(view.window == nil),怀疑这导致了 UITableView 内部状态不一致。

但通过对照实验推翻了这个假设:我们有另一个功能相同但布局实现不同的 ContainerVC_B。替换后,即使同样在 off-screen 时触发 reloadData,重新打开后 cellForRowAtIndexPath 正确执行了 2 次

结论:off-screen 时的 reloadData 不是问题,问题在 ContainerVC 自身的实现。

4.3 对比两个容器的实现差异

逐行对比发现,关键差异在 ListVC.view 的 AutoLayout 约束上。

ContainerVC_B(正常)—— 约束相对于 containerView:

// containerView 尺寸通过 frame 设定,是固定值
containerView.frame = CGRectMake(0, offScreenY, fixedWidth, fixedHeight);

// ListVC.view 的宽度 = containerView.width = 固定值
[listVC.view mas_makeConstraints:^(MASConstraintMaker *make) {
    make.leading.trailing.equalTo(containerView);
}];

ContainerVC(异常)—— 约束跨越了视图层级:

// containerView 尺寸也是固定的
containerView.frame = CGRect(x: offScreenX, y: 0, width: fixedWidth, height: fixedHeight)

// 但 headerView 的 trailing 锚定到了 VC 主 view 的 safeArea
headerView.snp.makeConstraints { make in
    make.leading.equalToSuperview()                              // = containerView.leading
    make.trailing.equalTo(view.safeAreaLayoutGuide.snp.trailing) // = VC 主 view 的右边缘
}

// ListVC.view 跟着 headerView 走
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(headerView) // width = headerView.width
}

这个跨视图层级的约束就是根因。

五、根因分析

5.1 跨视图约束如何导致 width=0

headerViewcontainerView 的子视图,但它的 trailing 约束锚定到了 VC 主 viewsafeAreaLayoutGuide.trailing

AutoLayout 解析约束时,会将所有边的位置转换到共同祖先的坐标系中计算。当 containerView 在屏幕外时:

headerView.leading  = containerView.leading  ≈ 844(屏幕外)
headerView.trailing = view.safeArea.trailing  ≈ 800(屏幕右边缘)

trailing(800) < leading(844) → 宽度为负 → 被压缩为 0

ListVC.viewleading.trailing 跟着 headerViewtableView.width = 0

ContainerVC_B 的约束全部相对于 containerView,后者的尺寸是 frame 设定的固定值,不随位置变化,所以 tableView 始终有有效宽度。

5.2 reloadData 在 width=0 时为什么会导致显示错乱?

根据日志观察到的现象,推测因果链如下:

  1. reloadData 在 width=0 时被触发。UITableView 计算可见行数为 0,因此不调用 cellForRow,也不回收旧 cell。但 UITableView 内部可能认为这次 reload 已经完成。

  2. reload 被"空转消费"—— 流程走了,但实际什么都没刷新。旧的 cell(第一次打开时创建的 CellA)仍然挂在 tableView 的 subview 上。

  3. containerView 滑入屏幕、tableView width 从 0 恢复正常时,触发了 layoutSubviews。但 UITableView 不再将其视为一次完整的 reload,而是当作尺寸变化引起的增量布局

  4. 增量布局中,UITableView 发现 row=0 位置已有一个 cell(上次残留的 CellA),直接复用,不调用 cellForRow。仅对 row=1 调用 cellForRow,返回数据 A 的 cell。

  5. 最终两行都显示数据 A。

六、修复

ListVC.viewleading.trailing 约束改为相对于 containerView

// 修复前:width 间接依赖 headerView(跨视图约束,position-dependent)
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(headerView)
}

// 修复后:width 直接依赖 containerView(固定尺寸,position-independent)
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(containerView)
}

containerView 的 width 是通过 frame 设定的固定值,不随位置变化。改动后 tableView 在任何时刻都有有效宽度,reloadData 不会被空转消费。

七、总结

归根到底,这是UITableView 的 reloadData 时的一个边界行为

当 tableView 的 bounds 宽度(或高度)为 0 时,reloadData 会走内部流程(查询行数),但可能不会创建或回收任何 cell。后续尺寸恢复时,UITableView 按增量布局处理,可能复用之前残留的旧 cell。

这可能不一定是 UITableView 的 bug,而是合理的优化 —— 没有可见区域时不创建 cell。但如果约束写法导致 tableView 在不该为 0 的时候 width 为 0,这个行为就会引发显示错乱。

排查建议

cellForRowAtIndexPath 的调用次数不符合预期时,优先检查 tableView 在 reloadData 时刻的 frame:

NSLog(@"reloadData: frame=%@, window=%@",
    NSStringFromCGRect(self.tableView.frame),
    self.tableView.window);

如果 width 或 height 为 0,reloadData 就会被空转消费。

# iOS Block 深度解析

全面剖析 Block 的本质、底层结构、内存管理、变量捕获、循环引用、线程安全、调试技巧与最佳实践。 力求深入而易懂——用类比代替术语堆砌,用图表代替大段代码。


一、Block 的本质

Block 是 C 语言层面的匿名函数 + 自动捕获上下文变量的能力 的组合体。

它不是 Objective-C 独有的特性,而是 Apple 对 C 语言的扩展(Clang 编译器实现),所以在 C、C++、Objective-C、Objective-C++ 中都可以使用。

一句话概括 Block 的本质:

Block 是一个封装了函数指针和捕获变量的 Objective-C 对象。

这意味着 Block 同时具备两重身份:

  • 作为函数:可以被调用、传参、返回
  • 作为对象:有 isa 指针,可以被 copy/release,参与 ARC 内存管理

类比理解: 普通函数像一台固定在车间里的机器——你去找它,它帮你加工。Block 像一台可以搬走的便携机器——你能带着它走,而且它随身携带了自己需要的原材料(捕获的变量)。

1.1 从编译器视角看 Block 的诞生

Clang 编译 Block 时经历的变换过程:

源代码层               编译器中间表示层              机器码层

^{ NSLog(@"Hi"); }
        │
        ▼
  语法解析为 BlockExpr
        │
        ▼
  分析捕获变量列表
  (遍历 Block 体内所有引用的外部变量)
        │
        ▼
  生成 Block_layout 结构体定义
  (根据捕获变量数量和类型动态确定结构体大小)
        │
        ▼
  将 Block 体内的代码提取为一个独立的 C 函数
  (函数名通常为 __文件名_block_func_序号)
  (第一个参数为 Block 结构体指针)
        │
        ▼
  在原位置生成结构体初始化代码
  (填充 isa、invoke、descriptor、捕获变量)
        │
        ▼
  ARM64 机器码

关键洞察: Block 的"闭包"能力不是运行时魔法,而是编译期的代码变换——编译器帮你把自由变量"打包"进了一个结构体。这就像你要出差,把办公桌上需要的文件全装进行李箱带走,到了酒店打开就能继续工作。

1.2 Block 与其他语言闭包的本质差异

┌──────────────┬────────────────────────────────────────────┐
│    语言       │    闭包实现方式                               │
├──────────────┼────────────────────────────────────────────┤
│ JavaScript   │ 通过作用域链引用外部变量(共享同一个变量)        │
│              │ 闭包和外部代码修改的是同一个变量                 │
│              │ 不需要特殊关键字                               │
├──────────────┼────────────────────────────────────────────┤
│ Swift        │ 默认捕获引用(和 JS 类似,共享变量)             │
│              │ [value] 显式值捕获                            │
│              │ 闭包是引用类型                                 │
├──────────────┼────────────────────────────────────────────┤
│ OC Block     │ 默认值捕获(拷贝一份副本)                      │
│              │ 需要 __block 才能共享变量                      │
│              │ Block 有 Stack/Malloc/Global 三种存储位置      │
│              │ 需要显式/隐式 copy 才能延长生命周期               │
├──────────────┼────────────────────────────────────────────┤
│ C++ Lambda   │ [=] 值捕获,[&] 引用捕获                     │
│              │ 编译为匿名类的 operator()                     │
│              │ 和 OC Block 最相似                            │
└──────────────┴────────────────────────────────────────────┘

OC Block 的独特之处:
  1. 默认值捕获 → 安全但反直觉(修改需要 __block)
  2. 有栈→堆迁移的概念 → 其他语言的闭包都直接在堆上
  3. 是 OC 对象 → 参与引用计数,有循环引用问题

1.3 为什么 Apple 选择了默认"值捕获"

这个设计决策背后有深层考虑:

JavaScript"引用捕获"经常制造 Bugfor (var i = 0; i < 3; i++) {
      setTimeout(function() { console.log(i); }, 0);
  }
  // 输出 3, 3, 3(而非期望的 0, 1, 2)
  // 因为闭包共享了变量 i,循环结束时 i = 3

OC"值捕获"避免了这类问题:
  
  for (int i = 0; i < 3; i++) {
      dispatch_async(queue, ^{ NSLog(@"%d", i); });
  }
  // 输出 0, 1, 2 ✓
  // 每个 Block 在创建时拍了一张 i 的快照

Apple 的设计哲学:
  "大多数场景下,Block 只需要读取变量的值,不需要修改"
  "让安全的事情成为默认,不安全的事情需要显式声明(__block)"
  
  这是典型的 Pit of Success 设计理念 ——
  让开发者默认就掉进成功的坑里,想犯错反而需要额外的努力。

二、Block 的底层结构

编译器会将每个 Block 转换为一个结构体 + 一个函数

Block 变量(指针)
    │
    ▼
┌──────────────────────────────────────┐
│           Block_layout 结构体          │
├──────────────────────────────────────┤
│  isa 指针          → 指向 Block 的类   │
│  flags             → 标志位            │
│  reserved          → 保留字段          │
│  invoke            → 函数指针 (核心)    │
│  descriptor        → 描述信息指针       │
│  ─────────────────────────────────── │
│  captured_var_1    → 捕获的变量 1       │
│  captured_var_2    → 捕获的变量 2       │
│  ...                                  │
└──────────────────────────────────────┘

类比: 把 Block 想象成一个快递包裹:

  • isa 是包裹类型标签(普通件/到付件/国际件)
  • flags 是物流状态码
  • invoke 是"使用说明书"(告诉你怎么打开和使用内容物)
  • descriptor 是"装箱清单"(描述包裹尺寸和特殊处理要求)
  • 捕获的变量就是包裹里的内容物

2.1 flags 标志位详解

flags 不是一个简单的整数,它是一个位域(bitfield),每一位都有特定含义:

flags 位域布局 (32 bit):

 31  30  29  28  27  26  25  24        16  15         1   0
┌───┬───┬───┬───┬───┬───┬───┬──── ─ ────┬───┬──── ─ ───┬───┐
│   │   │   │   │   │   │   │           │   │          │   │
└───┴───┴───┴───┴───┴───┴───┴──── ─ ────┴───┴──── ─ ───┴───┘
  │   │   │   │                   │               │
  │   │   │   │                   │               └── bit 0: BLOCK_DEALLOCATING
  │   │   │   │                   │                   正在被释放
  │   │   │   │                   │
  │   │   │   │                   └── bit 1~15: 引用计数(存储在这里!)
  │   │   │   │                       堆上 Block 的 retainCount
  │   │   │   │
  │   │   │   └── bit 24: BLOCK_NEEDS_FREE
  │   │   │       表示是堆上的 Block,需要 free 释放
  │   │   │
  │   │   └── bit 25: BLOCK_HAS_COPY_DISPOSE
  │   │       表示有 copy_helper 和 dispose_helper
  │   │       (即捕获了对象或 __block 变量)
  │   │
  │   └── bit 26: BLOCK_HAS_CTOR
  │       捕获的变量有 C++ 构造函数
  │
  └── bit 30: BLOCK_HAS_SIGNATURE
      表示有方法签名(可通过 NSMethodSignature 获取参数/返回值类型)

隐藏知识: Block 的引用计数不像普通 OC 对象存在 SideTable 中,而是直接编码在 flags 的 bit 1~15 中。这是一个性能优化——避免了每次 retain/release 都要查 SideTable 的哈希表。这意味着 Block 最大引用计数为 2^15 - 1 = 32767,不过实际场景中绰绰有余。

2.2 descriptor 的多态结构

descriptor 不是固定结构,它根据 Block 捕获的内容有不同的版本

版本 1(不捕获对象/不捕获 __block 变量):
┌─────────────────────────────┐
│  unsigned long reserved     │  → 0unsigned long size         │  → Block 结构体总字节数
└─────────────────────────────┘

版本 2(捕获了对象或 __block 变量,BLOCK_HAS_COPY_DISPOSE = 1):
┌─────────────────────────────┐
│  unsigned long reserved     │
│  unsigned long size         │
│  void (*copy_helper)()      │  → 新增:拷贝时调用
│  void (*dispose_helper)()   │  → 新增:销毁时调用
└─────────────────────────────┘

版本 3(有方法签名,BLOCK_HAS_SIGNATURE = 1):
┌─────────────────────────────┐
│  unsigned long reserved     │
│  unsigned long size         │
│  void (*copy_helper)()      │  → 可能有
│  void (*dispose_helper)()   │  → 可能有
│  const char *signature      │  → 新增:Block 的类型编码字符串
└─────────────────────────────┘
    例如 signature = "v8@?0" 表示 void(^)(void)

这种多态设计的好处:
  - 不捕获对象的 Block,descriptor 更小,节省内存
  - 编译器根据情况选择最紧凑的版本
  
类比:就像飞机上的行李标签——
  国内经济舱行李只需要简单标签(版本 1)
  含易碎品的行李需要额外的特殊处理标签(版本 2)
  国际航班行李还需要海关申报信息(版本 3

2.3 invoke 函数的隐含参数

当你写这样的代码:

  int x = 10;
  void(^block)(int) = ^(int y) { printf("%d", x + y); };
  block(20);

编译器生成的 invoke 函数签名是:

  static void __main_block_func_0(
      struct __main_block_impl_0 *__cself,  // ← 隐含的第一个参数!
      int y                                  // ← 你写的参数
  )

调用过程:
  block(20)
      → block->invoke(block, 20)
         ↑ Block 把自己作为第一个参数传入
         这样 invoke 函数就能访问 Block 结构体中捕获的变量

这和 OC 方法调用极其类似:
  [obj doSomething]objc_msgSend(obj, @selector(doSomething))
  block(20)          → block->invoke(block, 20)

2.4 Block 的类型编码(Type Encoding)

Block 在 runtime 中也有类型签名,这让它可以与 NSInvocation 配合使用:

Block 签名编码规则:

  void(^)(void)             → "v8@?0"
  void(^)(int)              → "v12@?0i8"
  NSString *(^)(int, BOOL)  → "@20@?0i8B12"

编码字符含义:
  v  = void
  @  = 对象指针
  @? = Block 类型(@ 表示对象,? 表示 Block)
  i  = int
  B  = BOOL
  数字 = 参数在栈帧中的偏移量

获取 Block 签名的方式:
  从 descriptor 的 signature 字段读取
  可用 NSMethodSignature 解析为可读格式

这个签名使得 Block 可以被 NSInvocation 动态调用,
也使得 libffi 能够在运行时对 Block 做各种 hook 和转发操作。

三、Block 的三种类型

Block 根据存储位置分为三种类型,这是理解 Block 内存管理的关键:

┌─────────────────────────────────────────────────────────────┐
│                      内存布局                                 │
│                                                              │
│  高地址  ┌──────────────┐                                     │
│         │     栈 Stack   │ ← __NSStackBlock__                 │
│         │   (向下增长)    │   栈上的 Block,离开作用域即销毁       │
│         ├──────────────┤                                     │
│         │              │                                     │
│         │     堆 Heap   │ ← __NSMallocBlock__                 │
│         │              │   堆上的 Block,引用计数管理            │
│         ├──────────────┤                                     │
│         │   全局/静态区   │ ← __NSGlobalBlock__                │
│         │   Data Segment│   全局 Block,程序结束才销毁           │
│         ├──────────────┤                                     │
│  低地址  │   代码区 Text  │                                     │
│         └──────────────┘                                     │
└─────────────────────────────────────────────────────────────┘

3.1 三种类型的判定规则

类型 条件 生命周期 isa 指向
__NSGlobalBlock__ 不捕获任何外部局部变量 与程序同生共死 _NSConcreteGlobalBlock
__NSStackBlock__ 捕获了外部局部变量(MRC 或未被强引用) 与所在栈帧同生共死 _NSConcreteStackBlock
__NSMallocBlock__ Stack Block 经 copy 引用计数为 0 时销毁 _NSConcreteMallocBlock

类比理解:

  • 全局 Block → 写在教科书上的公式——永远在那里,谁都能用
  • 栈 Block → 写在白板上的草稿——会议结束就擦掉
  • 堆 Block → 拍了照片保存在手机里的公式——照片在就在,删了就没了

3.2 三种类型的 retain/copy/release 行为差异

                    retain        copy          release
                    ──────        ────          ───────
GlobalBlock         什么都不做     返回自身        什么都不做
                    (它是不死的)   (不需要 copy)   (不能被释放)

StackBlock          什么都不做     拷贝到堆上      什么都不做
                    (栈管理)      返回堆上新副本   (栈自动管理)
                                  ★这是唯一有效操作

MallocBlock         引用计数 +1   引用计数 +1     引用计数 -1
                                  (不会重新拷贝)   (归零则释放)

关键洞察:
  - 对 StackBlock 做 retain 是无效的(不增加引用计数)
  - 必须 copy 才能将其"救"到堆上
  - 这就是为什么 Block 属性要用 copy 而不是 strong(MRC 时代的历史原因)
  - ARC 下 strongcopy 对 Block 效果相同(编译器自动插入 copy

面试陷阱: "对一个已经在堆上的 Block 再做 copy 会发生什么?"——答案是只增加引用计数(+1),不会创建新副本。这和 NSMutableArray copy 会创建新对象是不同的行为。

3.3 ARC 下的自动 copy —— 编译器到底做了什么

在 ARC 环境下,以下场景编译器会自动将栈 Block copy 到堆上

自动 copy 触发场景:
├── ① Block 作为函数/方法返回值
│      编译器在 return 语句处插入 objc_retainBlock() → 内部调用 _Block_copy()
│
├── ② Block 赋值给 __strong 修饰的变量
│      void(^block)(void) = ^{ ... };
│      编译器在赋值处插入 objc_retainBlock()
│
├── ③ Block 作为 GCD API 的参数
│      dispatch_async 内部实现中调用 _Block_copy()
│
├── ④ Block 作为 Cocoa 框架中含 usingBlock 的方法参数
│      框架内部负责 copy
│
└── ⑤ Block 被传入方法的参数(编译器启发式判断)
       如果方法签名暗示会保存 Block(如 completion handler),编译器插入 copy

编译器不会自动 copy 的场景(ARC 下罕见但存在):
├── Block 作为函数参数传递时,如果调用者没有保存它
│   (此时 Block 可能在栈上,但因为是同步使用所以安全)
└── 使用 __unsafe_unretained 修饰的变量接收 Block

3.4 为什么 Block 要有栈这个中间状态?

设计哲学:性能与安全的权衡

如果 Block 一律在堆上创建(像 Swift 闭包那样):
  ✅ 简单,不需要考虑栈→堆迁移
  ❌ 每次创建 Block 都要 malloc,频繁的堆分配 + GC 压力

OC Block 的策略:
  ✅ 大量临时 Block(如 for 循环中的 Block)直接在栈上创建和销毁
     无 malloc 开销、无引用计数开销
  ✅ 只有需要"逃逸"的 Block 才 copy 到堆上
  ⚠️ 代价是引入了栈→堆迁移的复杂性

类比理解:
  栈 Block → 临时工,干完活就走,不占编制
  堆 Block → 正式员工,办了入职手续(malloc),有档案(引用计数)
  全局 Block → 终身教授,和学校同在

在 ARC 时代,编译器自动处理迁移,开发者几乎无感。
但理解这个机制对排查内存问题至关重要。

3.5 逃逸(Escape)与非逃逸(Non-Escape)

这个概念虽然在 Swift 中用 @escaping 关键字显式化了,但在 OC Block 中一直存在,只是没有语法层面的区分:

什么是逃逸?
  Block 的使用范围"逃"出了它被创建的那个函数作用域。

非逃逸 Block(不需要 copy 到堆上):
  - 在当前函数内同步调用然后丢弃
  - 例如:enumerateObjectsUsingBlocksortUsingComparator
  - 函数结束时 Block 还在栈上,随栈帧销毁即可
  
逃逸 Block(必须 copy 到堆上):
  - 被保存到属性/实例变量中
  - 被异步 dispatch 执行
  - 作为返回值传出当前函数
  - 被传入会保存它的 API(如 NSNotificationCenterblock observer)

逃逸 Block 的特征:
  ┌──────────────────────────────────────────────────────┐
  │ Block 在创建者的作用域结束之后仍然可能被调用 → 逃逸      │
  │ Block 只在创建者的作用域内被调用 → 非逃逸                │
  └──────────────────────────────────────────────────────┘

Swift 把这个概念升级为编译器强制检查:
  - func doWork(completion: @escaping () -> Void)   // 编译器知道会逃逸
  - func doWork(block: () -> Void)                  // 默认非逃逸,性能更好

OC 中虽然没有编译器检查,但 ARC 的自动 copy 机制帮你兜了底:
  该 copy 的时候自动 copy,不需要显式操心。

四、变量捕获机制

这是 Block 最核心、最容易出问题的部分。

4.1 捕获规则总览

┌──────────────────┬────────────────────┬──────────────────────┐
│    变量类型        │    捕获方式          │    Block 内能否修改    │
├──────────────────┼────────────────────┼──────────────────────┤
│ 局部基本类型变量    │ 值拷贝 (copy)       │ 不能(编译报错)       │
│ 局部对象类型变量    │ 指针值拷贝 (copy)    │ 不能改指针指向         │
│                  │                    │ 能改指向对象的属性      │
│ __block 局部变量   │ 封装为堆上结构体引用  │ 能                   │
│ 静态局部变量       │ 指针引用 (不拷贝)     │ 能                   │
│ 全局变量          │ 不捕获 (直接访问)     │ 能                   │
│ self             │ 强引用捕获           │ 能访问属性/方法        │
└──────────────────┴────────────────────┴──────────────────────┘

4.2 局部变量的值捕获 —— 为什么是"快照"

捕获时刻:Block 定义时(不是调用时)

int a = 10;

void(^block)(void) = ^{
    // 这里的 a 是定义时拷贝进来的值 = 10
};

a = 20;
block();  // 输出 10,不是 20

原理: 编译器在 Block 结构体中新增了一个 int a 成员变量,在 Block 创建的那一刻,将外部 a 的值拷贝进去。之后外部 a 的变化与 Block 内部无关。

Block 结构体                外部栈帧
┌──────────────┐           ┌──────────────┐
│ isa           │           │              │
│ invoke        │           │ a = 20       │  ← 外部已改为 20
│ ...           │           │              │
│ a = 10        │ ← 独立副本 │              │
└──────────────┘           └──────────────┘
      ↑ Block 内部读到的始终是 10

为什么编译器禁止修改? 不是技术上不能改(结构体成员当然可以改),而是改了会造成语义困惑——开发者期望改的是外部变量 a,但实际改的是内部副本,外部完全无感。编译器选择了"报错"而非"允许但行为诡异"。

类比: 值捕获就像你拍了一张照片。照片定格了那一瞬间的画面。之后现实场景怎么变化,照片里的内容不会变。而且你在照片上涂改也改不了现实。

4.3 对象类型的值捕获 —— 指针拷贝的微妙之处

对象类型的捕获容易让人困惑,因为需要区分"指针"和"指针所指的对象":

NSMutableArray *arr = [NSMutableArray new];

void(^block)(void) = ^{
    [arr addObject:@"hello"];  // ✅ 合法!
    arr = [NSMutableArray new]; // ❌ 编译报错!
};

为什么前者合法,后者报错?

Block 拷贝的是"指针的值"(即对象的地址),不是对象本身。

┌───── Block 结构体 ─────┐          ┌─── NSMutableArray ───┐
│                        │          │                      │
│  arr = 0x1234 (拷贝) ──┼────→     │  contents: [...]     │
│                        │          │                      │
└────────────────────────┘          └──────────────────────┘
                                          ↑
┌───── 外部栈帧 ─────────┐               │
│                        │               │
│  arr = 0x1234 (原件) ──┼───────────────┘
│                        │
└────────────────────────┘

两个 arr 指针虽然是独立副本,但都指向同一个 NSMutableArray 对象。
所以:
  - [arr addObject:] → 通过指针操作对象 → 合法(没改指针本身)
  - arr = xxx        → 修改指针本身 → 报错(和修改 int a 是一样的道理)

4.4 静态局部变量与全局变量 —— 为什么不需要捕获

为什么静态变量和全局变量不需要"捕获"?

全局变量:
  存储在数据段(Data Segment),地址在编译期确定
  程序任何地方都可以通过固定地址直接访问
  → Block 内直接用地址访问,不需要拷贝到结构体

静态局部变量:
  虽然作用域是局部的,但存储在数据段
  地址在编译期确定,生命周期是全程序
  → Block 捕获的是变量的指针(地址),不是值
  → 通过指针间接访问,所以能读也能改

  Block 结构体中存储的是:int *countPtr = &count;
  访问时:*(countPtr) = *(countPtr) + 1;

对比:
  局部变量 → 值拷贝(因为栈帧会销毁,地址会失效)
  静态变量 → 指针拷贝(地址永远有效)
  全局变量 → 不捕获(直接用符号地址)

类比:
  局部变量 → 别人白板上的草稿,你必须抄一份带走(值拷贝)
  静态变量 → 图书馆书架上的书,你只需要记住书架号(指针拷贝)
  全局变量 → 墙上的公告,不需要抄,大家都能看到(不捕获)

4.5 __block 修饰符的深层原理

__block 不是简单的"允许修改",它触发了一个复杂的底层变换

原始代码:                   编译器转换后:
                            
__block int a = 10;    →    struct __Block_byref_a {
                                void *__isa;
                                __Block_byref_a *__forwarding;  // 关键!
                                int __flags;
                                int __size;
                                int a;           // 真正的值
                            };
                            
                            __Block_byref_a a = {
                                .isa = 0,
                                .__forwarding = &a,  // 指向自己
                                .a = 10
                            };

理解 __block 的类比: 普通变量捕获就像你把书抄了一页带走——你在副本上改东西,原书不变。而 __block 就像把整本书放进一个共享文件柜(堆上的 __Block_byref 结构体),然后给所有需要的人配一把钥匙(指针)——大家打开柜子看到的、改的都是同一本书。

__forwarding 指针的精妙设计

╔══════════════════════════════════════════════════════════════════╗
║  __forwarding 存在的根本原因:                                     ║
║  解决"同一个 __block 变量可能同时被栈上代码和堆上 Block 访问"的问题   ║
╚══════════════════════════════════════════════════════════════════╝

阶段一:Block 还在栈上时

  Stack 栈帧
  ┌─────────────────────┐
  │ __Block_byref_a     │
  │ ├── __forwarding ───┼──→ 指向自己(栈上地址)
  │ └── a = 10          │
  └─────────────────────┘
  
  此时所有访问 a 的代码都通过:byref->__forwarding->a
  由于 __forwarding 指向自己,等价于 byref->a
  看起来多此一举?往下看——

阶段二:Block 被 copy 到堆上

  Stack                        Heap
  ┌─────────────────────┐     ┌─────────────────────┐
  │ __Block_byref_a     │     │ __Block_byref_a     │
  │ ├── __forwarding ───┼──┬──┼── __forwarding ───┼──→ 指向自己(堆上)
  │ └── a = 10          │  │  │ └── a = 10          │
  └─────────────────────┘  │  └─────────────────────┘
                           │         ↑
                           └─────────┘
                     栈上的 __forwarding 被修改!
                     现在指向堆上副本

  关键效果:
  - 栈上代码访问 a → byref_stack->__forwarding->a → 堆上的 a ✓
  - Block 内访问 a  → byref_heap->__forwarding->a  → 堆上的 a ✓
  - 两边修改的是同一个 a!

如果没有 __forwarding:
  - 栈上代码修改 byref_stack->a = 20,改的是栈上的副本
  - Block 内读取 byref_heap->a,读的是堆上的副本
  - 两边不一致!Bug!

类比: __forwarding 就像一个邮件转发地址。一开始你住在家里(栈上),所有信都寄到家里地址。后来你搬到公司(堆上),你在家里设置了邮件转发——所有寄到家里的信都会自动转到公司。这样无论别人往哪个地址寄信,最终都送到你手上(堆上)。

__block 变量被多个 Block 捕获时

__block int a = 10;

void(^block1)(void) = ^{ a = 20; };
void(^block2)(void) = ^{ a = 30; };

底层发生了什么?

block1 copy 到堆时:
  → __Block_byref_a 从栈 copy 到堆(第一次)
  → block1 结构体中保存指向堆上 byref 的指针

block2 copy 到堆时:
  → 发现 __Block_byref_a 已经在堆上了(通过 __forwarding 判断)
  → 不再重复 copy,直接引用计数 +1
  → block2 结构体中保存同一个堆上 byref 的指针

结果:
  block1 和 block2 共享同一个堆上的 __Block_byref_a
  修改的是同一个 a
  __Block_byref_a 的引用计数 = 2(被两个 Block 持有)

__block 与对象类型的特殊交互(MRC vs ARC)

__block 在 MRC 和 ARC 下对对象类型变量的行为完全不同!

MRC 下:
  __block id obj = [[NSObject alloc] init];
  → __Block_byref 结构体中的 obj 不会被 retain
  → 可以用来避免循环引用(因为不强引用)
  → 这是 MRC 时代避免循环引用的手段之一

ARC 下:
  __block id obj = [[NSObject alloc] init];
  → __Block_byref 结构体中的 obj 会被 strong 引用
  → 不能用来避免循环引用!
  → 这是很多从 MRC 迁移到 ARC 的项目踩过的坑

ARC 下避免循环引用应该用 __weak,不是 __block

总结:
  MRC: __block 不 retain 对象 → 可用于打破循环引用
  ARC: __block 会 retain 对象 → 不能打破循环引用,需要 __weak

4.6 对象类型变量的捕获与内存管理

捕获对象时的引用关系:

NSObject *obj = [[NSObject alloc] init];

void(^block)(void) = ^{
    NSLog(@"%@", obj);
};

┌──────────────────┐         ┌───────────────┐
│ Block (堆上)       │         │ NSObject 实例  │
│ ├── isa            │         │               │
│ ├── invoke         │         │  retainCount  │
│ └── obj (strong) ──┼────→    │  (被 Block +1) │
└──────────────────┘         └───────────────┘

Block copy 到堆时,会调用 descriptor 中的 copy_helper,
对捕获的对象执行 _Block_object_assign:
  - 强引用 (默认) → 等同于 retain
  - __weak 修饰    → 弱引用,不增加引用计数
  - __unsafe_unretained → 不增加引用计数,不置 nil(危险)

_Block_object_assign 的内部逻辑

void _Block_object_assign(void *destAddr, const void *object, int flags) {
    
    flags 决定行为:
    
    BLOCK_FIELD_IS_OBJECT (3):    // 捕获的是 OC 对象_Block_retain_object(object)
        → 等价于 [object retain] 或 objc_storeStrong
        → 如果是 __weak,走 objc_initWeak 路径
    
    BLOCK_FIELD_IS_BLOCK (7):     // 捕获的是另一个 Block_Block_copy(object)
        → 被捕获的 Block 也会被 copy 到堆上(递归 copy)
    
    BLOCK_FIELD_IS_BYREF (8):     // 捕获的是 __block 变量_Block_byref_copy(object)
        → 将 __block 结构体 copy 到堆上
        → 修改 __forwarding 指针
    
    BLOCK_FIELD_IS_WEAK (16):     // __weak 修饰的对象objc_initWeak(destAddr, object)
        → 注册到 SideTable 的弱引用表中
}

4.7 self 的捕获 —— 隐式 vs 显式

═══════════════════════════════════════════════════════════════
  self 捕获是循环引用的最大根源,必须深入理解
═══════════════════════════════════════════════════════════════

显式捕获(容易意识到):
  ^{ [self doSomething]; }     // 明确写了 self
  ^{ self.name = @"xxx"; }     // 明确写了 self

隐式捕获(容易忽略!):
  ^{ _name = @"xxx"; }         // 直接访问 ivar,编译器转为 self->_name
                                // 同样强引用捕获了 self!
  
  ^{ [self->_delegate call]; } // 同理

  ^{ _block(); }               // 如果 _block 是实例变量
                                // 也隐式捕获了 self

更隐蔽的情况:
  ^{ doSomething(); }          // 如果 doSomething 是当前类的方法
                                // 编译器转为 [self doSomething]
                                // 隐式捕获 self

  ^{ NSLog(@"%@", _array[0]); }  // _array 是 ivar → 捕获了 self

  ┌──────────────────────────────────────────────────────┐
  │ 规则:Block 内只要访问了实例变量或调用了实例方法,        │
  │       就一定捕获了 self,无论有没有写 "self." 前缀       │
  └──────────────────────────────────────────────────────┘

4.8 捕获变量的内存对齐

Block 结构体中捕获变量的排列遵循 C 语言的内存对齐规则:

假设捕获了以下变量:
  char c;     // 1 byte
  int i;      // 4 bytes  
  double d;   // 8 bytes

在 Block 结构体中的排列:
  
  偏移量 0~7:   Block_layout 固有字段(isa, flags 等)
  ...
  偏移量 N:     char c    (1 byte)
  偏移量 N+1~N+3: padding (3 bytes 填充,对齐到 4)
  偏移量 N+4:   int i     (4 bytes)
  偏移量 N+8:   double d  (8 bytes)

编译器的优化:
  有时编译器会重新排列捕获变量的顺序
  把大类型放前面,小类型放后面
  以减少 padding,使 Block 结构体更紧凑

这对日常开发影响不大,但在分析 crash log 中 Block 结构体
的内存布局时,理解对齐规则有助于定位问题。

五、循环引用的本质与解法

5.1 循环引用的形成

经典循环引用:

self → 强引用 → block → 强引用 → self

┌──────────┐  strong   ┌──────────┐  strong   ┌──────────┐
│  self     │─────────→│  Block   │─────────→│  self     │
│ (对象)     │          │ (堆上)    │          │ (同一个)   │
└──────────┘          └──────────┘          └──────────┘
      ↑                                          │
      └──────────────────────────────────────────┘
      引用计数永远不归零,两者都无法释放 → 内存泄漏

类比: 循环引用就像两个人互相握手不肯先松开——只要对方不松手我也不松手,结果两个人永远僵持在那里。__weak 就是让其中一个人用力气很小的方式搭在对方手上(不算真正的"握"),一旦对方松开,自己的手自动滑落。

5.2 复杂循环引用链(实际项目中更常见)

不是所有循环引用都是直接的 self  block  self

间接循环引用(三角环):
  self  viewModel  completionBlock  self
  
  ViewController 持有 ViewModel
  ViewModel 持有 completionBlock
  completionBlock 捕获了 ViewController(self)
   三者形成环,都无法释放

更隐蔽的多层环:
  self  manager  handler  service  callback  self

  ┌────────┐    ┌─────────┐    ┌─────────┐    ┌──────────┐
   self    │──→│ manager │──→│ handler │──→│ callback 
  └────────┘    └─────────┘    └─────────┘    └──────────┘
                                                  
      └────────────────────────────────────────────┘

NSTimer 的经典循环引用:
  self  timer (strong)
  timer  target: self (strong,NSTimer 强引用 target)
  timer  block (如果用 block API)

  NSTimer 的特殊性:
  - RunLoop 强引用 timer
  - timer 强引用 target (self)
  - 即使 self 不强引用 timer,RunLoop  timer  self 也会导致 self 不释放
  - 必须手动 invalidate timer 才能打破

NSTimer 循环引用的根本原因:
  ┌──────────────────────────────────────────────────────────┐
   NSTimer 的设计缺陷:它强引用 target 直到 invalidate     
   这打破了常规的"弱引用 delegate"范式                      
   解决方案:使用 iOS 10+ 的 block-based API + weakSelf,    
   或者使用 NSProxy 中间人模式                              
  └──────────────────────────────────────────────────────────┘

5.3 解决方案的原理对比

方案一:__weak(推荐)

self  strong  Block  weak  self
                         
                    弱引用不增加引用计数
                    self 释放后自动置 nil

特点:
   安全,self 释放后 weakSelf 自动为 nil
   Block 调用时需要判断 weakSelf 是否为 nil
  ⚠️ Block 执行过程中 self 可能随时被释放(多线程场景)

─────────────────────────────────────────

方案二:__weak + __strong(Weak-Strong Dance)

Block 外部:__weak typeof(self) weakSelf = self;
Block 内部:__strong typeof(weakSelf) strongSelf = weakSelf;

执行流程:
  ① Block 被调用
  ② strongSelf = weakSelf(如果 self 已释放,strongSelf = nil,提前 return)
  ③ strongSelf 临时持有 self,保证 Block 执行期间 self 不被释放
  ④ Block 执行完毕,strongSelf 出栈,临时强引用消失

特点:
   保证 Block 执行期间 self 存活
   Block 执行完后不阻止 self 释放
   最佳实践

─────────────────────────────────────────

方案三:__block + 手动置 nilMRC 遗留思路)

__block typeof(self) blockSelf = self;
self.block = ^{
    [blockSelf doSomething];
    blockSelf = nil;  // 手动打破循环
};

self  Block  blockSelf  self(执行后 blockSelf = nil 打破)

特点:
  ⚠️ Block 必须被执行才能打破循环
  ⚠️ 如果 Block 永远不被调用  内存泄漏
   不推荐

5.4 Weak-Strong Dance 的深层理解

为什么单纯 __weak 在某些场景下不够?

场景:Block 执行到一半时 self 被释放

__weak typeof(self) weakSelf = self;
self.block = ^{
    [weakSelf doStep1];    // ① weakSelf 非 nil,执行成功
    
    // ──── 此时另一个线程释放了 self ────
    
    [weakSelf doStep2];    // ② weakSelf 变成 nil!不执行了
    weakSelf.name = @"xx"; // ③ 也不执行
    // 步骤不完整,数据可能处于不一致状态
};

加了 __strong 后:

__weak typeof(self) weakSelf = self;
self.block = ^{
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) return;  // self 已死,整体不执行
    
    [strongSelf doStep1];     // ✅ 安全
    [strongSelf doStep2];     // ✅ 安全(strongSelf 临时持有,self 不会中途释放)
    strongSelf.name = @"xx";  // ✅ 安全
    
    // Block 执行完,strongSelf 出栈,临时强引用消失
    // 不影响 self 的正常释放
};

关键理解:
  __strong 创建的是一个临时的、局部的强引用
  它只在 Block 执行期间生效
  Block 不执行时,它不存在,不会造成循环引用
  Block 执行时,它临时延长 self 的生命周期
  Block 执行完,它随着栈帧销毁而消失

类比: Weak-Strong Dance 就像电影院的座位预留机制。__weak 是"不预留座位"——你来了有空位就坐,来晚了位子可能被撤了。__strong 是在进场(Block 开始执行)时确认一下"这个座位还在不在",如果在就暂时锁定它,看完电影(Block 执行完)自动解锁。

5.5 不是所有 Block 都会循环引用

不会循环引用的场景:
├── UIView 动画 Block
│   └── [UIView animateWithDuration:animations:] 
│       系统持有 Block,Block 引用 self,但 self 不持有 Block
│       → 单向引用,不成环
│
├── GCD 一次性 Block
│   └── dispatch_async(queue, ^{ self.xxx; });
│       GCD 持有 Block(直到执行完),Block 引用 self
│       self 不持有 GCD 的 Block → 不成环
│       ⚠️ 但如果 dispatch_after 延时很长,self 的释放会被推迟
│
├── 局部变量 Block
│   └── void(^block)(void) = ^{ self.xxx; }; block();
│       block 是局部变量,函数结束即销毁 → 不成环
│
├── NSArray/NSDictionary 的 enumerate Block
│   └── [array enumerateObjectsUsingBlock:^{ self.xxx; }];
│       Block 同步执行完即释放 → 不成环
│
├── Masonry / SnapKit 的约束 Block
│   └── [view mas_makeConstraints:^{ make.top.equalTo(self.view); }];
│       Block 同步执行完即释放 → 不成环
│
└── 判断标准:
    ┌─────────────────────────────────────────────────┐
    │ 画一条从 self 出发的"持有链"                       │
    │ 如果能绕回 self → 循环引用                         │
    │ 如果不能绕回 self → 安全                           │
    │                                                  │
    │ self → 属性 → Block → self (环!)                │
    │ 系统 → Block → self (不成环,安全)                │
    │ 局部 → Block → self (不成环,安全)                │
    └─────────────────────────────────────────────────┘

5.6 循环引用的检测方法

检测循环引用的实用手段:

1. dealloc 日志法(最简单)
   在类的 dealloc 方法中打印日志
   如果页面退出后看不到日志 → 该对象没有被释放 → 可能存在循环引用

2. Instruments - Leaks
   Xcode 自带工具,能自动检测泄漏的对象
   可以看到泄漏对象的引用关系图
   局限:不是所有循环引用都会被 Leaks 检测到

3. Instruments - Allocations(更可靠)
   查看对象的生命周期(分配和释放历史)
   如果一个对象只有 alloc 没有 dealloc → 泄漏
   可以按类名过滤,非常方便

4. Memory Graph Debugger(推荐)
   Xcode 调试时点击左下角的 Memory Graph 按钮
   会以图形方式展示所有对象的引用关系
   循环引用会被清晰地标注出来(紫色警告图标)
   ⭐ 最直观的检测方式

5. 第三方工具
   MLeaksFinder(腾讯开源):自动检测 UIViewController 的泄漏
   FBRetainCycleDetector(Facebook 开源):运行时检测循环引用
   两者可以配合使用
   
6. Debug Memory Graph + lldb
   在 Memory Graph 中选中可疑对象
   在 lldb 中执行 po 命令查看对象详情
   使用 malloc_history 命令追踪对象分配堆栈

排查思路流水线:
  dealloc 没触发 → Memory Graph 看引用关系 → 找到环 → 分析哪个引用应该用 weak

六、Block 与内存管理的进阶话题

6.1 Block 的 copy 语义链

Block copy 时发生的事情(连锁反应):

Block copy 到堆上
    │
    ├── Block 结构体从栈拷贝到堆(malloc + memcpy)
    │
    ├── 调用 descriptor->copy_helper
    │   │
    │   ├── 对捕获的 OC 对象执行 _Block_object_assign
    │   │   ├── strong 对象 → retain(引用计数 +1)
    │   │   ├── weak 对象   → objc_initWeak(注册弱引用)
    │   │   └── block 对象  → 递归 _Block_copy
    │   │
    │   └── 对 __block 变量执行 _Block_object_assign
    │       ├── __block 结构体从栈 copy 到堆
    │       ├── 修改栈上 __forwarding 指向堆上副本
    │       └── 对 __block 内部的 OC 对象执行相应的 retain/weak
    │
    └── 修改 isa 指针:_NSConcreteStackBlock → _NSConcreteMallocBlock
        修改 flags:设置 BLOCK_NEEDS_FREE 位,引用计数初始化为 1

6.2 Block 的 dispose 语义链

Block 引用计数归零时:

Block release → retainCount == 0
    │
    ├── 调用 descriptor->dispose_helper
    │   │
    │   ├── 对捕获的 OC 对象执行 _Block_object_dispose
    │   │   ├── strong 对象 → release(引用计数 -1)
    │   │   ├── weak 对象   → objc_destroyWeak(注销弱引用)
    │   │   └── block 对象  → 递归 _Block_release
    │   │
    │   └── 对 __block 变量执行 _Block_object_dispose
    │       └── __block 结构体引用计数 -1,归零则:
    │           ├── 对内部 OC 对象执行 release/destroyWeak
    │           └── free(__block 结构体)
    │
    └── free(block) 释放 Block 堆内存

6.3 Block 的 retain/release 实现细节

_Block_copy 的内部逻辑(简化版):

void *_Block_copy(const void *arg) {
    struct Block_layout *src = (struct Block_layout *)arg;
    
    if (src->flags & BLOCK_NEEDS_FREE) {
        // 已经在堆上了 → 只增加引用计数
        latching_incr_int(&src->flags);  // flags 中的引用计数 +1
        return src;
    }
    
    if (src->flags & BLOCK_IS_GLOBAL) {
        // 全局 Block → 什么都不做,返回自身
        return src;
    }
    
    // 栈上 Block → 拷贝到堆上
    struct Block_layout *dst = malloc(src->descriptor->size);
    memmove(dst, src, src->descriptor->size);      // 整体内存拷贝
    
    dst->isa = _NSConcreteMallocBlock;              // 改 isa
    dst->flags |= BLOCK_NEEDS_FREE;                 // 标记为堆 Block
    dst->flags = (dst->flags & ~0xFFFF) | 1;        // 引用计数 = 1
    
    if (dst->flags & BLOCK_HAS_COPY_DISPOSE) {
        dst->descriptor->copy_helper(dst, src);      // 处理捕获变量
    }
    
    return dst;
}

性能洞察:
  - Block copy 涉及 malloc + memmove + 可能的多次 retain
  - 这就是为什么频繁创建和 copy Block 有性能开销
  - 也是为什么 GCD 内部对 Block 的处理做了大量优化

6.4 Block 属性用 copy 还是 strong?

MRC 时代:
  @property (nonatomic, copy) void(^block)(void);
  
  必须用 copy!
  如果用 retain,Block 仍然在栈上,函数返回后 Block 失效 → 野指针 crash
  copy 会把 Block 从栈拷贝到堆上,延长生命周期

ARC 时代:
  @property (nonatomic, copy) void(^block)(void);
  @property (nonatomic, strong) void(^block)(void);
  
  两者效果完全相同!
  ARC 编译器对 Block 赋值时自动插入 _Block_copy
  无论你写 copy 还是 strong,底层都会执行 copy 操作

  但惯例上仍然写 copy,原因:
  ① 代码自文档化——看到 copy 就知道"这是 Block,有特殊的内存语义"
  ② 向后兼容——万一哪天代码被挪到 MRC 环境也能正确工作
  ③ 团队共识——Apple 官方文档和社区都推荐 copy

  ┌─────────────────────────────────────────────────┐
  │ ARC 下用 strong 也完全正确,但写 copy 更规范     │
  └─────────────────────────────────────────────────┘

七、Block 与线程安全

7.1 Block 本身的线程安全性

Block 对象一旦创建完成(copy 到堆上后),其内部状态是只读的。
invoke 指针、descriptor、捕获的变量值都不会再变。

因此:
  ✅ 多线程同时调用(invoke)同一个 Block → 安全(只读操作)
  ✅ 多线程同时对同一个 Block 做 retain/release → 安全
     (引用计数操作是原子的,使用了 OSAtomicCompareAndSwapInt)
  ❌ 如果 Block 捕获了可变对象,多线程调用时对该对象的修改 → 不安全

  ┌──────────────────────────────────────────────────────────┐
  │ Block 的"壳"是线程安全的,但"内容物"不一定是。              │
  │ 就像一个上了锁的保险箱(Block)里面放了一把没有安全锁的刀    │
  │ (NSMutableArray)——保险箱是安全的,但刀可以伤人。            │
  └──────────────────────────────────────────────────────────┘

7.2 捕获变量的线程安全问题

场景一:多个线程通过 Block 读写同一个 __block 变量

  __block int counter = 0;
  
  for (int i = 0; i < 1000; i++) {
      dispatch_async(concurrentQueue, ^{
          counter++;  // 多线程同时 ++ → 数据竞争!结果不可预期
      });
  }
  
  问题:counter++ 不是原子操作(读-改-写三步)
  解决:用 dispatch_barrier_async 或 @synchronized 或 os_unfair_lock

场景二:Block 捕获的对象在另一个线程被释放

  __weak typeof(self) weakSelf = self;
  dispatch_async(bgQueue, ^{
      // 此时 self 可能已经被主线程释放
      [weakSelf doSomething];  // weakSelf 可能为 nil → 消息发给 nil,安全但无效
      
      NSLog(@"%@", weakSelf.name); // 同理,可能返回 nil
  });
  
  这不是 crash,但可能导致逻辑不正确
  → 需要 Weak-Strong Dance

场景三:Block 中修改捕获的可变集合

  NSMutableArray *arr = [NSMutableArray new];
  
  dispatch_async(queue1, ^{ [arr addObject:@"A"]; });
  dispatch_async(queue2, ^{ [arr addObject:@"B"]; });
  
  两个 Block 捕获同一个 arr 指针(指向同一个可变数组)
  同时修改 → crash(NSMutableArray 非线程安全)
  
  解决:
  ① 使用串行队列保护
  ② 每个 Block 使用独立的 copy
  ③ 使用并发队列 + barrier

7.3 Block 与 GCD 的线程交互模式

常见模式及其线程安全分析:

模式 1:主线程 → 后台 → 回主线程

  dispatch_async(bgQueue, ^{
      id result = [self heavyComputation];  // 后台线程
      
      dispatch_async(dispatch_get_main_queue(), ^{
          self.label.text = result;          // 主线程更新 UI
      });
  });
  
  线程安全性:
  - heavyComputation 在后台线程执行 → 不能操作 UI
  - result 是局部变量,被内层 Block 值捕获 → 安全
  - 内层 Block 在主线程执行 → 可以操作 UI ✓
  - 注意:self 被两层 Block 捕获 → 是否循环引用取决于 self 是否持有 queue

模式 2:dispatch_group 汇聚多个异步任务

  dispatch_group_t group = dispatch_group_create();
  __block NSArray *data1, *data2;
  
  dispatch_group_async(group, queue, ^{ data1 = [self fetchData1]; });
  dispatch_group_async(group, queue, ^{ data2 = [self fetchData2]; });
  
  dispatch_group_notify(group, mainQueue, ^{
      [self updateUIWithData1:data1 data2:data2];  // 两个任务都完成后
  });
  
  线程安全性:
  - data1 和 data2 用 __block 修饰,多个 Block 共享同一个堆上变量
  - 但因为两个 async 的 Block 各写各的变量 → 不冲突
  - notify 的 Block 在两个 async 都完成后才执行 → 此时 data1/data2 已写入
  - 安全 ✓(但如果多个 Block 写同一个变量就不安全了)

八、Block 在底层框架中的角色

8.1 GCD 中的 Block

dispatch_async(queue, block)

执行流程:
    │
    ├── 1. Block 被 copy 到堆上(GCD 内部调用 _Block_copy)
    │
    ├── 2. Block 被封装进 dispatch_continuation_t 结构体
    │      ┌──────────────────────────────┐
    │      │ dispatch_continuation_t       │
    │      │ ├── do_vtable (虚表指针)       │
    │      │ ├── dc_func (执行函数)         │
    │      │ ├── dc_ctxt (Block 指针)       │
    │      │ ├── dc_voucher              │
    │      │ └── dc_priority             │
    │      └──────────────────────────────┘
    │      放入 queue 的 FIFO 链表
    │
    ├── 3. 线程池中的 worker thread 取出 continuation
    │      调用 dc_func(dc_ctxt) → block->invoke(block)
    │
    └── 4. 执行完毕后 _Block_release(block)
           Block 引用计数 -1,归零则触发 dispose 链

dispatch_sync 的差异:
  - 同步调用不需要 copy Block(Block 在调用者栈上即可)
  - 调用者线程阻塞等待,Block 在 queue 的线程上执行
  - 执行完后调用者才继续,此时 Block 仍然有效
  - 但要注意死锁:在串行队列中 dispatch_sync 到同一队列 → 死锁!

8.2 RunLoop 中的 Block

CFRunLoopPerformBlock(runloop, mode, block)

    │
    ├── Block 被 copy 到堆上
    ├── 挂载到 RunLoop 指定 mode 的 _blocks 链表
    ├── RunLoop 在对应 mode 的迭代中遍历链表执行
    └── 执行后从链表移除并 release

RunLoop 与 Block 的生命周期关系:
  - Block 提交后到执行前,一直被 RunLoop 持有
  - 如果 RunLoop 切换到其他 mode,Block 不会执行
  - 如果 RunLoop 退出,未执行的 Block 会被 release

performSelector:withObject:afterDelay: 的底层也是 RunLoop + Timer + Block

8.3 Notification/KVO 中的 Block

id token = [[NSNotificationCenter defaultCenter]
    addObserverForName:@"xxx"
    object:nil
    queue:nil
    usingBlock:^(NSNotification *note) {
        // 这个 Block 被 NotificationCenter 持有
        // 直到 removeObserver 才释放
    }];

生命周期陷阱:
  NotificationCenter → observer(内部对象) → Block → self
                                                     ↑
  如果 self 持有 token 并在 dealloc 中 removeObserver:
  - self 的 dealloc 永远不会调用(因为 Block 强引用 self)
  - token 永远不会被 remove
  → 经典死锁式内存泄漏

解决:Block 内必须用 weakSelf

8.4 Block 作为 Associated Object

objc_setAssociatedObject(self, key, block, OBJC_ASSOCIATION_COPY_NONATOMIC);

  - COPY 策略会调用 _Block_copy
  - Block 被关联到对象上,对象释放时 Block 才释放
  - 如果 Block 捕获了该对象 → 循环引用!
  - 这是很多第三方库(如方法交换添加 Block 回调)的常见泄漏原因

8.5 Block 在 KVO 中的新 API

iOS 11+ 提供了基于 Block 的 KVO API:

  self.observation = [self.model observe:@selector(name) 
                                 options:NSKeyValueObservingOptionNew 
                                 changeHandler:^(Model *model, ...) {
      // 注意:Block 参数直接给了被观察对象,不需要 self
      // Apple 有意设计成不需要捕获 self
      NSLog(@"%@", model.name);
  }];

  self.observation 持有 observation token
  observation token 持有 Block
  Block 引用 model(不是 self)
  → 不形成循环引用
  → observation token 在 self dealloc 时被释放
  → 自动移除观察者

这个设计是 Apple 总结了无数 KVO 内存泄漏问题后的改良方案。

九、Block 的性能考量

9.1 Block 的开销分析

Block 的性能开销来源:

1. 创建开销
   ├── 栈 Block:几乎为零(只是栈指针移动 + 结构体初始化)
   ├── 堆 Block:malloc + memcpy + 可能的多次 retain
   └── 全局 Block:零开销(编译期确定)

2. 调用开销
   ├── 通过函数指针间接调用(和 C 函数指针相同)
   ├── 比 OC 方法调用快(没有 objc_msgSend 的查找过程)
   ├── 比直接函数调用慢(多一次指针解引用)
   └── 和 C++ 虚函数调用类似的性能级别

3. 销毁开销
   ├── 栈 Block:零(栈帧弹出即可)
   ├── 堆 Block:dispose_helper + free + 可能的多次 release
   └── 全局 Block:永不销毁

性能对比(从快到慢):
  直接函数调用 ≈ 内联 Block
  > C 函数指针调用 ≈ Block 调用
  > objc_msgSend(方法调用)
  > performSelector

9.2 编译器对 Block 的优化

编译器在开启优化时(-O1 及以上)会对 Block 做以下优化:

1. 内联优化
   如果 Block 在定义后立即调用且只使用一次
   编译器可能将其内联,消除 Block 开销

2. 栈提升为全局
   如果 Block 不捕获变量,即使写在函数内部
   编译器也会将其提升为 GlobalBlock

3. copy 消除
   如果编译器能证明 Block 不会逃逸出当前作用域
   可能跳过 copy 操作

4. 捕获变量合并
   多个 Block 捕获相同变量时,编译器可能优化内存布局

9.3 大量 Block 场景的性能优化建议

场景:高频回调(如滚动监听、动画帧回调)

问题:
  每次回调都创建新 Block → 频繁 malloc/free
  Block 捕获大量对象 → 频繁 retain/release

优化策略:

  ① 复用 Block:将 Block 保存为属性,避免重复创建
     // Bad:每次滚动都创建新 Block
     scrollView.didScroll = ^{ [self handleScroll]; };
     
     // Good:初始化时创建一次
     self.scrollHandler = ^{ [weakSelf handleScroll]; };
     scrollView.didScroll = self.scrollHandler;

  ② 减少捕获变量:只捕获真正需要的变量
     // Bad:隐式捕获整个 self(包含所有 ivar 的引用)
     ^{ _array = ...; _dict = ...; }
     
     // Good:只传入需要的对象
     NSMutableArray *arr = _array;
     ^{ [arr addObject:...]; }

  ③ 考虑用函数指针替代 Block(极致性能场景)
     在 C 层面的高频回调中,函数指针比 Block 更轻量
     因为没有结构体创建、copy、dispose 的开销
     
  ④ 使用 dispatch_block_create 的 DISPATCH_BLOCK_NO_QOS_CLASS 标志
     避免 QoS 传播的额外开销

十、Block 的调试技巧

10.1 在运行时识别 Block 类型

调试时经常需要确认 Block 的类型和捕获信息:

lldb 命令:
  (lldb) po block
  → 输出 Block 的描述,包含类型信息

  (lldb) po [block class]
  → __NSGlobalBlock__ / __NSStackBlock__ / __NSMallocBlock__

  (lldb) po [block superclass]
  → NSBlock

  (lldb) memory read --size 8 --count 5 (void *)block
  → 读取 Block 结构体的前 5 个字段(isa, flags, reserved, invoke, descriptor)

  (lldb) po (void *)((void **)block)[3]
  → 读取 invoke 函数指针地址

  (lldb) image lookup -a <invoke 地址>
  → 反查 invoke 函数对应的源代码位置
     通常输出类似:__ClassName_methodName_block_invoke

10.2 在汇编层面识别 Block 调用

Block 调用在 ARM64 汇编中的特征:

Block 创建:
  通常会看到 __copy_helper_block_ 和 __destroy_helper_block_ 的引用
  以及 ___block_descriptor_ 相关的符号

Block 调用:
  ldr x8, [x0, #16]    // 从 Block 结构体偏移 16 字节处加载 invoke 指针
  blr x8                // 跳转到 invoke 函数执行
  
  ↑ 这两条指令是 Block 调用的标志性模式
  x0 既是 Block 指针,也作为 invoke 的第一个参数(隐含 self)

Block 捕获变量访问:
  在 invoke 函数内部,通过 x0(Block 指针)+ 偏移量 来访问捕获的变量
  ldr x8, [x0, #32]    // 访问第一个捕获变量(偏移量取决于结构体布局)

10.3 排查 Block 相关的 Crash

常见 Block Crash 类型及排查方法:

1. EXC_BAD_ACCESS —— 调用已释放的 Block
   原因:栈 Block 在栈帧销毁后被调用
   特征:crash 在 block->invoke(block, ...) 处
   排查:检查 Block 是否被正确 copy 到堆上
         MRC 下尤其常见

2. EXC_BAD_ACCESS —— Block 内访问已释放的对象
   原因:Block 用 __unsafe_unretained 捕获了一个已释放的对象
   特征:crash 在 Block 内部的 objc_msgSend 处
   排查:将 __unsafe_unretained 改为 __weak

3. Block 为 nil 时调用 → 直接 crash
   原因:Block 指针为 nil 时,调用会触发 EXC_BAD_ACCESS
         因为底层是 block->invoke(block),nil 解引用
   特征:crash 地址通常是 0x10 附近(nil + invoke 偏移量)
   排查:调用前判空 → if (block) { block(); }
   
   ┌──────────────────────────────────────────────────┐
   │ OC 方法调用可以安全地发给 nil → [nil doSomething]    │
   │ Block 调用不能发给 nil → nil() 会 crash!            │
   │ 这是一个容易被忽略的差异。                             │
   └──────────────────────────────────────────────────┘

4. 野 Block(Block 指针指向已被回收的内存)
   原因:使用 __unsafe_unretained 接收 Block,Block 被释放后指针未置 nil
   特征:crash 地址随机,表现不稳定
   排查:不要用 __unsafe_unretained 存储 Block

10.4 使用 clang 查看 Block 编译后的 C++ 代码

在终端中执行以下命令,可以看到 Block 被编译器转换后的 C++ 代码:

  clang -rewrite-objc main.m -o main.cpp

这会把所有 Block 转换为对应的结构体和函数,帮助你理解底层原理。

输出中你会看到:
  - __main_block_impl_0 结构体(Block 的结构体)
  - __main_block_func_0 函数(Block 的 invoke 函数)
  - __main_block_desc_0 结构体(Block 的 descriptor)
  - __Block_byref_xxx 结构体(__block 变量的结构体)

注意:
  这个命令生成的代码是简化版的,和 ARC 实际编译结果有差异。
  它主要用于学习和理解原理,不是 100% 精确的编译输出。
  如果需要精确的汇编输出,使用 Xcode 的 Product → Perform Action → Assemble。

十一、Block 与 Swift 的桥接

11.1 OC Block 在 Swift 中的映射

OC Block 类型与 Swift 闭包类型的对应关系:

  OC:    void(^)(void)                → Swift: () -> Void
  OC:    void(^)(int, NSString *)     → Swift: (Int32, String) -> Void
  OC:    NSString *(^)(int)           → Swift: (Int32) -> String
  OC:    void(^)(BOOL *stop)          → Swift: (UnsafeMutablePointer<ObjCBool>) -> Void

桥接规则:
  ① OC 的 Block 类型自动桥接为 Swift 的 @convention(block) 闭包
  ② 参数和返回值类型按照 Swift-OC 桥接规则转换
  ③ nullable Block 映射为 Optional 闭包

在 Swift 中使用 OC Block API:
  OC 定义:
    - (void)fetchDataWithCompletion:(void(^)(NSArray *data, NSError *error))completion;
  
  Swift 调用:
    obj.fetchData { data, error in
        // data 是 [Any]?,error 是 Error?
    }

逃逸标注的影响:
  OC 中标注了 NS_NOESCAPE 的 Block 参数 → Swift 中映射为非逃逸闭包
  OC 中未标注的 Block 参数 → Swift 中映射为 @escaping 闭包

这意味着:
  如果 OC API 的 Block 参数是同步使用的,应该标注 NS_NOESCAPE
  这样 Swift 调用者不需要写 self.(非逃逸闭包不捕获 self 的强引用)

11.2 Swift 闭包在 OC 中的使用

Swift 闭包可以桥接到 OC Block,但有限制:

可以桥接的:
  ✅ 不捕获泛型类型的闭包
  ✅ 使用 @convention(block) 标注的闭包
  ✅ 返回值和参数都是 OC 可表达类型

不能桥接的:
  ❌ 捕获了 Swift 特有类型(如 struct、enum with associated values)
  ❌ 使用了 Swift only 的特性(如 throws、async)
  
@convention(block) 的作用:
  告诉 Swift 编译器:"把这个闭包按照 OC Block 的 ABI 来生成"
  而不是 Swift 原生闭包的 ABI

  let block: @convention(block) (Int) -> String = { num in
      return "\(num)"
  }
  
  这个闭包现在是一个合法的 OC Block 对象
  可以传给任何接受 Block 的 OC API

11.3 Swift 闭包 vs OC Block 的关键差异

┌───────────────────┬─────────────────────┬────────────────────────┐
   维度                OC Block              Swift 闭包            
├───────────────────┼─────────────────────┼────────────────────────┤
 默认捕获方式        值捕获                引用捕获                
 修改外部变量        需要 __block           默认就可以              
 存储位置          //全局             堆上(编译器优化除外)    
 逃逸标注           无(手动注意)          @escaping 编译器强制     
 循环引用处理        weakSelf/strongSelf   [weak self] 捕获列表     
 nil 安全           nil Block 调用 crash   Optional 闭包安全       
 类型系统           弱类型(仅 runtime)    强类型(编译期检查)     
 内存管理           ARC + 手动 copy       ARC                 
└───────────────────┴─────────────────────┴────────────────────────┘

Swift 的设计吸取了 OC Block 的教训:
  - 默认引用捕获  不需要 __block 的心智负担
  - @escaping 强制标注  编译期就发现逃逸问题
  - 闭包直接在堆上  没有栈堆迁移的复杂性
  - 捕获列表 [weak self]  比 weakSelf/strongSelf 更简洁
  - Optional 闭包  nil 安全,不会 crash

十二、Block 的常见坑与反模式

12.1 十大常见 Block 陷阱

陷阱 1:忘记 Block 可以为 nil
  self.completionHandler(result);
  // 如果 completionHandler 为 nil → crash!
  
  正确做法:
  if (self.completionHandler) {
      self.completionHandler(result);
  }

─────────────────────────────────────────

陷阱 2:在 dealloc 中依赖 weakSelf
  __weak typeof(self) weakSelf = self;
  self.block = ^{
      [weakSelf cleanup];  // dealloc 流程中 weakSelf 可能已经是 nil!
  };
  
  dealloc 时 weak 引用可能已经被清零(取决于时机)

─────────────────────────────────────────

陷阱 3:Block 内创建局部 strong 引用后以为不会循环引用
  __weak typeof(self) weakSelf = self;
  self.block = ^{
      __strong typeof(weakSelf) strongSelf = weakSelf;
      strongSelf.anotherBlock = ^{
          [strongSelf doSomething];  // strongSelf 是局部变量,但被内层 Block 捕获
          // self → block → 外层 Block → strongSelf 被内层 Block 捕获为强引用
          // 内层 Block 被赋值给 self.anotherBlock → 形成循环引用!
      };
  };
  
  Weak-Strong Dance 只保护一层,嵌套 Block 需要再次 weak!

─────────────────────────────────────────

陷阱 4:在栈 Block 出作用域后使用(MRC)
  void(^block)(void);
  {
      int x = 42;
      block = ^{ NSLog(@"%d", x); };
  }
  block();  // crash!Block 在栈上,出作用域后失效

─────────────────────────────────────────

陷阱 5:误以为 copy 会创建独立副本
  void(^block)(void) = ^{ NSLog(@"hello"); };
  void(^block2)(void) = [block copy];
  // block2 == block(堆上 Block copy 只增加引用计数)
  // 不像 NSMutableArray copy 会创建新对象

─────────────────────────────────────────

陷阱 6:dispatch_after 的 Block 延长了对象生命周期
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC), 
                 dispatch_get_main_queue(), ^{
      [self doSomething];  // self 至少活到 30 秒后
  });
  
  用户关闭页面后,ViewController 30 秒内不会被释放
  这不是"循环引用",但是"生命周期延长"

─────────────────────────────────────────

陷阱 7:在 Block 内使用 C 数组
  int arr[3] = {1, 2, 3};
  void(^block)(void) = ^{
      NSLog(@"%d", arr[0]);  // 编译错误!C 数组不能被 Block 捕获
  };
  
  C 数组不是一等公民,不能被值拷贝
  解决:使用 __block 修饰,或者用 NSArray/指针代替

─────────────────────────────────────────

陷阱 8:同步 Block 中的 return 语义
  - (BOOL)check {
      [array enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {          if ([obj isKindOfClass:[NSString class]]) {
              return;  // 这个 return 只退出 Block,不退出 check 方法!
          }
      }];
      return NO;  // 无论如何都会执行到这里
  }
  
  Block 内的 return 退出的是 Block,不是外层方法

─────────────────────────────────────────

陷阱 9:多次调用一次性 Block
  if (self.completion) {
      self.completion(result);
      self.completion(result);  // 二次调用!可能导致重复操作
  }
  
  最佳实践:调用后立即置 nil
  if (self.completion) {
      void(^completion)(id) = self.completion;
      self.completion = nil;
      completion(result);
  }

─────────────────────────────────────────

陷阱 10:忽略 Block 的 copy 开销(性能敏感场景)
  for (int i = 0; i < 10000; i++) {
      self.handler = ^{ ... };  // 每次循环都创建新 Block 并 copy 到堆上
  }
  
  在热路径中频繁创建和赋值 Block 可能导致性能问题
  解决:Block 不变时在循环外创建一次

12.2 Block 的最佳实践总结

✅ DO(推荐做法):
  1. Block 属性用 copy(即使 ARC 下 strong 等效,copy 更清晰)
  2. 使用 Weak-Strong Dance 处理循环引用
  3. 调用 Block 前检查是否为 nil
  4. Completion Block 调用后置 nil
  5. 使用 typedef 为复杂 Block 类型定义别名
  6. API 中的 Block 参数注明是否逃逸(NS_NOESCAPE)
  7. 嵌套 Block 中的每一层都检查循环引用

❌ DON'T(避免做法):
  1. 不要在 Block 内直接访问 ivar(容易隐式捕获 self)
  2. 不要用 __unsafe_unretained 代替 __weak(野指针风险)
  3. 不要在 ARC 下用 __block 来打破循环引用(ARC 下 __block 会 retain)
  4. 不要在不确定是否为 nil 的情况下直接调用 Block
  5. 不要在高频回调中频繁创建新 Block
  6. 不要在 Block 内做耗时操作而不考虑线程
  7. 不要忘记 NSTimer/NotificationCenter 的 Block 生命周期管理

十三、Block vs Delegate vs Notification —— 如何选择

三种回调机制的对比:

┌──────────────┬──────────────┬──────────────┬──────────────┐
│   维度        │   Block       │   Delegate    │ Notification │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 关系         │ 一对一        │ 一对一        │ 一对多       │
│ 耦合度       │ 高(代码内联)│ 低(协议隔离)│ 最低(解耦)  │
│ 代码位置     │ 就地书写      │ 分散在方法中  │ 分散 + 跨文件│
│ 类型安全     │ 中            │ 高            │ 低           │
│ 内存风险     │ 循环引用      │ 较少          │ 忘记 remove  │
│ 适合场景     │ 一次性回调    │ 多方法协议    │ 广播通知     │
│ 调试难度     │ 较容易        │ 中            │ 难追踪       │
└──────────────┴──────────────┴──────────────┴──────────────┘

选择建议:

  单一回调(如网络请求完成)→ Block
    优势:代码集中,上下文清晰
    例:[api fetchDataWithCompletion:^(Data *d) { ... }];

  多个回调方法(如 UITableView 的数据源)→ Delegate
    优势:职责明确,方法独立,可选实现
    例:UITableViewDataSource 有多个 required/optional 方法

  一对多广播(如登录状态变化通知所有页面)→ Notification
    优势:完全解耦,任意对象可监听
    例:用户登出后通知所有页面刷新

  混合使用:实际项目中经常组合使用
    例:网络层用 Block 回调给业务层,
        业务层通过 Notification 广播给 UI 层

十四、知识体系脑图

Block
├── 本质
│   ├── C 语言扩展(Clang 实现)
│   ├── 匿名函数 + 上下文捕获
│   ├── 底层是 OC 对象(有 isa)
│   ├── 编译期变换:源码 → 结构体 + 函数
│   └── 默认值捕获的设计哲学(Pit of Success)
│
├── 底层结构
│   ├── Block_layout 结构体
│   │   ├── isa → 类型标识(Global/Stack/Malloc)
│   │   ├── flags → 位域(引用计数 + 多种标志)
│   │   ├── invoke → 函数指针(隐含 self 参数)
│   │   └── descriptor → 多态结构(size/copy/dispose/signature)
│   ├── 捕获变量存储在结构体尾部(动态大小)
│   └── 类型编码(Type Encoding)与 NSMethodSignature
│
├── 三种类型
│   ├── GlobalBlock(不捕获局部变量,在数据段)
│   ├── StackBlock(捕获变量,在栈上,生命周期同栈帧)
│   ├── MallocBlock(copy 后在堆上,引用计数管理)
│   ├── 为什么有栈?性能优化,避免不必要的 malloc
│   └── 逃逸与非逃逸的概念
│
├── 变量捕获(核心难点)
│   ├── 局部基本类型 → 值拷贝(快照语义)
│   ├── 局部对象 → 指针拷贝 + ARC 内存管理(strong/weak)
│   ├── __block → 封装为 __Block_byref 结构体
│   │   ├── __forwarding 保证栈堆访问一致性
│   │   ├── 多 Block 共享时引用计数管理
│   │   └── MRC vs ARC 下对对象的不同行为
│   ├── 静态变量 → 指针捕获(地址不变,无需拷贝值)
│   ├── 全局变量 → 不捕获(直接按地址访问)
│   └── self 的隐式捕获(访问 ivar 也会捕获 self)
│
├── 循环引用
│   ├── 本质:强引用环导致引用计数无法归零
│   ├── 直接环 vs 间接环(多层持有链)
│   ├── __weak(打破强引用)
│   ├── __weak + __strong(Weak-Strong Dance,保证执行完整性)
│   ├── NSTimer 的特殊循环引用
│   ├── 判断标准:从 self 出发能否画回 self
│   └── 检测方法(Memory Graph / Instruments / MLeaksFinder)
│
├── 内存管理
│   ├── copy 链:malloc → memcpy → copy_helper → retain/weak/递归copy
│   ├── dispose 链:dispose_helper → release/destroyWeak → free
│   ├── 引用计数存储在 flags 位域中(非 SideTable)
│   ├── ARC 下编译器自动 copy5 种场景)
│   └── copy vs strong 属性修饰符的选择
│
├── 线程安全
│   ├── Block 对象本身的线程安全性(只读 + 原子引用计数)
│   ├── 捕获变量的线程安全问题
│   ├── __block 变量的数据竞争
│   └── GCD + Block 的常见线程安全模式
│
├── 性能
│   ├── 创建:栈≈0 / 堆=malloc+copy / 全局=0
│   ├── 调用:比 objc_msgSend 快,比直接调用慢一点
│   ├── 销毁:栈=0 / 堆=dispose+free
│   ├── 编译器优化:内联、全局提升、copy 消除
│   └── 高频场景的优化建议
│
├── 框架应用
│   ├── GCD(dispatch_continuation_t 封装)
│   ├── RunLoop(Block 链表管理)
│   ├── Notification/KVO(Block 生命周期陷阱)
│   ├── Associated Object(COPY 策略注意循环引用)
│   └── KVO 新 API 的设计改进
│
├── 调试技巧
│   ├── lldb 命令查看 Block 类型和捕获信息
│   ├── 汇编层面识别 Block 调用
│   ├── 常见 Block Crash 类型及排查
│   └── clang -rewrite-objc 查看编译产物
│
├── Swift 桥接
│   ├── OC Block → Swift 闭包的类型映射
│   ├── @convention(block) 的作用
│   ├── NS_NOESCAPE 对 Swift 侧的影响
│   └── Swift 闭包 vs OC Block 的关键差异
│
├── 常见坑与反模式
│   ├── nil Block 调用 crash
│   ├── 嵌套 Block 的循环引用
│   ├── dispatch_after 延长对象生命周期
│   ├── Block 内 return 的语义
│   ├── C 数组不能被 Block 捕获
│   └── 一次性 Block 多次调用
│
└── 设计选择
    ├── Block vs Delegate vs Notification 的取舍
    └── Block 回调的最佳实践

十五、面试高频考点速查

问题 核心答案
Block 的本质是什么? 封装了函数指针和捕获变量的 OC 对象(结构体),有 isa 指针
Block 的 invoke 函数有什么特点? 第一个隐含参数是 Block 结构体自身指针,类似 OC 的 self
Block 有几种类型?怎么判定? Global(不捕获局部变量)、Stack(捕获了,在栈上)、Malloc(copy 后在堆上)
什么时候 Block 会从栈 copy 到堆? 作为返回值、赋值给 strong 变量、传给 GCD、ARC 编译器自动处理
为什么要有栈 Block? 性能优化,临时 Block 无需 malloc/free,生命周期随栈帧
Block 的引用计数存在哪里? flags 字段的 bit 1~15,不在 SideTable 中
为什么局部变量在 Block 内不能修改? 捕获的是值副本,修改副本无意义且语义混乱,编译器直接禁止
__block 的底层原理? 将变量封装为 __Block_byref 堆上结构体,通过 __forwarding 指针保证栈堆访问一致
__forwarding 为什么存在? 解决 Block copy 后栈上代码和堆上 Block 访问同一个 __block 变量的一致性问题
多个 Block 捕获同一个 __block 变量会怎样? 共享同一个堆上 __Block_byref 结构体,引用计数管理
__block 在 MRC 和 ARC 下有什么区别? MRC 下不 retain 对象(可打破循环引用),ARC 下会 retain(不能打破循环引用)
访问 ivar 会捕获 self 吗? 会,编译器将 _name 转为 self->_name,隐式强引用捕获 self
循环引用怎么产生的? self 持有 Block,Block 强引用捕获 self,形成引用环
__weak__unsafe_unretained 区别? weak 对象释放后自动置 nil;unsafe_unretained 不置 nil,可能野指针
Weak-Strong Dance 的意义? weak 避免循环引用,strong 保证 Block 执行期间 self 不被中途释放
Block 捕获 self 一定循环引用吗? 不一定,只有 self(直接或间接)持有 Block 时才会形成环
Block 和 C 函数指针的区别? Block 能捕获上下文,是 OC 对象,参与 ARC;函数指针都不能
Block 和 Swift 闭包的主要区别? Block 默认值捕获,Swift 默认引用捕获;Block 有栈→堆迁移,Swift 闭包直接在堆上
Block 调用比 objc_msgSend 快还是慢? 快,Block 通过函数指针直接调用,省去了方法查找(SEL→IMP)的过程
Block 为 nil 时调用会怎样? Crash(EXC_BAD_ACCESS),不像 OC 消息发送给 nil 是安全的
_Block_object_assign 做了什么? 根据 flags 对捕获变量做 retain/initWeak/递归copy/byref_copy 等操作
GCD Block 需要 weakSelf 吗? 通常不需要,因为 self 不持有 GCD Block,不成环。但长延时的 dispatch_after 会推迟 self 释放
Block 的 copy 属性在 ARC 下还有意义吗? 功能上 strong 等效,但 copy 更具自文档性,是社区推荐写法
如何检测循环引用? Xcode Memory Graph Debugger、Instruments Leaks/Allocations、MLeaksFinder
什么是逃逸 Block? 超出创建它的函数作用域后仍可能被调用的 Block,必须 copy 到堆上
NSTimer 为什么容易循环引用? NSTimer 强引用 target,RunLoop 强引用 timer,即使 self 不强引用 timer,仍可能泄漏
Block 线程安全吗? Block 对象本身线程安全(只读),但捕获的可变对象的操作不是线程安全的

单元测试系列:如何测试不愿暴露的私有状态

引言

在多人协作的大型工程中,编写单元测试时有一个绑定出现的矛盾:生产代码追求封装,尽可能把实现细节藏在 private 后面;测试代码需要观测和控制内部状态,才能验证逻辑是否正确。

当你依赖的某个属性被另一个团队从 internal 改成了 private,你的测试就从"编译通过"变成了"编译失败"。这在日常开发中反复上演。

本文聚焦于一个具体问题:当被测对象的关键状态被封装为私有时,测试该怎么写? 我们按照推荐优先级介绍三种模式,并讨论如何让这类测试在多人协作中保持可维护。


一、问题的本质

以一个典型场景为例:一个管理器(Manager)内部持有若干数据源(DataSource),它的公有方法 totalCount() 聚合所有数据源的计数。你想测试的是聚合逻辑——非子集的数据源应该被加总,子集的应该被排除。

要测试这个逻辑,你需要让不同数据源返回不同的计数值。但数据源存储在管理器的 private 属性中,计数值也是数据源的 private 属性。你"两头都摸不到"。

直觉的反应是"把它改成 internal 就好了"——但在多人协作的项目中,放松封装来迁就测试往往会带来更大的问题。被放松的接口会被其他模块误用,而且你也不一定有权修改别人的代码。

所以我们需要在不修改生产代码的前提下解决这个问题。


二、三种模式

模式一:通过公有行为间接验证

原则:不直接访问私有状态,而是通过被测对象的公有方法观察其行为。

// 不要直接读取私有变量
XCTAssertEqual(object.privateCount, 10)

// 通过公有接口验证行为
object.performAction()
XCTAssertEqual(object.publicResult(), expectedValue)

适用场景:被测对象有足够的公有 API 来覆盖验证需求。

局限:当你需要设置特定的内部状态来测试某个分支时(例如"当未读数为 10 时,聚合应返回 10"),仅靠公有 API 可能无法将对象置于期望状态。此时需要下一个模式。

模式二:子类覆写

当需要控制被测对象内部依赖的返回值时,可以在测试文件中定义一个子类,覆写返回内部状态的方法:

private class StubDataSource: RealDataSource {
    private var mockValues: [QueryType: Int] = [:]

    func setValue(_ value: Int, for type: QueryType) {
        mockValues[type] = value
    }

    override func getValue(for type: QueryType) -> Int {
        return mockValues[type] ?? 0
    }
}

被测对象调用 getValue(for:) 时,实际执行的是 Stub 的逻辑,返回我们预设的值。不需要修改任何生产代码。

适用条件:被覆写的方法是非 final 的。

注意:Stub 子类应保持轻量,只覆写必要的方法。如果父类初始化有副作用(注册通知、启动定时器等),需要留意。

模式三:运行时注入

模式二解决了"让依赖返回可控值"的问题,但还有一个问题:如何把 Stub 塞进被测对象?

理想情况下,被测对象应通过构造器或属性注入依赖。但在大型存量项目中,很多类的依赖是内部创建并存储在 private 属性中的,没有公开的注入点。

此时,对于 NSObject 子类,可以借助 Objective-C 运行时直接操作 ivar:

private func setIvar<T>(_ name: String, on object: AnyObject, value: inout T) {
    let cls: AnyClass = type(of: object)
    guard let ivar = class_getInstanceVariable(cls, name) else {
        XCTFail("Cannot find ivar '(name)' — property may have been renamed.")
        return
    }

    let actualSize = computeIvarSize(ivar, in: cls)
    guard actualSize == MemoryLayout<T>.size else {
        XCTFail("Ivar '(name)' size mismatch — type may have changed.")
        return
    }

    let offset = ivar_getOffset(ivar)
    let ptr = Unmanaged.passUnretained(object).toOpaque().advanced(by: offset)
    ptr.assumingMemoryBound(to: T.self).pointee = value
}

这段代码包含两层防护,对应两类变更场景:

生产代码变更 防护机制 测试行为
属性被改名 class_getInstanceVariable 返回 nil XCTFail + 安全返回
属性名不变,类型变了 MemoryLayout<T>.size 与 ivar 实际大小不匹配 XCTFail + 安全返回

这确保了不论生产代码如何变化,测试都报错(failure)而非崩溃(crash)

限制:仅适用于 NSObject 子类。纯 Swift 类没有 ObjC 运行时元数据,此方法不可用。

选择优先级

优先级 模式 条件 风险
1 通过公有行为验证 公有 API 足以覆盖
2 子类覆写 方法非 final
3 运行时注入 NSObject 子类,无注入点 中(需防护)

模式三是"最后手段",不是常规工具。如果你发现自己频繁使用它,更值得推动的是让生产代码提供正式的依赖注入接口。


三、让这类测试在协作中存活

解决了技术问题之后,还有一个同样重要的问题:在多人协作的环境中,这些测试能否被团队中的其他人理解和维护?

3.1 封装脆弱操作,暴露清晰意图

运行时注入是"脆弱"的——它依赖属性名字符串、内存布局等编译器无法检查的假设。关键原则是:把所有脆弱操作封装在一个辅助方法中,让每个测试方法只表达业务意图。

// 辅助方法封装了所有运行时细节
private func injectStubDataSources(_ entries: [(id: String, isSubset: Bool, ds: StubDataSource)]) {
    // ... runtime injection logic ...
}

// 测试方法只表达意图
func test_totalCount_excludesSubset() {
    let primary = makeStub(count: 10)
    let subset = makeStub(count: 5)
    injectStubDataSources([
        (id: "primary", isSubset: false, ds: primary),
        (id: "filter",  isSubset: true,  ds: subset)
    ])

    XCTAssertEqual(manager.totalCount(), 10)
}

这样做的好处:

  • 单点维护:属性改名或类型变更时,只需修改 injectStubDataSources 一处。

  • 可读性:团队成员读到测试方法时,看到的是"注入一个非子集数据源(10)和一个子集数据源(5),期望聚合结果为 10",不需要理解运行时细节。

3.2 用命名传递信息

在多人协作中,测试方法名是最重要的"文档"。一个好的命名应该在不打开方法体的情况下就能传达:测什么、在什么条件下、期望什么结果。

test_[被测方法]_[场景]

test_totalCount_excludesSubsetDataSources
test_totalCount_multipleDataSources_sumsNonSubset
test_totalCount_allSubset_returnsZero

当这些测试出现在 CI 的失败报告中时,任何人——即使从未接触过这个模块——都能从方法名推断出问题所在。

3.3 报错,不要崩溃

这是使用 unsafe 技术时最重要的设计原则。

  • 失败(Failure) :CI 报告中标注 test_totalCount_excludesSubset FAILED: Cannot find ivar 'dataContextMap'。开发者 5 秒内定位问题。

  • 崩溃(Crash) :CI 报告中只有 EXC_BAD_ACCESS (code=1, address=0x...)。开发者需要调试半小时。

每一步 unsafe 操作前都必须有 guard ... else { XCTFail(...); return } 的防护链。没有例外。


小结

处理私有成员的测试难题,本质上是在封装性可测试性之间找到平衡。在不修改生产代码的前提下,三种模式提供了递进的解决方案:

  1. 优先通过公有行为验证——最安全,零风险。

  2. 子类覆写控制返回值——利用多态,风险低。

  3. 运行时注入作为兜底——突破封装,但必须有防护。

技术方案之外,同样重要的是协作层面的设计:将脆弱操作封装在一处、用命名传递意图、确保测试报错而非崩溃。这些原则让测试不仅"能用",而且"能活"——在团队成员轮换、生产代码持续演进的环境中,持续发挥保护作用。

# Flutter Engine、Dart VM、Runner、iOS 进程与线程 —— 深度解析

一、整体架构总览

┌─────────────────────────────────────────────────────────┐
│                    iOS 进程 (Process)                     │
│  ┌───────────────────────────────────────────────────┐  │
│  │              Runner (iOS Host App)                 │  │
│  │  ┌─────────────────────────────────────────────┐  │  │
│  │  │           FlutterEngine 实例                  │  │  │
│  │  │  ┌───────────────────────────────────────┐  │  │  │
│  │  │  │            Dart VM                     │  │  │  │
│  │  │  │  ┌─────────────────────────────────┐  │  │  │  │
│  │  │  │  │       Dart Isolate (main)        │  │  │  │  │
│  │  │  │  │    (你写的 Dart 业务代码)          │  │  │  │  │
│  │  │  │  └─────────────────────────────────┘  │  │  │  │
│  │  │  │  ┌─────────────────────────────────┐  │  │  │  │
│  │  │  │  │    Dart Isolate (spawned)        │  │  │  │  │
│  │  │  │  │    (compute / Isolate.spawn)     │  │  │  │  │
│  │  │  │  └─────────────────────────────────┘  │  │  │  │
│  │  │  └───────────────────────────────────────┘  │  │  │
│  │  │                                             │  │  │
│  │  │  ┌──────────┐ ┌──────────┐ ┌────────────┐  │  │  │
│  │  │  │ UI Thread│ │IO Thread │ │ GPU Thread  │  │  │  │
│  │  │  │(Platform)│ │          │ │ (Raster)    │  │  │  │
│  │  │  └──────────┘ └──────────┘ └────────────┘  │  │  │
│  │  └─────────────────────────────────────────────┘  │  │
│  │                                                    │  │
│  │  ┌──────────────────────────────────────────────┐ │  │
│  │  │  Native iOS 代码 (AppDelegate, ViewController)│ │  │
│  │  └──────────────────────────────────────────────┘ │  │
│  └───────────────────────────────────────────────────┘  │
│                                                          │
│  iOS Main Thread ─── GCD Queues ─── 其他系统线程           │
└─────────────────────────────────────────────────────────┘

二、逐层深度解析

2.1 iOS 进程 (Process)

iOS 进程是最顶层的容器,是操作系统分配资源的基本单位。

  • 每个 iOS App 运行在独立的沙盒进程中,由 launchd 守护进程启动
  • 进程拥有独立的虚拟内存空间(通常 4GB 虚拟地址空间)
  • 进程内包含:代码段、数据段、堆、栈、内存映射区域
  • 一个进程内可以有多个线程

关键点: 无论你的 Flutter App 多复杂,在 iOS 上它始终运行在一个进程内。Runner、FlutterEngine、Dart VM 都是这个进程内的组成部分。

iOS 进程
├── 进程内存空间
│   ├── Runner 的 Native 代码 (Objective-C/Swift)
│   ├── Flutter Engine 的 C++ 代码
│   ├── Dart VM 的运行时
│   ├── Dart 堆内存 (Dart Heap)
│   └── 共享库 (dylib)
├── 文件描述符表
├── 信号处理表
└── 线程表 (所有线程)

2.2 iOS 线程 (Thread)

线程是CPU 调度的基本单位,同一进程内的线程共享内存空间。

iOS 中的关键线程类型

线程 说明
Main Thread UI 线程,所有 UIKit 操作必须在此执行
GCD Worker Threads libdispatch 管理的线程池
pthread POSIX 线程,底层线程创建方式

Flutter 创建的线程

Flutter Engine 启动后会在 iOS 进程内创建4 个核心线程(Task Runner):

┌──────────────────────────────────────────────────────┐
│                  Flutter 四大线程                       │
├──────────────┬───────────────────────────────────────┤
│ Platform     │ 复用 iOS Main Thread                    │
│ Thread       │ 处理 Platform Channel、插件调用、原生交互   │
├──────────────┼───────────────────────────────────────┤
│ UI Thread    │ 独立 pthread                            │
│ (Dart Thread)│ 运行 Dart 代码、构建 Widget Tree/Layer Tree│
├──────────────┼───────────────────────────────────────┤
│ Raster       │ 独立 pthread                            │
│ Thread       │ GPU 光栅化,将 Layer Tree 转为 GPU 指令    │
├──────────────┼───────────────────────────────────────┤
│ IO Thread    │ 独立 pthread                            │
│              │ 图片解码、资源加载等耗时 IO 操作             │
└──────────────┴───────────────────────────────────────┘

重要区分: Flutter 的 "UI Thread" 不是 iOS 的 Main Thread。Flutter 的 Platform Thread 才是 iOS 的 Main Thread。

2.3 Runner (iOS Host App)

Runner 是 Flutter 工程中 ios/Runner 目录下的 iOS 宿主工程,本质上就是一个标准的 iOS App。

Runner 的职责

Runner (Xcode Project)
├── AppDelegate.swift / .m
│   └── 创建 FlutterEngine / FlutterViewController
├── Info.plist
│   └── App 配置、权限声明
├── Assets.xcassets
│   └── App Icon、LaunchImage
├── Main.storyboard (可选)
│   └── LaunchScreen
└── Frameworks/
    ├── Flutter.framework        ← Flutter Engine 二进制
    └── App.framework            ← 编译后的 Dart 代码 (AOT)

Runner 的生命周期

iOS 系统启动进程
    │
    ▼
main() 函数执行
    │
    ▼
UIApplicationMain() 
    │
    ▼
AppDelegate 初始化
    │
    ├── 创建 FlutterEngine 实例
    │       │
    │       ├── 初始化 Dart VM
    │       ├── 创建 4 个 Task Runner (线程)
    │       └── 加载 Dart AOT snapshot
    │
    ├── 创建 FlutterViewController
    │       │
    │       ├── 关联 FlutterEngine
    │       ├── 创建 Metal/OpenGL 渲染表面
    │       └── 注册 Platform Channels
    │
    └── App 进入 RunLoop

Runner 与 FlutterEngine 的关系

// 典型的 AppDelegate 代码
@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
    // FlutterAppDelegate 内部持有 FlutterEngine
    // 它本质上是一个 UIApplicationDelegate 的实现
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // 此时 FlutterEngine 已经被创建并启动
        // Dart VM 已经初始化
        // main.dart 的 main() 已经开始执行
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

核心理解:Runner 是"壳",FlutterEngine 是"核心"。 Runner 负责 iOS 层面的事务(权限、推送、深链接等),FlutterEngine 负责 Flutter 的一切。

2.4 Flutter Engine

Flutter Engine 是用 C/C++ 编写的核心运行时,是连接 Dart 代码和底层操作系统的桥梁。

Engine 的组成

FlutterEngine
├── Dart Runtime (Dart VM)
│   ├── Dart Isolate 管理
│   ├── GC (垃圾回收器)
│   └── JIT / AOT 编译器
│
├── Shell (壳层)
│   ├── Platform Shell (iOS 适配层)
│   │   ├── Metal 渲染后端
│   │   ├── 触摸事件转发
│   │   └── 系统 API 桥接
│   └── Task Runner 管理
│       ├── PlatformTaskRunner → iOS Main Thread
│       ├── UITaskRunner → Dart 执行线程
│       ├── RasterTaskRunner → GPU 光栅化线程
│       └── IOTaskRunner → IO 线程
│
├── Skia / Impeller (图形引擎)
│   ├── 2D 渲染 API
│   ├── 文字排版 (txt 库 / libtxt)
│   └── 图片解码
│
├── Text Layout (文字排版引擎)
│
└── Platform Channel 机制
    ├── MethodChannel
    ├── BasicMessageChannel
    └── EventChannel

Engine 在 iOS 中的存在形式

Flutter.framework (约 40~50 MB, Release 模式)
├── Flutter (Mach-O 动态库)
│   ├── Dart VM 运行时
│   ├── Skia / Impeller 图形库
│   ├── Shell 层 (iOS 适配)
│   └── ICU 国际化数据
└── Headers/
    ├── FlutterEngine.h
    ├── FlutterViewController.h
    ├── FlutterPlugin.h
    └── FlutterChannels.h

App.framework (Dart 业务代码编译产物)
├── App (Mach-O 动态库)
│   ├── Dart AOT Snapshot (机器码)
│   └── 资源数据
└── flutter_assets/
    ├── kernel_blob.bin (Debug 模式)
    ├── vm_snapshot_data
    ├── isolate_snapshot_data
    └── AssetManifest.json

2.5 Dart VM

Dart VM 是 FlutterEngine 内部的虚拟机运行时,负责执行 Dart 代码。

Dart VM 的两种运行模式

┌─────────────────────────────────────────┐
│              Debug 模式 (JIT)              │
│                                          │
│  Dart 源码 → Kernel Binary → JIT 编译     │
│           → 解释执行 + 热编译为机器码        │
│                                          │
│  特点:支持 Hot Reload / Hot Restart       │
│       有 Dart VM 完整编译器                 │
│       性能较低                             │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│            Release 模式 (AOT)              │
│                                          │
│  Dart 源码 → AST → IR → ARM64 机器码       │
│           → 直接 CPU 执行                  │
│                                          │
│  特点:不支持 Hot Reload                   │
│       无 JIT 编译器 (体积更小)              │
│       性能接近原生                          │
└─────────────────────────────────────────┘

Dart VM 的内存结构

Dart VM 内存布局
├── New Space (新生代)
│   ├── Semi-Space A (活跃区)
│   └── Semi-Space B (备用区)
│   └── Scavenger GC (复制算法, STW 很短)
│
├── Old Space (老生代)
│   ├── 大对象区
│   └── 普通对象区
│   └── Mark-Sweep / Mark-Compact GC
│
├── Code Space (代码区)
│   └── AOT 编译后的机器码
│
├── Image Space (镜像区)
│   ├── vm_snapshot_data
│   └── isolate_snapshot_data
│
└── Isolate 独有内存
    ├── 每个 Isolate 有独立的堆
    └── Isolate 间不共享内存 (通过消息传递通信)

Dart Isolate 与 iOS 线程的关系

┌─────────────────────────────────────────────────┐
│                                                  │
│  Dart Isolate ≠ iOS Thread                       │
│  但 Dart Isolate 运行在 iOS Thread 之上            │
│                                                  │
│  ┌─────────────┐      ┌─────────────────┐       │
│  │ Root Isolate │ ──→  │ UI Thread        │       │
│  │ (main)       │      │ (固定绑定)        │       │
│  └─────────────┘      └─────────────────┘       │
│                                                  │
│  ┌─────────────┐      ┌─────────────────┐       │
│  │ Spawned      │ ──→  │ Dart VM 线程池中   │       │
│  │ Isolate      │      │ 的某个 pthread    │       │
│  └─────────────┘      └─────────────────┘       │
│                                                  │
│  一个 Isolate 在同一时刻只在一个线程上运行          │
│  但 Dart VM 可以将 Isolate 调度到不同线程上         │
│                                                  │
└─────────────────────────────────────────────────┘

关键区别:

  • Isolate 是 Dart 的并发模型,拥有独立的堆内存,通过 SendPort/ReceivePort 通信
  • Thread 是 OS 级别的执行单元,共享进程内存
  • Root Isolate 始终运行在 Flutter 的 UI Thread
  • Isolate.spawn() 创建的 Isolate 运行在 Dart VM 管理的线程池中

三、协作流程深度解析

3.1 一帧的渲染流程

时间线 ──────────────────────────────────────────────────────→

VSync 信号到达 (16.67ms 一次 @60fps)
    │
    │  ① Platform Thread (iOS Main Thread)
    │  ┌──────────────────────────────┐
    │  │ 接收 VSync 回调               │
    │  │ 通过 CADisplayLink            │
    │  │ 通知 UI Thread 开始新一帧      │
    │  └──────────────┬───────────────┘
    │                 │
    │  ② UI Thread (Dart Thread)
    │  ┌──────────────▼───────────────┐
    │  │ 执行 Dart 代码                │
    │  │ ├── Build Phase (Widget)      │
    │  │ ├── Layout Phase (大小/位置)   │
    │  │ ├── Paint Phase (绘制指令)     │
    │  │ └── 生成 Layer Tree            │
    │  └──────────────┬───────────────┘
    │                 │
    │  ③ Raster Thread (GPU Thread)
    │  ┌──────────────▼───────────────┐
    │  │ 接收 Layer Tree               │
    │  │ ├── Skia/Impeller 光栅化      │
    │  │ ├── 生成 GPU 指令             │
    │  │ └── 提交到 Metal/OpenGL       │
    │  └──────────────┬───────────────┘
    │                 │
    │  ④ iOS 合成器 (Core Animation)
    │  ┌──────────────▼───────────────┐
    │  │ 合成 Flutter 层与原生 UI 层    │
    │  │ 提交到屏幕显示                 │
    │  └──────────────────────────────┘

3.2 Platform Channel 调用流程

Dart 代码 (UI Thread)                     Native 代码 (Platform Thread)
       │                                           │
       │  MethodChannel.invokeMethod('getBattery')  │
       │  ─────────────────────────────────────→    │
       │  [序列化为二进制消息]                         │
       │  [从 UI Thread 调度到 Platform Thread]       │
       │                                           │
       │                            ┌──────────────▼──────┐
       │                            │ iOS Main Thread      │
       │                            │ 执行原生代码          │
       │                            │ UIDevice.current     │
       │                            │   .batteryLevel      │
       │                            └──────────────┬──────┘
       │                                           │
       │  ←─────────────────────────────────────   │
       │  [结果序列化]                               │
       │  [从 Platform Thread 调度回 UI Thread]      │
       │                                           │
       ▼                                           ▼
  收到 Future 结果                            调用完成

3.3 多 Engine 场景 (Add-to-App)

iOS 进程
├── Runner
│   ├── FlutterEngine A (主引擎)
│   │   ├── Dart VM (进程内唯一,共享)  ◄──── 重要!
│   │   ├── Root Isolate A
│   │   ├── 4 个线程 (Platform/UI/Raster/IO)
│   │   └── FlutterViewController A
│   │
│   ├── FlutterEngine B (第二个引擎)
│   │   ├── Dart VM (复用同一个)  ◄──── 同一个 Dart VM
│   │   ├── Root Isolate B (独立的 Isolate)
│   │   ├── 4 个线程 (Platform共享/UI独立/Raster独立/IO独立)
│   │   └── FlutterViewController B
│   │
│   └── FlutterEngineGroup (管理多引擎)
│       └── 共享 Dart VM,减少约 99% 的额外内存开销

核心要点: 一个 iOS 进程中只有一个 Dart VM 实例,但可以有多个 FlutterEngine,每个 Engine 有自己独立的 Root Isolate。


四、关系总结图

┌─────────────────────────────────────────────────────┐
│                                                      │
│  包含关系 (从外到内):                                  │
│                                                      │
│  iOS 进程                                             │
│    └── Runner (iOS Host App)                         │
│          └── FlutterEngine                           │
│                ├── Dart VM (进程唯一)                  │
│                │     └── Dart Isolate (可多个)         │
│                ├── Skia / Impeller                    │
│                └── Shell (平台适配层)                   │
│                                                      │
│  运行关系 (线程维度):                                  │
│                                                      │
│  iOS Main Thread ═══ Flutter Platform Thread          │
│       │                    │                          │
│       │                    ├── 插件调用                 │
│       │                    ├── 原生 UI 交互             │
│       │                    └── 生命周期管理              │
│       │                                               │
│  Flutter UI Thread ─── Dart Root Isolate 运行于此       │
│       │                    │                          │
│       │                    ├── Widget 构建              │
│       │                    ├── 布局计算                 │
│       │                    └── 绘制指令生成              │
│       │                                               │
│  Flutter Raster Thread ─── GPU 光栅化于此               │
│       │                                               │
│  Flutter IO Thread ─── 资源加载/图片解码于此             │
│       │                                               │
│  Dart VM 线程池 ─── spawned Isolate 运行于此            │
│                                                      │
└─────────────────────────────────────────────────────┘

五、常见误区澄清

误区 1:Flutter 的 UI Thread 就是 iOS 的 Main Thread

错!

  • Flutter Platform Thread = iOS Main Thread
  • Flutter UI Thread 是单独的 pthread,专门运行 Dart 代码
  • 在 Dart 代码里调用 Platform Channel 时,消息从 UI Thread 发送到 Platform Thread(即 iOS Main Thread)

误区 2:Dart Isolate 就是一个线程

错!

  • Isolate 是 Dart 的并发抽象,拥有独立的堆内存
  • Isolate 运行在线程之上,但两者不是一一对应关系
  • Dart VM 内部使用线程池来调度 Isolate
  • Root Isolate 固定绑定在 UI Thread 上,但 spawned Isolate 可能被调度到不同的线程

误区 3:多个 FlutterEngine 就有多个 Dart VM

错!

  • 一个 iOS 进程中只有一个 Dart VM
  • 多个 FlutterEngine 共享同一个 Dart VM
  • 每个 FlutterEngine 有自己独立的 Root Isolate
  • 使用 FlutterEngineGroup 可以高效创建多引擎,内存开销极小

误区 4:Runner 就是 Flutter

错!

  • Runner 只是一个标准的 iOS App 工程(壳)
  • 真正的 Flutter 运行时是 Flutter.framework 中的 FlutterEngine
  • Runner 可以同时包含原生 Swift/ObjC 代码和 Flutter 页面
  • 在 Add-to-App 场景中,Runner 甚至不叫 Runner,就是你已有的 iOS 工程

六、实际影响与性能调优启示

场景 涉及组件 优化方向
界面卡顿 UI Thread (Dart) 减少 build() 复杂度,使用 const Widget
光栅化卡顿 Raster Thread 减少 saveLayerclipPath 等 GPU 密集操作
平台通信慢 Platform Thread ↔ UI Thread 减少 Channel 调用频率,批量传输数据
图片加载慢 IO Thread 预缓存、降低分辨率、使用 precacheImage
内存爆炸 Dart VM (Isolate Heap) 控制 Isolate 数量、及时释放大对象
原生插件阻塞 Platform Thread (Main Thread) 插件内部开子线程处理耗时逻辑
App 启动慢 Engine 初始化 + Dart VM 启动 预热 Engine (FlutterEngine 提前初始化)

Macbook Neo:苹果重回校园的起点 -- 肘子的 Swift 周报 #126

issue126.webp

Macbook Neo:苹果重回校园的起点

上周,苹果推出了若干新款硬件产品。与以往的发布会不同,这次发布显得异常低调。起初我只对其中新发布的显示器感兴趣,但在看到不少数码媒体对 Macbook Neo 配置的吐槽后,也不由得多留意了这款产品。相较于其“减配”的表象,我更从其精准的定价中看到了苹果重返教育市场的决心。

十几年前,苹果还曾是教育硬件市场的重要参与者。那些在校园中使用苹果设备成长起来的学生,也有相当一部分在进入社会后顺理成章地成为苹果软硬件的长期消费者。但随着谷歌持续加大在 Chromebook 上的投入,而苹果又缺乏更具价格竞争力的产品,这一以 K12 为核心的市场逐渐被对手占据(Chromebook 曾一度拿下美国基础教育市场近 60% 的份额)。这不仅让苹果损失了一部分收入,更重要的是削弱了其在青少年群体中、围绕台式机与笔记本这种计算形态所建立的品牌亲和力。相比平板设备,笔记本在教学体验、适用场景、耐用性以及 IT 集中管理等方面依然具有明显优势。

在服务优先的今天,硬件往往与生态深度绑定。Chromebook 早早培养出一大批习惯使用 Google Docs 的年轻用户。随着年龄增长与数据的积累,即便他们日后具备购买苹果设备的能力,也很难再与苹果的服务生态形成深度绑定,更难形成真正的品牌信仰。

Neo 精准的定价改变了这一局面。599的起售价、599 的起售价、499 的教育优惠,让更多孩子有机会在学校就开始使用苹果设备、拥有 Apple ID,从而顺着苹果“预设”的轨迹,逐步购买更多产品与服务。至于被广泛批评的"减配"——A18 Pro 芯片对 K12 日常使用场景而言完全足够,它缺的不是性能,而是定位本就如此。苹果用移动端芯片换来了激进的定价空间,这是一笔算得很清楚的账。

采用订阅制的 Apple Creator Studio,同样展现了苹果希望让更多人与其生态建立长期联系的野心。对于学校而言,廉价硬件+强大的创作软件套件,构成了闭环。Macbook Neo 的硬件性能或许不算强劲,但足以在每台设备约 4–5 年的生命周期中提供稳定、可用的体验,让使用者逐步融入苹果的服务体系。从这个角度来看,MacBook Neo 更像是苹果抛向 Z 世代与 Alpha 世代的一枚“生态锚点”。

太多消费者和数码博主过于聚焦于苹果产品是否炫酷、是否有创新,却忘记了苹果的来时路——教育硬件市场深植于它的基因之中,今天的成功源于数十年前的积累,而现在它需要补上最近十几年的空缺。对于本周报的读者来说,Neo 大概率不是你的菜。但这并不妨碍它成为一款极具针对性、也颇具野心的产品——不是用来赚快钱的,而是苹果为未来二十年的生态版图所做的一次长期押注。

本期内容 | 前一期内容 | 全部周报列表

原创

跨域传递 NSManagedObjectContext 为什么在 Swift 6.2 中不再报错?真正的变化不在编译器

当同一段与并发有关的代码在旧版 Xcode 中无法通过,却在新版 Xcode 26(Swift 6.2)中顺利编译时,你第一时间会想到什么?很多人最初的判断可能是 Swift 编译器的并发分析(如 Region-Based Isolation)又进化了,但现实并没有这么简单。本文记录了我最近遇到的一次非常有意思的排查过程:从测试失败出发,通过构建最小复现用例,一步步追溯到 Core Data 的 SDK interface,最终发现,问题的关键并不完全在 Swift 编译器本身,而是框架的导入语义发生了变化——在新的 SDK 中,NSManagedObjectContext 获得了 NS_SWIFT_SENDABLE 等宏标注,使其在 Swift 中拥有了 Sendable 语义。

尽管 SwiftData 是未来苹果生态最重要的持久化框架,但作为其基础的 Core Data 并没有被苹果冷落。在过去几年中,苹果一直在默默改善其在 Swift 6 中的兼容性和并发体验,这是一个很好的现象。

近期推荐

Notepad.exe — Swift 新特性的第一个实验场

Swift 6 出了新语法?Xcode 太重,Playground 又太慢。Notepad.exe 让你在 30 秒内写代码、跑结果,专注验证想法本身。支持多版本工具链切换,集成模拟器,随开随用。


Swift 语言 2 月新动态 (What's new in Swift: February 2026 Edition)

Karen ChuDave Lester 在官方博客上整理了 2026 年 2 月 Swift 社区的生态动态。内容不仅涵盖了 Swift 在 FOSDEM(全球最大开源会议)上的活跃表现,还推介了多项重磅的开源进展与 Swift Evolution 提案。其中的 SE-0506 尤为让我惊喜。该提案为 withObservationTracking 增加了 Options 参数,开发者现在可以精确控制是观察变化前(willSet)、变化后(didSet)还是对象的生命周期(如 deinit)。并且通过 withContinuousObservationTracking 无需再手动递归注册,即可实现稳定、自动循环的连续事件追踪。

SE-0506 提案的通过意义重大。它不仅完美补齐了状态追踪的时机控制和连续性能力,更标志着 Swift 原生的 Observation 已经彻底成熟——它不再仅仅是 SwiftUI 的“专属附庸”,而是真正蜕变为了 Swift 语言中足以应对各种工业级、高性能状态流调度的核心基础设施。


写在 2026 年的 macOS 输入法开发规范

vChewing 唯音输入法 的作者 ShikiSuen 基于多年深耕 macOS 输入法的开发经验,全面梳理了 InputMethodKit (IMK) 的历史包袱,以及它在 Swift 6 严格并发检查下暴露出的种种痛点。文章深入探讨了 NSConnection 的命名规范、启用沙盒的必要性、MainActor 隔离冲突,以及高频中英切换(CapsLock)导致的 ARC 拥堵、macOS 26 Liquid Glass 机制下 NSWindow 运存不释放等棘手问题。面对苹果“上古框架”与现代 Swift 并发模型的碰撞,作者没有停留在抱怨上,而是提出了一套像“风险控制模型”一样的工程规范——将控制器退化为纯转发层、把业务逻辑剥离到弱引用 Session、使用运存自查自尽、彻底避开 IMKCandidates 等。

可贵的是,ShikiSuen 基于上述思路开发并开源了 IMKSwift 库。它为 Swift 6+ 提供了 @MainActor 完整隔离的 IMKInputSessionController 基类,完美覆盖了原生 IMKInputController 的并发地雷区。如果你需要开发 macOS 桌面端应用或输入法,这个库能让你无需手动 Hack,就能写出类型安全、无 data-race 警告的现代代码,非常值得学习与使用。


SwiftUI 的洋葱式架构:Swift Effects 实践 (SwiftUI, Swift Effects: A Beautiful Onion Architecture)

在 SwiftUI 中处理数据加载状态几乎是每个应用都会面对的问题:loadingloadedfailed 三种状态往往伴随着网络请求、缓存、日志记录等副作用逻辑,很容易让视图代码逐渐变得臃肿。Salgara 在本文中提出了一种类似 Onion Architecture 的思路:通过 ViewState + Effect Handlers 将 Fetch、缓存、日志等副作用拆分为多个可组合层级,并利用 AsyncSequence 与可注入的 Effect Handler 驱动状态变化,使 UI 仅根据状态进行渲染。这样一来,视图保持纯粹,而数据获取与副作用则沿着一条清晰的“Effect 管道”逐层流动。同时,这种结构也天然具备良好的可测试性——测试代码可以直接拦截并模拟数据源返回值,从而验证完整的状态转换流程。

Salgara 坦言,这种架构目前仍然是实验性的:原型优先,并尝试将一切视为视图(Everything as a View)。随着越来越多开发者从不同角度思考并尝试构建更符合 SwiftUI 特性的架构,这类探索不仅可能让 SwiftUI 本身受益,也有机会反过来丰富整个声明式编程范式,而不再只是复制其他 UI 框架的既有实践。


Spec-Driven Development:当 AI 写代码之后

随着 Cursor、Claude Code 等 AI 编程智能体(Agent)的普及,开发者们正面临一个新的痛点:当 AI 能在几分钟内跨越几十个文件生成上千行代码时,人类该如何有效审查?又该如何应对 AI 在长流程中逐渐出现的“上下文遗忘(Context Decay)”与幻觉问题?为此,一种新的开发范式正在逐渐成形:Spec-Driven Development(SDD)。在这一模式下,开发者的主要任务不再是直接编写代码,而是定义清晰的规格(Spec),再由 AI 根据这些规格生成实现。

Snow 通过四篇文章系统梳理了这一思路:从 “Vibe Coding” 的局限出发,介绍以规格为核心的开发流程,并进一步探讨规格在未来软件工程中的角色——代码或许不再是项目的中心,而只是规格的衍生物。

在 AI 逐渐承担实现细节的时代,软件工程的重心或许正在从“写代码”转向“表达意图”。SDD 尝试在人类的模糊意图与 AI 的无差别生成之间,建立一层强有力的约束。


为 SwiftUI Preview 构建一个 Mini Linker (Building a Mini Linker for SwiftUI Previews)

在 Xcode 26.3 的 mcpbridge 提供的众多工具中,RenderPreview 可以直接返回 SwiftUI Preview 的截图,方便 AI 进行分析。对于暂时无法使用 Xcode 26.3 mcpbridge 的开发者,Hesham Salman 在本文中介绍的思路以及配套工具,同样可以实现类似的能力。其技术亮点在于利用 SwiftSyntax 构建声明依赖图,再通过 BFS 找出当前 Preview 真正需要的最小源文件集合,从而避免编译整个 App Target 带来的构建等待。

本文的精华在于思路:利用 SwiftSyntax + BFS 快速定位 Preview 依赖的代码片段。过去 SwiftSyntax 的使用门槛较高,但在 AI 辅助开发逐渐普及的今天,它正逐渐成为理解代码结构的重要基础设施。即便你不像 Hesham Salman 那样熟练掌握该工具,了解其基本能力后,也可以借助 AI 将类似思路落地——而这类工具在过去往往只属于少数熟悉编译器工具链的开发者。


Swift 的规模化实践:TelemetryDeck 的分析服务 (Swift at scale: building the TelemetryDeck analytics service)

很多人讨论 Swift on Server 时,关注点往往停留在“能不能用”,而 TelemetryDeck 给出的则是一个更实际的答案:不仅能用,而且已经支撑起一项面向开发者、每月处理超过 1600 万用户数据的 analytics 服务。Daniel Jilg 在这篇文章中回顾了团队为何选择 Swift + Vapor 构建后端,并分享了不少来自生产环境的经验:例如如何借助 Codable 简化 API 编解码与校验、为什么应让 DTO 更贴近 controller、以及为何缓存 TTL、API 版本管理和错误监控这些“老生常谈”,在规模化的生产环境中往往才是真正的护城河。


是时候告别 SwiftGen 了吗? (Get Rid of Your SwiftGen Dependency)

很长一段时间,开发者需要依赖类似 SwiftGen 这样的工具来解决 Apple 资源系统中的一个老问题:资源访问是字符串类型(stringly-typed)。无论是 localization key、图片名称还是颜色资产,一旦拼写错误,往往只能在运行时才会暴露问题。Asser Osama 指出,随着 String Catalog(.xcstrings)与 Asset Catalog Symbols 的引入与逐步完善,Xcode 已经能够在编译阶段自动生成资源符号,这种原生能力在不少现代项目中或许已经足以替代 SwiftGen。

需要说明的是,“移除依赖”的前提是项目完全运行在标准的 Xcode 生态中。Xcode 的符号生成属于构建系统内部机制,而不是 Swift 编译器或 Swift Package Manager 的能力——这意味着对于使用 Bazel、Buck 等非标准构建系统的团队来说,SwiftGen 仍然可能是更可移植、更可控的选择。

工具

SwiftUI Agent Skill

Paul Hudson 编写的 SwiftUI Agent Skill,旨在帮助开发者编写更智能、更简洁、更现代的 SwiftUI 代码。该项目发布仅两天便获得了 1k+ Star。

在过去几周中,本周报已经推荐了不少知名开发者编写的各类 Skill。尽管这些 Skill 都凝聚了作者的经验,但我仍不建议开发者直接“拿来即用”。至少应在采用前完整阅读一遍:Skill 更像是作者对自己数十甚至上百篇文章经验的提炼,而不是可以直接替代思考的“最佳实践”。开发者在理解其背后的设计思路后,再根据自己的开发习惯与项目需求进行取舍,这样才能更大地发挥它们的价值。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

iOS App 安全加固流程记录,代码、资源与安装包保护

项目上线前的安全处理,经常被放在发布流程的最后一步。很多团队在代码开发阶段关注功能实现,等到准备提交 App Store 时,才开始思考应用被反编译或资源被提取的问题。

在一个包含 Swift + Flutter 模块的项目中,我们曾经遇到过这样一个情况:测试包被外部获取后,对方直接解压 IPA,通过类名和资源目录快速定位了核心模块。那次经历之后,我们把 iOS app 保护单独整理成一套固定流程,并加入到发布前的检查清单中。

这篇文章按实际操作过程记录一个流程。工具不会只有一个,而是组合使用系统能力、命令行工具以及 Ipa Guard 等二进制处理工具。


一、检查 IPA 内部结构

在进行任何保护操作之前,可以先观察当前 IPA 包含的信息。

.ipa 文件复制一份并改名为 .zip

mv app.ipa app.zip
unzip app.zip

进入目录:

Payload/AppName.app

此时可以看到:

  • 可执行二进制文件
  • 图片资源
  • json 配置
  • HTML / JS
  • Storyboard 或 xib
  • embedded.mobileprovision

如果资源目录中存在明显业务含义的文件,例如:

vip_purchase_bg.png
subscription_config.json
payment_success.html

那么即使没有阅读代码,也能推测应用功能结构。


二、在源码阶段减少符号暴露

在 IPA 层处理之前,可以在 Xcode 构建阶段减少调试信息。

Release 配置中可以检查两个选项:

Strip Debug Symbols During Copy = YES
Deployment Postprocessing = YES

构建完成后,用命令查看二进制中的字符串:

strings AppBinary | grep ViewController

如果能看到大量业务类名,例如 OrderManagerVipViewController,说明符号仍然暴露。

源码阶段可以通过脚本或重命名策略减少可读性,但很多项目已经进入稳定阶段,不希望再修改代码结构。这时可以转向 IPA 级处理。


三、对 IPA 二进制进行符号混淆

在编译完成的情况下,可以通过 Ipa Guard 直接对 IPA 包进行处理,而不需要修改项目源码。

加载 IPA 后,工具会解析其中的 Mach-O 二进制结构,并列出类名与方法列表。

在界面中可以看到类似结构:

代码模块
 ├─ OC 类
 ├─ Swift 类
 ├─ OC 方法
 └─ Swift 方法

加载ipa

实际操作时,我们只勾选包含业务逻辑的类,例如:

OrderManager
VipSubscriptionController
PaymentService

处理后,这些名称会被替换为无意义字符串,从而降低反编译可读性。

Ipa Guard 支持 Objective-C、Swift、Flutter、Unity3D 等多种开发平台,因此混合项目也可以统一处理。


四、处理资源文件结构

代码不是唯一需要保护的内容。资源文件往往更容易暴露信息。

在 Ipa Guard 的资源模块中,可以选择处理以下文件类型:

  • 图片
  • json
  • js
  • html
  • mp3
  • xib
  • storyboard

工具会执行两类操作:

1. 文件名混淆

例如:

vip_background.png

会变为:

a9d3f21.png

这样在解包 IPA 时无法通过名称判断用途。 文件名称

2. 修改 MD5

图片或资源的 MD5 值也可以被修改,这可以打散资源特征值。

处理完成后,重新解压 IPA 可以看到所有资源名称已经变为随机字符串。 md5


五、处理 HTML 与 JS 文件

如果应用包含 H5 页面,需要额外处理 JS 与 HTML 文件。

在构建阶段可以使用前端压缩工具,例如:

terser
uglify-js

压缩完成后再由 Ipa Guard 修改资源名称。

这样做的效果是:

  • 文件内容被压缩
  • 文件名称失去语义

即使解包 IPA,也很难通过资源结构还原功能模块。


六、删除调试信息

很多项目在构建过程中会留下调试日志或符号信息。

Ipa Guard 提供调试信息清理功能,可以删除:

  • 自动注释
  • 调试符号
  • 部分字符串信息

处理后可以再次检查:

strings AppBinary

输出内容会明显减少。


七、重新签名并安装测试

任何 IPA 内容修改都会导致签名失效。

因此混淆完成后需要重新签名。

可以使用签名工具,例如:

kxsign sign my.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

参数 -i 会尝试直接安装到连接的设备。

也可以使用 Ipa Guard 内置签名模块,在混淆完成后直接选择证书并生成新 IPA。

设备测试阶段主要检查:

  • 页面加载是否正常
  • 动态调用方法是否失效
  • H5 页面是否可以打开
  • 是否出现崩溃日志

八、发布阶段生成最终 IPA

测试通过后,需要重新签名生成发布版本。

发布阶段只需要更换证书:

Distribution Certificate
App Store Provisioning Profile

生成的 IPA 将用于提交 App Store。

发布类型 IPA 不允许直接安装到设备,但可以通过 Xcode Organizer 或上传工具提交审核。


iOS app 保护并不是单一技术,而是一组连续操作:减少符号暴露、混淆代码名称、处理资源文件、清理调试信息、重新签名并验证运行。

参考链接:ipaguard.com/tutorial/zh…

iOS App 性能测试工具怎么选?使用克魔助手(Keymob)结合 Instruments 完成

在移动应用开发中,性能测试不是某个阶段才开始做的事情。很多问题在开发早期就已经发生,只是在功能逐渐增多之后才表现出来。例如:

  • 页面滚动出现卡顿
  • 内存持续增长
  • 启动时间越来越长

如果只依赖单一工具去分析这些问题,往往会比较吃力。实际项目中更常见的做法是多工具组合使用,让每个工具负责不同方面。

这里结合一次真实项目中的测试,介绍一套比较实用的 iOS App 性能测试流程。


性能测试通常关注哪些指标

在开始之前,需要先确定要观察哪些数据。常见的性能指标包括:

  • CPU 使用率
  • 内存占用
  • 帧率(FPS)
  • 网络请求
  • 应用能耗

不同阶段关注的重点会有所不同。开发阶段通常更关注函数级性能,而测试阶段更关注设备整体运行情况。


第一方面,设备本机性能监控

在很多团队里,测试人员并不一定使用 Mac 环境。如果需要在 Windows 或 Linux 上查看设备性能,就需要借助设备监控工具。

我在项目中比较常用的是 克魔助手(Keymob) 来做这方面的数据采集。

它的作用主要是:

  • 查看设备运行时 CPU / 内存 / FPS
  • 指定某个 App 进行监控
  • 记录性能变化趋势

这类监控通常用于快速发现问题出现的时间点。


使用克魔助手监控 App 性能

实际操作过程比较简单。

连接设备

准备一台测试设备,然后:

  1. 使用数据线连接 iPhone
  2. 打开克魔助手
  3. 等待设备识别完成

设备识别后可以看到当前设备信息。


进入性能图表

在左侧导航中选择:

性能图表

这里会显示设备当前的资源使用情况。


选择监控指标

在界面右上角可以选择需要观察的指标,例如:

  • CPU
  • 内存
  • FPS

如果只是测试页面流畅度,通常只需要勾选 CPU 和 FPS。 图表


指定要监控的应用

点击 选择 App

输入应用名称即可找到目标应用。 也可以同时勾选 系统总 CPU,用来判断设备整体负载。 选择app


开始测试

点击 开始 按钮之后,就可以在手机上执行测试流程,例如:

  • 打开首页
  • 滑动列表
  • 进入详情页

性能图表会实时显示资源变化。

通过观察曲线可以判断:

  • 哪个操作触发了 CPU 峰值
  • 是否出现持续高占用

第二层:深入分析工具

设备监控工具只能告诉我们问题出现在哪里,但不能直接解释原因。

当发现异常之后,通常需要回到开发工具进行深入分析。


Instruments

Instruments 是 iOS 官方提供的性能分析工具。

它可以分析:

  • 方法调用耗时
  • 内存分配
  • GPU 渲染
  • 线程状态

例如,当设备监控发现某个操作 CPU 突然升高,可以用 Instruments 再跑一次相同操作。

这样可以找到具体的函数或对象。


一个案例

有一次测试人员反馈:

“进入某个页面之后滑动明显卡顿。”

排查过程是这样的:

第一步

使用克魔助手监控 CPU 与 FPS。

发现滑动列表时 CPU 占用突然升高,同时 FPS 出现下降。


第二步

在 Mac 上使用 Instruments 重新测试。

最终定位到问题原因:

页面滚动时触发了大量图片解码。


第三步

修改代码,将图片解码改为后台线程处理。

再次测试后 CPU 曲线明显平稳。


为什么不建议只依赖一个工具

有些开发者希望找到一个全能工具,但在实际项目中很少存在这种工具。

更合理的方式通常是:

设备监控工具,用于观察设备运行情况

开发分析工具,用于定位具体代码问题

这样可以形成一个完整的测试流程。

性能测试并不是某个阶段才进行的工作,而是贯穿整个开发周期的过程。只要在每个版本发布前进行简单的性能监控,就可以提前发现很多潜在问题。

参考链接:keymob.com/tutorial/zh…

# iOS weak 原理详解

一、weak 是什么

weak 是 Objective-C / Swift 中的一种弱引用修饰符。它的核心行为只有两条:

  1. 不增加引用计数:持有对象但不影响对象的生命周期
  2. 对象释放时自动置 nil:不会产生野指针

这个"自动置 nil"是 weak 最关键的特性,也是它和 __unsafe_unretained(不置 nil,会变野指针)的根本区别。


二、为什么需要 weak

循环引用问题

两个对象互相强引用,谁都释放不了:

A 强引用 BB 的引用计数 +1B 强引用 AA 的引用计数 +1)

想释放 A → 但 B 还在引用 AA 释放不了
想释放 B → 但 A 还在引用 BB 释放不了
→ 内存泄漏

把其中一方改成 weak 就打破了循环:

A 强引用 BB 的引用计数 +1B 弱引用 AA 的引用计数不变)

外部释放 AA 的引用计数归零 → A 被销毁 → A 释放对 B 的强引用 → B 也被销毁

常见场景:delegate、block 捕获 self、父子视图关系。


三、weak 的底层实现:SideTable

这是 weak 原理的核心。Runtime 维护了一套 SideTable 数据结构来管理 weak 引用。

3.1 整体架构

全局有一个 SideTable 数组(StripedMap<SideTable>)
包含 64 个 SideTable(根据对象地址哈希分配到不同的表,减少锁竞争)

每个 SideTable 包含三样东西:
+---------------------------+
|  spinlock_t 自旋锁         |   用于多线程安全
+---------------------------+
|  RefcountMap 引用计数表     |   存储对象的引用计数(非 isa 优化时)
+---------------------------+
|  weak_table_t 弱引用表     |   存储所有 weak 指针的信息
+---------------------------+

3.2 weak_table_t 的结构

weak_table_t
+-----------------------------------+
|  weak_entry_t *weak_entries       |   哈希数组
|  size_t num_entries               |   当前条目数
|  uintptr_t mask                   |   哈希掩码(数组大小 - 1)
|  uintptr_t max_hash_displacement  |   最大哈希冲突偏移
+-----------------------------------+

每个 weak_entry_t 对应一个被弱引用的对象:
weak_entry_t
+-----------------------------------+
|  referent(被弱引用的对象地址)      |   Key:对象是谁
|  referrers(弱引用指针的地址数组)   |   Value:谁在弱引用它
+-----------------------------------+

用人话说就是:Runtime 维护了一张大表,Key 是对象地址,Value 是所有指向这个对象的 weak 指针的地址列表

类比理解:

想象一个"粉丝登记簿"。每个明星(对象)有一页,上面记着所有粉丝(weak 指针)的联系方式。明星退役(对象释放)时,工作人员翻到那一页,逐个通知粉丝"他退了"(置 nil),然后撕掉这一页。


四、weak 的完整生命周期

4.1 创建 weak 引用时发生了什么

当你写 __weak id weakObj = obj; 时,Runtime 调用 objc_initWeak,完整流程:

objc_initWeak(&weakObj, obj)
    |
    v
storeWeak(&weakObj, obj)
    |
    v
1. 根据 obj 的地址,哈希计算找到对应的 SideTable
    |
    v
2. 加锁(SideTable  spinlock)
    |
    v
3.  weak_table 中查找 obj 对应的 weak_entry_t
   - 如果不存在:创建一个新的 weak_entry_t,插入 weak_table
   - 如果已存在:直接使用
    |
    v
4.  &weakObj(weak 指针的地址)添加到 weak_entry_t  referrers 数组中
    |
    v
5. 解锁
    |
    v
6. 返回 obj(weakObj 现在指向 obj,但不增加引用计数)

4.2 读取 weak 引用时发生了什么

当你使用 weakObj 时(比如 [weakObj doSomething]),Runtime 调用 objc_loadWeakRetained

objc_loadWeakRetained(&weakObj)
    |
    v
1. 读取 weakObj 当前指向的对象
    |
    v
2. 如果对象正在被释放(deallocating)→ 返回 nil
    |
    v
3. 如果对象还活着  对它做一次 retain(引用计数 +1)
    |
    v
4. 返回对象(调用方用完后会 release)

为什么读取时要 retain? 防止你拿到对象后、使用之前的瞬间,对象被其他线程释放。retain 一下确保对象在使用期间不会消失。

这也是为什么常见的模式是:

__weak typeof(self) weakSelf = self;
[obj doSomething:^{
    __strong typeof(weakSelf) strongSelf = weakSelf;  // 读取时 retain
    if (!strongSelf) return;                           // 如果已释放就退出
    [strongSelf doWork];                               // 安全使用
}];

__strong typeof(weakSelf) strongSelf = weakSelf 这一步就触发了 retain,保证 block 执行期间 self 不会消失。

4.3 对象释放时发生了什么(最关键的部分)

当一个被弱引用的对象引用计数归零时,dealloc 过程中会清理所有 weak 引用。

完整流程:

对象引用计数归零
    |
    v
objc_object::rootDealloc()
    |
    v
object_dispose()
    |
    v
objc_destructInstance(obj)
    |
    v
clearDeallocating(obj)
    |
    v
clearDeallocating_slow(obj)
    |
    v
1. 根据 obj 地址找到对应的 SideTable
    |
    v
2. 加锁
    |
    v
3.  weak_table 中查找 obj 对应的 weak_entry_t
    |
    v
4. 遍历 weak_entry_t  referrers 数组
   对每个 weak 指针地址:*referrer = nil  (置 nil!)
    |
    v
5.  weak_table 中删除这个 weak_entry_t
    |
    v
6. 解锁
    |
    v
7. 释放对象内存(free)

第 4 步就是 weak 自动置 nil 的核心:Runtime 遍历所有指向这个对象的 weak 指针,把它们全部设为 nil。

4.4 weak 引用被覆盖或销毁时

当 weak 指针指向新对象或超出作用域时,Runtime 调用 objc_destroyWeak

objc_destroyWeak(&weakObj)
    |
    v
storeWeak(&weakObj, nil)
    |
    v
1. 找到旧对象的 SideTable
    |
    v
2. 从旧对象的 weak_entry_t  referrers 中移除 &weakObj
    |
    v
3. 如果 referrers 为空了,删除这个 weak_entry_t

五、SideTable 的哈希设计

5.1 为什么用 64 个 SideTable

如果只有一个全局的 SideTable,所有线程操作 weak 引用时都要抢同一把锁,性能极差。

64 个 SideTable 通过对象地址哈希分散到不同的表上,不同表用不同的锁,大幅减少了锁竞争。

对象 A(地址 0x1000)→ 哈希 → SideTable[3]  → 锁3
对象 B(地址 0x2000)→ 哈希 → SideTable[17] → 锁17
对象 C(地址 0x3000)→ 哈希 → SideTable[3]  → 锁3(和A竞争,但概率低)

5.2 weak_entry_t 内部的优化

weak_entry_t 内部存储 referrers(弱引用指针数组)有两种模式:

  • 内联模式(inline):当弱引用数量不超过 4 个时,直接用一个固定大小的数组存储(WEAK_INLINE_COUNT = 4),避免堆内存分配
  • 动态模式(outline):超过 4 个时,切换为动态分配的哈希数组

大多数对象的 weak 引用数量不超过 4 个(通常就一两个 delegate),所以内联模式覆盖了大部分场景,性能更好。


六、weak 和 isa 的关系

6.1 isa 中的 weakly_referenced 位

现代 Objective-C 使用"优化的 isa"(Non-pointer isa),把引用计数和一些标志位直接存在 isa 指针里:

isa 指针(64位):
| 1bit  | 1bit        | 1bit            | 33bit    | ...   |
| nonptr| has_assoc   | has_cxx_dtor    | shiftcls | ...   |
|       |             |                 |          |       |
|       |             |                 |          | weakly_referenced (1bit)

weakly_referenced 位标记这个对象是否有 weak 引用。dealloc 时,如果这个位是 0,就跳过 weak 清理流程,加速释放。

dealloc 快速路径:
  if (!nonpointer) → 慢路径
  if (weakly_referenced) → 需要清理 weak 表 → 慢路径
  if (has_assoc) → 需要清理关联对象 → 慢路径
  if (has_cxx_dtor) → 需要调用 C++ 析构 → 慢路径
  否则 → 直接 free,最快

所以一个没有 weak 引用、没有关联对象、没有 C++ 析构的纯 OC 对象,释放速度是最快的。


七、weak 的性能开销

weak 不是"免费的",它有实实在在的性能开销:

操作 开销
创建 weak 引用 哈希查找 SideTable + 加锁 + 插入 weak_entry
读取 weak 引用 读取 + retain + autorelease(或 release)
对象释放时 哈希查找 + 加锁 + 遍历所有 weak 指针置 nil + 删除条目

对比 __unsafe_unretained:创建和读取都只是普通的指针赋值和读取,几乎零开销。代价是对象释放后变成野指针。

实际影响:在绝大多数场景下,weak 的开销完全可以忽略。但在极端高频的场景下(比如每秒创建销毁上万个弱引用对象),可以考虑用 __unsafe_unretained 配合手动管理来优化。


八、weak 和 autoreleasepool 的关系

在 MRC 和早期 ARC 实现中,读取 weak 变量会自动将对象注册到 autoreleasepool:

id obj = objc_loadWeak(&weakObj);
// 等价于:
id obj = objc_loadWeakRetained(&weakObj);
objc_autorelease(obj);

这意味着在一个循环里频繁读取 weak 变量,会导致 autoreleasepool 膨胀:

for (int i = 0; i < 100000; i++) {
    NSLog(@"%@", weakObj);  // 每次读取都往 pool 里加一个
}
// pool 里积累了 10 万个对象,直到 pool drain 才释放

解决方案:在循环外用 strong 变量接住,循环里用 strong 变量。

现代 ARC(编译器优化后)在很多场景下已经不走 autorelease 了,但理解这个机制仍然重要。


九、Tagged Pointer 和 weak

Tagged Pointer 是苹果对小对象(短 NSString、小 NSNumber 等)的优化:把值直接编码在指针里,不是真正的堆对象。

对 Tagged Pointer 做 weak 引用时:

  • 因为它不是真正的对象,没有引用计数,不会被"释放"
  • Runtime 检测到是 Tagged Pointer 后,不会走 SideTable 的注册/清理流程
  • weak 指针直接存储 Tagged Pointer 的值,永远不会被置 nil

十、Swift 的 weak 和 Objective-C 的区别

维度 Objective-C weak Swift weak
类型 可以是任意 OC 对象 必须是 Optional 类型
类限制 只能用于 class 类型(AnyObject)
底层机制 SideTable Swift 有自己的实现,但原理类似
unowned 没有直接等价物 有 unowned(类似 unsafe_unretained,但 debug 模式有检查)

Swift 的 unowned vs weak

  • weak:可选类型,对象释放后变 nil,有 SideTable 开销
  • unowned:非可选类型,假设对象一定还活着。释放后访问会在 debug 模式 crash(比野指针更安全)。性能比 weak 好(不走 SideTable)

十一、常见面试问题

Q1:weak 是怎么实现自动置 nil 的?

Runtime 维护了一个全局的 SideTable 结构,其中的 weak_table 以对象地址为 Key,以所有指向该对象的 weak 指针地址数组为 Value。对象 dealloc 时,Runtime 从表中找到所有 weak 指针,逐个置 nil,然后删除表项。

Q2:weak 和 assign 有什么区别?

assign 只是简单的指针赋值,对象释放后指针变成野指针(指向已释放的内存)。weak 会在对象释放时自动置 nil,安全。assign 用于基本类型(int、CGFloat 等),weak 用于对象类型。

Q3:为什么 weak 比 strong 慢?

strong 只是引用计数的原子操作(+1/-1)。weak 需要额外的哈希查找、加锁、SideTable 操作。读取时还需要 retain 保证线程安全。但在绝大多数场景下差异可忽略。

Q4:一个对象可以有多少个 weak 引用?

理论上没有限制。weak_entry_t 内部先用 4 个内联槽位,超过后切换为动态哈希数组,可以按需增长。

Q5:weak 对象在什么线程被置 nil?

在触发 dealloc 的那个线程。谁释放了最后一个 strong 引用,就在谁的线程上走 dealloc 流程,进而清理 weak 表。


十二、一句话总结

weak 的本质就是 Runtime 维护了一张"对象 -> 弱指针列表"的全局哈希表(SideTable 中的 weak_table)。创建 weak 引用时注册,读取时 retain 保安全,对象释放时遍历置 nil 后删除条目。代价是哈希查找和加锁的开销,换来的是零野指针的安全性。

# iOS 电量优化详解

一、电量是怎么被消耗的?

手机电池的电量本质上就是电能。App 的各种操作最终都会驱动硬件工作,硬件工作就要消耗电能。

主要的耗电硬件:

┌────────────────────────────────────────────────────┐
│                    App 的各种操作                     │
├──────┬──────┬──────┬──────┬──────┬──────┬──────────┤
│ CPU  │ GPU  │ 网络  │ 定位  │ 屏幕 │ 传感器│ 蓝牙/NFC │
│      │      │模块   │模块   │背光  │      │          │
├──────┴──────┴──────┴──────┴──────┴──────┴──────────┤
│                    电池                              │
└────────────────────────────────────────────────────┘

关键认知:硬件有两种状态——空闲态和活跃态。

空闲态几乎不耗电,活跃态耗电量可能是空闲态的 10-100 倍。电量优化的核心就是:尽量让硬件处于空闲态,减少活跃态的持续时间。


二、iOS 的电量管理机制

2.1 合并唤醒(Coalescing)

iOS 不会让硬件被频繁地"唤醒-休眠-唤醒-休眠"。它会把多个 App 的小任务合并到同一个时间窗口集中处理。

不合并:
  App1 ─▮─────────▮─────────▮─────────   (每 10 秒唤醒一次)
  App2 ──────▮─────────▮─────────▮────   (每 10 秒唤醒一次)
  CPU   ─▮───▮──▮──▮───▮──▮──▮───▮──▮   (被唤醒了 9 次)

合并后:
  App1 ─▮─────────▮─────────▮─────────
  App2 ─▮─────────▮─────────▮─────────   (和 App1 对齐)
  CPU   ─▮─────────▮─────────▮─────────   (只被唤醒 3 次)

对开发者的启示: 不要自己用精确的 Timer 去定时做事,用系统提供的 API(如 BGTaskScheduler),让系统帮你合并。

2.2 能量计量(Energy Gauges)

iOS 在系统层面持续监控每个 App 的能量消耗。如果你的 App 耗电异常:

  • 设置 → 电池 里会显示高耗电
  • 系统可能会限制你的后台执行时间
  • App Store 审核可能因为耗电问题被拒
  • 用户看到你耗电高就卸载了

2.3 后台执行限制

iOS 对后台 App 的电量管控非常严格:

状态 允许做什么 时间限制
前台 任何事 无限制
后台(刚切走) 完成当前任务 约 30 秒(可申请延长到 ~3 分钟)
后台(挂起) 什么都不能做 0(被冻结)
后台模式(音乐/导航/VoIP等) 特定任务 持续但受监控

App 被挂起后,CPU 完全不分配给它,所以不耗电。 这是 iOS 比 Android 省电的核心原因之一。


三、八大耗电场景与优化

3.1 CPU —— 最大的耗电户

为什么耗电

CPU 频率越高、负载越重、持续时间越长,耗电越多。

常见问题

问题 场景
死循环 / 忙等待 while(flag) {} 没有 sleep
过度计算 主线程做复杂的 JSON 解析、图片处理
Timer 间隔太短 每 0.01 秒刷新一次,但界面根本看不出差别
后台还在跑 切后台了 Timer 还在走

优化策略

1. 避免忙等待,用事件驱动替代轮询

❌ 轮询:每 0.1 秒检查一次数据有没有准备好
   while (!dataReady) { usleep(100000); }

✅ 事件驱动:数据好了通知我
   NotificationCenter / KVO / Completion Handler / Combine

2. Timer 的电量陷阱

NSTimer / DispatchSourceTimer 默认是精确触发的,会阻止 CPU 进入深度休眠。

优化方式:给 Timer 加 tolerance(容差)

timer.tolerance = interval * 0.1  // 允许 10% 的偏差

加了 tolerance 后,系统可以把你的 Timer 和其他 Timer 合并触发,减少 CPU 唤醒次数。

苹果的建议:tolerance 至少设为间隔的 10%。

3. 用合适的 QoS(Quality of Service)

iOS 的任务队列有不同的优先级,低优先级的任务系统会安排在"电量友好"的时间执行:

QoS 级别 用途 CPU 调度
.userInteractive UI 更新、动画 最高优先级,立即执行
.userInitiated 用户触发的操作(点击后加载) 高优先级
.default 默认 中等
.utility 长时间任务(下载、导入) 低优先级,省电模式可能延迟
.background 用户不关心何时完成(预加载、备份) 最低,系统自行安排

原则:不需要立即响应的任务,用 .utility.background 系统会在电量充足或充电时才执行这些任务。

3.2 网络 —— 隐形的耗电大户

为什么网络特别耗电

蜂窝网络模块(4G/5G)有三种功耗状态:

空闲态(Idle)─── 几乎不耗电
    │  有数据要发送
    ▼
升频态(Ramp Up)─── 功耗急剧上升(从空闲到全速需要 1-2 秒)
    │
    ▼
全速态(Active)─── 高功耗传输数据
    │  数据传完
    ▼
拖尾态(Tail)─── 仍保持高功耗约 10-15 秒!等待可能的后续请求
    │  超时无新数据
    ▼
空闲态(Idle)

关键问题在"拖尾态": 传完数据后,蜂窝模块不会立刻休眠,而是保持活跃 10-15 秒等待新数据。如果你的 App 每 20 秒发一个小请求,蜂窝模块就永远无法进入空闲态。

❌ 零散请求(蜂窝模块永远醒着):
  请求──拖尾──请求──拖尾──请求──拖尾──请求──拖尾
  ████████████████████████████████████████████  全程高功耗

✅ 批量请求(只唤醒一次):
  ──────────批量请求──拖尾──────────────────────
  ░░░░░░░░░░█████████████░░░░░░░░░░░░░░░░░░░░  大部分时间低功耗

优化策略

1. 请求合并(Batching)

不要每个事件都立即发网络请求。把多个请求攒在一起,一次性发送。

例如:埋点数据不要实时上报,累积 20 条或间隔 30 秒批量上报。

2. 避免轮询,用推送替代

❌ 每 30 秒轮询一次服务器检查新消息
✅ 用 APNs 推送通知客户端有新消息

3. 适配网络类型

  • WiFi 比蜂窝省电得多(没有拖尾态问题)
  • 大文件下载、数据同步等操作尽量在 WiFi 环境下进行
  • NWPathMonitorReachability 判断当前网络类型

4. 减少数据传输量

  • 开启 HTTP 压缩(gzip / br)
  • 用 HTTP/2 的头部压缩
  • 图片用 WebP / HEIF 替代 PNG/JPEG
  • API 只返回需要的字段(GraphQL 的优势)
  • 合理使用缓存(URLCacheETagLast-Modified

5. 超时和重试策略

  • 设置合理的超时时间(不要太长等不来也不放手)
  • 重试用指数退避(1s → 2s → 4s → 8s),不要固定间隔疯狂重试
  • 失败后等 WiFi 或充电时再重试

3.3 定位 —— 精度越高越耗电

各精度的耗电对比

精度 API 耗电 适用场景
最佳精度 kCLLocationAccuracyBest 极高(GPS 全速运转) 导航
10 米 kCLLocationAccuracyNearestTenMeters 跑步记录
100 米 kCLLocationAccuracyHundredMeters 附近商家
公里级 kCLLocationAccuracyKilometer 天气、城市级服务
3公里级 kCLLocationAccuracyThreeKilometers 很低 粗略地理围栏
显著位置变化 startMonitoringSignificantLocationChanges 极低 只在基站切换时触发

GPS 芯片功耗约 25-35mW,WiFi 定位约 5-10mW,基站定位约 1-2mW。

优化策略

1. 用够了就关

开始定位 → 拿到位置 → 立即 stopUpdatingLocation

很多 App 犯的错误:开启定位后忘了关,GPS 一直在后台运转。

2. 用最低够用的精度

外卖 App 展示附近餐厅用 100 米精度足够了,不需要 Best。只有导航才需要最高精度。

3. 用"显著位置变化"替代持续定位

如果你只需要在用户换了个区域时更新内容(比如新闻 App 根据城市推荐),用 startMonitoringSignificantLocationChanges。它基于基站切换触发,几乎不额外耗电。

4. distanceFilter 过滤无意义的更新

locationManager.distanceFilter = 50  // 移动 50 米以上才回调

默认是 kCLDistanceFilterNone(每次都回调),设一个合理的值可以大幅减少回调次数。

5. allowsBackgroundLocationUpdates 谨慎使用

只有导航、运动记录等真正需要后台定位的场景才开启。开启后要搭配 pausesLocationUpdatesAutomatically = true,让系统在检测到用户静止时自动暂停。

3.4 GPU / 图形渲染

耗电的渲染操作

操作 为什么耗电
离屏渲染 需要额外的帧缓冲区,GPU 要来回切换上下文
大量透明度混合 每一层都要计算混合,层越多越慢
大图缩小显示 GPU 要对大图做缩放计算
实时模糊(UIBlurEffect) 每帧都要对底层内容做高斯模糊
高帧率动画 120Hz 的计算量是 60Hz 的两倍

优化策略

1. 避免不必要的离屏渲染

触发离屏渲染的操作:
  - cornerRadius + masksToBounds(圆角裁剪)
  - shadow(阴影,没有设 shadowPath 时)
  - mask(遮罩)
  - group opacity(组透明度)

优化方式:
  - 圆角:用贝塞尔曲线预先裁剪成圆角图片,或在绘图时直接画圆角
  - 阴影:设置 shadowPath,避免实时计算阴影形状
  - 模糊:对静态内容用截图+模糊的方式,而不是实时 UIVisualEffectView

2. 图片大小匹配显示大小

一张 3000x3000 的图显示在 100x100 的 ImageView 里,GPU 每帧都要缩放。应该在加载时就缩放到显示尺寸。

3. 降低不必要的帧率

不是所有动画都需要 60fps / 120fps。滚动和交互动画需要高帧率,但一个缓慢变化的进度条用 30fps 就够了。

CADisplayLink 可以设置 preferredFramesPerSecond

3.5 蓝牙(BLE)

两种扫描模式的耗电差异

模式 耗电 说明
主动扫描(Active Scan) 蓝牙模块持续发射扫描请求
被动监听 只监听广播包

优化策略

  • 扫描到目标设备后立即停止扫描
  • 设置 CBCentralManagerScanOptionAllowDuplicatesKey = NO,避免重复上报同一个设备
  • 后台扫描比前台限制更严格,系统会自动降低扫描频率
  • 不需要实时数据时,用 notify 替代 read(让外设主动通知,而不是 App 轮询读取)

3.6 后台任务

beginBackgroundTask 的正确用法

切后台时申请额外执行时间来完成当前任务:

关键要点:
① 一定要在超时回调里调用 endBackgroundTask,否则系统会杀掉你的 App
② 不要用它来"偷偷"执行长时间任务
③ 系统给的时间在 iOS 13+ 只有约 30 秒(以前是 3 分钟)

BGTaskScheduler(iOS 13+)

用于安排后台任务,系统会在合适的时间执行:

类型 用途 触发条件
BGAppRefreshTask 数据刷新(拉新闻、同步) 系统根据用户使用习惯决定
BGProcessingTask 重计算任务(数据库清理、ML训练) 通常在充电 + WiFi 时

系统会综合考虑电量、网络、充电状态、用户使用习惯来决定何时执行你的任务。 你只需要提交任务,不需要操心何时执行。

3.7 推送通知

静默推送的耗电陷阱

静默推送(content-available: 1)会唤醒 App 在后台执行代码。如果推送频率太高(比如每分钟一次),相当于 App 每分钟被唤醒一次,持续消耗 CPU 和网络。

苹果会限制频率: 如果系统检测到你的静默推送太频繁,会开始丢弃推送。

优化: 静默推送只用于"有重要数据需要预加载"的场景,不要当成轮询的替代品。

3.8 传感器

传感器 耗电 优化
加速度计 用 CMMotionManager 的合理更新频率,不用就 stop
陀螺仪 同上
磁力计(指南针) headingFilter 过滤微小变化
气压计 按需使用
摄像头 极高 分辨率调到够用即可,不用就释放
麦克风 用 VAD(语音活动检测)避免持续录音

四、电量监控与测量

4.1 开发阶段

Instruments - Energy Log

Xcode 的 Instruments 提供 Energy Log 模板,能看到:

  • CPU 活动(Overhead 级别:0-20)
  • 网络活动
  • 定位活动
  • GPU 活动
  • 前台/后台状态

每个指标用 0-20 的等级表示功耗水平。

Xcode Energy Gauges

Debug Navigator 里实时显示 Energy Impact(低/中/高/极高),直观但粗略。

Energy Impact 的颜色含义:

  • 绿色(低):正常
  • 黄色(中):有优化空间
  • 红色(高/极高):需要关注

sysdiagnose

在设备上触发 sysdiagnose(同时按 音量上 + 音量下 + 电源键),生成一份详细的系统诊断报告,包含详细的电量日志。

4.2 线上监控

MetricKit(iOS 13+)

苹果提供的官方线上性能监控框架,每 24 小时汇总一次数据:

Metric 说明
MXCPUMetric CPU 使用指令数
MXGPUMetric GPU 使用时间
MXNetworkTransferMetric 网络传输量(上/下行)
MXLocationActivityMetric 定位活动时间
MXCellularConditionMetric 蜂窝信号质量(信号差时更耗电)
MXAppRunTimeMetric 前台/后台运行时间

这些数据以直方图形式提供,包含 P50/P90/P99 分位值。 可以帮你了解真实用户的耗电情况。

Xcode Organizer - Energy Reports

Xcode → Window → Organizer → Energy,可以看到线上用户的能量报告。如果你的 App 被系统判定为"耗电异常",这里会有日志。

4.3 电量归因:到底是谁在耗电?

当发现 App 耗电高时,排查思路:

1. CPU 高?
   └── 用 Time Profiler 找到热点函数
       └── 是主线程还是子线程?
       └── 是否有不必要的循环/计算?
       └── 后台是否有 Timer 在跑?

2. 网络频繁?
   └── 用 Network instrument 看请求频率和数据量
       └── 是否有轮询?
       └── 请求是否可以合并?
       └── 是否在蜂窝网络下做了大量传输?

3. 定位一直开着?
   └── 检查 CLLocationManager 的 start/stop 配对
       └── 精度是否过高?
       └── 后台是否还在定位?

4. GPU 负载高?
   └── 用 Core Animation instrument 检查离屏渲染
       └── 是否有不必要的透明度混合?
       └── 帧率是否过高?

五、Low Power Mode(低电量模式)适配

用户开启低电量模式后,系统会:

  • 降低 CPU/GPU 频率
  • 减少后台活动
  • 降低屏幕亮度
  • 关闭 5G(降回 4G)
  • 停止自动下载和邮件获取

你的 App 应该监听并适配:

检测方式 说明
ProcessInfo.processInfo.isLowPowerModeEnabled 查询当前状态
NSProcessInfoPowerStateDidChangeNotification 监听状态变化

适配建议:

  • 低电量模式下降低动画帧率或关闭动画
  • 停止非关键的后台数据同步
  • 降低定位精度
  • 减少网络请求频率
  • 延迟非紧急的计算任务

六、优化原则总结

六字箴言:少做、晚做、批量做

原则 含义 例子
少做 能不做就不做 不需要的数据不请求,不在屏的 View 不渲染
晚做 能推迟就推迟 非关键 SDK 延迟初始化,后台任务等充电时做
批量做 能合并就合并 网络请求合并,埋点批量上报

优化优先级

按耗电影响从大到小:

1. 🔴 网络(尤其是蜂窝网络的拖尾效应)
2. 🔴 定位(GPS 持续开启)
3. 🟡 CPU(后台 Timer、忙等待、过度计算)
4. 🟡 GPU(离屏渲染、高帧率)
5. 🟢 蓝牙(持续扫描)
6. 🟢 传感器(持续采集)

一张检查清单

□ Timer 是否设置了 tolerance?
□ 切后台后是否停止了不必要的 Timer / 定位 / 蓝牙扫描?
□ 网络请求是否有合并?是否有缓存?
□ 定位精度是否是最低够用的?用完是否关闭了?
□ 是否有轮询可以用推送替代?
□ 后台任务是否用了 BGTaskScheduler 而不是自己计时?
□ 图片是否压缩到了合适的尺寸?
□ 是否适配了低电量模式?
□ 大型计算任务是否标记了合适的 QoS?
□ 是否用 MetricKit 监控了线上耗电数据?

HTTP 各版本演进与 HTTPS 原理详解

一、先搞清楚 HTTP 是什么

HTTP(HyperText Transfer Protocol)就是浏览器和服务器之间"对话"的规则。你在浏览器输入一个网址,浏览器按照 HTTP 规则发一个请求,服务器按照 HTTP 规则回一个响应。

浏览器:「我要 /index.html」          →  请求(Request)
服务器:「给你,200 OK,内容如下...」   ←  响应(Response

HTTP 从 1991 年诞生到现在,经历了 5 个大版本。每个版本都是为了解决上一个版本的痛点。


二、HTTP/0.9(1991)—— 最原始的版本

特点

  • 只支持 GET 方法
  • 没有请求头、没有响应头
  • 只能传 HTML 文本
  • 响应完就断开连接

一次对话长这样

请求:GET /hello.html
响应:<html>Hello World</html>
(连接断开)

就这么简单粗暴。没有状态码,没有 Content-Type,啥都没有。

存在的问题

  • 只能传 HTML,不能传图片、CSS、JS
  • 没有任何元数据(不知道内容多大、什么类型、什么编码)
  • 每次请求都要新建连接

三、HTTP/1.0(1996)—— 有模有样了

解决了什么

新增能力 说明
请求头 & 响应头 可以携带元数据了(Content-Type、Content-Length 等)
多种方法 GET、POST、HEAD
状态码 200、404、500 等,知道请求成功还是失败了
Content-Type 可以传图片、音频、视频,不再局限于 HTML
版本号 请求行里带上 HTTP/1.0

一次对话长这样

请求:
GET /logo.png HTTP/1.0
Host: www.example.com
Accept: image/png

响应:
HTTP/1.0 200 OK
Content-Type: image/png
Content-Length: 4096

(图片二进制数据)
(连接断开)

存在的问题

最大的问题:短连接。 每个请求都要经历 TCP 三次握手 → 传数据 → 四次挥手。

一个网页有 1 个 HTML + 10 个图片 + 3 个 CSS + 5 个 JS = 19 个请求,就要建立 19 次 TCP 连接。

连接1[三次握手] → GET /index.html[响应][四次挥手]
连接2[三次握手] → GET /style.css[响应][四次挥手]
连接3[三次握手] → GET /logo.png[响应][四次挥手]
...重复 19

每次握手和挥手都要消耗时间和系统资源,极其浪费

虽然有些实现支持非标准的 Connection: keep-alive,但这不是规范的一部分,行为不统一。


四、HTTP/1.1(1997)—— 用了二十多年的主力

解决了什么

1. 持久连接(Keep-Alive)—— 最重要的改进

默认开启 TCP 连接复用。一个连接可以发多个请求,不用每次都握手挥手。

HTTP/1.0:
  连接1 → 请求1 → 响应1 → 断开
  连接2 → 请求2 → 响应2 → 断开
  连接3 → 请求3 → 响应3 → 断开

HTTP/1.1:
  连接1 → 请求1 → 响应1 → 请求2 → 响应2 → 请求3 → 响应3 → 断开

2. 管线化(Pipelining)

理论上可以不等响应就发下一个请求:

客户端:请求1 → 请求2 → 请求3 →
                                  等待...
服务端:                    ← 响应1 ← 响应2 ← 响应3

但实际几乎没人用(原因见下面的问题)。

3. 分块传输(Chunked Transfer)

不需要预先知道内容的总大小,边生成边发送:

HTTP/1.1 200 OK
Transfer-Encoding: chunked

5\r\n
Hello\r\n
6\r\n
 World\r\n
0\r\n
\r\n

适用场景:服务端流式输出(比如 ChatGPT 的逐字输出)。

4. 其他新增

能力 说明
Host 头必选 一个 IP 可以托管多个域名(虚拟主机)
Cache-Control 更精细的缓存控制(替代 Expires)
Range 请求 支持断点续传(下载了一半断了,可以接着下)
100 Continue 先问服务器"我要发一个大文件,你准备好了吗?"
PUT / DELETE / OPTIONS / PATCH 更多的方法,RESTful API 的基础

存在的问题

问题一:队头阻塞(Head-of-Line Blocking)

这是 HTTP/1.1 最致命的问题。

虽然支持管线化,但响应必须按请求的顺序返回。如果第一个请求处理很慢,后面的请求即使已经处理完了,也必须排队等着。

请求顺序:请求1(慢查询)→ 请求2(静态图片)→ 请求3(CSS)

实际情况:
  请求1 ████████████████████░░░░░(处理中...3秒)
  请求2 ░░░░░░░░░░░░░░░░░░░████░  (早就好了,但必须等请求1)
  请求3 ░░░░░░░░░░░░░░░░░░░░░░██  (也在排队等)

就像高速公路只有一条车道,前面的大卡车开得慢,后面的跑车再快也超不过去。

问题二:并发限制的妥协

为了绕开队头阻塞,浏览器的做法是 对同一个域名开 6 个并行 TCP 连接

但这导致了新的问题:

  • 每个连接都要三次握手 + TLS 握手,开销不小
  • 服务器要维护大量连接
  • 催生了"域名分片"等 hack 手段(把资源分散到 cdn1.xxx.com、cdn2.xxx.com 来突破 6 个限制)

问题三:头部冗余

每个请求都要带上完整的头部(Cookie、User-Agent、Accept 等),这些头部在同一个连接上几乎不变,但每次都要重复发送。一个大 Cookie 可能有 1-2KB,100 个请求就白白多传 100-200KB。

问题四:只能客户端主动

服务器不能主动给客户端推数据,只能客户端请求、服务器响应。想实现"服务器推送"只能用长轮询或 WebSocket。


五、HTTP/2(2015)—— 质的飞跃

核心思想

在一条 TCP 连接上实现真正的并行传输。

解决了什么

1. 二进制分帧(Binary Framing)—— 基础革新

HTTP/1.x 是文本协议(人能直接看懂),HTTP/2 改成了二进制协议。

所有数据被拆分成更小的 帧(Frame),每个帧有一个 Stream ID,标记它属于哪个请求/响应。

HTTP/1.1(文本):
  GET /index.html HTTP/1.1\r\n
  Host: example.com\r\n
  \r\n

HTTP/2(二进制帧):
  ┌──────────┬───────────┬──────────────┐
  │ Length:9Type:HEADStream ID: 1HEADERS 帧
  ├──────────┴───────────┴──────────────┤
  │ :method = GET, :path = /index.html  │
  └─────────────────────────────────────┘

2. 多路复用(Multiplexing)—— 最重要的改进

多个请求/响应可以在同一个 TCP 连接上交错传输,互不阻塞。

HTTP/1.1:6 条连接,每条排队
  连接1: [请求1 ████████████]
  连接2: [请求2 ████]
  连接3: [请求3 ██████]
  连接4: [请求4 ███]
  连接5: [请求5 ████████]
  连接6: [请求6 ██]

HTTP/2:1 条连接,交错并行
  连接1: [帧1a][帧2a][帧3a][帧1b][帧2b][帧3b][帧1c][帧2c]...
          ↑stream1  ↑stream2  ↑stream3  ↑stream1  ↑stream2

类比理解:

HTTP/1.1 就像有 6 条单行道,每条道上车要排队。HTTP/2 就像有一条超宽的高速公路,所有车可以同时跑,通过车牌号(Stream ID)区分。

这彻底解决了 HTTP 层的队头阻塞,也不再需要"域名分片"等 hack 手段。

3. 头部压缩(HPACK)

HTTP/2 用 HPACK 算法压缩头部:

  • 维护一个静态表(61 个常见头部,如 :method: GETcontent-type: text/html),用索引号代替完整字符串
  • 维护一个动态表,记录当前连接用过的头部,后续只发索引号
  • 对值做 Huffman 编码 压缩
第一次请求:
  Cookie: session=abc123def456...   (完整发送,同时存入动态表,索引 62)

第二次请求:
  62                                 (只发一个索引号,省掉了几百字节)

效果:头部大小减少 85-95%

4. 服务器推送(Server Push)

服务器可以主动推送客户端可能需要的资源。

客户端请求 /index.html
服务器响应 /index.html
服务器主动推送 /style.css   ← 不用等客户端解析 HTML 后再请求
服务器主动推送 /app.js      ← 提前到达,减少等待

但实际效果争议较大——很难准确预测客户端需要什么,推错了反而浪费带宽。Chrome 已在 2022 年移除了对 Server Push 的支持。

5. 流优先级(Stream Priority)

可以给不同的请求设置优先级。比如 CSS 优先级高于图片,因为 CSS 会阻塞渲染。

存在的问题

TCP 层的队头阻塞 —— HTTP/2 的阿喀琉斯之踵

HTTP/2 解决了 HTTP 层的队头阻塞,但底下的 TCP 层还有队头阻塞

TCP 保证数据按序交付。如果一个 TCP 包丢失了,即使后面的包已经到达,TCP 也不会把它们交给应用层,而是等丢失的包重传回来。

TCP 传输:包1 → 包2(丢了!) → 包3 → 包4 → 包5

TCP 层:包1 ✓   包2 ?等重传...  包3-5 已到但不能用
         └── 所有 Stream 都被阻塞!

HTTP/1.1 开 6 个连接,一个连接丢包只影响那一个连接上的请求。HTTP/2 所有请求共用一个连接,一个包丢失会阻塞所有请求

在网络质量差(丢包率 > 2%)的环境下,HTTP/2 的表现可能反而不如 HTTP/1.1

TLS 握手开销

HTTP/2 虽然协议本身不强制加密,但所有浏览器都要求 HTTP/2 必须走 HTTPS。TLS 握手需要额外的 1-2 个 RTT。


六、HTTP/3(2022)—— 换掉了 TCP

核心思想

既然 TCP 的队头阻塞无法在 TCP 层面解决,那就 不用 TCP 了,改用 QUIC(基于 UDP)。

QUIC 是什么

QUIC(Quick UDP Internet Connections)是 Google 设计的传输层协议,跑在 UDP 上。你可以把它理解为"重新实现了一个更好的 TCP"。

HTTP/2 的协议栈:          HTTP/3 的协议栈:
┌──────────┐              ┌──────────┐
│  HTTP/2  │              │  HTTP/3  │
├──────────┤              ├──────────┤
│   TLS    │              │   QUIC   │ ← 把 TLS 融合进来了
├──────────┤              ├──────────┤
│   TCP    │              │   UDP    │
├──────────┤              ├──────────┤
│    IP    │              │    IP    │
└──────────┘              └──────────┘

解决了什么

1. 彻底消灭队头阻塞

QUIC 在传输层就支持多路复用。每个 Stream 独立管理自己的数据包顺序,一个 Stream 丢包不影响其他 Stream

HTTP/2 + TCP:
  Stream 1 的包丢了 → 所有 Stream 被阻塞等重传

HTTP/3 + QUIC:
  Stream 1 的包丢了 → 只有 Stream 1 等重传,Stream 2/3/4 正常收发

2. 0-RTT 连接建立

TCP + TLS 需要的握手:

TCP 三次握手:   1 RTT(客户端→服务器→客户端)
TLS 1.2 握手:  2 RTT
TLS 1.3 握手:  1 RTT
──────────────────
总计:TCP + TLS 1.3 = 2 RTT(数据才能开始传)

QUIC 的握手:

首次连接:1 RTT(QUIC 把传输层握手和加密握手合并了)
重连(0-RTT):0 RTT(第一个包就可以带数据!)

类比理解:

TCP + TLS 就像打电话:先拨号等接通(TCP),再输密码验证身份(TLS),然后才能说话。QUIC 就像发微信:直接把消息和身份验证一起发出去,对方收到就能回。重连时更像对方还认识你,直接开聊。

3. 连接迁移

TCP 连接用"源IP + 源端口 + 目标IP + 目标端口"四元组标识。你手机从 WiFi 切到 4G,IP 变了,TCP 连接就断了,必须重新握手。

QUIC 用一个 Connection ID 标识连接。IP 变了没关系,只要 Connection ID 不变,连接就能无缝切换。

TCP:WiFi → 4G = 连接断开 → 重新三次握手 + TLS 握手 → 恢复
QUIC:WiFi → 4G = IP 变了 → Connection ID 没变 → 无缝继续

对移动端体验提升巨大(电梯、地铁、从室内走到室外)。

4. 改进的头部压缩(QPACK)

HPACK 依赖严格的包顺序(因为动态表需要同步更新),和 QUIC 的乱序特性冲突。QPACK 解决了这个问题,允许在乱序到达的情况下也能正确解压头部。

存在的问题

问题 说明
UDP 被运营商/防火墙限制 部分网络环境会限速或丢弃 UDP 包,需要回退到 TCP
中间设备不友好 很多老旧的路由器、防火墙对 UDP 支持不好
CPU 开销 QUIC 在用户态实现(不在内核里),加解密和拥塞控制消耗更多 CPU
生态还在成熟 服务端支持(Nginx 2022 才正式支持)、调试工具都还在完善
无法利用 TCP 的内核优化 TCP 经过几十年优化,内核的 TCP 栈非常高效;QUIC 在用户态,暂时没法比

七、各版本一张图对比

        HTTP/0.9    HTTP/1.0    HTTP/1.1      HTTP/2        HTTP/3
年份      1991        1996        1997         2015          2022
传输层    TCP         TCP         TCP          TCP           UDP(QUIC)
连接方式  短连接      短连接      持久连接      多路复用       多路复用
并发能力  无          无          管线化(废了)   Stream并行     Stream并行
头部格式  无          文本        文本          HPACK压缩      QPACK压缩
加密      无          可选        可选          事实强制       强制(内置TLS)
队头阻塞  -           有          有(HTTP层)    有(TCP层)      无!
握手RTT   1(TCP)      1(TCP)      1(TCP)       2-3(TCP+TLS)  0-1(QUIC)
服务器推  无          无          无            支持(已废弃)    无

八、HTTPS 详解

8.1 为什么需要 HTTPS?

HTTP 是明文传输,存在三大安全风险:

风险 场景 后果
窃听 连公共 WiFi 时,路由器能看到所有内容 密码、银行卡号泄露
篡改 运营商在网页中插入广告 页面被注入恶意代码
冒充 钓鱼网站伪装成银行 用户被骗输入密码

HTTPS 就是 HTTP + TLS,用加密解决这三个问题:

安全需求 解决方案
防窃听 对称加密(AES),加密传输内容
防篡改 消息认证码(MAC),验证数据完整性
防冒充 数字证书 + 非对称加密(RSA/ECDSA),验证服务器身份

8.2 对称加密 vs 非对称加密

理解 HTTPS 之前,必须搞清楚这两种加密方式。

对称加密

加密和解密用同一把钥匙

明文 "Hello" + 密钥 K → 加密 → 密文 "x7$f2"
密文 "x7$f2" + 密钥 K → 解密 → 明文 "Hello"
  • 优点:速度快(AES 可达 GB/s 级别)
  • 缺点:密钥怎么安全地传给对方?如果密钥被截获,加密就白搭

常见算法:AES、ChaCha20

非对称加密

有两把钥匙:公钥(公开)和 私钥(保密)。公钥加密的只有私钥能解,私钥加密的只有公钥能解。

公钥加密:明文 + 公钥 → 密文(只有私钥能解)
私钥签名:数据 + 私钥 → 签名(公钥可验证)
  • 优点:不需要传递私钥,天然解决密钥分发问题
  • 缺点:速度极慢(比对称加密慢 100-1000 倍)

常见算法:RSA、ECDSA、Ed25519

HTTPS 的选择:混合加密

既然对称加密快但密钥分发难,非对称加密安全但太慢,那就结合使用

① 用非对称加密安全地交换一个"临时密钥"(只需要一次,慢就慢吧)
② 之后所有数据都用这个临时密钥做对称加密(快!)

8.3 TLS 握手流程(TLS 1.2)

这是 HTTPS 最核心的部分——建立安全连接的过程。

客户端                                              服务器
  │                                                    │
  │ ① ClientHello                                      │
  │    - 支持的 TLS 版本                                 │
  │    - 支持的加密套件列表                               │
  │    - 客户端随机数(Client Random)                    │
  │ ─────────────────────────────────────────────────►  │
  │                                                    │
  │                              ② ServerHello         │
  │    - 选定的 TLS 版本                                 │
  │    - 选定的加密套件                                   │
  │    - 服务器随机数(Server Random)                    │
  │                              ③ 服务器证书            │
  │                              ④ ServerHelloDone     │
  │ ◄─────────────────────────────────────────────────  │
  │                                                    │
  │ ⑤ 验证证书(证书链 → 根证书)                          │
  │ ⑥ 生成预主密钥(Pre-Master Secret)                  │
  │ ⑦ 用服务器公钥加密预主密钥                             │
  │ ⑧ ClientKeyExchange(发送加密后的预主密钥)            │
  │ ⑨ ChangeCipherSpec("我准备好了,之后都加密")         │
  │ ⑩ Finished(第一条加密消息)                          │
  │ ─────────────────────────────────────────────────►  │
  │                                                    │
  │                    ⑪ 用私钥解密得到预主密钥             │
  │                    ⑫ 双方各自计算会话密钥               │
  │                       = f(Client Random +           │
  │                          Server Random +            │
  │                          Pre-Master Secret)         │
  │                    ⑬ ChangeCipherSpec                │
  │                    ⑭ Finished                       │
  │ ◄─────────────────────────────────────────────────  │
  │                                                    │
  │ ═══════ 之后所有数据用会话密钥做对称加密 ═══════════    │

需要 2 个 RTT 才能开始传数据。

为什么要用三个随机数?

最终的会话密钥由三个随机数共同生成:

会话密钥 = PRF(Pre-Master Secret, Client Random, Server Random)
  • Client Random:防止服务器重放攻击
  • Server Random:防止客户端重放攻击
  • Pre-Master Secret:只有双方知道的秘密(通过非对称加密安全交换)

三个随机数混合,即使其中一个被猜到,也无法推导出会话密钥。

8.4 TLS 1.3(2018)—— 更快更安全

TLS 1.3 是对 TLS 1.2 的大幅简化和优化。

主要改进

改进 TLS 1.2 TLS 1.3
握手 RTT 2 RTT 1 RTT(首次),0 RTT(重连)
密钥交换 RSA 或 ECDHE 只支持 ECDHE(前向安全)
加密套件 几十种 精简到 5 种
废弃的算法 - 砍掉 RC4、DES、3DES、MD5、SHA-1 等不安全算法
握手加密 握手过程明文 握手消息也加密(ServerHello 之后)

TLS 1.3 握手流程(只需 1 RTT)

客户端                                              服务器
  │                                                    │
  │ ClientHello                                        │
  │    - 客户端随机数                                    │
  │    - 支持的加密套件                                   │
  │    - 客户端的 ECDHE 公钥 ← 关键!直接带上了            │
  │ ─────────────────────────────────────────────────►  │
  │                                                    │
  │                              ServerHello           │
  │                              + 服务器的 ECDHE 公钥   │
  │                              + 证书                 │
  │                              + Finished            │
  │ ◄─────────────────────────────────────────────────  │
  │                                                    │
  │ 双方用 ECDHE 算出相同的密钥                           │
  │ Finished                                           │
  │ ─────────────────────────────────────────────────►  │
  │                                                    │
  │ ═══════ 1 RTT 后就可以传数据了 ═══════════           │

为什么快了? 因为 TLS 1.2 客户端要等拿到服务器证书和公钥后才能开始密钥交换,而 TLS 1.3 客户端直接在 ClientHello 里就把自己的 ECDHE 公钥发出去了(赌服务器会接受),省了一个来回。

0-RTT 重连

如果之前连过这个服务器,客户端缓存了一个 PSK(Pre-Shared Key):

客户端:ClientHello + PSK + 加密的应用数据 →
                                           服务器直接解密处理

第一个包就能带业务数据,延迟降到极致。

安全代价: 0-RTT 数据没有前向安全性,而且可能被重放攻击。所以只适合幂等请求(GET),不适合会产生副作用的操作(POST 转账)。

8.5 数字证书:怎么证明"你是你"

问题

非对称加密解决了"传密钥"的问题,但引入了新问题:你怎么知道拿到的公钥是真的?

中间人攻击:

客户端 ←→ 中间人(伪装成服务器) ←→ 真正的服务器

中间人把自己的公钥发给客户端
客户端以为这是服务器的公钥,用它加密数据
中间人解密 → 看到明文 → 用服务器真正的公钥重新加密 → 发给服务器

解决:数字证书 + CA

引入一个双方都信任的第三方 —— CA(Certificate Authority,证书颁发机构)

① 服务器生成公私钥对
② 服务器把公钥 + 域名信息提交给 CA
③ CA 验证服务器确实拥有这个域名
④ CA 用自己的私钥对"服务器公钥 + 域名信息"做数字签名
⑤ CA 颁发证书(包含:服务器公钥 + 域名 + CA 签名 + 有效期等)
⑥ 客户端收到证书后,用 CA 的公钥验证签名
⑦ 签名正确 → 公钥可信 → 建立加密连接

类比理解:

就像你去政务大厅办事,需要身份证(证书)。身份证是公安局(CA)发的,上面有你的照片(公钥)和钢印(CA 签名)。办事员通过钢印确认身份证是真的,从而相信你就是本人。

证书链

CA 的公钥又是谁来保证的?答案是证书链

根证书(Root CA)         预装在操作系统/浏览器中,无条件信任
  
  └── 中间证书(Intermediate CA)  CA 签发的
        
        └── 服务器证书(End Entity)    中间 CA 签发的

验证过程从下往上:

  1. 用中间 CA 的公钥验证服务器证书的签名
  2. 用根 CA 的公钥验证中间 CA 证书的签名
  3. 根 CA 证书在本地受信任列表中 → 整条链可信

为什么要分层?如果根 CA 直接签发所有证书,一旦根 CA 私钥泄露,后果不堪设想。分层后,即使中间 CA 出问题,只需要吊销这个中间 CA,不影响其他。

8.6 前向安全(Forward Secrecy)

问题

如果用 RSA 做密钥交换(TLS 1.2 的一种模式),一旦服务器私钥泄露,攻击者可以:

  1. 解密之前录制的所有流量中的 Pre-Master Secret
  2. 从而推导出所有历史会话密钥
  3. 所有历史通信全部暴露

解决:ECDHE 密钥交换

每次连接都生成临时的(Ephemeral) 公私钥对,用完即销毁。

连接1:临时密钥对 A → 会话密钥 X → 销毁临时密钥对 A
连接2:临时密钥对 B → 会话密钥 Y → 销毁临时密钥对 B
连接3:临时密钥对 C → 会话密钥 Z → 销毁临时密钥对 C

即使服务器的长期私钥泄露,也无法解密之前的通信,因为临时密钥已经不存在了。

这就是"前向安全"(Forward Secrecy)。TLS 1.3 强制要求使用 ECDHE,所有连接都具有前向安全性。

8.7 HTTPS 的性能影响

开销 说明 缓解方案
握手延迟 TLS 1.2 多 2 RTT,TLS 1.3 多 1 RTT 升级 TLS 1.3、会话复用、0-RTT
加解密 CPU AES 加解密消耗 CPU 现代 CPU 都有 AES-NI 硬件加速,开销几乎可忽略
证书传输 证书链可能 3-5KB OCSP Stapling 减少验证开销
内存 每个连接需要维护加密上下文 影响很小

现代环境下 HTTPS 的额外开销已经非常小了,Google 的数据显示 HTTPS 只增加了不到 2% 的 CPU 负载和不到 10ms 的延迟。

8.8 常见 HTTPS 相关概念

概念 解释
HSTS 服务器告诉浏览器"以后只用 HTTPS 访问我",防止降级攻击
OCSP Stapling 服务器主动把证书有效性证明发给客户端,省去客户端向 CA 查询
CT(Certificate Transparency) 所有颁发的证书必须公开记录,防止 CA 偷偷签发恶意证书
SNI 客户端在 TLS 握手时告诉服务器要访问哪个域名(一个 IP 上多个 HTTPS 站点)
ESNI / ECH 加密 SNI,防止中间人知道你在访问哪个网站
证书固定(Pinning) App 内置预期的证书指纹,防止中间人使用合法但非预期的证书
Let's Encrypt 免费、自动化的 CA,推动了 HTTPS 的全面普及
双向认证(mTLS) 不仅服务器要证书,客户端也要证书(常见于企业内部、金融系统)

九、一张图总结

1991  HTTP/0.9 ─── 只能传 HTML,连个状态码都没有
        
1996  HTTP/1.0 ─── 有了头部、状态码、多媒体,但每次请求都要新建连接
        
1997  HTTP/1.1 ─── 持久连接、缓存控制、断点续传,但有队头阻塞
                     └── HTTPS(TLS 1.0~1.2) 解决安全问题
        
2015  HTTP/2 ──── 二进制分帧、多路复用、头部压缩,但 TCP 层仍有队头阻塞
                     └── TLS 1.3:1-RTT 握手、前向安全
        
2022  HTTP/3 ──── 换用 QUIC(UDP),彻底消灭队头阻塞,0-RTT 连接,连接迁移

每一代都在解决上一代留下的问题,同时也带来了新的挑战。技术演进就是这样一步步往前走的。

iOS 应用启动流程与优化详解

一、什么算"启动"?

从用户点击 App 图标,到第一个页面完整渲染出来,这段时间就是启动时间。

苹果把启动分为两个阶段:

用户点击图标
    │
    ▼
┌──────────────────────────────┐
│         Pre-main 阶段         │  ← 系统在干活,你的代码还没执行
│  (dyld 加载 → Runtime 初始化)  │
└──────────────┬───────────────┘
               │
               ▼  main() 函数被调用
┌──────────────────────────────┐
│         Post-main 阶段        │  ← 你的代码开始执行
│  (AppDelegate → 首页渲染完成)  │
└──────────────────────────────┘
               │
               ▼
          用户看到首页

苹果的标准:冷启动应在 400ms 以内完成,超过 20 秒系统会杀掉 App(Watchdog 机制)。

冷启动 vs 温启动 vs 热启动

类型 条件 耗时
冷启动 App 不在内存中,从零开始加载 最长
温启动 App 刚被杀掉,部分数据还在系统缓存中 中等
热启动 App 在后台,从挂起状态恢复 最短(几乎瞬间)

启动优化主要针对冷启动,因为它是最慢的。


二、Pre-main 阶段:系统在干什么?

从点击图标到 main() 函数执行,中间经历了以下步骤:

① 内核 fork 进程,加载可执行文件(Mach-O)
                    │
                    ▼
② dyld 接管,开始加载动态库
                    │
                    ▼
③ Rebase & Bind:修复指针、绑定外部符号
                    │
                    ▼
④ Objc Runtime 初始化:注册类、处理 Category
                    │
                    ▼
⑤ 执行 +load 方法和 C++ 静态构造函数
                    │
                    ▼
⑥ 调用 main()

2.1 加载可执行文件(Mach-O)

iOS 的可执行文件格式叫 Mach-O(Mach Object)。内核先 fork 出一个新进程,把 Mach-O 文件映射到内存中。

Mach-O 的结构:

┌─────────────────┐
│    Header        │ ← 架构信息(arm64)、文件类型
├─────────────────┤
│  Load Commands   │ ← 告诉 dyld 需要加载哪些动态库、各段放在哪
├─────────────────┤
│   __TEXT 段       │ ← 代码(只读)
├─────────────────┤
│   __DATA 段       │ ← 全局变量、指针(可读写)
├─────────────────┤
│  __LINKEDIT 段    │ ← 符号表、签名信息
└─────────────────┘

2.2 dyld 加载动态库

dyld(dynamic link editor) 是苹果的动态链接器,负责把 App 依赖的所有动态库(.dylib / .framework)加载到内存中。

一个普通 App 依赖的动态库数量:

  • 系统库(UIKit、Foundation、CoreGraphics 等):100~400 个
  • 第三方库(如果用了动态 framework):几个到几十个

每个动态库的加载过程:

  1. 从磁盘找到 .dylib 文件
  2. 验证代码签名(安全检查)
  3. 映射到内存
  4. 如果这个库还依赖其他库,递归加载(这就是为什么依赖关系复杂时会很慢)

2.3 Rebase & Bind

由于 ASLR(地址空间布局随机化)的存在,每次启动时 Mach-O 被加载到内存的地址都不同。但代码里的指针是编译时确定的固定地址,所以需要修正。

Rebase(内部指针修正):

  • 把 Mach-O 内部指向自己的指针,加上一个随机偏移量(slide)
  • 比如:代码里写的是 0x1000,ASLR slide 是 0x5000,修正后变成 0x6000

Bind(外部符号绑定):

  • 把 Mach-O 引用的外部符号(比如 UIKit 里的 UIViewController)绑定到实际的内存地址
  • 需要在符号表中查找,比 Rebase 更慢

类比理解:

Rebase 就像搬家后更新通讯录里自己家人的地址(内部),Bind 就像更新朋友的地址(外部,需要打电话问)。

2.4 Objc Runtime 初始化

  • 注册所有 Objective-C 类到全局类表
  • 处理 Category(把 Category 中的方法附加到对应的类上)
  • 确保 selector 唯一性

类越多,这一步越慢。 如果你的项目有上万个类,这里的耗时就很可观。

2.5 Initializers(+load 和静态构造函数)

这是 Pre-main 阶段最后一步,也是开发者唯一能直接控制的部分

  • 执行所有类的 +load 方法(按编译顺序,先父类后子类,先主类后 Category)
  • 执行 C++ 的全局/静态对象的构造函数
  • 执行标记了 __attribute__((constructor)) 的 C 函数

这些代码在 main() 之前就执行了,而且是在主线程上同步执行。如果你在 +load 里做了耗时操作(比如 Swizzle 大量方法、读文件、网络请求),启动就会被拖慢。


三、Post-main 阶段:你的代码在干什么?

main()
  │
  ▼
UIApplicationMain()
  │
  ▼
application:didFinishLaunchingWithOptions:
  │  ← 大量初始化代码通常堆在这里
  │     SDK 初始化、数据库初始化、推送注册、路由注册...
  ▼
创建 UIWindow、设置 rootViewController
  │
  ▼
首页 viewDidLoad → viewWillAppear → viewDidAppear
  │
  ▼
首帧渲染完成 → 用户看到界面

这个阶段的耗时主要来自 didFinishLaunchingWithOptions 中的各种初始化。


四、dyld 版本演进与优化

这是重点中的重点。苹果在 dyld 上做了三次大的版本迭代,每次都大幅优化了启动速度。

4.1 dyld 1.0(远古时代,macOS 早期)

最初的版本,设计简单粗暴:

  • 全量加载:启动时把所有动态库一次性全部加载到内存
  • 无缓存:每次启动都重新解析、绑定
  • 无优化:Rebase/Bind 逐个处理,没有批量优化

问题:随着系统库越来越多,启动速度越来越慢。

4.2 dyld 2.0(iOS 3.1 ~ iOS 12)

这是大家最熟悉的版本,做了很多重要优化:

核心改进

优化项 做了什么 效果
共享缓存(dyld shared cache) 把几百个系统库预先合并成一个大的缓存文件 系统库加载速度大幅提升
懒绑定(Lazy Binding) 外部符号不在启动时全部绑定,而是在第一次调用时才绑定 减少启动时的 Bind 耗时
符号缓存 缓存已解析的符号地址 避免重复查找

共享缓存(dyld shared cache)详解

这是 dyld 2 最重要的优化。

问题: 一个 App 可能依赖 300+ 个系统动态库。如果每次启动都逐个加载、解析,太慢了。

解决: 苹果在系统更新(或首次启动)时,预先把所有系统库打包合并成一个大文件,叫 dyld shared cache。存放在 /System/Library/dyld/

打包前:                          打包后:
UIKit.framework    ─┐
Foundation.framework ├──→  dyld_shared_cache_arm64
CoreGraphics.framework│     (一个约 1-2GB 的文件)
libsystem.dylib     ─┘

所有系统库的 Rebase/Bind 已经预先完成
所有系统库共用一个地址空间

好处:

  • 系统库启动时只需映射这一个文件,不需要逐个解析
  • Rebase/Bind 已经预先做完,启动时不需要再做
  • 所有 App 共享同一份缓存,节省内存

懒绑定(Lazy Binding)

dyld 2 引入了 PLT(Procedure Linkage Table) 机制:

启动时:
  外部函数调用 → PLT 桩函数 → dyld_stub_binder(绑定真实地址并修改 PLT 条目)

首次调用后:
  外部函数调用 → PLT 桩函数 → 直接跳到真实地址(已经绑定好了)

意思是:你的 App 引用了 UIKit 的 100 个函数,启动时不会全部绑定。而是在你第一次调用某个函数时,才去解析它的真实地址。这样启动时的 Bind 工作就分散到了运行时。

dyld 2 的残留问题

尽管有了很多优化,dyld 2 仍然是串行、逐步执行的:

解析 Mach-O → 查找依赖库 → 逐个加载 → Rebase → Bind → 初始化
              └── 每一步都在主线程上同步执行 ──┘

而且:

  • 第三方动态库没法享受 shared cache
  • 每次启动还是要做一遍 Rebase/Bind(对 App 自身的 Mach-O)
  • 安全校验(代码签名)也是启动时做的

4.3 dyld 3.0(iOS 13+,重大重构)

dyld 3 是一次架构级别的重写,核心思想是:把能预先做的工作提前到"启动之外"去做。

三层架构

┌─────────────────────────────────────────────┐
│            ① 进程外的 Mach-O 解析器            │
│    (App 安装/更新时运行,不在启动路径上)         │
│                                             │
│    - 解析 Mach-O header 和依赖关系             │
│    - 查找所有依赖库的位置                       │
│    - 执行安全校验(代码签名)                    │
│    - 把结果写入 启动闭包(Launch Closure)        │
└────────────────────┬────────────────────────┘
                     │ 预先计算好的结果
                     ▼
┌─────────────────────────────────────────────┐
│             ② 启动闭包缓存                     │
│                                             │
│    一个预先序列化好的数据结构,包含:              │
│    - 所有 dylib 的加载地址                      │
│    - 所有需要的 Rebase/Bind 信息                │
│    - 初始化顺序                                │
│    - 已验证的代码签名结果                        │
└────────────────────┬────────────────────────┘
                     │ 直接读取缓存
                     ▼
┌─────────────────────────────────────────────┐
│             ③ 进程内的引擎                     │
│     (真正在 App 启动时运行的部分)               │
│                                             │
│    - 读取启动闭包(一次 mmap)                   │
│    - 按预先计算好的结果直接加载                   │
│    - 极少的运行时计算                           │
└─────────────────────────────────────────────┘

启动闭包(Launch Closure)

这是 dyld 3 最核心的概念。

类比: dyld 2 就像每次做菜都要翻菜谱、找食材、洗切配。dyld 3 相当于提前把所有食材洗好切好配好放在盒子里(启动闭包),做菜时直接下锅就行。

闭包在什么时候创建?

  • App 安装时
  • App 更新时
  • 系统更新时(shared cache 变了)

闭包里存了什么?

  • 完整的依赖关系图
  • 每个 dylib 的磁盘路径和内存加载地址
  • Rebase/Bind 所需的全部信息
  • 代码签名验证结果(通过/失败)
  • 初始化器的执行顺序

dyld 3 vs dyld 2 对比

维度 dyld 2 dyld 3
Mach-O 解析 每次启动都做 安装时做好,缓存到闭包
依赖库查找 每次启动都在文件系统搜索 闭包里已记录完整路径
代码签名校验 每次启动都验证 安装时验证,结果缓存
Rebase/Bind 计算 每次启动都计算 闭包里已预计算
安全性 解析器在进程内,有被攻击风险 解析器在进程外,更安全
启动速度 快 40%+

4.4 dyld 4.0(iOS 16+ / WWDC 2022)

dyld 4 没有大的架构变化,主要是在 dyld 3 基础上做了进一步优化:

主要改进

优化项 说明
统一两种模式 dyld 3 有"有闭包"和"无闭包"两种路径(模拟器上不用闭包),dyld 4 统一成一种
Just-In-Time 加载 更激进的懒加载,某些 dylib 推迟到真正使用时才加载
页面级别的按需加载 不再把整个 dylib 映射进来,而是按页(Page)按需加载
更好的 Swift 支持 优化了 Swift metadata 的初始化
Compact Info 用更紧凑的格式存储链接信息,减少 __LINKEDIT 段的大小
Pre-warming 系统会在后台预热高频 App 的启动闭包

4.5 dyld 各版本一张图总结

dyld 1   →   dyld 2      →   dyld 3      →   dyld 4
(原始)       (iOS 3.1)        (iOS 13)        (iOS 16)
  │             │                │                │
  │        共享缓存            启动闭包           统一架构
  │        懒绑定             进程外解析          按页懒加载
  │        符号缓存            签名缓存           Swift 优化
  │             │                │                │
  ▼             ▼                ▼                ▼
全量加载      减少重复工作      大量工作移到      极致的懒加载
每次解析      分散绑定时机      安装/更新时       页级按需加载

五、启动优化实战指南

5.1 Pre-main 阶段优化

减少动态库数量

每多一个动态库,就多一次查找、加载、签名校验的过程。

做法 效果
合并自己的动态 framework 直接减少加载次数
能用静态库就不用动态库 静态库在编译时已合并进主二进制,启动时无额外加载
控制 Pods 的 use_frameworks! 改用 use_frameworks! :linkage => :static
苹果建议:第三方动态库不超过 6 个 超过就考虑合并

减少 Rebase/Bind

  • 减少 Objective-C 类的数量(合并功能相近的类)
  • 减少 Category 的数量
  • 减少 C++ 虚函数
  • 用 Swift Struct 代替 OC 对象(Struct 不需要 Rebase)

干掉 +load

+load 是启动优化的头号敌人。

原方案 优化方案
+load 中做 Method Swizzling 移到 +initialize(首次使用时才触发)
+load 中注册路由 改用编译期方案(__attribute__((section)) 写入 Mach-O 段)
+load 中初始化 SDK 移到 didFinishLaunching 或更晚

+initialize vs +load 的关键区别:

  • +load:App 启动时全部执行,即使这个类从未被使用
  • +initialize:某个类第一次收到消息时才执行,懒加载

二进制重排(Page Fault 优化)

这是近年最热门的 Pre-main 优化手段。

问题: App 启动时需要执行很多函数,但这些函数分散在不同的内存页上。每访问一个新页面就会触发一次 Page Fault(缺页中断),内核需要从磁盘加载这一页到物理内存。每次 Page Fault 大约耗时 0.1~1ms。

启动时可能触发几百到上千次 Page Fault,累计就是几百毫秒。

解决: 把启动时需要执行的函数重新排列,让它们尽量排在相邻的内存页上,减少 Page Fault 次数。

优化前:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A    │ X    │ B    │ Y    │ C    │   启动需要 AB→C,触发 3 次 Page Fault
│      │      │      │      │      │
└──────┴──────┴──────┴──────┴──────┘

优化后:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A    │ X    │ Y    │      │      │   启动需要 AB→C,只触发 1 次 Page Fault
│ B    │      │      │      │      │
│ C    │      │      │      │      │
└──────┴──────┴──────┴──────┴──────┘

怎么做?

  1. 用 Clang 的 -fsanitize-coverage 插桩,收集启动时调用的所有函数的顺序
  2. 生成一个 order 文件,列出这些函数的符号名
  3. 在 Xcode 的 Build Settings 中设置 Order File 路径
  4. 链接器会按照这个顺序重新排列函数在二进制中的位置

5.2 Post-main 阶段优化

分级初始化

不要把所有 SDK 初始化都堆在 didFinishLaunchingWithOptions 里。

┌─────────────────────────────────────────────────────┐
│                   分级初始化策略                       │
├─────────────┬──────────────────┬────────────────────┤
│   必须立即做   │    首页出现后做    │    用到时才做       │
│              │                  │                    │
│  崩溃统计     │  推送注册         │  分享 SDK          │
│  日志系统     │  数据统计 SDK     │  地图 SDK          │
│  网络库初始化  │  ABTest          │  支付 SDK          │
│  数据库核心表  │  开屏广告         │  蓝牙/定位         │
│              │                  │  AI 相关 SDK       │
└─────────────┴──────────────────┴────────────────────┘

首页渲染优化

优化手段 说明
首页用纯代码布局 避免 xib/storyboard 解析的开销
首页数据缓存 先展示上次的缓存数据,再异步请求新数据
预加载 viewDidLoad 发起网络请求,不要等 viewDidAppear
骨架屏 先展示骨架屏,给用户"已经在加载"的感觉
减少首页层级 AutoLayout 约束越少越好,层级越浅越好

子线程分担

把不依赖 UI 的初始化工作放到子线程:

主线程:UI 配置 → rootVC 创建 → 首页渲染
子线程:SDK 初始化 / 数据库 Migration / 缓存预热

注意:UIKit 相关的操作必须在主线程,但大部分 SDK 的 init 是线程安全的。


六、启动耗时测量

6.1 Pre-main 耗时

在 Xcode 的 Scheme → Arguments → Environment Variables 中添加:

DYLD_PRINT_STATISTICS = 1        // 基础信息
DYLD_PRINT_STATISTICS_DETAILS = 1  // 详细信息

会输出类似:

Total pre-main time:  420.17 milliseconds (100.0%)
         dylib loading time: 154.88 milliseconds (36.8%)
        rebase/binding time:  37.43 milliseconds (8.9%)
            ObjC setup time:  52.29 milliseconds (12.4%)
           initializer time: 175.54 milliseconds (41.7%)

每一项对应的优化方向一目了然。

6.2 Post-main 耗时

main() 开头和首页 viewDidAppear 各打一个时间戳,相减就是 Post-main 耗时。

更精细的测量可以用 Instruments 的 App Launch 模板(Xcode 11+),它会自动标注各阶段的耗时。

6.3 MetricKit(线上监控)

iOS 13+ 提供了 MetricKit 框架,可以在线上采集启动耗时数据:

  • MXAppLaunchMetric:冷启动 / 恢复启动的耗时分布
  • 以直方图形式提供 P50 / P90 / P99 数据

七、优化优先级总结

按性价比从高到低排列:

优先级 优化项 预期收益 难度
★★★★★ 删除 +load,改用 +initialize 立竿见影
★★★★★ didFinishLaunching 分级初始化 几十到几百 ms
★★★★☆ 减少动态库数量 / 改用静态库 每个库 5-10ms
★★★★☆ 首页数据缓存 体感提升明显
★★★☆☆ 减少 OC 类数量 / 用 Swift Struct Rebase 阶段提升
★★★☆☆ 子线程并行初始化 分担主线程压力
★★☆☆☆ 二进制重排 约 10-30% Page Fault 减少
★★☆☆☆ 骨架屏 / 闪屏优化 体感优化(非真正提速)

八、一张图总结全流程

用户点击图标
    │
    ├── 内核 fork 进程,加载 Mach-O
    │
    ├── dyld 启动
    │     ├── [dyld 3/4] 读取启动闭包(大量工作已预先完成)
    │     ├── 加载动态库(系统库走 shared cache,极快)
    │     ├── Rebase(修复内部指针)
    │     ├── Bind(绑定外部符号,非懒绑定部分)
    │     └── 加载完成
    │
    ├── Objc Runtime 初始化
    │     ├── 注册所有类
    │     └── 处理 Category
    │
    ├── Initializers
    │     ├── +load 方法(尽量消灭它们!)
    │     └── C++ 静态构造函数
    │
    ╞══════════════════════ main() ═══════════════
    │
    ├── UIApplicationMain
    │
    ├── didFinishLaunchingWithOptions
    │     ├── 🔴 必须立即做的初始化
    │     ├── 🟡 延迟到首页出现后
    │     └── 🟢 延迟到用到时
    │
    ├── 首页 ViewController 初始化
    │     ├── viewDidLoad(发起网络请求)
    │     ├── viewWillAppear
    │     └── viewDidAppear ← 首帧渲染完成
    │
    ▼
用户看到首页 ✅

对组件化与模块化的思考与总结

 

前言

前段时间反复研读了蘑菇街 App 的组件化之路蘑菇街 App 的组件化之路·续iOS应用架构谈 组件化方案,然后又找到了其它一些研究组件化、模块化方案的文章,但是总觉得差点什么,所以还是决定从头开始思考。文章的标题起的好宽泛,感觉给自己挖了个深坑-。-,其实只是自己对组件化、模块化的一些看法、总结。

为什么

先总结下为什么要大动干戈的对代码分模块、拆组件。

代码量膨胀,不利于维护,更不利于新功能的开发

现在随便开发一个App的代码行数都是数以万计的,如果不对代码做合理的拆分,那简直就是灾难性的,估计只有最初的开发人员知道如何维护修改,如果换人开发的话,难以下手,更不用说开发新功能了。

不同业务代码耦合严重,难以多人合作,职责不分明

多人一起开发时,如果代码结构、模块化的不好,就很难对不同业务划分出分界线,难以明确各自的职责,牵一发动全身,出了问题更是容易相互扯皮(这个时候只能说一句“怪我咯o(╯□╰)o”),更不用提合并代码时的冲突了。

所以,合理的组织代码,划分模块、拆分组件是项目可以高效迭代的基础。

疑问

那到底什么是模块化、组件化?查资料的时候一会儿模块,一会儿组件,有什么联系,有什么区别?有人说这只是叫法习惯问题,知道大概意思就好,不用咬文嚼字,但是总觉得没有个“定义”感觉不踏实,所以还是求助了万能的维基百科=。=

模块化

维基百科的Modular programming的开头定义如下:

Modular programming is a software design technique that emphasizes separating the functionality of a program into independent, interchangeable modules, such that each contains everything necessary to execute only one aspect of the desired functionality.

接着,在Key aspects部分的开头也说了:

With modular programming, concerns are separated such that modules perform logically discrete functions, interacting through well-defined interfaces.

可以总结为:模块化的目的在于将一个程序按照其功能做拆分,分成相互独立的模块,以便于每个模块只包含与其功能相关的内容,模块之间通过接口调用。

当然,模块化编程的具体概念是包含了很多内容的,读者可以详细阅读下维基百科的定义。

组件化

关于组件化,能找到的比较接近的就是维基百科的Component-based software engineering,其开头内容如下:

Component-based software engineering (CBSE), also known as component-based development (CBD), is a branch of software engineering that emphasizes the separation of concerns in respect of the wide-ranging functionality available throughout a given software system. It is a reuse-based approach to defining, implementing and composing loosely coupled independent components into systems.

乍一看,这不是跟模块化Modular programming的定义很相似嘛=。=
的确,文中也提到组件化跟模块化是很类似的,都是主要为了对一个系统做拆分,比如文中提到:

All system processes are placed into separate components so that all of the data and functions inside each component are semantically related (just as with the contents of classes). Because of this principle, it is often said that components are modular and cohesive.

同时,组件还具有其他属性,如可替代性(substitutable),通过接口(interface)访问,可重用性(Reusability)等,读者可自行阅读。

对比

难道模块化跟组件化真的是完全一样的?的确,很多时候两者的概念完全可以相互替换,在实践中更是经常混用。

在求助谷歌,甚至阅读了大量的前端技术等其它技术领域的组件化、模块化的文章后,我觉得如果真要将它们两者做个对比,大概总结如下:

  • 模块化强调的是拆分,无论是从业务角度还是从架构、技术角度,模块化首先意味着将代码、数据等内容按照其职责不同分离,使其变得更加容易维护、迭代,使开发人员可以分而治之。
  • 组件化则着重于可重用性,不管是界面上反复使用的用户头像按钮,还是处理数据的流程中的某个部件,只要可以被反复使用,并且进行了高度封装,只能通过接口访问,就可以称其为“组件”。

当然,并不是说模块就不能被复用,还是要根据实际情况来看,使系统更加容易维护,开发更加方便,才是最终目的。

如何拆分

无论是模块化还是组件化,首先肯定是做拆分,但是如何拆分?怎么下手?依照什么标准?
下面简单总结一些方法。

横向拆分业务、功能模块

很多时候,一个完整的软件程序是同时为多种业务服务的,所有可以优先按照业务的不同,将整个系统进行拆分。

如一个电商类型的App,就可以分出商品浏览模块、订单模块、购物车模块、消息模块、支付模块等。又如微信这种社交型应用,可以拆分出联系人模块、朋友圈模块、聊天模块、消息模块等。

其实就是从用户使用的角度,按照功能的不同划分模块,当然,这种业务模块是要由各种技术模块作支撑的。

横向拆分业务模块示例编辑

纵向拆分技术、架构模块

如果脱离业务,只从技术角度来看,则可以尝试纵向对系统拆分模块。

其实这里的纵向拆分跟对系统的架构做分层有点像=。=,现如今只要需要联网请求API的App都免不了有网络请求、数据缓存、数据加工处理、数据展示、反馈用户操作等行为,所有这些环节层层递进才能完成一个功能。

当开始着手规划一个完整软件系统,或者说App时,就可以按照这些环节划分模块,纵向分层次的组合,搭建出一个以技术模块组成的简易系统架构图,方便后续的开发,如下图。

纵向拆分技术模块示例编辑

大体上的技术模块划分好以后,就可以按照具体的需求,实现每个技术模块,乃至细分出更多的子模块,如缓存模块可能由键值对缓存(NSUserDefaults)、数据库缓存(SQLite、Realm)、图片缓存等子模块组成,根据具体情况而定。

从界面入手,拆分可视化组件

现在再来看看如何从界面入手拆分可复用的组件。假如有如下布局的界面:

从界面入手拆分可视化组件编辑

很多时候,像界面里面的“搜索框”、“头像按钮”、“内容框”和显示提示用的“加载中”HUD,甚至整个内容的Cell,都是可能在很多地方出现的,而且本身的样式、功能比较集中。
如头像可能要支持点击跳转,头像图片圆角,内容框有特定的Padding和字体大小等,所以可以将这些界面上的元素“提”出来,单独封装成一个组件,供整个App复用。或者直接用第三方的组件,如图中的“加载中”HUD,就可以用SVProgressHUD、MBProgressHUD等开源库。

其实这里的组件有种sunnyxx大大提到过的“Self-Manager”的味道=。=,组件本身负责自己的所有功能、样式,参考:iOS 开发中的 Self-Manager 模式。当然跟前端的组件化也挺像的,如React里面的component,样式、功能都封装到component里面,以便更好地解耦复用。

从数据入手,拆分数据加工组件

再来看看从数据入手,拆分可复用的组件。假如有如下数据处理流程:

一数据处理流程示例编辑

其实大部分时候,拆分模块、组件都是以清晰的流程、逻辑为基础的,就如上图的过程,当流程清晰后,可以拆分复用的组件也就“出来了”。

如从JSON数据实例化出对应的Entity对象,这个功能就是一个完整独立的组件,当然实际开发中会用Mantle、JSONModel等库实现。

以此类推,校验、格式化日期(如“几秒钟前、几天前”)、多语言等环节,都可以独立成一个个的组件。

当然,这里的组件一般是指能在多个模块使用的功能组件,如果只是在某个界面上才用的,倒不如放到ViewModel、Presenter等这些直接跟界面有关的类里面。

小节

上面的几种方法比较适合不知道如何下手时使用=。=,真正的开发中,还是要根据实际情况考虑,情况也会复杂些。不过倒是可以总结几点原则:

  • 单一职责,意味着一个模块、一个组件只做一件事,绝不多做。
  • 正交性,意思是不重复,一个模块跟另一个模块的职责是正交的,没有重叠,组件也是一样。
  • 单向依赖,模块之间最多是单向的依赖,如果出现A依赖B,B也依赖A,那么要么是A、B应该属于一个模块,要么就是整体的拆分有问题。一个完整的软件系统的模块依赖应该是一张有向无环图。(当然这是最终理想=。=)
  • 紧凑性,模块、组件对外暴露的接口、属性应该尽可能的少,接口的参数个数也要少。
  • 面向接口,模块、组件对外提供服务时最好是面向接口的,以便后期可以灵活的变更实现。

最后

一切为了更加干净整洁的代码,“May the clean code be with you”

参考

面试常问的 RunLoop,到底在Loop什么?

大家好,我是嘉豪。

这两天我又把 RunLoop 重新翻了一遍。这个话题在 iOS 里其实一点都不新,甚至已经算老朋友了,但有意思的是:很多同学平时天天在和它打交道,却未必真的知道它在干什么。

比如下面这些场景,你大概率都见过:

  • 为什么 NSTimer 在滑动 ScrollView 的时候会“失灵”?
  • 为什么 performSelector:afterDelay: 有时候不执行?
  • 为什么子线程里的定时任务就是不回调?
  • 为什么主线程明明没有代码在跑,却也不会退出?

这些问题看起来东一榔头西一棒子,实际上背后都能收敛到同一个东西:RunLoop

所以这篇文章,我不打算只聊“RunLoop 是什么”,而是想带大家把这件事真正串起来:它和线程是什么关系、内部都有哪些角色、每一轮循环在做什么,以及它到底怎么影响我们平时的业务开发。RunLoop 本质上是线程基础设施的一部分,是一个事件处理循环:有事就处理,没事就让线程休眠;主线程的 RunLoop 会在应用启动过程中由系统自动建立并运行,子线程则通常需要你自己决定是否显式启动。

前言:为什么 RunLoop 值得理解?

我一直觉得,RunLoop 这个东西最容易被误解的地方,在于它听起来太“底层”了,于是很多人会下意识觉得:业务开发也用不上。

但真相往往比较朴素,甚至有点滑稽:你不是用不上 RunLoop,而是你天天在被 RunLoop 影响。

主线程为什么能不断响应点击、手势、定时器、刷新 UI?因为它背后一直有一个 RunLoop 在接收事件、分发事件、决定什么时候睡眠、什么时候醒来。Apple 官方对它的定义也很直白:RunLoop 是线程关联的基础设施之一,用来调度任务并协调传入事件的接收。

所以理解 RunLoop,不只是为了背面试题,而是为了在遇到卡顿、定时器异常、线程保活、异步回调这些问题时,不至于两眼一黑,开始对着代码做法事。

RunLoop 到底是什么?

先别急着上源码,我们先用最朴素的方式理解它。

如果让我们自己写一个“线程不退出,但能不断处理事件”的模型,伪代码大概会长这样:

function loop() {
  while (!stopped) {
    const event = getNextEvent();
    if (event) {
      handle(event);
    } else {
      sleep();
    }
  }
}

RunLoop 的本质,和这个思路几乎一模一样。它是一个“事件循环”模型:线程进入循环后,反复执行“接收消息 -> 处理消息 -> 没消息就休眠 -> 被唤醒后继续处理”这一套流程。Apple 官方文档也明确说明,RunLoop 是一个 event processing loop;而从经典的 CFRunLoop 源码解析视角来看,它也完全可以理解成线程内部长期运行的事件循环。

所以从结果上看,RunLoop 解决的是两个核心问题:

  1. 让线程在有事做的时候保持工作。
  2. 让线程在没事做的时候别空转耗 CPU。

这个设计非常重要。否则主线程如果一直死循环轮询事件,手机发热和掉电会快得像开了涡轮;如果线程处理完一个任务就退出,那 App 也根本不可能持续响应事件。宇宙不会允许这种离谱工程存在太久。

RunLoop 和线程是什么关系?

这一点其实是 RunLoop 最关键的前置知识。

可以先记住一句话:RunLoop 和线程是一一对应理解的。

Apple 文档里给出的说法是:每个线程都有关联的 RunLoop 对象,主线程的 RunLoop 会由应用框架自动配置并运行,而二级线程是否运行 RunLoop,则取决于你自己。只有在你真的需要它的时候,才需要显式启动。

这句话翻译成人话就是:

  • 主线程一定有 RunLoop,而且系统已经帮你跑起来了。
  • 子线程就算能拿到 RunLoop,也不代表它已经在跑。
  • 如果子线程要长期存活、处理 Timer、接收 Selector、接收 Port/Source 事件,那你得自己把它跑起来。

还有一个很容易踩坑的点:RunLoop 里必须至少有一个输入源(source)或者 timer,否则一启动就会立刻退出。 Apple 官方文档对此写得很直白。

所以,很多同学在子线程里写个 Timer,结果发现根本不回调,本质原因通常不是 Timer 坏了,而是线程的 RunLoop 根本没跑,或者刚跑起来就退出了。

RunLoop 里到底有什么?

从概念上讲,一个 RunLoop 主要围绕四类东西运转:

  • Mode
  • Source
  • Timer
  • Observer

Apple 文档里把 RunLoop Mode 描述为:一组要监听的 input sources、timers,以及要通知的 observers 的集合。每次 RunLoop 运行时,只会在某个特定 mode 下处理对应的事件;不属于当前 mode 的 source/timer,不会在这一轮被处理。

1. Mode:不是模式切换开关,而是“事件分组”

很多人第一次看 Mode,会觉得这名字有点抽象。其实你可以把它理解成:

RunLoop 当前这一轮,只看哪一组事件。

这就像你开了一个筛子。默认状态下,线程处理一部分事件;当用户开始拖拽 ScrollView 时,RunLoop 可以切到另一个 mode,只处理和拖拽更相关的输入,暂时忽略别的一些东西。Apple 也明确说明了,mode 的作用是根据 source 来过滤事件,而不是根据事件类型本身来过滤。

常见的几个模式可以先记住:

  • NSDefaultRunLoopMode / kCFRunLoopDefaultMode:默认模式,大多数情况下主线程都在这个模式下运行。
  • UITrackingRunLoopMode:控件跟踪时使用的模式,比如滑动列表时。Apple 当前文档对它的描述很直接:这是 tracking 发生时使用的模式,可用于让某些 timer 在 tracking 期间继续触发。
  • NSRunLoopCommonModes / .common:这是一个“伪 mode”,表示一组 common modes。把对象加到这里后,RunLoop 会在所有 common modes 下都监控它。

2. Source:事件从哪来

Apple 官方主要把 input source 分成两类:

  • Port-Based Source:基于端口,通常由内核自动发信号。
  • Custom Input Source:自定义 source,需要你自己定义事件传递机制,并在另一个线程手动 signal。

如果你平时看的是 CFRunLoop 源码分析文章,那还会经常见到 Source0Source1 这套说法。可以先粗暴理解成:

  • Source0:更偏“手动触发”的 source;
  • Source1:更偏“基于 port 被唤醒”的 source。

这两套说法并不冲突,只是一个更偏官方抽象分类,一个更偏底层实现语境。

3. Timer:线程给自己定闹钟

Timer 属于时间源。Apple 文档里强调了两件很重要的事:

  • Timer 不是实时机制,它不是“时间一到,立刻绝对执行”;
  • Timer 也受 RunLoop mode 影响,如果它不在当前被监控的 mode 里,就不会触发;如果 RunLoop 根本没在跑,那它永远不会触发。

这也是为什么你不能把 NSTimer 当成一把精确到毫秒的手术刀。它更像一个“尽量按时提醒你”的闹钟,而不是原子钟。

4. Observer:旁观者,但很重要

Observer 不产生事件,它负责观察 RunLoop 当前走到了哪一步。Apple 文档列出的典型观察时机包括:

  • 即将进入 RunLoop
  • 即将处理 Timer
  • 即将处理 Input Source
  • 即将进入休眠
  • 刚从休眠中唤醒
  • 即将退出 RunLoop

这个东西很关键,因为系统里很多“顺便做一下”的工作,恰恰就是挂在这些观察点上的。

RunLoop 一次循环到底会发生什么?

Apple 官方文档把一次 RunLoop 的执行顺序列得很清楚,大体可以压缩成下面这条主线:先通知 observer -> 处理 timer/source -> 没事就休眠 -> 被 timer、source、超时或显式唤醒后再继续处理。

为了更好理解,我把它翻译成一个更贴近开发直觉的版本:

1. 进入 loop
2. 通知 observer:我要处理 timer 了
3. 通知 observer:我要处理 source 了
4. 处理非 port 的 source
5. 如果没有可立即处理的事,就准备休眠
6. 线程休眠,等待被 timer / source / wakeup 唤醒
7. 被唤醒后,处理对应事件
8. 决定是继续下一轮,还是退出 loop

如果你看过一些调用栈或者源码解析文章,会发现 RunLoop 的底层核心休眠/唤醒机制和 mach port 消息密切相关;这也是为什么它能做到“没事就睡,有事马上醒”。

为什么滑动列表时,NSTimer 会不执行?

这个问题几乎是 RunLoop 的必考题了。

原因并不神秘:你创建出来的 Timer,大概率默认被加在了 DefaultMode 里;而当你拖拽 ScrollView 时,主线程 RunLoop 会进入 tracking 相关的 mode,这时候默认 mode 下的 timer 就不会被处理。 Apple 文档明确说明,timer 和 source 都和特定 mode 绑定;不在当前 mode 里的对象,要等 RunLoop 以后切回支持它的 mode 才会触发。

所以解决思路也就顺理成章了:把 Timer 加到 common modes。

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
                                        repeats:YES
                                          block:^(NSTimer * _Nonnull timer) {
    NSLog(@"tick");
}];

[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

common 本质上不是一个真正的独立 mode,而是一个“公共模式集合”。把 timer 加进去以后,它就能在多种 common mode 下都被监控,自然也就不会在滚动时轻易“哑火”了。

RunLoop 在系统里都干了些什么?

如果只把 RunLoop 理解成“Timer 的家”,那就太小看它了。结合 Apple 文档和经典的 CFRunLoop 源码解析,RunLoop 至少和下面这些机制高度相关。

1. 自动释放池的维护

经典分析里提到,主线程 RunLoop 上挂了和 autorelease pool 相关的 observer:进入 loop 时创建池,准备休眠时销毁旧池并重建,退出 loop 时再做一次销毁。也就是说,我们很多主线程回调,其实天然就被 autorelease pool 包着。

2. 事件响应

触摸、手势、各种输入事件之所以能不断进入 App,被分发到 UIWindowUIViewUIGestureRecognizer,背后同样离不开主线程 RunLoop 对事件源的处理。经典解析中也展示了系统事件如何通过 Source1 进入应用内部分发链路。

3. 界面刷新与提交

很多 setNeedsLayoutsetNeedsDisplay 并不会让 UI 立刻重绘,而是先标记“需要更新”,再等到 RunLoop 的某个合适时机统一提交。经典分析中把这部分和 BeforeWaiting / Exit 这些阶段关联了起来。

4. performSelector 系列方法

Apple 官方文档明确说了:performSelector:onThread: 这一类调用,目标线程必须有一个 active run loop;performSelector:withObject:afterDelay: 也是在当前线程的下一次 run loop cycle 中调度执行。

所以它们“有时不执行”的根本原因,经常不是 selector 本身有问题,而是:

  • 当前线程没有 RunLoop
  • 目标线程的 RunLoop 没启动
  • 或者当前 mode 不对

什么时候你需要手动启动子线程 RunLoop?

Apple 给出的建议其实很实用:只有当子线程需要更强交互性时,才需要显式运行 RunLoop。 比如下面这些场景:

  • 线程间通过 port 或自定义 input source 通信
  • 在线程里使用 timer
  • 使用 performSelector...
  • 想让这个线程长期存活,周期性处理任务

如果你的线程只是做一个明确的、一次性的耗时任务,比如图片解码、文件处理、纯计算,那干完退出往往更合适,没必要强行塞一个 RunLoop 进去。别什么都开火车,线程也会累。

一个很常见的“子线程保活”写法大概是这样:

- (void)threadMain {
    @autoreleasepool {
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

这个写法的核心不是 NSMachPort 本身有多神秘,而是:先往 RunLoop 里塞一个 source,避免它因为空空如也而直接退出,然后再让 RunLoop 跑起来。 Apple 官方文档也明确说明,secondary thread 的 RunLoop 在启动前必须至少附着一个 input source 或 timer,否则会立刻结束。

几个常见误区

误区一:线程创建出来,就等于 RunLoop 在工作

不是。主线程是系统自动托管的,子线程通常需要你自己决定是否获取、配置并运行 RunLoop。

误区二:NSTimer 不回调,就是 Timer 不准

也不一定。它可能只是:

  • 当前 RunLoop 没跑;
  • 当前 mode 不匹配;
  • 当前正在执行长任务,错过了触发时机。Apple 官方文档明确说明 timer 不是实时机制,而且如果错过一个或多个计划时间点,也不会把所有错过的触发一股脑补回来。

误区三:NSRunLoopCFRunLoop 完全一样,随便跨线程改

这也不对。Apple 文档提到,Core Foundation 那套 API 通常是线程安全的;但 NSRunLoop 本身并不像底层 CFRunLoopRef 那么天然线程安全,最好只在拥有它的线程里修改它。

小结

到这里,RunLoop 的主线其实就已经很清楚了。

它不是某个冷门 API,也不只是 NSTimer 的背景板。它本质上是线程背后的事件循环机制,负责把 source、timer、observer 和 mode 组织起来,让线程做到:

  • 有事件就处理;
  • 没事件就休眠;
  • 在合适的时机完成事件分发、定时任务、界面提交等工作。

很多平时看起来零碎的问题,比如 Timer 在滚动时失效、子线程任务不回调、performSelector 不执行、UI 为什么不是立刻刷新,本质上都能用 RunLoop 这套模型解释清楚。

所以 RunLoop 这东西,真的不是为了面试八股才学。它更像是一把钥匙:平时你可能把它丢在抽屉里,但一旦遇到线程、事件、时序、刷新相关的问题,它就会突然变得非常好用。

Flutter GetX 深入浅出详解

一、GetX 是什么?

GetX 是 Flutter 生态中的一个 全家桶式框架,它不只是一个状态管理方案,而是把状态管理、路由导航、依赖注入三件事打包在了一起。

很多人第一次接触 GetX 的感受是:"怎么什么都能做?" —— 这既是它的优势,也是它的争议所在。

GetX 的三大核心模块

GetX
 ├── 状态管理(State Management)  ── 替代 setState / Provider / Bloc
 ├── 路由管理(Route Management)  ── 替代 Navigator 系列 API
 └── 依赖注入(Dependency Injection)── 替代 Provider / get_it

为什么这么多人用?

一个字:简单

用 Bloc 写一个计数器功能,你需要:Event 类 + State 类 + Bloc 类 + BlocProvider + BlocBuilder,至少 4~5 个文件。

用 GetX 写同样的功能:一个 Controller 类 + Obx(() => Text()),两行搞定。


二、状态管理

2.1 两种响应式风格

GetX 提供了两种状态管理方式,理解它们的区别是用好 GetX 的第一步。

简单状态管理(GetBuilder)

  • 手动调用 update() 通知刷新
  • 类似 setState(),但作用域更小
  • 性能好,适合不需要频繁自动响应的场景

工作原理很朴素:Controller 内部维护一个监听者列表,调用 update() 时遍历通知所有 GetBuilder Widget 重建。本质就是一个 手动版的观察者模式

响应式状态管理(Obx)

  • 变量用 .obs 标记,变化时自动触发 UI 刷新
  • 不需要手动调用 update()
  • 类似 Vue 的响应式、MobX 的 observable
// 声明
var count = 0.obs;

// 修改(自动触发 UI 刷新)
count.value++;

// UI 监听
Obx(() => Text('${controller.count}'))

2.2 .obs 的底层原理

.obs 是 GetX 最核心的魔法。要理解它,需要拆解三个问题:

问题一:.obs 做了什么?

当你写 var count = 0.obs,实际上是把一个普通的 int 包装成了一个 RxInt 对象。这个 Rx 对象内部持有:

  • 真正的值(_value
  • 一个监听者列表(Stream

它本质上就是一个 带通知能力的值容器

问题二:修改值时发生了什么?

当你写 count.value++Rx 对象的 set value 被触发。在 setter 内部:

  1. 更新 _value
  2. 通过 Stream 广播一个"值变了"的事件
  3. 所有订阅了这个 Stream 的监听者收到通知

问题三:Obx 怎么知道要监听哪些变量?

这是 GetX 最巧妙的设计。Obx 并不需要你手动告诉它"我依赖了哪些变量",它是 自动收集依赖 的。

原理分三步:

  1. Obx 在首次 build 时,先打开一个"全局监听开关"
  2. 执行你传入的 builder 函数(比如 () => Text('${count.value}')
  3. count.value 的 getter 被调用时,Rx 对象检测到"监听开关"是打开的,就把自己注册到 Obx 的依赖列表中
  4. builder 执行完毕,关闭"监听开关"

之后任何被收集到的 Rx 变量发生变化,Obx 就会自动重建。

Obx 首次 build
  → 开启"依赖收集模式"
  → 执行 builder: () => Text('${count.value}')
    → count.value 的 getter 被调用
    → count(Rx 对象)发现正在收集依赖,把自己注册进去
  → 关闭"依赖收集模式"
  → 依赖收集完成:[count]

之后 count.value++ 触发
  → Rx 广播变化
  → Obx 收到通知,重新执行 builder

这个机制和 Vue 3 的 watchEffect、MobX 的 autorun 原理几乎一模一样 —— 基于 getter 劫持的自动依赖收集

2.3 GetBuilder 的底层原理

相比之下,GetBuilder 的原理简单得多:

  1. GetBuilderinitState 时,把自己注册到 Controller 的监听者列表
  2. Controller 调用 update(),遍历列表,调用每个 GetBuildersetState
  3. GetBuilderdispose 时,从列表中移除自己

没有 Stream、没有依赖收集,就是最朴素的 观察者模式 + setState

2.4 两种方式怎么选?

场景 推荐 原因
表单页面、简单列表 GetBuilder 手动控制,性能开销最小
数据频繁联动、多变量交叉依赖 Obx + .obs 自动依赖收集,代码更简洁
超大列表、高性能场景 GetBuilder + update([id]) 可以精确控制刷新范围

三、依赖注入

3.1 是什么?

GetX 内置了一套依赖注入系统,核心 API 就两个:

  • Get.put(Controller()) —— 注册
  • Get.find<Controller>() —— 获取

你可以把它理解成一个 全局的"服务柜台":先把东西放进去,需要的时候按类型取出来。

3.2 底层原理

GetX 内部维护了一个 全局的 Map<String, Object>,key 是类型名(或类型名 + tag),value 是实例。

全局容器(简化理解):
{
  "HomeController": HomeController 实例,
  "UserService": UserService 实例,
  "ApiClient_v2": ApiClient 实例(带 tag)
}

Get.put() 就是往 Map 里写,Get.find() 就是从 Map 里读。

3.3 四种注册方式的区别

方式 何时创建 何时销毁 适用场景
Get.put() 立即创建 手动或路由关闭时 页面 Controller
Get.lazyPut() 首次 find 同上 可能用不到的依赖
Get.putAsync() 立即创建(支持异步) 同上 需要异步初始化的服务
Get.create() 每次 find 都新建 不自动销毁 每次需要新实例的场景

3.4 SmartManagement:自动内存管理

GetX 最被低估的能力之一。它有一套 智能内存管理机制,可以在路由关闭时自动销毁关联的 Controller。

三种模式:

  • full(默认):不被任何路由或 Widget 使用的 Controller 自动销毁
  • onlyBuilder:只有通过 GetBuilder / GetX 使用的 Controller 才自动管理
  • keepFactory:销毁实例但保留工厂函数,下次 find 时重新创建

这解决了 Flutter 状态管理中一个常见的痛点:谁来负责销毁 Controller? 在 Provider/Bloc 中你需要手动处理,GetX 帮你自动化了。


四、路由管理

4.1 为什么要替换 Flutter 原生路由?

Flutter 原生路由的痛点:

  • 跳转需要 context,在非 Widget 层(Service、Controller)中很难拿到
  • 传参和接收返回值写法繁琐
  • 路由动画自定义复杂

GetX 的路由通过全局 NavigatorKey 持有 Navigator 的引用,所以 不需要 context 就能跳转。

4.2 底层原理

GetX 路由的核心做了两件事:

第一:全局 NavigatorKey

GetX 在 GetMaterialApp 初始化时,创建了一个全局的 GlobalKey<NavigatorState>,保存在静态变量中。之后所有路由操作都通过这个 key 拿到 Navigator,不再依赖 context。

第二:路由与依赖注入联动

这是 GetX 路由最独特的地方。当你用 Get.to(HomePage()) 跳转时:

  1. 创建一个路由条目
  2. 如果 HomePage 关联了 Controller(通过 GetBuilderBindings),自动 put 进依赖容器
  3. 当路由 pop 时,自动 delete 关联的 Controller
Get.to(HomePage())
  → 创建路由
  → 自动注册 HomeController(如果有 Binding)
  → 用户在 HomePage 操作...
  → Get.back()
  → 路由 pop
  → 自动销毁 HomeController
  → 内存释放

这就形成了一个 路由驱动的生命周期管理:Controller 的生死和页面的进出自动绑定。

4.3 Bindings:依赖与路由的桥梁

Bindings 是连接路由和依赖注入的纽带。它定义了"进入某个页面时需要准备哪些依赖"。

你可以把它类比为 iOS 的 viewDidLoad —— 页面加载时做初始化工作,页面销毁时自动清理。

4.4 中间件(Middleware)

GetX 路由支持中间件,可以在路由跳转前/后插入逻辑:

  • 登录拦截:未登录自动跳转登录页
  • 权限检查:没有权限的页面拒绝访问
  • 埋点:自动记录页面访问

中间件按优先级执行,可以中断跳转(返回 null 表示拦截),和 Web 框架的中间件概念一致。


五、GetX 的其他能力

GetX 是个全家桶,除了三大核心模块,还打包了很多实用工具:

能力 说明
国际化(i18n) 'hello'.tr 即可翻译,动态切换语言
主题切换 Get.changeTheme() 一行切换深色/浅色
网络请求 GetConnect 封装了 HTTP 客户端
本地存储 GetStorage 类似 SharedPreferences 但更快
响应式表单验证 配合 .obs 做实时校验
Snackbar / Dialog / BottomSheet 不需要 context 的全局弹窗
Worker ever / debounce / interval 等响应式工具

Worker 机制

Worker 是 GetX 响应式系统中很实用的工具,用于对 .obs 变量的变化做 节流、防抖、一次性监听 等处理:

Worker 行为
ever(count, callback) 每次变化都执行
once(count, callback) 只在第一次变化时执行
debounce(count, callback) 停止变化后一段时间才执行(搜索场景)
interval(count, callback) 变化期间按固定间隔执行(节流)

底层实现就是对 Rx 的 Stream 做了 listen / first / debounceTime / throttle 等 Dart Stream 操作的封装。


六、GetX 的底层架构总结

把所有模块串起来,GetX 的底层可以概括为三个核心机制:

1. Rx + Stream:响应式引擎

.obs 变量(Rx 对象)
  └── 内部持有 Stream
        └── Obx / Worker 订阅 Stream
              └── 变量变化 → Stream 广播 → 订阅者响应

这是 Dart 语言自带的 Stream 机制,GetX 没有发明新东西,只是在 Stream 之上做了 语法糖封装.obsObxever 等),降低了使用门槛。

2. 全局 Map:依赖注入容器

静态 Map<String, InstanceInfo>
  └── Get.put() 写入
  └── Get.find() 读取
  └── Get.delete() 删除
  └── SmartManagement 自动清理

没有复杂的 IoC 容器,就是一个 Map。简单直接。

3. 全局 NavigatorKey:脱离 context 的路由

GetMaterialApp 初始化 → 持有全局 NavigatorKey
  └── Get.to() / Get.back() → 通过 Key 拿到 Navigator → 执行路由操作
  └── 路由变化 → 触发 Bindings → 联动依赖注入的创建/销毁

七、GetX 的争议

赞成派观点

  • 开发效率极高:原型开发、中小项目飞速
  • 学习曲线平缓:API 直觉化,新手友好
  • 全家桶一站式:不用在多个库之间做选型和协调

反对派观点

  • 过度封装:把 Flutter 的很多设计理念(如 BuildContext、InheritedWidget)绕过了,新手可能对 Flutter 本身理解不深
  • 隐式行为多:自动依赖收集、自动销毁,出了问题难以调试
  • 大型项目维护难:全局状态 + 隐式依赖,随着项目变大,依赖关系会变得不透明
  • 和 Flutter 官方方向渐行渐远:Flutter 团队推崇的是 Riverpod / Provider 思路

客观建议

项目类型 推荐度 建议
个人项目 / Demo 强烈推荐 快速出活
中小型商业项目 推荐 配合良好的分层架构使用
大型团队协作项目 谨慎 建议考虑 Riverpod / Bloc,或严格约束 GetX 的使用范围
学习 Flutter 阶段 不推荐先学 先理解 Flutter 原生机制,再用 GetX 提效

八、GetX vs 其他状态管理方案

维度 GetX Provider Riverpod Bloc
学习成本
模板代码量 极少
依赖 context 不需要 需要 不需要 需要
内置路由
内置依赖注入 自身就是 DI 自身就是 DI 无(需配合)
可测试性
官方推荐 是(早期) 是(现在) 社区主流
适合规模 小中型 中型 中大型 大型

九、一句话总结

GetX 的哲学是 "约定优于配置,简单优于正确"。它牺牲了一些架构上的严谨性,换来了极致的开发效率。理解它的底层原理(Rx Stream + 全局 Map + 全局 NavigatorKey),你就能用好它,也知道它的边界在哪里。

iOS 可视化埋点与无痕埋点详解

一、为什么需要不同的埋点方式?

最早大家都是 手动写代码埋点:在每个按钮点击、页面出现的地方,手动调用 track("事件名")。这种方式最精确,但有两个痛点:

  1. 每加一个埋点就要改代码、发版,周期太长
  2. 埋点需求爆炸式增长,开发根本忙不过来

于是业界开始思考:能不能让机器自动采集?能不能让运营自己配置?

这就催生了三种埋点方式的演进:

手动代码埋点 → 无痕埋点(全自动) → 可视化埋点(半自动)
维度 代码埋点 无痕埋点 可视化埋点
谁来埋 开发 机器自动 运营圈选
需要发版吗 需要 不需要 不需要
能带业务参数吗 能(商品ID、金额等) 不能 有限支持
数据量 按需 巨大 按需
精确度 最高 最低 中等

二、无痕埋点(全埋点)

一句话理解

不写任何埋点代码,SDK 自动采集用户的所有操作。

原理:偷梁换柱

iOS 有个强大的 Runtime 机制叫 Method Swizzling —— 可以在运行时把系统方法的实现"偷偷换掉"。

举个例子,iOS 中所有按钮点击最终都会走 UIControlsendAction:to:forEvent: 方法。SDK 做的事情就是:

原本的调用链:
  用户点击按钮 → sendAction → 执行业务逻辑

Swizzle 之后:
  用户点击按钮 → SDK 拦截,记录"谁在哪个页面点了什么" → 再调用原始 sendAction → 执行业务逻辑

业务方完全无感知,SDK 悄悄在中间插了一层数据采集。

SDK 需要 Hook 哪些地方?

拦截点 能采集到什么
UIControl 的点击事件 按钮、开关、滑块等操作
UITableView 的 Cell 点击 列表项点击
UIViewController 的页面出现 页面浏览量(PV)
UIGestureRecognizer 手势操作

核心难题:怎么标识"点的是哪个按钮"?

SDK 需要给每个 UI 元素生成一个 唯一标识(ViewPath),方式是沿着 View 层级往上爬,记录每一层的类名和位置:

UIWindow / UINavigationController / 首页VC / UIView / UITableView / 第3个Cell / 购买按钮

转化成路径就是:

UIWindow[0]/UINavigationController[0]/HomeVC[0]/UIView[0]/UITableView[0]/Cell[3]/UIButton[0]

这就像是给每个按钮一个"门牌号"。

致命缺陷

  1. 门牌号不稳定:UI 稍微改一下层级(比如在按钮外面多套一层 View),路径就变了,之前的数据就对不上了

  2. 只知道行为,不知道内容:SDK 能告诉你"用户点了第3个 Cell 里的按钮",但不知道那个 Cell 显示的是什么商品、多少钱

  3. 数据量爆炸:用户每一次点击、每一次滑动都会上报,90% 的数据可能没人看


三、可视化埋点

一句话理解

在无痕埋点的基础上,加了一个"后台圈选"的功能。运营在后台看到 App 截图,用鼠标点选要追踪的元素,SDK 只上报被选中的事件。

工作流程

第一步:App 和后台建立 WebSocket 连接

第二步:App 截图 + View 树结构 → 发给后台
       (后台能看到 App 当前界面的"透视图")

第三步:运营在后台的截图上点击"立即购买"按钮
       → 后台自动识别出这个按钮的 ViewPath
       → 运营给它命名为 "click_buy_button"

第四步:后台把配置下发给 SDK
       { viewPath: "xxx", eventName: "click_buy_button" }

第五步:SDK 在 Hook 点拦截事件时,拿当前元素的 ViewPath 去配置表里匹配
       → 匹配到了才上报,匹配不到就忽略

和无痕埋点的本质区别

无痕埋点:先采集所有数据 → 后期在数据平台筛选(先采后筛)
可视化埋点:先配置要采什么 → 只采集配置过的(先筛后采)

这就像是:

  • 无痕埋点 = 装了 360 度全景摄像头,24 小时录像,需要的时候回看
  • 可视化埋点 = 在关键位置装定向摄像头,只拍你关心的区域

优势

  • 运营自助:不需要开发介入,运营在后台圈选即可生效
  • 动态生效:配置下发后立即生效,不需要发版
  • 数据可控:只采集被圈选的事件,数据量小

局限

  • 依赖 ViewPath 稳定性:和无痕埋点一样,如果 UI 层级变了,之前圈选的配置就失效了
  • 无法携带复杂业务参数:你能圈选"购买按钮被点击",但很难自动带上"买的是哪个商品"
  • WebView / H5 页面支持复杂:需要额外注入 JS SDK

四、实战中怎么选?

成熟的 App 不会只用一种,而是 混合使用

场景 推荐方式 原因
页面 PV、App 启动/退出 无痕埋点 标准化行为,不需要业务参数
运营活动按钮、Tab 切换 可视化埋点 需求变化快,运营自助配置
支付、注册、加购、分享 代码埋点 需要精确的业务参数(金额、商品ID)

一个简单的原则:

数据越重要、越需要业务参数的事件,越应该用代码埋点;越通用、越标准化的行为,越适合自动采集。


五、ViewPath 稳定性:所有自动埋点方案的阿喀琉斯之踵

ViewPath 不稳定是无痕埋点和可视化埋点最大的技术挑战。业界的应对思路:

  1. 用 accessibilityIdentifier 做锚点:给关键元素设置固定 ID,优先用 ID 而不是层级位置来标识
  2. 模糊匹配:不要求路径完全一致,允许中间层级有增减,只要首尾和关键节点匹配度达到 80% 就算命中
  3. 哈希指纹:结合元素类型、文本内容、相对位置等多维度信息生成指纹,不完全依赖层级路径

六、SwiftUI 时代的新挑战

传统方案依赖 UIKit 的 View 层级树,但 SwiftUI 的渲染机制完全不同 —— 开发者写的 Button 和实际渲染出来的 View 层级之间没有稳定的对应关系。

目前的解决方向:

  • 利用 SwiftUI 的 ViewModifier 机制,做类似 .tracked("buy_button") 的声明式埋点
  • 借助 Accessibility Tree(辅助功能树)作为更稳定的元素标识来源
  • 通过 SwiftUI Introspect 获取底层 UIKit View 做桥接

这个领域还在发展中,还没有像 UIKit 时代那样成熟的方案。


七、隐私合规

  • 截图上传时必须对密码框、身份证号等敏感区域做 模糊处理
  • 不采集键盘输入内容
  • 需要在隐私政策中明确告知用户数据采集范围
  • 遵循 GDPR / 中国个人信息保护法
  • SDK 必须提供关闭开关

八、业界参考

平台 特点
GrowingIO 国内可视化埋点先驱,圈选体验好
神策 Sensors Analytics 全埋点 + 可视化 + 代码埋点全覆盖,iOS SDK 开源
Mixpanel 可视化埋点 + 代码埋点,国际主流
Heap 全埋点理念的代表,"Capture Everything"
Firebase Analytics Google 出品,自动事件 + 自定义事件

iOS 图片取色完全指南:从像素格式到工程实践

本文从一个真实的取色 Bug 出发,系统梳理 iOS 图片取色所需的基础知识,包括色彩模型、色彩空间、位深度、像素格式、图片文件格式,以及业界主流的取色方案对比。

我的 Github: github.com/RickeyBoy/R…

起因:一个 Display P3 引发的取色 Bug

在开发一个取色功能时,遇到了一个诡异的问题:用户用 iPhone 拍照后进行取色,得到的颜色跟肉眼看到的完全不一样。

问题代码:

guard let pixelData = self.cgImage?.dataProvider?.data else { return nil }
let data: UnsafePointer<UInt8> = CFDataGetBytePtr(pixelData)
let pixelInfo: Int = (pixelWidth * Int(point.y * scale) + Int(point.x * scale)) * 4

let r = CGFloat(data[pixelInfo]) / 255.0
let g = CGFloat(data[pixelInfo+1]) / 255.0
let b = CGFloat(data[pixelInfo+2]) / 255.0

这段代码假设所有图片都是 8-bit RGBA 格式。但现在 iPhone 拍摄的照片使用 Display P3 广色域,部分图片的像素数据是 16-bit per channel。当遇到这类图片时:

  1. 偏移量算错 — 每像素实际占 8 字节(4 通道 × 2 字节),但代码按 × 4 计算
  2. 数值解析错 — 16-bit 值域是 0~65535,用 UInt8 读只取了低 8 位,再除以 255,得到的颜色完全不对

要理解并修复这个问题,需要掌握一系列图片和色彩的基础知识。


一、色彩模型

色彩模型定义如何用数字描述颜色,但不定义具体哪个数字对应哪个物理颜色(那是色彩空间的事)。

1.1 RGB

RGB 是加色模型,通过混合红、绿、蓝三种光来生成颜色。

分量 归一化范围 8-bit 范围 说明
R (红) 0.0 ~ 1.0 0 ~ 255 红光强度
G (绿) 0.0 ~ 1.0 0 ~ 255 绿光强度
B (蓝) 0.0 ~ 1.0 0 ~ 255 蓝光强度
  • (0, 0, 0) = 黑色(无光)
  • (255, 255, 255) = 白色(全光)

RGB 直接对应屏幕像素的发光方式(每个像素由红、绿、蓝子像素组成),是像素存储和取色的底层数据格式。

局限性:RGB 不是感知均匀的。从 (100, 0, 0)(110, 0, 0) 的视觉差异与 (200, 0, 0)(210, 0, 0) 的视觉差异并不相同。

1.2 HSB/HSV

HSB(也叫 HSV)是 RGB 的柱坐标变换,更符合人类对颜色的直觉理解。

分量 范围 说明
H (色相 Hue) 0° ~ 360° 色轮位置。0°=红,120°=绿,240°=蓝
S (饱和度 Saturation) 0% ~ 100% 颜色纯度。0%=灰色,100%=最纯
B (明度 Brightness) 0% ~ 100% 0%=黑色,100%=最亮

HSB vs HSL:两者不同。HSB 中 B=100%, S=0% 是白色;HSL 中 L=100% 不管 H 和 S 都是白色。设计工具(Photoshop、Figma、Sketch)普遍使用 HSB,CSS/Web 开发常用 HSL。

在 iOS 中,UIColor 提供了 getHue(_:saturation:brightness:alpha:) 方法进行 RGB 和 HSB 的互转。HSB 通常用来构建用户可见的取色器 UI。

1.3 CIELAB

CIELAB(Lab*)是国际照明委员会(CIE)在 1976 年定义的感知均匀色彩模型,与设备无关。

分量 范围 说明
L* 0 ~ 100 明度。0=黑,100=白
a* 约 -128 ~ +127 绿色(负)↔ 红色(正)
b* 约 -128 ~ +127 蓝色(负)↔ 黄色(正)

CIELAB 的核心价值:给定的数值变化(ΔE)在整个色彩空间内对应近似相等的视觉变化。当你需要判断"取到的颜色跟目标色差多少"时,Lab 空间的 ΔE 计算比 RGB 欧氏距离有意义得多。

小结

模型 最佳用途
RGB 像素存储、渲染、取色底层数据
HSB 取色器 UI、基于色相的颜色操作
Lab 颜色差异度量、感知均匀的颜色比较

二、色彩空间

色彩空间 = 色彩模型 + 三个具体定义:

  1. 原色(Primaries) — R、G、B 三个基准色的精确色度坐标
  2. 白点(White Point) — "白色"的色温定义
  3. 传输函数(Transfer Function / Gamma) — 线性光值到编码值的映射曲线

同样的 (255, 0, 0) 在 sRGB 和 Display P3 里是不同的红色

2.1 sRGB

属性
原色 R(0.64, 0.33), G(0.30, 0.60), B(0.15, 0.06)
白点 D65 (6504K)
传输函数 分段:接近零时线性,之后约 γ2.2
CIE 1931 色域覆盖 ~35%

sRGB 是互联网、Windows 和绝大多数消费显示器的默认色彩空间,1996 年由 HP 和微软联合标准化(IEC 61966-2-1)。

它的传输函数并非简单的 γ=2.2 幂函数,而是在接近零的部分有一段线性区域,过渡到移位幂函数。实践中很多实现近似为纯 γ2.2。

2.2 Display P3

属性
原色 R(0.680, 0.320), G(0.265, 0.690), B(0.150, 0.060)
白点 D65(与 sRGB 相同)
传输函数 与 sRGB 相同
CIE 1931 色域覆盖 ~45%

Display P3 是 Apple 对 DCI-P3 电影标准的消费级适配。它保留了 DCI-P3 的广色域原色,但将白点从电影的氙灯 (~6300K) 换成 D65,传输函数换成 sRGB 曲线。

与 sRGB 的关系:Display P3 在 CIE xy 色度图上比 sRGB 大约 25% ,体积上大约 50% 。额外的颜色主要在红色、橙色和绿色方向——这些色相可以达到更高的饱和度。

Apple 设备时间线

时间 设备
2015 年底 iMac Retina 5K(首款 P3 显示器的 Apple 设备)
2016.3 9.7 寸 iPad Pro
2016.9 iPhone 7 / 7 Plus(首款 P3 显示 + P3 相机的 iPhone)
2017+ 所有新 iPhone、iPad 和 Retina Mac

2.3 Adobe RGB

属性
原色 R(0.64, 0.33), G(0.21, 0.71), B(0.15, 0.06)
白点 D65
传输函数 纯 γ2.2
CIE 1931 色域覆盖 ~52.1%

Adobe RGB 的设计目标是涵盖 CMYK 打印机可达的大部分颜色,色域优势主要在青绿区域。它是印刷摄影工作流的标准工作空间。

iOS 可以读取和显示 Adobe RGB 图片(通过嵌入的 ICC 配置文件),但 Display P3 的色域并不完全包含 Adobe RGB——部分 Adobe RGB 的绿色和青色超出了 P3 范围,Core Graphics 会自动进行色域映射。

2.4 ProPhoto RGB

属性
原色 部分使用虚拟原色以最大化覆盖
白点 D50 (5003K)——与其他空间不同
传输函数 纯 γ1.8
CIE 1931 色域覆盖 ~79.2%

ProPhoto RGB 覆盖了 CIE Lab* 中超过 90% 的表面色,但约 13% 的可表示颜色是虚拟色——不对应任何可见光。

关键注意:因为色域极广,8-bit 编码会导致明显的色带(banding)。使用 ProPhoto RGB 必须搭配 16-bit 位深

色域对比总结

色彩空间 CIE 覆盖 相对 sRGB 白点 Gamma
sRGB ~35% 1.0x(基准) D65 ~2.2(分段)
Display P3 ~45% ~1.25x D65 sRGB 曲线
Adobe RGB ~52% ~1.5x D65 2.2
ProPhoto RGB ~79% ~2.3x D50 1.8

三、位深度

位深度决定每个颜色通道有多少个离散级别。更多位 = 更细的渐变 = 更少的色带。

位深 每通道值域 RGB 总颜色数 每通道字节 典型用途
8-bit 0 ~ 255 ~1677 万 1(UInt8 消费级图片,JPEG
10-bit 0 ~ 1023 ~10.7 亿 需特殊打包 HDR 视频,专业相机
16-bit 0 ~ 65535 ~281 万亿 2(UInt16 RAW 处理,专业编辑

几个关键事实:

  • iPhone 照片(HEIC)是 8-bit,不是 10-bit。这是非常常见的误解。
  • iPhone 视频可以是 10-bit Dolby Vision HDR(iPhone 12 起)。
  • Apple ProRAW 是 12-bit 或 14-bit 传感器数据,存储在 DNG 格式中。
  • 位深太低 + 色域太广 = 可见色带。这就是 ProPhoto RGB 强制要求 16-bit 的原因。

除整数位深外,iOS 还支持浮点格式

格式 范围 用途
16-bit 半精度浮点 ~6.1e-5 到 65504 Core Image、Metal、扩展范围色
32-bit 单精度浮点 IEEE 754 全范围 Core Image、科学计算

浮点格式可以表示 [0, 1] 范围之外的值,这对扩展范围颜色(extended range colors)和 HDR 内容至关重要。


四、像素格式

4.1 CGImage 的关键属性

当你拿到一个 CGImage 时,以下属性描述了它的像素数据布局:

cgImage.bitsPerComponent  // 每通道位数:8 或 16
cgImage.bitsPerPixel      // 每像素总位数:32 (RGBA8) 或 64 (RGBA16)
cgImage.bytesPerRow       // 每行字节数(可能包含对齐填充)
cgImage.width             // 像素宽度
cgImage.height            // 像素高度
cgImage.colorSpace        // 色彩空间(sRGB、Display P3 等)
cgImage.alphaInfo         // Alpha 通道配置
cgImage.bitmapInfo        // 组合标志:alphaInfo + 字节序

bytesPerRow 的坑bytesPerRow 可能大于 width × bytesPerPixel,因为系统会做内存对齐填充。计算像素偏移时必须用 bytesPerRow,不能假设紧密排列。

4.2 RGBA vs BGRA

在 iOS(ARM,小端序)上,原生最优格式是 BGRA

格式 内存布局 对应 bitmapInfo 说明
RGBA [R][G][B][A] premultipliedLast 常用,直觉友好
BGRA [B][G][R][A] premultipliedFirst + byteOrder32Little iOS 原生最优,GPU 友好

如果你创建了 RGBA 的 CGContext 却按 BGRA 顺序读取,红色和蓝色会互换——取出来的颜色色相完全不对。

iOS 上常见的像素配置

格式 bitsPerComponent bitsPerPixel bytesPerPixel 布局
RGBA8 8 32 4 R, G, B, A
BGRA8 8 32 4 B, G, R, A
RGBA16 16 64 8 R, G, B, A (UInt16)
RGBAf 32 128 16 R, G, B, A (Float32)

4.3 预乘 Alpha(Premultiplied Alpha)

iOS 默认使用预乘 Alpha(premultiplied alpha),即存储的 RGB 值已经乘过 Alpha。

原始色:R=255, G=0, B=0, A=128"纯红,50% 透明"
预乘后:R=128, G=0, B=0, A=128  → 存储的值
// 因为:255 × (128/255) ≈ 128

为什么用预乘?

  1. 合成更快 — 标准 "over" 操作每通道少一次乘法
  2. 避免颜色溢出 — 混合直通 Alpha 颜色在子像素边界可能产生光晕

取色时的影响:如果 Alpha < 255,需要反预乘才能得到真实颜色:

let a = CGFloat(pixelData[offset + 3]) / 255.0
guard a > 0 else { return .clear }
let r = CGFloat(pixelData[offset]) / 255.0 / a    // 反预乘
let g = CGFloat(pixelData[offset + 1]) / 255.0 / a
let b = CGFloat(pixelData[offset + 2]) / 255.0 / a

4.4 CGBitmapContext 支持的格式组合

创建 CGBitmapContext 时,只有特定的参数组合是合法的:

色彩空间 bitsPerComponent bitmapInfo 说明
RGB 8 premultipliedFirst + byteOrder32Little BGRA8(原生最优)
RGB 8 premultipliedLast RGBA8(常用)
RGB 8 noneSkipFirst + byteOrder32Little BGRx8(无 Alpha)
RGB 8 noneSkipLast RGBx8(无 Alpha)
RGB 16 premultipliedLast RGBA16
RGB 32 (float) premultipliedLast + floatComponents RGBAf
Gray 8 .none 灰度 8-bit

五、图片文件格式

5.1 JPEG

属性 支持情况
位深 仅 8-bit
通道 3 (RGB),不支持 Alpha
色彩空间 sRGB(默认),可通过嵌入 ICC 支持 P3、Adobe RGB
压缩 有损(DCT)

JPEG 压缩原理:图片从 RGB 转换为 Y'CbCr(亮度 + 色度),色度通道降采样(4:2:0 或 4:2:2),每个 8×8 块进行 DCT 变换、量化(有损步骤)和熵编码。

5.2 PNG

属性 支持情况
位深 1, 2, 4, 8, 或 16-bit
通道 1~4(灰度、灰度+Alpha、RGB、RGBA)
Alpha 完整支持(8 或 16 bit)
色彩空间 通过嵌入 ICC 或 sRGB chunk
压缩 无损(DEFLATE)

16-bit PNG 每通道 65536 级,一个 RGBA16 PNG 每像素 8 字节,文件大小约为同尺寸 8-bit PNG 的两倍。

5.3 HEIF/HEIC

属性 支持情况
位深 8-bit 或 10-bit(规范支持 16-bit)
通道 3 (RGB) 或 4 (RGBA)
Alpha 支持
色彩空间 sRGB、Display P3 等
压缩 有损或无损(HEVC)
压缩率 同等画质下约为 JPEG 的 2 倍

关键事实:iPhone HEIC 照片是 8-bit。尽管 HEIF 规范支持 10-bit 及更高,Apple iPhone 相机拍摄的 HEIC 静态照片始终是 8-bit per channel。不过 HEIC 照片包含额外的 8-bit HDR 增益图(gain map),使系统能在 HDR 屏幕上展示扩展动态范围,但基础图像数据是 8-bit。

不同厂商的 HEIF 实现有差异:

厂商 HEIF 位深
Apple iPhone 8-bit(附 HDR 增益图)
Canon (R5, R6 等) 10-bit
Nikon (Z8, Z9) 10-bit

格式对比

特性 JPEG PNG HEIF/HEIC
最大位深 8-bit 16-bit 16-bit(iPhone 实际 8-bit)
Alpha 通道 不支持 支持 支持
有损压缩 支持 不支持 支持
无损压缩 不支持 支持 支持
广色域 (P3) 通过 ICC 通过 ICC 原生
HDR 增益图 不支持 不支持 支持
文件大小 最小

六、iOS 取色方案对比

方案 A:dataProvider 直接读原始数据

guard let cgImage = image.cgImage,
      let data = cgImage.dataProvider?.data,
      let bytes = CFDataGetBytePtr(data) else { return nil }

let offset = (y * cgImage.bytesPerRow) + (x * bytesPerPixel)
let r = bytes[offset]
let g = bytes[offset + 1]
let b = bytes[offset + 2]

特点

  • 最快,零拷贝,仅指针运算
  • 致命缺陷:读到的是图片的原始像素数据,格式完全取决于源图片
  • 必须自己处理 8/16-bit、RGBA/BGRA、不同色彩空间等差异
  • 本文开头的 Bug 就是这个方案导致的

适用场景:已知图片格式固定且追求极致性能的场景。生产环境不推荐。

方案 B:CGContext 重绘(推荐)

// 使用 Device RGB,系统根据设备自动适配(P3 屏保留广色域)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue

var pixelData = [UInt8](repeating: 0, count: bytesPerRow * height)

guard let context = CGContext(
    data: &pixelData,
    width: width, height: height,
    bitsPerComponent: 8,
    bytesPerRow: bytesPerRow,
    space: colorSpace,
    bitmapInfo: bitmapInfo
) else { return nil }

context.draw(cgImage, in: CGRect(origin: .zero, size: CGSize(width: width, height: height)))
// 现在 pixelData 保证是 RGBA8 格式,不管源图片是什么格式

特点

  • 业界最主流。Stack Overflow、简书、掘金上绝大多数取色方案都是此方式

  • 你定义输出格式,Core Graphics 自动完成所有转换:

    • 16-bit → 8-bit 降采样
    • Display P3 → sRGB 色彩空间转换
    • BGRA → RGBA 字节重排
    • 直通 Alpha → 预乘 Alpha
  • 代价:需要分配完整的像素缓冲区并重绘(12MP ≈ 48MB)

适用场景:通用取色,各类图片来源不可控的生产环境。

方案 C:Core Image

// CIAreaAverage —— 取区域平均色
let filter = CIFilter(name: "CIAreaAverage", parameters: [
    kCIInputImageKey: ciImage,
    kCIInputExtentKey: CIVector(cgRect: extent)
])

特点

  • CIImage 是操作图(recipe),不是像素缓冲区,只有在 render 时才产生像素
  • 适合取区域平均色或主题色提取
  • 创建 CIContext + 渲染管线的开销大,单像素取色太重
  • Core Image 内部有三级色彩空间管理(输入、工作、输出)

适用场景:图片主题色提取、区域平均色分析。不适合实时拖动取色。

方案 D:vImage(Accelerate 框架)

let format = vImage_CGImageFormat(
    bitsPerComponent: 8,
    bitsPerPixel: 32,
    colorSpace: CGColorSpaceCreateDeviceRGB(),
    bitmapInfo: ...
)
var buffer = try vImage_Buffer(cgImage: cgImage, format: format)
// 通过 buffer.data 访问像素

特点

  • Apple 官方高性能图像处理框架,SIMD 优化
  • vImageConverter 可以精确控制任意格式间的色彩空间转换
  • API 较复杂,单像素取色有点 overkill

适用场景:批量像素处理、需要最高色彩精度控制的专业场景。

方案对比总结

维度 dataProvider (A) CGContext (B) Core Image (C) vImage (D)
格式安全 危险 安全 安全 安全
色彩空间处理 自动转换 3 级管线 精细控制
16-bit/P3 支持 需手动处理 自动 自动 自动
单像素性能 最快 缓存后 O(1) 最慢 中等
批量性能 快但脆弱 最佳
API 复杂度 低但易错 适中 较高 较高
可靠性

七、工程实践:PixelReader 缓存方案

方案 B(CGContext 重绘)的问题是:如果每次取色都重新创建 CGContext 并绘制,在拖动放大镜时(每秒 60+ 次)会非常卡顿。解决方案是缓存——只在初始化时绘制一次,后续取色做数组索引查找。

public final class PixelReader {
    private let pixelData: [UInt8]  // 缓存的像素数据
    private let width: Int
    private let height: Int
    private let bytesPerRow: Int
    private let colorSpace: CGColorSpace

    /// 初始化时一次性完成绘制和缓存
    public init?(image: UIImage) {
        guard let cgImage = image.cgImage else { return nil }
        self.width = cgImage.width
        self.height = cgImage.height

        // 使用 Device RGB,系统会根据设备能力自动适配(P3 屏保留广色域)
        self.colorSpace = CGColorSpaceCreateDeviceRGB()

        let bytesPerPixel = 4
        self.bytesPerRow = bytesPerPixel * width
        var data = [UInt8](repeating: 0, count: bytesPerRow * height)

        let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue

        guard let context = CGContext(
            data: &data,
            width: width, height: height,
            bitsPerComponent: 8,
            bytesPerRow: bytesPerRow,
            space: colorSpace,
            bitmapInfo: bitmapInfo
        ) else { return nil }

        context.draw(cgImage, in: CGRect(origin: .zero,
                     size: CGSize(width: width, height: height)))
        self.pixelData = data  // 缓存
    }

    /// 快速查询——仅数组索引,O(1)
    /// 注意:因为 CGContext 使用 premultipliedLast,需要反预乘还原真实颜色
    public func color(at point: CGPoint) -> UIColor? {
        let x = Int(point.x)
        let y = Int(point.y)
        guard x >= 0, x < width, y >= 0, y < height else { return nil }

        let offset = y * bytesPerRow + x * 4

        // 反预乘 Alpha,还原真实 RGB 值
        let a = CGFloat(pixelData[offset + 3]) / 255.0
        guard a > 0 else { return nil }
        let r = min(CGFloat(pixelData[offset])     / 255.0 / a, 1.0)
        let g = min(CGFloat(pixelData[offset + 1]) / 255.0 / a, 1.0)
        let b = min(CGFloat(pixelData[offset + 2]) / 255.0 / a, 1.0)

        return UIColor(red: r, green: g, blue: b, alpha: a)
    }
}

在视图层只创建一次,缓存复用:

@State private var pixelReader: PixelReader? = nil

.onFirstAppear {
    fixedImage = UIImage.fixedOrientation(for: image) ?? image
    pixelReader = PixelReader(image: fixedImage) // 只创建一次
}
无缓存 PixelReader 缓存
每次取色 分配缓冲区 + CGContext + draw 数组下标访问
时间复杂度 O(W×H) / 次 O(1) / 次
拖动时开销 每秒 60+ 次全量位图解码 仅初始化时一次

本质上是一个经典的空间换时间优化


八、取色常见坑点

坑点 说明 解决方案
Scale 倍率 UIImage.size 是点(point),不是像素。@3x 设备上 100pt = 300px 取色坐标需要乘以 UIImage.scale
色彩空间选择 CGColorSpace(name: CGColorSpace.sRGB)! 会强制转换到 sRGB,丢失 P3 色域 CGColorSpaceCreateDeviceRGB() 让系统根据设备自动适配,P3 屏保留广色域
bytesPerRow 填充 系统可能在行尾添加对齐字节 始终用 bytesPerRow 计算偏移,不要用 width × 4
图片方向 CGImage 不存方向信息,UIImage 的 imageOrientation 可能是旋转/镜像的 取色前先调用 fixedOrientation 校正方向
预乘 Alpha 半透明区域的 RGB 不是原始值 需要反预乘:R_real = R_stored / A
HEIC ≠ 10-bit iPhone 照片是 8-bit HEIC,不要误判为 16-bit 检查 cgImage.bitsPerComponent 确认实际位深
内存 12MP RGBA8 ≈ 48MB,48MP(iPhone 15 Pro)≈ 192MB 注意内存压力,必要时降采样
16-bit 像素 部分 PNG 或专业相机输出是 16-bit 用 CGContext 重绘方案自动转换,或检查 bitsPerComponent 分支处理

参考资料

iOS 26手势返回到根页面时TabBar的动效问题

问题描述

我在适配完iOS 26时发现一个很奇怪的问题:

在第一次手势返回根页面时,tabBar没有渐显动画直接显示在顶部。但是如果第一次没有返回,再次手势返回时则有渐显动效。如下图所示:(测试设备iPhone 17;iOS 26.2)

图片

代码实现

一开始代码实现如下,在跳页的时候使用hidesBottomBarWhenPushed来隐藏tabBar

SecondVC *secondVC = [[SecondVC alloc] init];
secondVC.titleName = @"首页";
secondVC.hidesBottomBarWhenPushed = YES; // 隐藏tabbar
UIViewController *currentVC = self.window.rootViewController;
if ([currentVC isKindOfClass:[UITabBarController class]]) {
   UITabBarController *tabBarController = (UITabBarController *)currentVC;
   UINavigationController *homeNav = (UINavigationController *)tabBarController.viewControllers[0];
   [homeNav pushViewController:secondVC animated:YES];
}

后面经过测试,发现使用[tabBarController setTabBarHidden:NO animated:animated]; 就正常渐显了。

关键代码如下

  1. 创建首页的UINavigationController时设置代理
RootTabBarController *tabBarController = [[RootTabBarController alloc] init];
    self.tabController = tabBarController;
    
    // 设置首页tab
    UINavigationController *homeNav = [[UINavigationController alloc] initWithRootViewController:homeVC];
    homeNav.tabBarItem.title = @"首页";
    homeNav.tabBarItem.image = [UIImage systemImageNamed:@"house"];
    // 设置代理
    homeNav.delegate = tabBarController;
    
    // 设置我的tab
    UINavigationController *profileNav = [[UINavigationController alloc] initWithRootViewController:profileVC];
    profileNav.tabBarItem.title = @"我的";
    profileNav.tabBarItem.image = [UIImage systemImageNamed:@"person"];
    // 设置代理
    profileNav.delegate = tabBarController;
    
    [tabBarController setViewControllers:@[homeNav, profileNav]];
    self.window.rootViewController = tabBarController;
    [self.window makeKeyAndVisible];

2. 实现 UINavigationControllerDelegate

#pragma mark - UINavigationControllerDelegate

- (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
    // 判断是否为根视图控制器
    if ([navigationController.viewControllers indexOfObject:viewController] == 0) {
        // 返回到根页面,显示tabbar
        [self setTabBarHidden:NO animated:animated];
    } else {
        // 跳转到子页面,隐藏tabbar
        [self setTabBarHidden:YES animated:animated];
    }
}

以上,希望有帮助到大家

使用 Ipa Guard 命令行版本将 IPA 混淆接入自动化流程

当项目进入稳定迭代阶段,很多团队都会把构建流程放进 CI,例如 Jenkins、GitHub Actions 或 GitLab CI。编译 IPA、运行测试、生成构建产物都可以自动完成。但如果需要在发布前做代码混淆或资源处理,图形界面工具就会显得有些不方便。

我在维护一个长期更新的 iOS 项目时遇到过类似问题:每次构建完成后,都需要对 IPA 进行一次混淆处理。如果完全依赖界面操作,就意味着要人工导入 IPA、选择符号、再导出结果。几次之后就会发现,这一步完全可以放进自动化脚本里。

Ipa Guard 的命令行版本正好适合这种场景。它把 IPA 解析、符号混淆、资源处理这些步骤拆成可以调用的命令,同时还能输出符号映射文件,方便排查崩溃问题。下面记录一套实际操作流程。


一、准备待处理的 IPA

CI 构建完成后会生成一个 Release IPA,例如:

build/game.ipa

这就是后续混淆操作的输入文件。

在开始处理前,可以简单检查一下包内结构:

unzip game.ipa

确认 Payload 中包含应用二进制与资源目录即可。之后重新打包,保持原始 IPA 作为备份。


二、导出可混淆符号列表

Ipa Guard 命令行工具的第一步是解析 IPA,提取可修改符号。

执行命令:

ipaguard_cli parse game.ipa -o sym.json

执行完成后会生成一个 sym.json 文件。

这个文件的作用很直接:列出 IPA 中可以被混淆的符号,例如类名、方法名或变量名,并附带相关引用信息。

打开文件后可以看到类似结构:

{
  "confuse": true,
  "name": "_isPreTTS",
  "refactorName": "_isPreTTS",
  "types": ["oc_method_name"]
}

name 是原始符号名, refactorName 用于填写混淆后的名称。


三、根据项目情况调整符号文件

这一步比较关键,因为它决定哪些符号会被修改。

编辑 sym.json 时需要注意两件事:

1. refactorName 长度要保持一致

某些二进制符号长度变化可能影响结构,因此建议保持长度不变。

例如:

_isPreTTS

可以改为:

_a1b2c3d4

字符数量一致即可。


2. 不适合混淆的符号需要关闭

例如下面这个方法:

addEventListener:

如果 JS 或 H5 模块中通过字符串调用它,修改后可能导致运行失败。

可以把:

"confuse": true

改成:

"confuse": false

sym.json 中的 fileReferences 字段可以帮助判断某个符号是否在脚本或资源文件中被引用。


四、使用符号文件执行混淆

完成符号文件修改后,就可以执行 IPA 混淆。

示例命令:

ipaguard_cli protect game.ipa -c sym.json --image --js -o confused.ipa --email ipaguard@gmail.com

参数含义:

  • -c sym.json 指定符号配置文件
  • --image 修改图片 MD5
  • --js 混淆 JS 资源
  • -o confused.ipa 输出文件
  • --email 登录账号

执行后会生成新的 IPA,例如:

confused.ipa

此时包内的符号和资源已经完成处理。


五、对混淆后的 IPA 进行签名

由于混淆修改了 IPA 内容,原有签名已经失效。

需要重新签名才能安装到设备。

可以使用签名工具,例如 kxsign

kxsign sign confused.ipa \
-c cert.p12 \
-p certpassword \
-m dev.mobileprovision \
-z test.ipa \
-i

参数说明:

  • -c 证书文件
  • -p 证书密码
  • -m 描述文件
  • -z 输出 IPA
  • -i 安装到设备

如果连接了测试手机,命令执行完成后会自动安装。


六、设备测试与崩溃排查

混淆后的版本一定要运行一遍完整流程,例如:

  • 登录
  • 支付
  • 页面加载
  • H5 模块调用

如果发生崩溃,可以借助 Ipa Guard 生成的符号映射文件查找原始函数名。

映射文件会记录:

混淆前符号
混淆后符号

这样在 Crash 日志中看到混淆名称时,仍然可以找到对应代码位置。


七、将混淆步骤接入 CI

当流程稳定后,可以写一个简单脚本:

build ipa
ipaguard_cli parse
edit sym.json
ipaguard_cli protect
kxsign sign

在 Jenkins 或 GitHub Actions 中执行即可。

这样每次构建完成都会自动生成混淆后的 IPA。


八、发布阶段的签名

测试通过后,签名流程保持一致,只需要换成发布证书:

kxsign sign confused.ipa \
-c dist.p12 \
-p certpassword \
-m dist.mobileprovision \
-z release.ipa

发布证书生成的 IPA 无法直接安装,但可以上传 App Store。

如果构建环境是 Linux 或 Windows,也可以使用上传工具完成提交。


结尾

将 IPA 混淆接入自动化流程后,发布过程会变得更稳定。符号解析、混淆处理、资源修改和签名测试都可以通过脚本完成,而不是依赖人工操作。

参考链接:ipaguard.com/tutorial/zh…

iOS 知识点 - 渲染机制、动画、卡顿小集合

一、基本骨架

从代码到像素,都经历了什么?一帧画面是怎么到屏幕上的?

┌──────────────────────────────────────────────────────────────────────────────┐
│                     一帧的完整生命周期 (Render Loop)                            │
│                                                                              │
│   VSYNCVSYNCVSYNC₃        │
│     │                               │                             │          │
│     │  ┌─────────── App 进程 ───────────────┐                      │          │
│     │  │ ① Handle EventCommit Transaction│                   │          │
│     │  │   (触摸/定时器)    ┌────────────────┐  │                   │          │
│     │  │                   │LayoutDisplay ││                   │          │
│     │  │                   │PreparePackage││                   │          │
│     │  │                   └────────────────┘│                    │          │
│     │  └──────────┬──────────────────────────┘                    │          │
│     │             │ Layer Tree 发送                                │          │
│     │             ▼                                               │          │
│     │  ┌─────────── Render Server (独立进程) ─────┐                 │          │
│     │  │ ③ Render PrepareRender Execute(GPU)│              │          │
│     │  │  (编译绘制指令)       (逐层合成到纹理)      │                 │          │
│     │  └──────────────────────────┬────────────────┘              │          │
│     │                             │ 最终纹理就绪                    │          │
│     │                             ▼                               │          │
│     │                          ┌──────────────────┐               │          │
│     │                          │ ⑤ Display/硬件合成│◀─── 帧上屏 ────│          │
│     │                          └──────────────────┘               │          │
│     │                                                             │          │
│     │◀─── 1 frame (16.67ms @60Hz / 8.33ms @120Hz) ──►│            │          │
│     │◀──────────── 2 frames: 事件到上屏的最小延迟 (Double Buffering) ──────►│   │
│                                                                              │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   超时在 App 端 (①②)  ──→  Hang (卡顿/无响应)  +  Commit Hitch (掉帧)          │
│   超时在 GPU 端 (③④)  ──→  Render Hitch (掉帧/动画抖动)                        │
│                                                                              │
│   Hang:  主线程被占 > 250ms,用户感知 "按不动" "界面冻结"                          │
│   Hitch: 帧未在 VSYNC deadline 前就绪,用户感知 "动画跳了一下"                     │
│                                                                              │
│   ┌──────────┐  ┌──────────┐  ┌──────────────┐  ┌───────────────────┐        │
│   │CPU: Event│→ │CPU: Commit│→│GPU: Render   │→ │Hardware: Display  │        │
│   │ 事件处理  │  │ 提交变更   │  │ 合成 + 离屏   │  │ 像素点亮           │        │
│   └──────────┘  └──────────┘  └──────────────┘  └───────────────────┘        │
└──────────────────────────────────────────────────────────────────────────────┘

渲染机制定义了每一帧画面从产生到上屏幕的流水线

动画是连续多帧的有规律变化,利用渲染流水线实现;

卡顿是流水线中任意环节超时,导致帧被丢弃;

  • 核心概念关系:
概念 本质 与其他概念的关系
Render Loop 系统以屏幕刷新率(60/120Hz)驱动的持续循环 所有可见变化的底层引擎
CALayer 视觉内容的载体,持有位图和属性 动画的作用对象,渲染的输入
Core Animation 动画+渲染的基础框架 管理 Layer Tree,驱动 Render Server
动画 (Animation) 属性随时间的插值变化 在 Render Loop 中被逐帧求值
Hang(卡顿) 主线程被占用导致事件无法及时处理 用户感知为"按不动""无反应"
Hitch(掉帧) 某帧未能在 VSYNC 截止时间前就绪 用户感知为动画跳跃、滚动卡顿

二、Render Loop(渲染循环):渲染机制拆解,一帧是怎样诞生的

2.1 五个阶段

Rnder Loop 是一个以 VSYNC 为节拍、流水线式并行的循环。在 Double Buffering 模式下,一帧从事件到上屏幕需要经过 2 个 VSYNC 周期。

                    VSYNC₁               VSYNC₂              VSYNC₃
                      │                    │                   │
  ┌─── App 进程 ───────┤                    │                   │
  │  ① Event Phase   │                    │                   │
  │  ② Commit Phase  │                    │                   │
  └───────────────────┤                    │                   │
                      │  ┌─ Render Server──┤                   │
                      │  │ ③ Prepare Phase │                   │
                      │  │ ④ Execute Phase │                   │
                      │  └─────────────────┤                   │
                      │                    │  ⑤ Display       │
                      │                    │     帧上屏         │
阶段 进程 做什么 关键耗时原因
Event App 接收触摸、定时器等事件,决定 UI 是否需要变化
Commit App Layout → Display(drawRect) → Prepare(图片解码) → 打包 Layer Tree 发给 Render Server 布局复杂、视图层级深、大图解码
Render Prepare Render Server 遍历 Layer Tree,编译为 GPU 绘制指令流水线 Layer 数量多、需要 Offscreen Pass
Render Execute GPU 逐层合成到最终纹理 Offscreen Pass、大面积模糊/阴影
Display 硬件 把纹理推上屏幕

2.2 Commit 阶段的四个子步骤

Commit 是 App 端最关键的阶段,它本身又分为四步:

Commit Transaction
  │
  ├─ 1. Layout        调用 layoutSubviews / SwiftUI body
  │                    → setNeedsLayout 触发
  │
  ├─ 2. Display       调用 drawRect / draw(_:)
  │                    → setNeedsDisplay 触发
  │                    → 生成 backing store (位图)
  │
  ├─ 3. Prepare        图片解码 + 色彩空间转换
  │                    → 大图 / 非标准格式图开销大
  │
  └─ 4. Package        递归打包 Layer Tree 发送
                       → 层级越深越慢
  • Commit Transaction 是一个 RunLoop 循环结束时自动提交的隐式事务;
  • Backing Store 是 Layer 的位图缓存。

2.3 Double Buffering 与 Triple Buffering

Double Buffering(双缓冲) 是 iOS 渲染流水线的默认工作模式,指系统同时维护 两个帧缓冲区,让 App 准备下一帧和屏幕显示当前帧可以并行进行,互不干扰。

  • Double Buffering(默认):App 和 Render Server 各占一个 VSYNC 周期,总延迟 2 帧。
  • Triple Buffering(降级模式):当 Render Server 来不及时,系统自动切换,给 Render Server 多一帧的时间。帧延迟增加到 3 帧,但能避免更严重的掉帧。

为什么需要缓冲区?

如果只有一个缓冲区(Single Buffering),屏幕 正在读取这个缓冲区显示画面 的同时,GPU 也在往里写新内容,就会出现 画面撕裂(Screen Tearing)——上半截是旧帧,下半截是新帧。


三、CALayer 与三棵树:动画的根基

3.1 Layer 是什么?

CALayer 是一个 模型对象,它不做绘制,它只持有:

  • 几何信息: bounds / position / transform / anchorPoint
  • 视觉属性: backgroundColor / opacity / cornerRadius / shadow
  • 内容: contents 位图

View 和 Layer 的关系: iOS 上每个 UIView 都自动持有一个 backing layer。View 负责事件响应(触摸、手势)和响应链,Layer 负责视觉呈现。你改 view.frame 其实改的是view.layer 的属性。Layer 不处理事件、不参与响应链。

3.2 三棵 Layer Tree

┌─────────────┐    ┌──────────────────┐    ┌─────────────┐
│  Model Tree │    │ Presentation Tree│    │ Render Tree │
│  (图层树)    │    │   (呈现树)        │    │  (渲染树)    │
│             │    │                  │    │             │
│ 你代码改的值  │    │ 动画进行中的当前值  │    │ 实际渲染用    │
│ = 动画目标值  │    │ = 屏幕上的即时值   │    │  (私有,不可访问)│
└─────────────┘    └──────────────────┘    └─────────────┘
       │                    ▲
       │    layer.presentationLayer
       └────────────────────┘
  • Model Tree(图层树): 比如 layer.position = newPos 改的就是它。它始终保存 “最终目标值”。
  • Presentation Tree(呈现树): 动画进行时,layer 实际所在位置是 layer.presentationLayer
  • Render Tree(渲染树): Core Animation 内部使用,无法访问。

关键推论: 给 layer 加动画后,Model Tree 里的值就已经是终点值了。动画结束后如果 removedOnCompletion = YES(默认),layer 就直接呈现 Model Tree 的值。如果你没改 Model Tree 的值,layer 就会"跳回去"——这就是动画结束后 layer 回到原位的经典问题。

presentationLayer 的使用场景: 用户在动画飞行途中点击/拖拽 layer 时,需要用 presentationLayer 获取当前真实位置来做 hitTest 或启动新动画。

3.3 CATransaction - 变更打包器

所有对 Layer 的属性修改都被 CATransaction 捕获:

  • 隐式事务: 哪怕不写 begin/commit,系统也会在每个 RunLoop 循环自动包裹一次。
  • 显式事务: [CATransaction begion] ... [CATransaction commit],可以控制动画时长、completionBlock 等。

隐式事务是 UIView 隐式动画(改 layer 属性自动产生 0.25s 动画)的底层机制。UIView 的 animateWithDuration: 本质上就是开一个显式事务并配置参数。


四、动画系统

动画 = 内容(什么在变) + 时间(多久完成) + 变化规律(怎么变)

要素 对应 API 说明
内容 keyPath (如 position, opacity, transform.rotation.z) 必须是 CALayer 上标记为 Animatable 的属性
时间 duration + timingFunction timingFunction 控制"时间的流速"(加速/减速/弹性)
变化规律 动画子类决定(Basic = 两点插值,Keyframe = 多点插值,Spring = 弹簧物理)
  • 动画类的继承体系:
CAAnimation (基类:timingFunction, delegate, removedOnCompletion)
  │
  ├─ CAPropertyAnimation (抽象:keyPath, additive, cumulative)
  │    │
  │    ├─ CABasicAnimation (fromValue / toValue / byValue)
  │    │    │
  │    │    └─ CASpringAnimation (mass / stiffness / damping / initialVelocity)
  │    │
  │    └─ CAKeyframeAnimation (values / keyTimes / path / calculationMode)
  │
  ├─ CATransition (type / subtype — 转场快照动画)
  │
  └─ CAAnimationGroup (animations[] — 组合多个动画)

4.1 CABasicAnimation — 两点插值

提供起止状态,系统通过插值(Interpolation)算出任意时刻的值。三个属性的语义:

  • fromValue:起始值(绝对值)
  • toValue:结束值(绝对值)
  • byValue:变化量(相对值,"变化了多少")

4.2 CAKeyframeAnimation — 多点插值

关键帧动画 = N 段 BasicAnimation 的串联。提供一组 values 和对应的 keyTimes(归一化 0~1),系统在相邻关键帧之间插值。

calculationMode 决定插值方式:linear(默认)

4.3 CASpringAnimation — 弹簧物理

继承自 CABasicAnimation,用弹簧力学模型驱动动画曲线:mass(质量越大,运动越慢,但衰减也越慢)等。

4.4 CATransition — 两张快照之间的过渡

CATransition 不指定 from/to 值。它的工作方式:

  1. 把动画添加到 layer 时,拍下当前 layer 的快照(开始状态)
  2. 紧接着你对 layer 做修改(比如替换子视图、改文字)
  3. 修改后的 layer 是结束状态
  4. 系统在两张快照之间播放指定的过渡效果

4.5 CAAnimationGroup — 组合动画

把多个动画放在 animations 数组里同时执行。注意:

  • Group 的 duration 是一个 硬截止:到时间所有子动画停止,不管子动画是否结束。
  • 各子动画独立执行,不互相等待。

五、Hang(卡顿/无响应):主线程被占的代价

Hang = 主线程无法在合理时间内处理用户事件。

WWDC23 统计过一期人类感知阈值,大概如下:

  0ms          100ms         250ms         500ms
   │─── 感觉即时 ──│── 微妙可感 ──│── 明显延迟 ──│── 严重卡顿 ──▶
                    │              │              │
              目标上限   Micro Hang(系统开始上报)    Hang

5.1 Hang 的三种类型

类型 主线程 CPU 表现 典型原因
Busy Main Thread 高(60~100%) 主线程在拼命算东西 大量布局计算、同步图片处理、JSON 解析
Blocked Main Thread 极低(~0%) 主线程在等锁/等IO/等网络 同步网络请求、信号量等待、锁竞争、同步文件IO
Asynchronous Hang 可高可低 不是当前事件导致的,而是之前调度到主线程的任务占了时间 dispatch_async(main) 的耗时任务、@MainActor 下的同步代码
同步 Hang:
  用户点击 → [────── 主线程处理耗时 ──────] → 响应
              ←─── 这段就是 hang ───→

异步 Hang:
  之前调度的任务 → [──── 主线程被占 ────]
                           ↑ 用户点击来了,但得排队
                           ←── 这段是 hang ──→

5.2 Swift Concurrency 中的陷阱

WWDC23 Session 10248 中详细阐述的一个经典问题:

struct BackgroundThumbnailView: View {
    var body: some View {  // body 隐式继承 @MainActor
        ProgressView()
            .task {  // .task 闭包继承外部 actor 隔离 → 也在 MainActor
                image = background.thumbnail  // 同步属性 → 在主线程执行!
            }
    }
}
  • 问题: .task 闭包继承 body@MainActor 隔离,同步属性 thumbnail 在主线程执行。await 只在调用 async 函数时才切换线程。
  • 解法: 把 thumbnail 改为 async getter,使其能在 Cooperative Thread Pool 上执行:
public var thumbnail: UIImage {
    get async { /* compute and cache */ }
}
// 使用处
.task {
    image = await background.thumbnail  // 现在能离开 @MainActor 了
}

六、Hitch(掉帧):动画不流畅的元凶

Hitch = 某一帧没能在 VSYNC deadline 前就绪,导致前一帧重复显示。

单次 hitch time(毫秒)不方便跨测试对比。Apple 定义了 Hitch Time Ratio:

Hitch Time Ratio = 总 hitch 时间 / 总持续时间   (单位: ms/s)
等级 Hitch Time Ratio 用户感知
Good < 5 ms/s 基本无感
Warning 5~10 ms/s 能注意到部分中断
Critical > 10 ms/s 严重影响体验,必须立即修复

6.1 Hitch 的两种类型

类型 超时发生在 常见原因
Commit Hitch App 端 Commit 阶段 复杂布局、drawRect 耗时、大图解码、深层级打包
Render Hitch Render Server / GPU Offscreen Pass 过多、大面积模糊/阴影、复杂遮罩

6.2 Offscreen Pass(离屏渲染)—— Render Hitch 的主要元凶

  • 当屏渲染:GPU 的任务是把所有 layer 从后往前逐个画到一块最终纹理上(就是你屏幕看到的那一帧画面):
最终纹理(屏幕画面)
┌──────────────────┐
│                  │
│  第1层:蓝色背景   │  ← GPU 先画这个
│  第2层:白色卡片   │  ← 再叠上这个
│  第3层:文字       │  ← 最后叠上这个
│                  │
└──────────────────┘

GPU 直接在最终纹理上一层层往上画,画完就上屏。
这就是"正常渲染",也叫"当屏渲染"
  • 离屏渲染:GPU 无法直接在最终纹理上绘制某个 layer,必须先在 离屏纹理 上画好再拷贝回来。每次 Offscreen Pass 都是额外的 纹理切换 + 像素拷贝

    • 为什么无法直接在最终纹理上绘制?
      • 如下图,阴影其实在 “最底层”,要先画;
      • 但是阴影的形状取决于 “上层的圆形和长条”,还没画呢。
      ┌─────────────────────────────────┐
      │         最终纹理                  │
      │                                 │
      │        ●●●●●                    │
      │       ●●●●●●●   ← 圆形          │
      │        ●●●●●                    │
      │       ████████  ← 长条           │
      │                                 │
      │  阴影的形状 = 圆形+长条的轮廓       │
      │  但 GPU 还没画圆形和长条呢!        │
      │  它怎么知道阴影该长什么样?          │
      └─────────────────────────────────┘
      
      • 解决办法 = 离屏渲染:
      步骤1:GPU 切到临时纹理,先把圆形和长条画上去
      ┌── 临时纹理 ──┐
      │    ●●●●●     │
      │   ●●●●●●●   │  → 现在知道轮廓了
      │    ●●●●●     │
      │   ████████   │
      └──────────────┘
      
      步骤2:把轮廓变黑 + 模糊 = 阴影形状
      ┌── 临时纹理 ──┐
      │   ░░░░░░░    │
      │  ░░░░░░░░░   │  → 这就是阴影
      │   ░░░░░░░    │
      │  ░░░░░░░░░░  │
      └──────────────┘
      
      步骤3:把阴影拷贝回最终纹理
      
      步骤4:在最终纹理上再画一次圆形和长条(盖在阴影上面)
      
      • 圆形和长条被画了两次,还多了纹理切换和拷贝。这就是离屏渲染慢的原因。
  • 四大触发场景:

场景 为什么必须离屏 怎么避免
阴影 GPU 不知道阴影形状,得先画内容才能反推 设 shadowPath,直接告诉 GPU 形状,不用反推
遮罩 (mask) 先画内容,再用 mask 裁剪,裁掉的像素不能污染最终纹理 用 cornerRadius + masksToBounds 代替自定义 mask layer
圆角 + 裁剪内容 子视图超出圆角范围需要被裁掉,和遮罩同理 确认子视图不超出 bounds 时去掉 masksToBounds
模糊/毛玻璃 需要拷贝底层像素到临时纹理再做模糊 不可避免,控制数量和面积

七、遇到问题怎么查?

用户反馈"卡"
  │
  ├─ 按钮按不动 / 界面冻结 → 这是 Hang
  │    │
  │    ├─ Time Profiler 看 CPU 高 → Busy Main Thread
  │    │    → 减少主线程计算、用 async/await 移到后台
  │    │
  │    └─ Thread States 看线程 Blocked → Blocked Main Thread
  │         → 找到阻塞的系统调用(锁/IO/信号量),异步化
  │
  └─ 滚动/动画跳帧 → 这是 Hitch
       │
       ├─ Animation Hitches 模板看 Commit 阶段超时 → Commit Hitch
       │    → 简化布局、减少 drawRect、预处理图片、扁平化层级
       │
       └─ Render/GPU 阶段超时 → Render Hitch
            → View Debugger 看 offscreen count
            → 设置 shadowPath、用 cornerRadius 代替 mask

八、GPU 优化

  • 图层混合(Blending):当 layer 不是完全不透明时(opacity < 1 或 backgroundColor 为 nil/透明),GPU 需要把当前 layer 和底下的 layer 做像素混合计算。
    • 优化方式:给 view 设不透明背景色、设 opaque = YES、避免不必要的透明。
  • shouldRasterize(光栅化缓存):把一个复杂的 layer 子树一次性渲染成位图缓存,后续帧直接复用。适合内容不常变的复杂视图(如带阴影+圆角+多子视图的卡片)。但缓存有 100ms 未使用自动释放的限制,且 内容变化时需要重新光栅化,用不好反而更慢
  • 像素对齐(Pixel Alignment):frame 的坐标不是整数像素时,GPU 需要做抗锯齿混合。用 CGRectIntegral 或 SnapKit 的 snp.makeConstraints 保持像素对齐。
❌