普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月2日首页

12-主题|内存管理@iOS-Option与内存优化技术

本文介绍与内存相关的几类优化与极限管理Option/位运算共用内存(多选项共用一个整数)、内存的极限管理(低内存策略与约束)、Copy-on-Write(写时拷贝)Tagged Pointer。与 01-内存五大分区11-深浅拷贝与内存07-实践与常见问题 配合阅读。


一、Option 与位运算共用内存

1.1 概念

  • 将多个布尔或选项压缩到一个整数的不同上,通过位运算读写,实现「多个开关/状态共占一块内存」;在 C/OC 中常用 NS_OPTIONS位域(bitfield),在 Swift 中对应 OptionSet
  • 内存:N 个独立 BOOLbool 可能占 N 个字节(甚至对齐后更多);用一个整型的若干位表示,只需 1 个整型(如 4 或 8 字节),在选项较多或实例数量巨大时显著节省内存并利于缓存。

1.2 NS_OPTIONS / 位运算示例(Objective-C)

// 多个「选项」共用一个整型,每位表示一种开关
typedef NS_OPTIONS(NSUInteger, ViewOptions) {
    ViewOptionsNone     = 0,
    ViewOptionsHidden   = 1 << 0,   // 1
    ViewOptionsDisabled = 1 << 1,   // 2
    ViewOptionsSelected = 1 << 2,   // 4
    ViewOptionsLoading  = 1 << 3,   // 8
};

// 使用:一个 NSUInteger 存下所有选项
ViewOptions opts = ViewOptionsHidden | ViewOptionsSelected;

// 判断
BOOL isHidden = (opts & ViewOptionsHidden) != 0;

// 置位 / 清位
opts |= ViewOptionsLoading;
opts &= ~ViewOptionsDisabled;
  • 上述 ViewOptions 只占 一个 NSUInteger(8 字节),即可表示 64 个独立布尔;若用 64 个 BOOL 属性,会占用更多内存且不利于缓存。

1.3 位域(bitfield)共用内存

// 结构体内用位域:多个成员共占一个或多个整型
struct PackedFlags {
    unsigned int visible  : 1;  // 1 bit
    unsigned int enabled  : 1;
    unsigned int selected : 1;
    unsigned int loading  : 1;
    unsigned int reserved : 4;  // 预留
};  // 整体可仅占 4 字节(一个 unsigned int)
  • 多个成员共享同一整型内存,适合配置、状态、权限等密集布尔/小范围枚举,在大量实例(如 Cell、配置项)时减少内存占用。

1.4 典型场景

场景 说明
UI 状态 如 hidden、enabled、selected、loading 等用 NS_OPTIONS 或 OptionSet 存为一个整数
权限/能力 读、写、执行等用位表示,一个整数表示一组权限
配置/特性开关 大量配置项用位域或 OptionSet,减少结构体/对象体积
网络/解析标志 协议中的 flags 字段,多位表示多种含义,共用内存

二、内存的极限管理

2.1 目标与场景

  • 内存紧张(低内存设备、后台、系统压力大)时,通过主动释放、限制缓存、延迟加载等手段,把占用控制在系统允许范围内,避免被系统杀掉或 OOM。
  • 07-实践与常见问题 中的「内存警告」「音视频/图层场景」配合使用。

2.2 策略要点

策略 说明
响应 didReceiveMemoryWarning 释放可重建的缓存(图片、数据)、释放非当前页大对象;主线程不阻塞,异步释放。
缓存上限与淘汰 图片/数据缓存设置 maxCount / maxCost,LRU 等淘汰;避免无界增长。
后台释放 进入后台时释放非必要资源(解码器、大缓冲、预览图),回到前台再按需加载。
按需加载 / 流式 大列表、大文件不一次性进内存;分页、流式读取、大图降采样。
@autoreleasepool 循环中大量临时对象用 @autoreleasepool {} 控制峰值,见 [05-AutoreleasePool与RunLoop](05-主题 内存管理@iOS-AutoreleasePool与RunLoop.md)。
内存映射 大文件用 mmap 等映射访问,减少常驻 RSS;注意映射大小与释放时机。

2.3 极限下的注意

  • 不保留可重建数据:能重新下载、重新计算的就不要在内存里常驻。
  • 控制单页/单模块占用:列表、相册、音视频播放等设定上限,避免单场景吃满内存。
  • Instruments:用 Allocations、VM Tracker、Leaks 做「极限场景」压测(反复进入退出、后台、低内存模拟),观察峰值与泄漏。

三、Copy-on-Write(写时拷贝)

3.1 概念

  • Copy-on-Write(COW):多个逻辑上的「副本」在未修改前共享同一份底层存储;仅在某一方发生写操作时才为该方复制出一份新存储,再修改,从而避免「一赋值就整块拷贝」的开销。
  • 与深浅拷贝的关系:浅拷贝是「多引用、共享子对象」;COW 是「多引用、共享存储,写时才真正拷贝」,在保证值语义的前提下减少内存与 CPU 消耗。详见 11-深浅拷贝与内存

3.2 Swift 中的 COW

  • Array、Dictionary、Set、String 等值类型在 Swift 标准库中实现了 COW:赋值时不立即复制底层 buffer,而是共享;首次发生写操作时,若检测到 buffer 被多处引用(非唯一引用),则先复制 buffer 再写。
  • 实现要点:内部持有一个引用类型的 buffer;写前通过 isKnownUniquelyReferenced(或等价机制)判断是否唯一引用,若不唯一则 copy buffer 再写。
  • 效果:大量「只读共享」的赋值与传参几乎零拷贝成本;只有写时才付出拷贝代价,适合读多写少的集合与字符串。

3.3 与内存的关系

  • 省内存:未修改的「副本」不占额外存储,仅多一个指向同一 buffer 的引用。
  • 写时峰值:在共享的 buffer 上首次写入会触发一次拷贝,此时有短暂的内存与 CPU 开销;若写非常频繁且共享多,需注意是否适合用 COW 结构。
  • 自定义值类型:Swift 不会自动为自定义 struct 实现 COW,若需要需自己维护「内部引用 + 写时复制」逻辑。

3.4 流程图(概念)

flowchart LR
    A[赋值/传参] --> B{写操作?}
    B -->|否| C[继续共享 buffer]
    B -->|是| D{唯一引用?}
    D -->|是| E[直接写]
    D -->|否| F[复制 buffer 再写]

四、Tagged Pointer

4.1 概念

  • Tagged Pointer 是 Apple 在 64 位 架构下的一种优化:把「小对象」的数据与类型信息直接编码进指针值本身,而不在堆上分配对象;该「指针」并不是指向堆地址,而是即是指针也是数据
  • 内存:不占用,不参与引用计数(retain/release 对 Tagged Pointer 为 no-op);仅占一个指针宽度(8 字节),无额外分配、无 isa、无引用计数块,极限节省小对象的内存与调用开销。

4.2 原理(64 位简要)

  • 64 位下对象指针通常 16 字节对齐,低 4 位恒为 0;系统用最高位或最低位(依平台而定,如 ARM64 常用最高位)作为 tag,表示「这是 Tagged Pointer」。
  • 其余位中:若干位表示类型(如 NSNumber、NSString、NSDate 等),其余位存数据(如小整数、短字符串的编码)。
  • 运行时通过「解 tag + 类型 + 数据位」还原出逻辑上的「对象」,不访问堆,不触发 retain/release。

4.3 典型类型与约束

类型 说明
NSNumber 小整数、部分浮点数可直接存进指针,不分配堆对象。
NSString 较短字符串(如 ASCII 或少量字符)在较新系统上可能用 Tagged Pointer;更长则仍为堆上分配。
NSDate 部分小对象类型在系统实现中可能使用 Tagged Pointer。
  • 约束:能编码进指针的数据量有限(几十 bit),仅适用于「小」数据;大数、长字符串、复杂对象仍走普通堆分配。

4.4 对内存管理的影响

  • 无堆分配:Tagged Pointer 不占堆,不增加 Allocations 中的对象数。
  • 无引用计数:对 Tagged Pointer 发 retain/release 会被识别并忽略,不会造成过度释放或泄漏(从引用计数角度)。
  • 不可假设地址:不能把 Tagged Pointer 当普通指针做指针运算或与 C 内存接口混用;判断是否 Tagged Pointer 可用运行时 API(如 objc_isTaggedPointer)。

4.5 小结对比

维度 普通堆对象 Tagged Pointer
存储位置 指针值本身(无堆)
引用计数 无(no-op)
内存占用 对象头 + 实例 + 指针 仅 8 字节指针
适用 任意对象 小数据(小整数、短字符串等)

五、思维导图

mindmap
  root((Option 与内存优化))
    Option 位运算
      NS_OPTIONS OptionSet
      位域 共用整型
    内存极限管理
      内存警告 缓存上限
      后台释放 按需加载
    CopyOnWrite
      写时复制 共享 buffer
      Swift 集合 isKnownUniquelyReferenced
    Tagged Pointer
      小对象编码进指针
      无堆 无引用计数

参考文献

11-主题|内存管理@iOS-深浅拷贝与内存

本文介绍 浅拷贝(Shallow Copy)深拷贝(Deep Copy) 的含义、在 Objective-C / Foundation 中的表现、与内存的关系(引用计数、新对象分配、共享与独立),以及 NSCopying、属性 copy、Swift 值类型与写时拷贝。前置知识见 03-引用计数与MRC详解04-ARC详解


一、浅拷贝与深拷贝的定义

1.1 概念

类型 含义 内存上的表现
浅拷贝 只复制「当前这一层」:得到一个新对象(新指针),但对象内部的元素/子对象仍指向原有的实例。 新对象占用新内存(新引用计数);内部元素复制,多出一份对原元素的引用(引用计数 +1)。
深拷贝 递归复制整棵对象树:当前对象及其内部所有引用到的对象都重新创建一份。 整棵对象树都占用新内存,拷贝前后完全独立,无共享引用。
  • 单层对象(如 NSString、NSData):浅拷贝与深拷贝在「是否共享内容」上的差异,取决于类型是否可变、实现是否共享底层存储(如 copy 后可能共享 buffer,仅引用计数 +1)。
  • 集合类(NSArray、NSDictionary 等):浅拷贝 = 新容器 + 元素仍指向原元素;深拷贝 = 新容器 + 对每个元素再递归 copy,需自行实现或使用 initWithArray:copyItems:YES 等 API。

1.2 与内存、引用计数的关系

  • 浅拷贝:生成一个新对象(容器或包装类),该对象对内部子对象的引用会使这些子对象的引用计数 +1;子对象本身不复制,内存上共享子对象。
  • 深拷贝:生成全新的对象图,每个被拷贝的对象都有新内存、新引用计数;原对象与拷贝无共享,释放一方不影响另一方。

二、Foundation 中的 copy 与 mutableCopy

2.1 常见类型的拷贝语义(概览)

类型 copy mutableCopy 说明
NSString 不可变副本(可能共享存储,引用计数 +1) NSMutableString 不可变 → 不可变 多为浅拷贝;不可变 → 可变 会分配新缓冲
NSMutableString 不可变 NSString(新内存) NSMutableString(浅拷贝) copy 得到不可变,防止外部修改
NSArray 浅拷贝,新数组、元素仍指向原元素 NSMutableArray(浅拷贝) 元素引用计数 +1,元素本身不复制
NSDictionary 浅拷贝 NSMutableDictionary(浅拷贝) 同上
NSData 浅拷贝(可能共享字节) NSMutableData 实现可能共享底层 buffer
自定义类 copyWithZone: 实现决定 mutableCopyWithZone: 决定 可做浅拷贝或深拷贝
  • 上述「浅拷贝」指:容器是新对象,元素仍是原对象引用;对容器增删不影响对方,对元素内容的修改可能影响对方(若元素可变)。

2.2 集合的「单层深拷贝」

  • [[NSArray alloc] initWithArray:array copyItems:YES]:会向每个元素发送 copy,得到新数组 + 一层新元素;若元素本身是集合,其内部不会递归 copy,因此是单层深拷贝,不是递归深拷贝。
  • 真正递归深拷贝需自己实现或使用序列化(如 NSKeyedArchiver)再反序列化,注意性能与内存。

三、NSCopying 与 NSMutableCopying

3.1 协议

  • NSCopying:实现 - (id)copyWithZone:(NSZone *)zone;调用 [obj copy] 时最终走 copyWithZone:
  • NSMutableCopying:实现 - (id)mutableCopyWithZone:(NSZone *)zone;调用 [obj mutableCopy] 时走 mutableCopyWithZone:

3.2 拷贝与内存管理

  • ARC:copy/mutableCopy 返回的对象由调用方持有(引用计数 +1),遵循 ARC 规则。
  • MRC:返回的对象为调用方拥有,需在适当时机 release 或 autorelease;在 copyWithZone: 里返回的对象应为 +1 所有权(alloc 或 copy 出来的)。

3.3 自定义类的浅拷贝与深拷贝示例(概念)

// 浅拷贝:新对象,但 property 仍指向原对象(retain/copy 使引用计数 +1)
- (id)copyWithZone:(NSZone *)zone {
    MyClass *copy = [[MyClass allocWithZone:zone] init];
    copy.name = self.name;           // 若 name 是 copy 属性,会 [self.name copy]
    copy.child = self.child;         // 若 child 是 strong,仅 retain,共享同一 child
    return copy;
}

// 深拷贝:递归复制子对象
- (id)copyWithZone:(NSZone *)zone {
    MyClass *copy = [[MyClass allocWithZone:zone] init];
    copy.name = [self.name copy];
    copy.child = [self.child copy];  // 子对象也 copy,完全独立
    return copy;
}
  • 选择浅拷贝还是深拷贝取决于业务:共享子对象可省内存但需注意多线程/可变性;完全独立则省心但内存与耗时更大。

四、属性的 copy 与内存

4.1 copy 属性

  • 声明为 @property (copy) NSString *name 时,setter 会对传入值调用 copy,即持有的是「传入对象的拷贝」的所有权;若传入的是 NSMutableString,拷贝后得到不可变 NSString,避免外部在别处修改导致当前实例被意外改动。
  • Block 使用 copy 属性:Block 的 copy 会把栈 Block 拷贝到堆(见 10-Block内存管理),并持有该堆 Block;与「深浅拷贝」中的「拷贝」语义不同,但都涉及「新对象 + 引用计数」。

4.2 深浅拷贝与属性

  • 若属性是 集合(如 NSArray),用 copy 只是对集合本身做浅拷贝(新容器、元素仍共享);若希望「外部传入的数组」与内部完全隔离,要么接受浅拷贝(元素共享),要么在 setter 里做一层 initWithArray:copyItems:YES 或自定义深拷贝,并注意内存与性能。

五、内存注意与选型

5.1 浅拷贝

优点 缺点
省内存、速度快 与原对象共享子对象;若子对象可变,一边修改会影响另一边;多线程需额外同步

5.2 深拷贝

优点 缺点
完全独立,无共享,线程安全更易控制 内存与 CPU 开销大,递归深拷贝需防循环引用与栈溢出

5.3 何时用哪种

  • 浅拷贝:只关心「多一份容器引用」、元素共享可接受(或元素不可变)时;Foundation 的 copy/mutableCopy 默认多为浅拷贝(容器层)。
  • 深拷贝:需要「完全独立副本」、避免外部修改或跨线程共享可变状态时;可单层深拷贝(copyItems:YES)或自定义递归深拷贝。

六、流程图:浅拷贝与深拷贝的内存关系(概念)

flowchart TB
    subgraph 浅拷贝
        A1[原容器] --> A2[新容器]
        A1 --> A3[元素a]
        A2 --> A3
    end
    subgraph 深拷贝
        B1[原容器] --> B2[新容器]
        B1 --> B3[元素a]
        B2 --> B4[元素a 的副本]
    end

七、Swift 中的「拷贝」与内存

7.1 值类型与引用类型

  • 值类型(struct、enum、基础类型):赋值与传参是拷贝语义(复制一份值);从「不共享同一块堆对象」的角度看,更像「深拷贝」。
  • 引用类型(class):赋值与传参是引用,不产生新对象,仅多一个指针;若要独立副本需显式实现拷贝(如实现 NSCopying 或自定义 copy() 方法)。

7.2 写时拷贝(Copy-on-Write)

  • Array、Dictionary、Set 等是值类型,但底层存储可能共享 buffer;修改时才复制一份(Copy-on-Write),既保证值语义又减少不必要的内存与拷贝开销。
  • 与 OC 的「浅拷贝」不同:Swift 集合的「拷贝」在未修改前可能共享存储,修改时再分配新内存,由标准库保证语义正确。COW 原理、Swift 实现要点(如 isKnownUniquelyReferenced)及与内存的关系见 12-Option与内存优化技术 中的「Copy-on-Write」一节。

八、思维导图:深浅拷贝与内存

mindmap
  root((深浅拷贝与内存))
    概念
      浅拷贝 新对象 共享元素
      深拷贝 新对象 递归复制
    引用计数
      浅拷贝 元素 rc+1
      深拷贝 全新对象图
    Foundation
      copy mutableCopy
      集合 copyItems
    NSCopying
      copyWithZone
      自定义浅/深拷贝
    属性 copy
      setter 调 copy
      Block NSString
    Swift
      值类型 拷贝语义
      CopyOnWrite

九、参考文献

10-主题|内存管理@iOS-Block内存管理

本文专门介绍 Objective-C BlockSwift 闭包内存管理:Block 的三种类型(全局/栈/堆)、捕获变量与内存、copy 语义、循环引用 与破除,以及作为属性/参数时的注意点。前置知识见 04-ARC详解06-weak与循环引用


一、Block 是什么(与内存的关系)

  • Block 是 Apple 对 C 语言扩展的闭包:可捕获外部变量、作为对象参与引用计数;在内存上既包含代码(函数指针),也包含捕获的变量(结构体形式),因此既有「存在位置」(栈/堆/全局)也有「对捕获对象的持有关系」。
  • 内存管理 需关注两点:Block 对象本身 的分配与释放(栈 block / 堆 block / 全局 block),以及 Block 对捕获变量(尤其是 OC 对象) 的强引用/弱引用,避免循环引用与泄漏。

二、Block 的三种类型与内存位置

2.1 类型与存储位置

类型(运行时 isa) 存储位置 产生条件(典型)
NSGlobalBlock 全局区(.data/.text) 未捕获任何外部变量(或仅捕获全局/静态变量)
NSStackBlock 捕获了自动变量(局部变量),且未 copy 到堆(MRC 下常见)
NSMallocBlock 对栈 block 执行 copy,或 ARC 下多数「需要逃逸」的 block 被编译器自动 copy 到堆
  • 全局 Block:不依赖栈帧,无需 copy,可当作单例使用。
  • 栈 Block:随栈帧销毁而失效,若要在作用域外使用(如存为属性、异步回调),必须先 copy 到堆;ARC 下编译器会在赋值给 strong/copy 属性、跨函数传递等场景自动插入 copy。
  • 堆 Block:参与引用计数,由 ARC/MRC 管理;copy 时引用计数 +1,release 时 -1。

2.2 简单判断示例(ARC)

// 无捕获 → 全局 Block(__NSGlobalBlock__)
void (^gBlock)(void) = ^{ NSLog(@"no capture"); };

// 捕获局部变量 → 栈 Block(__NSStackBlock__),若赋给 strong/copy 属性则会被 copy 成堆 Block
int a = 1;
void (^sBlock)(void) = ^{ NSLog(@"%d", a); };
// 赋值给 copy/strong 属性或作为参数传给需要「持有」的 API 时,会变成 __NSMallocBlock__

2.3 MRC 下 Block 的 copy 必要性

  • MRC 下,栈上的 Block 在函数返回后栈帧被回收,若此时 block 已被传给调用方或存到堆对象(如属性),再执行会野指针/未定义行为
  • 因此 MRC 下:凡是需要跨作用域保留的 block,必须对其执行一次 copy,将栈 block 拷贝到堆上,得到 NSMallocBlock,之后按普通 OC 对象做 retain/release;用完后要对堆 block 做 release(或 autorelease)。
  • ARC 下:编译器在「赋值给 strong/copy 属性、作为参数传给会保留 block 的 API」等场景自动插入 copy,一般无需手写 [block copy]

三、Block 捕获变量与内存

3.1 捕获方式概览

捕获对象/变量 默认行为(OC 对象) 对引用计数的影响
局部 OC 对象(自动变量) 强引用(strong) Block 被 copy 到堆时,会 retain 被捕获的对象;block 释放时 release
局部标量(int、结构体等) 值拷贝 不涉及引用计数
__block 修饰的变量 生成结构体,block 与外部共享 若 __block 变量指向 OC 对象,需注意 MRC/ARC 下 retain 行为;__block 可改写
__weak 修饰的对象 弱引用 Block 不持有该对象,不增加引用计数,可避免循环引用

3.2 对象捕获与循环引用

  • Block 若强引用了某个对象 A(如直接使用 self),而 A 又强引用了该 block(如 block 被 A 的 strong/copy 属性持有),则形成 self → block → self 的循环,两者都不会释放。
  • 解决:在 block 外使用 __weak typeof(self) wself = self,block 内使用 wself,这样 block 对 self 是弱引用;若在 block 执行过程中担心 self 被释放,可在 block 内再用 __strong typeof(wself) sself = wself 强引用一次(仅限 block 执行期),避免执行到一半 self 被置 nil。详见 06-weak与循环引用

3.3 __block 与内存(简述)

  • __block 使局部变量在 block 内可被修改,编译器会生成一个包装结构,block 捕获的是该结构;若 __block 变量指向 OC 对象,在 ARC 下通常不会造成 block 对对象的强引用(对象存在 __block 结构里),但若在 block 内给该变量赋新值,会涉及旧值 release、新值 retain。MRC 下 __block 不会自动 retain 对象,需自行管理。
  • 历史上用 __block 打破循环(__block self + block 内置 nil)的写法在 ARC 下不推荐,应使用 __weak 打破循环。

四、Block 作为属性、参数与返回值

4.1 属性声明

属性修饰 说明
copy 设值时对 block 执行 copy;MRC 时代推荐,ARC 下 strong 与 copy 对 block 效果类似(都会 copy 到堆),习惯上仍常用 copy 表达「这是 block」的语义。
strong ARC 下与 copy 类似,赋值时也会把栈 block copy 到堆并强引用。
  • Block 属性应避免用 assign(栈 block 离开作用域后失效,会野指针)。

4.2 作为参数与返回值

  • 作为参数:若 API 会保存 block(如延迟执行、存入数组),API 内部应对传入的 block 做 copy(或由 ARC 在传入时保证是堆 block);调用方传栈 block 时,由被调用方 copy 到堆是常见约定。
  • 作为返回值:返回 block 时,若希望调用方在函数返回后仍能使用,应返回堆上的 block(MRC 下 return 前对 block 做 copy/autorelease;ARC 下编译器会根据返回类型自动处理)。

五、ARC 与 MRC 下 Block 内存小结

场景 MRC ARC
栈 block 需跨作用域使用 必须对 block 执行 copy,用完后 release 编译器在赋值给 strong/copy、传参等场景自动 copy
Block 属性 copy,setter 里对 block copy、对旧 block release copystrong 均可,都会导致 copy 到堆并强引用
Block 内引用 self 避免 self→block→self:用 __weak 或 __block+置 nil __weak self,必要时 block 内 __strong 一次
Block 捕获的 OC 对象 copy 到堆时 block 会 retain 捕获的对象;block release 时 release 这些对象 同左,由编译器插入

六、流程图:Block 从创建到释放(概念)

flowchart LR
    A[定义 Block] --> B{是否捕获自动变量?}
    B -->|否| C[__NSGlobalBlock__ 全局]
    B -->|是| D[__NSStackBlock__ 栈]
    D --> E[赋值给 strong/copy 或 传参]
    E --> F[copy 到堆 __NSMallocBlock__]
    F --> G[Block 被 release]
    G --> H[对捕获对象 release]

七、Swift 闭包与内存

  • Swift 闭包 与 OC Block 语义对应:闭包会捕获外部变量,默认对类对象是强引用
  • 循环引用:若对象强引用闭包,闭包内又使用了 self(或捕获了 self),则形成循环;解决方式为在闭包捕获列表中写 [weak self][unowned self](后者在 self 一定不会先于闭包释放时使用,否则会野指针)。
  • @escaping:标记闭包会「逃逸」出当前函数(如异步回调),编译器会按需将闭包拷贝到堆上,与 OC 中「block 被 copy 到堆」对应。

八、思维导图:Block 内存管理知识结构

mindmap
  root((Block 内存管理))
    三种类型
      全局 Block 无捕获
      栈 Block 捕获未 copy
      堆 Block copy 后
    捕获与引用
      对象默认强引用
      __weak 破循环
      __block 可改写
    属性与生命周期
      copy/strong 属性
      MRC 需手写 copy
      ARC 自动 copy
    循环引用
      self → block → self
      weak self strong self

九、参考文献

09-主题|内存管理@iOS-Category与关联对象内存管理

本文介绍 Objective-C Category(分类) 与内存的关系,以及通过 关联对象(Associated Objects) 在 Category 中「挂载」数据时的内存管理:关联策略(policy)、释放时机、循环引用与最佳实践。前置知识见 04-ARC详解06-weak与循环引用


一、Category 与内存的关系

1.1 Category 是什么

  • Category 用于在不修改原类的前提下,为已有类添加方法(以及通过关联对象间接添加「属性」式的存储)。
  • Category 不能直接添加实例变量(ivar),因此不会改变类实例的内存布局sizeof;实例大小由原类及其子类的 ivar 决定。

1.2 对内存管理的影响

维度 说明
实例大小 Category 不增加实例占用,无需从「对象体积」角度做特殊内存管理。
方法实现 Category 中的方法若创建或持有对象,仍遵循 ARC/MRC 规则(谁持有谁释放、避免循环引用)。
「属性」存储 若在 Category 中通过 关联对象 模拟属性,则关联的 value 的持有方式association policy 决定,需正确设置以避免泄漏或野指针。

下文重点说明关联对象的内存语义与使用注意。


二、关联对象(Associated Objects)简述

2.1 作用

  • 不增加 ivar 的前提下,把键值对绑在某个对象上:主对象被释放时,运行时会自动释放其关联的 value(按 policy 做 release 等)。
  • 常用于在 Category 中为已有类添加「存储型属性」、或为任意对象挂载扩展数据。

2.2 API(Objective-C 运行时)

// 设置:object 为主对象,key 为键,value 为值,policy 为关联策略
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy);

// 获取
id objc_getAssociatedObject(id object, const void *key);

// 移除(将 value 设为 nil 即可,会按 policy 释放原 value)
objc_setAssociatedObject(object, key, nil, policy);

三、关联策略(policy)与内存管理

3.1 常用策略对照表

策略常量 语义(对 value 的持有方式) 适用场景
OBJC_ASSOCIATION_RETAIN 强引用(retain),主对象释放时对 value release 普通 OC 对象属性(类似 strong)
OBJC_ASSOCIATION_COPY 拷贝后强引用(copy),主对象释放时对拷贝 release 字符串、block 等需拷贝的类型
OBJC_ASSOCIATION_ASSIGN 不持有(assign),主对象释放时不对 value 做 release 基本类型、或「弱引用」场景(注意野指针)
OBJC_ASSOCIATION_RETAIN_NONATOMIC 同 RETAIN,非原子 性能敏感、不需原子性时
OBJC_ASSOCIATION_COPY_NONATOMIC 同 COPY,非原子 同上

3.2 与 ARC 属性修饰符的对应

若属性声明为 关联时建议 policy
strong(对象) OBJC_ASSOCIATION_RETAIN
copy(block/NSString) OBJC_ASSOCIATION_COPY
assign / weak OBJC_ASSOCIATION_ASSIGN(assign 不保证置 nil,若为对象有野指针风险;true weak 需运行时支持,关联对象常用 ASSIGN 存 weak 包装或非持有)

3.3 释放时机

  • 主对象 dealloc 时,运行时会自动对所有关联的 value 按各自 policy 执行 release(或等效操作),无需在 dealloc 里手动 objc_setAssociatedObject(..., nil, ...) 或单独 release。
  • 若在业务上希望提前解除某条关联,可主动 objc_setAssociatedObject(object, key, nil, policy),原 value 会按 policy 被释放。

四、Category 中「属性」的常见写法与内存

4.1 强引用存储(RETAIN)

// Category 中为 NSObject 添加一个“强引用”属性
static const void *kMyKey = &kMyKey;

- (void)setMyProperty:(id)obj {
    objc_setAssociatedObject(self, kMyKey, obj, OBJC_ASSOCIATION_RETAIN);
}
- (id)myProperty {
    return objc_getAssociatedObject(self, kMyKey);
}
  • 内存:set 时对新 value retain、对旧 value release;主对象 dealloc 时自动 release 当前 value,无泄漏。
  • 注意:若 myProperty 内部又强引用主对象(如 block 捕获 self),会循环引用,需用 weak 打破(见下)。

4.2 拷贝存储(COPY,如 block)

- (void)setMyBlock:(void (^)(void))block {
    objc_setAssociatedObject(self, kBlockKey, block, OBJC_ASSOCIATION_COPY);
}
  • Block 常用 COPY,与属性 copy 一致;主对象释放时会对拷贝的 block release。

4.3 弱引用 / 不持有(ASSIGN)与循环引用

  • 若用 OBJC_ASSOCIATION_ASSIGN 存一个对象指针,主对象不会持有该对象;但主对象 dealloc 时不会把该指针置 nil,若外部未持有,可能产生野指针
  • 循环引用:主对象 A 通过 RETAIN 关联了对象 B,B 又强引用了 A → 双方都不释放。解决办法:让 B 对 A 使用 weak(若 B 是自定义类可改);或 A 不通过 RETAIN 关联 B,改用 ASSIGN + 弱引用包装(需注意生命周期与野指针)。
  • Category 中若「属性」是 delegate 或会反向引用 self 的对象,应避免用 RETAIN 持有该对象,可考虑 ASSIGN 存 weak 包装或不在 Category 里存该引用。

五、流程图:关联对象生命周期

flowchart LR
    A[主对象存在] --> B[setAssociatedObject value policy]
    B --> C[value 被 retain/copy 等]
    A --> D[主对象 dealloc]
    D --> E[运行时按 policy 释放所有关联 value]
    E --> F[value 引用计数减一 或 置空]

六、小结与最佳实践

场景 建议
Category 中存普通 OC 对象 使用 OBJC_ASSOCIATION_RETAIN(或 RETAIN_NONATOMIC)。
Category 中存 block / 需拷贝类型 使用 OBJC_ASSOCIATION_COPY
不持有、仅赋值指针(如 delegate) 可用 OBJC_ASSOCIATION_ASSIGN,注意主对象释放后不置 nil,避免野指针。
避免循环引用 不在 Category 中用 RETAIN 关联「会强引用主对象」的对象;或对方对主对象使用 weak。
释放 主对象 dealloc 时关联会自动清理,一般无需在 dealloc 里手动移除。

参考文献

08-主题|内存管理@iOS-内存对齐

本文介绍 内存对齐(Memory Alignment) 的概念、为何需要对齐、结构体内存对齐 的规则与示例,以及在 iOS/ARM64 下的典型约定。与「内存五大分区」中数据在栈、堆、全局区的布局密切相关,见 01-主题|内存管理@iOS-内存五大分区


一、什么是内存对齐

1.1 定义

  • 内存对齐:数据在内存中的起始地址满足一定约束,通常是「地址为自身所占字节数的整数倍」(或按平台规定的对齐值)。
  • 例如:4 字节的 int 在多数平台上需** 4 字节对齐**(地址为 4 的倍数);8 字节的 double8 字节对齐(地址为 8 的倍数)。

1.2 为什么需要对齐

原因 说明
CPU 访问效率 许多 CPU 对未对齐访问有性能惩罚或需多次总线访问;对齐后可按固定步长、单次或更少次数访问。
硬件与 ABI 要求 ARM、x86 等架构对某些类型有对齐要求;未对齐访问在部分平台可能触发异常(如 ARM 未对齐访问可配置为 fault)。
以空间换时间 通过填充(padding) 满足对齐,会多占一些字节,但换来稳定、高效的访问。

二、基本类型的对齐(典型值)

以下为 64 位 iOS/ARM64 下常见类型的典型对齐与大小(具体以 ABI 与编译器为准):

类型 大小(字节) 典型对齐(字节)
char / bool 1 1
short 2 2
int 4 4
long / 指针(64 位) 8 8
float 4 4
double 8 8
long double 8 或 16 8 或 16

平台约定:iOS 64 位(ARM64)下,编译器常采用 8 字节 作为结构体整体对齐的上限之一(即结构体大小与起始地址常为 8 的倍数);32 位下多为 4 字节。


三、结构体内存对齐规则

3.1 三条常见规则

  1. 成员对齐:结构体第一个成员的偏移为 0;后续成员的起始偏移 = 该成员自身对齐值的整数倍,不足则插入 padding
  2. 嵌套结构体:若成员是结构体,该成员的起始偏移 = 其内部最大成员对齐值的整数倍(即嵌套结构体按自身「最严格」对齐要求对齐)。
  3. 整体对齐:结构体的总大小 = 其内部最大成员对齐值的整数倍;末尾不足则补足,以便结构体数组时每个元素仍对齐。

3.2 流程图:计算结构体布局(伪流程)

flowchart TB
    A[遍历每个成员] --> B[当前偏移 是 该成员对齐的整数倍?]
    B -->|否| C[补 padding 到满足]
    B -->|是| D[放置该成员]
    C --> D
    D --> E[偏移 += 成员大小]
    E --> A
    F[所有成员放完] --> G[总大小 是 最大成员对齐的整数倍?]
    G -->|否| H[末尾补 padding]
    G -->|是| I[得到 sizeof]
    H --> I

四、示例:结构体大小与 padding

4.1 C / Objective-C 示例

// 假设 64 位:指针 8 字节、int 4 字节、char 1 字节
struct Example1 {
    double a;   // 8 字节,偏移 0,[0-7]
    char b;     // 1 字节,偏移 8,[8]
    int c;      // 4 字节,需 4 对齐,故偏移 12,[12-15]
    short d;    // 2 字节,偏移 16,[16-17]
};              // 最大成员对齐 8,总大小需 8 的倍数:18 → 24,末尾补 6 字节
// sizeof(Example1) == 24
成员 大小 对齐 起始偏移 说明
a 8 8 0 第一个成员
b 1 1 8 无 padding
c 4 4 12 偏移 9、10、11 不满足 4 对齐,补 3 字节
d 2 2 16 无 padding
(尾部) 18→24 总大小凑成 8 的倍数

4.2 成员顺序对大小的影响

同一批成员、顺序不同会导致 padding 不同,从而总大小不同

struct Compact {
    double a;   // 0-7
    int b;      // 8-11
    int c;      // 12-15
    char d;     // 16
};              // 总大小 17 → 对齐 8 → 24 字节(末尾补 7)

struct Sparse {
    char a;     // 0
    double b;   // 需 8 对齐 → 8-15,前补 7
    int c;      // 16-19
};              // 总大小 20 → 对齐 8 → 24 字节

实践建议:若需节省结构体占用,可将大类型放前、小类型集中,减少中间 padding。


五、Swift 中的内存布局与对齐

5.1 MemoryLayout

  • MemoryLayout<T>.size:类型 T 的实际占用字节数(不含尾部为数组元素对齐而留的 padding)。
  • MemoryLayout<T>.stride:在连续存储(如数组)中,相邻两个 T 的起始地址之差,即「对齐后的大小」。
  • MemoryLayout<T>.alignment:类型 T 的对齐要求(字节数)。

5.2 示例

struct SHPerson {
    var age: Int    // 8 字节
    var weight: Int // 8 字节
    var sex: Bool   // 1 字节
}
// size  = 17(实际成员占用)
// stride = 24(8 字节对齐后,用于数组等)
// alignment = 8

六、与内存五大分区的关系

  • 栈、堆、全局区中存放的局部变量、对象、全局/静态变量,其起始地址与内部成员都受对齐约束;编译器与运行时在分配时会保证对齐。
  • 理解对齐有助于:估算结构体/类实例占用、排查「sizeof 与预期不符」、与 C 互操作或做底层布局时避免未对齐访问。

七、自定义对齐与 packed(简述)

手段 说明
_attribute_((aligned(n))) 指定变量或结构体按 n 字节对齐(如缓存行 64 字节)。
_attribute_((packed)) 取消结构体内部 padding,成员紧挨排列;可减小体积但可能未对齐,访问效率或安全性下降,需谨慎使用。

八、小结(思维导图)

mindmap
  root((内存对齐))
    目的
      CPU 访问效率
      ABI 与硬件要求
    规则
      成员按自身对齐
      整体大小为最大对齐的整数倍
    iOS/ARM64
      常用 8 字节整体对齐
      size 与 stride
    实践
      成员顺序影响大小
      packed / aligned 慎用

参考文献

07-主题|内存管理@iOS-实践与常见问题

本文在 01~06 基础上,汇总 内存警告Instruments 排查与泄漏分析Timer 管理野指针音视频与图层场景 等实践要点,以及常见问题与最佳实践。建议先掌握总纲与 ARC、weak 等再阅读本文;Timer 与 NSProxy 见 06-weak与循环引用


一、内存警告(Memory Warning)

1.1 机制

  • 系统在内存紧张时向应用发送 UIApplication 内存警告(如 didReceiveMemoryWarning);若不释放非必要缓存,系统可能终止进程

1.2 响应建议

做法 说明
释放缓存 图片缓存、数据缓存等可重建的,在收到警告时清理或缩小
释放不可见资源 非当前页的大图、大模型等可延迟重新加载的,可先释放
不阻塞主线程 释放与重建尽量异步,避免卡顿

1.3 回调示例(ViewController)

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // 释放可重建的缓存、大图等
}

二、内存泄漏(Leak)排查

2.1 常见原因

  • 循环引用:对象成环,引用计数永不为 0 → 用 weak 打破。
  • 定时器/观察者未移除:NSTimer、KVO、Notification 等强引用 target/observer,未在 dealloc 前移除 → 及时 invalidate/removeObserver。
  • Block/闭包强引用:block 强引用 self 且 self 强引用 block → [weak self];Block 类型与 copy 语义见 10-Block内存管理
  • Category 关联对象:用 objc_setAssociatedObject 时若用 RETAIN 关联了「会强引用主对象」的对象(如 block 捕获 self),会形成循环引用;应避免或改用 weak 打破。详见 09-Category与关联对象内存管理

2.2 Instruments(Leaks / Allocations)

  • Leaks:检测进程内已无法被引用到的「泄漏」内存块。
  • Allocations:查看各对象分配与存活情况,结合 GenerationsMark Generation 观察某操作后是否持续增长不降。
  • 结合 Call Tree 与源码,定位泄漏对象与引用链。

2.3 内存泄漏的内存分析(进阶)

  • 堆快照与对比:在 Allocations 中多次 Mark Generation(如进入页面前 Mark、返回后再 Mark),对比两次快照的「Persistent」对象数量与大小,找出本应释放却仍存活的对象。
  • VM 区域:在 Allocations 的 StatisticsVM Tracker 中查看各 VM 区域(如 CG image、Image IO、IOSurface、Audio 等),定位是哪类内存持续增长(如解码图、音视频缓冲未释放)。
  • 引用链分析:对疑似泄漏对象右键 Show in Memory Graph 或查看 Reference History,看清「谁在持有它」的引用链,从而找到应改为 weak 或应 invalidate/remove 的持有方。
  • Malloc Stack / Call Tree:开启 Malloc Stack(Allocations 模板或 Edit Scheme → Diagnostics)可看到分配时的调用栈,便于确认泄漏对象来自哪段代码;Call Tree 的「Invert Call Tree」「Hide System」可快速聚焦业务代码。
  • Leaks 与 Allocations 配合:Leaks 只报「不可达」的泄漏;很多「仍被错误持有」的对象不会报 Leak,需用 Allocations 的 Generation 对比 + 引用链分析。

2.4 Timer 管理(详细)

  • NSTimer 会强引用 target,且 RunLoop 会持有 timer;若 VC 强引用 timer 且 target 是 self,则 VC → timer → self 形成循环,VC 不会 dealloc。
  • 解决:① 在 dealloc 前 invalidate(若循环未破,dealloc 不会被调用,故须先破环);② 用 NSProxy 弱引用 self 作为 timer 的 target,使 timer 强引用的是 proxy 而非 VC,详见 06-weak与循环引用 中的「NSProxy 与 Weak、Timer 管理」;③ iOS 10+ 使用 block 版 scheduledTimerWithTimeInterval:repeats:block:,block 内用 weak self,timer 不直接强引用 self。
  • CADisplayLink 同样强引用 target,需用相同思路(proxy 或 block 若可用)并在 dealloc 里 invalidate

三、野指针与崩溃

3.1 成因

  • 对象已 release/dealloc,仍有指针访问该内存 → 野指针;再次向该对象发消息或访问成员易 EXC_BAD_ACCESS 等崩溃。

3.2 预防

手段 说明
ARC + weak 使用 weak 时,对象释放后指针自动置 nil,发送消息无效果但不会崩溃
不重复 release(MRC) MRC 下严格配对,避免对同一对象多次 release
置空指针 释放后将指针置 nil(ARC 中 weak 自动完成)

四、音视频场景内存注意

  • 解码缓冲与采样缓冲:音视频解码会产生 CVPixelBufferCMSampleBuffer 等,若不及时释放或重复堆积,会快速推高内存;播放/渲染完或不再需要时应及时释放,避免在回调或队列中积压。
  • 大文件/流:避免一次性将整段音视频读入内存;使用 AVAssetReader流式读取 等按需加载,及时释放已解码帧或已播放的缓冲。
  • 后台与生命周期:进入后台时释放非必要解码器、清空大缓冲或暂停解码,回到前台再重建,可配合 UIApplication 后台通知didReceiveMemoryWarning
  • 循环引用:在 AVFoundation 回调、block 中若使用 self,需 weak self,避免 VC 或播放器持有 block 且 block 强引用 self 导致不释放。
  • CVPixelBuffer / 图像缓冲:渲染或处理完及时 CVPixelBufferRelease(若自己 retain 过)或交给系统回收;避免在缓存中无上限保留未释放的 buffer。

内存极限管理(缓存上限、后台释放、按需加载、内存映射等)见 12-Option与内存优化技术 中的「内存的极限管理」一节。


五、图层处理场景内存注意

  • 图片解码与尺寸:UIImage 在赋值给 UIImageView 或绘制前会解码为位图,大图会占用 宽×高×4 字节 量级内存;应对大图做降采样(如用 Image I/O 或 Core Graphics 按显示尺寸解码),或使用缩略图/裁剪,避免全尺寸解码多张大图。
  • CALayer 与 backing store:图层有内容(如 contents、drawRect)时会有 backing store 占用内存;离屏渲染(圆角+裁剪、阴影、group opacity 等)会生成额外离屏缓冲,多而大时会增加内存与 GPU 压力,可适当减少离屏层或用位图缓存。
  • 离屏渲染与缓存shouldRasterize = YES 会缓存光栅化结果,图层复杂或尺寸大时缓存会占内存;在不需要时关闭或缩小 layer bounds。
  • 大图列表:列表(UITableView/UICollectionView)中大量大图时,做好 复用按需加载内存警告时释放;可配合 didReceiveMemoryWarning 清空图片缓存。
  • Core Graphics / 位图:自己创建的 CGContextCGBitmapContext 在不用时 CGContextRelease;UIGraphics 的 context 若为自己创建需对应释放。

六、最佳实践小结

场景 建议
属性默认 对象类型用 strong;delegate/dataSource 用 weak
Block 若 block 被 self 持有且 block 内用 self,用 weak self;block 内若需保证执行期 self 存活,可再 strong 一次;Block 属性用 copy/strong,详见 [10-Block内存管理](10-主题 内存管理@iOS-Block内存管理.md)
定时器/通知 在 dealloc 前 invalidate timer、removeObserver,避免强引用导致不释放
大量临时对象 循环内使用 @autoreleasepool 控制峰值
内存警告 实现 didReceiveMemoryWarning,释放可重建缓存
Category 关联对象 OBJC_ASSOCIATION_RETAIN/COPY 存对象、OBJC_ASSOCIATION_COPY 存 block;避免关联会强引用主对象的对象以防循环引用,详见 [09-Category与关联对象内存管理](09-主题 内存管理@iOS-Category与关联对象内存管理.md)
集合/对象拷贝 区分浅拷贝(新容器、元素共享)与深拷贝(完全独立);属性 copy 对集合仅浅拷贝,需完全隔离时考虑深拷贝或 copyItems,详见 [11-深浅拷贝与内存](11-主题 内存管理@iOS-深浅拷贝与内存.md)
Timer NSProxy 弱引用 self 作 target 破循环,或 iOS 10+ 用 block 版 API;dealloc 前必须 invalidate,详见 [06-weak与循环引用](06-主题 内存管理@iOS-weak与循环引用.md)
音视频 及时释放解码/采样缓冲,流式加载大文件,后台释放非必要资源,回调中用 weak self
图层/大图 大图降采样、控制离屏渲染与 rasterize 缓存、列表复用与按需加载、CGContext 及时释放

七、流程图:泄漏排查思路

flowchart LR
    A[怀疑泄漏] --> B[Instruments Leaks]
    B --> C[看引用链]
    C --> D[查循环引用/未移除的观察者等]
    D --> E[weak/移除/改设计]

参考文献

06-主题|内存管理@iOS-weak与循环引用

本文介绍 weak(弱引用) 的语义、在运行时中的实现思路(SideTable/weak_table)、循环引用 的成因与破除方式,以及 block、delegate 等场景下的注意点。ARC 基础见 04-ARC详解。Block 的三种类型、copy 与捕获变量见 10-Block内存管理


一、weak 的语义

1.1 定义

  • weak:不增加对象的引用计数,不拥有对象;当对象被释放时,所有指向它的 weak 指针会被自动置为 nil,避免野指针。
  • strong 对比:strong 持有对象(rc+1),strong 不释放则对象不 dealloc;weak 不持有,对象可被其他引用释放,释放后 weak 自动置 nil。

1.2 使用场景

场景 说明
打破循环引用 A → B → A,将其中一条边改为 weak,避免双方都无法释放
非拥有关系 delegate、dataSource 等,通常用 weak,由外部持有生命周期
block 内引用 self 使用 [weak self] 避免 self → block → self 循环

二、循环引用(Retain Cycle)

2.1 成因

  • 循环引用:对象 A 强引用 B,B 又强引用 A(或经过多条边回到 A),形成环;双方引用计数都不为 0,永远无法 dealloc,造成泄漏。

2.2 常见情形与破除

情形 破除方式
两个对象互相 strong 一方改为 weak(如 child 对 parent 用 weak)
self → block → self block 内用 [weak self],必要时内部再 strong 一次避免提前释放
delegate 双方都 strong 通常 delegate 属性声明为 weak,由外部持有
Timer 强引用 target VC 强引用 timer,timer 强引用 target(即 VC)→ 循环;用 NSProxy 弱引用 VC 作为 timer 的 target,或 iOS 10+ 用 block 版 API

2.3 Block 中 weak self 示例(Objective-C)

__weak typeof(self) wself = self;
self.block = ^{
    __strong typeof(wself) sself = wself; // 避免 block 执行过程中 self 被释放
    if (!sself) return;
    [sself doSomething];
};

三、NSProxy 与 Weak、Timer 管理

3.1 NSTimer 的循环引用问题

  • NSTimer强引用target;若 target 是 VC(或任意对象 A),且 A 又强引用了该 timer(如 self.timer),则形成 A → timer → target(A) 的循环,A 与 timer 都不会释放。
  • 仅在 A 里用 __weak self 给 timer 的 target 传参无效:timer 内部保存的是传入的 target 指针并对其强引用,不会因为调用方用 weak 而改为弱引用。

3.2 用 NSProxy 打破 Timer 循环引用

  • 思路:让 timer 的 target 不是一个强引用 self 的对象,而是一个中间对象;该中间对象对 self 只持 weak,并把 timer 的回调转发给 self。这样引用关系为:VC → timer → proxy(弱引用 VC),VC 释放时 proxy 的 weak 置 nil,proxy 可随之释放;timer 需在 VC 的 dealloc 里 invalidate,或由 proxy 在转发时发现 target 为 nil 时 invalidate(视实现而定)。
  • NSProxy 是专门做「转发」的根类,不继承自 NSObject,实现 forwardInvocation:methodSignatureForSelector:,把消息转给 weak 持有的 target 即可;内存上 proxy 只多一个 weak 指针,不增加 target 的引用计数。

3.3 WeakProxy 示例(Objective-C)

@interface WeakProxy : NSProxy
@property (nonatomic, weak) id target;
+ (instancetype)proxyWithTarget:(id)target;
@end

@implementation WeakProxy
+ (instancetype)proxyWithTarget:(id)target {
    WeakProxy *p = [WeakProxy alloc];
    p.target = target;
    return p;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.target methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.target];
}
@end

// 使用:timer 强引用的是 proxy,proxy 只 weak 引用 self
WeakProxy *proxy = [WeakProxy proxyWithTarget:self];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:proxy selector:@selector(onTick) userInfo:nil repeats:YES];
// dealloc 中仍须 [self.timer invalidate],否则 RunLoop 仍持有 timer

3.4 Timer 管理要点小结

要点 说明
invalidate VC(或持有 timer 的对象)dealloc 前必须调用 [timer invalidate],否则 RunLoop 持有 timer,timer 又强引用 target,导致泄漏或野指针。
block 版 API(iOS 10+) +[NSTimer scheduledTimerWithTimeInterval:repeats:block:] 的 block 里用 [weak self],timer 不直接强引用 self,可避免 timer→self 的强引用;仍需在 dealloc 里 invalidate。
子线程 子线程 RunLoop 默认不跑,timer 需加到 RunLoop 并 run;线程结束时记得 invalidate。

四、weak 实现思路(简述)

4.1 全局 weak 表

  • 运行时维护全局的 weak 表(与对象地址关联):记录「哪些 weak 指针正在指向该对象」。
  • 当对象 dealloc 时,查该表,把表中所有 weak 指针置为 nil,再销毁对象。

4.2 SideTable 与 weak_table(概念)

  • 为减少锁竞争,常用 SideTable 分片:根据对象地址映射到某一张 SideTable;每张表内有 weak_table,存「对象 → 指向它的 weak 指针列表」。
  • storeWeak 等函数:在注册 weak 指针、对象释放时更新对应 SideTable 中的 weak 表。

4.3 流程图:对象释放时 weak 置 nil

flowchart TB
    A[对象 dealloc] --> B[查 weak 表]
    B --> C[遍历指向该对象的 weak 指针]
    C --> D[将每个 weak 指针置为 nil]
    D --> E[销毁对象]

五、思维导图小结

mindmap
  root((weak 与循环引用))
    weak 语义
      不增加引用计数
      对象释放时置 nil
    循环引用
      成环 无法释放
      破除 一方改 weak
    Block
      weak self
      strong self 防提前释放
    NSProxy 与 Timer
      Timer 强引用 target
      WeakProxy 转发 破循环
    实现
      SideTable weak_table
      dealloc 时清空 weak

参考文献

05-主题|内存管理@iOS-AutoreleasePool与RunLoop

本文介绍 自动释放池(AutoreleasePool) 的原理、底层结构(AutoreleasePoolPage)、与 RunLoop 的协作关系,以及对象何时被批量 release。引用计数基础见 03-引用计数与MRC详解


自动释放池是什么(简要介绍)

自动释放池(AutoreleasePool) 是用于延迟释放对象的机制:当对象收到 autorelease 时,不会立即让引用计数 -1,而是被加入当前线程的自动释放池;当池被 pop/drain 时,池会对其中所有对象统一发送 release,从而在「某一时刻」批量 -1。在 MRC 下需手写 autorelease;在 ARC 下由编译器在需要时自动插入。主线程的 RunLoop 在每次循环开始会 push 一个池、在休眠或退出前 pop 该池,因此主线程上的 autorelease 对象多在「本次事件处理结束」时被释放。子线程若无 RunLoop,应显式使用 @autoreleasepool { } 控制释放时机,避免临时对象堆积。


一、AutoreleasePool 的作用

1.1 为什么需要

  • autorelease 表示「稍后再 release」:不立刻 -1,而是把对象交给当前自动释放池,由池在某一时刻统一对池内对象发送 release。
  • 作用:延迟释放,避免在密集创建临时对象的场景下频繁立刻 release,可将多次 release 合并到池 drain 时执行,有利于性能与局部性。

1.2 与 RunLoop 的关系(主线程)

  • 主线程 RunLoop 在一次循环中会:
    • 进入时:push 一个 AutoreleasePool;
    • 休眠/退出前:pop 该池,即对池内所有对象执行 release(drain)。
  • 因此,主线程上没有显式 @autoreleasepool 时,当前 RunLoop 迭代结束前创建的 autorelease 对象,会在本次迭代末尾被批量释放。

二、@autoreleasepool 语法与底层

2.1 语法

@autoreleasepool {
    // 池内创建的 autorelease 对象,在 } 时统一 release
    id obj = [SomeObject createObject]; // 若返回 autorelease 对象
}
// 池 pop,obj 收到 release

2.2 底层对应(伪代码)

  • @autoreleasepool { ... } 编译后等价于:
    • 入口:objc_autoreleasePoolPush()(入栈一个哨兵/边界);
    • 出口:objc_autoreleasePoolPop()(pop 到该边界,对之间加入的对象依次 release)。

2.3 AutoreleasePoolPage(简述)

  • 自动释放池由 AutoreleasePoolPage 组成的栈结构实现;每页约 4KB,存若干对象指针。
  • push 时可能新开一页或复用当前页;pop 时从栈顶向栈底对每个对象 release,直到遇到对应 push 的边界。

三、释放时机小结

场景 释放时机
主线程、无显式 @autoreleasepool 当前 RunLoop 迭代结束前(休眠/退出时 pop 顶层池)
显式 @autoreleasepool { } 离开 } 时 pop,池内对象立即被 release
子线程 若没有 RunLoop 或未手动加池,需在线程中显式 @autoreleasepool,否则 autorelease 对象可能堆积到线程退出

四、流程图:RunLoop 与 AutoreleasePool 协作(主线程)

flowchart LR
    subgraph RunLoop 一次迭代
        A[进入] --> B[Push Pool]
        B --> C[处理事件]
        C --> D[休眠/退出前]
        D --> E[Pop Pool]
        E --> F[池内对象 release]
    end

五、应用场景

  • 循环中大量创建临时对象:在循环内层包一层 @autoreleasepool { },每轮迭代结束即释放,避免峰值过高。
  • 子线程中创建大量 autorelease 对象:在线程入口或循环内使用 @autoreleasepool,避免只依赖线程退出才释放。

参考文献

04-主题|内存管理@iOS-ARC详解

本文介绍 ARC(Automatic Reference Counting,自动引用计数) 的机制、strong/weak/unowned 等所有权修饰符、编译器如何插入引用计数代码,以及常见应用场景与注意事项。前置知识见 03-引用计数与MRC详解


ARC 是什么(简要介绍)

ARCAutomatic Reference Counting:在编译期由编译器根据代码中的所有权修饰符(如 strong、weak)和代码结构,自动插入 retain、release、autorelease 等调用,开发者不再手写这些方法。底层仍然使用与 MRC 相同的引用计数规则,只是「谁在何时 +1/-1」由编译器决定。ARC 自 iOS 5 / WWDC 2011 引入,现为 Objective-C 与 Swift 的推荐方式;Swift 仅支持 ARC。使用 ARC 时仍需理解强引用与弱引用循环引用自动释放池 的释放时机,见 05-AutoreleasePool与RunLoop06-weak与循环引用


一、ARC 是什么

1.1 定义

  • ARC 是编译期特性:编译器根据所有权修饰符代码结构,在合适位置自动插入 retain、release、autorelease 等调用。
  • 与 MRC 使用同一套引用计数规则,对象生命周期语义一致;开发者不再手写 retain/release,减少遗漏与错误。

1.2 与 MRC 对比

维度 MRC ARC
谁写 retain/release 开发者手写 编译器自动插入
所有权表达 通过方法名约定 + 手写调用 通过变量/属性修饰符(strong/weak 等)
autorelease 手写 autorelease 编译器在需要时插入
循环引用 需手写 weak 或打破引用 同样需用 weak/unowned 打破

二、所有权修饰符(Objective-C)

2.1 常见修饰符

修饰符 含义 引用计数影响
__strong(默认) 强引用,拥有对象 赋值时 retain,离开作用域或置 nil 时 release
__weak 弱引用,不拥有对象 不增加引用计数;对象释放时自动置为 nil
__unsafe_unretained 不保留引用,不拥有 不增加引用计数;对象释放后不置 nil,可能野指针
__autoreleasing 通过引用传入并在 autorelease 池中释放 用于 out 参数等场景

2.2 属性与修饰符对应

属性声明 默认修饰符 说明
strong __strong 强引用,常用
weak __weak 弱引用,打破循环或非拥有关系
copy __strong(拷贝语义) 设值时 copy,用于 block、NSString 等;深浅拷贝与 copy 语义见 [11-深浅拷贝与内存](11-主题 内存管理@iOS-深浅拷贝与内存.md)
assign __unsafe_unretained 不持有,多用于基本类型或需避免循环时(非对象慎用)

三、ARC 下的典型场景

3.1 强引用与释放时机

// 局部变量:离开作用域时自动 release
- (void)foo {
    NSObject *obj = [[NSObject alloc] init]; // 强引用,rc=1
    // 使用 obj
} // 作用域结束,编译器插入 release,obj 可能 dealloc

3.2 弱引用与循环引用

// 两个对象互相强引用 → 循环引用,都无法释放
// 解决:一方改为 weak
@interface Child : NSObject
@property (nonatomic, weak) Parent *parent; // 弱引用父类
@end

3.3 Block 中的循环引用

// self → block → self,形成循环
__weak typeof(self) wself = self;
self.block = ^{
    __strong typeof(wself) sself = wself;
    [sself doSomething];
};

详见 06-weak与循环引用。Block 的三种类型(全局/栈/堆)、copy 语义与 MRC/ARC 差异见 10-Block内存管理


四、流程图:ARC 编译期插入示意

flowchart TB
    subgraph 源码
        A[strong 赋值]
        B[变量离开作用域]
    end
    subgraph 编译器插入
        A --> C[插入 retain]
        B --> D[插入 release]
    end

五、Swift 中的 ARC

  • Swift 仅支持 ARC,无 MRC。
  • strong(默认)、weakunowned 与 OC 语义对应;闭包捕获列表 [weak self] / [unowned self] 用于避免循环引用。
  • 详见 Swift 官方 - Automatic Reference Counting

参考文献

03-主题|内存管理@iOS-引用计数与MRC详解

本文介绍 引用计数(Reference Counting) 的原理、MRC(Manual Reference Counting,手动引用计数) 下的规则与配对原则,以及 retain/release/autorelease 的语义与典型用法。ARC 在此基础上由编译器自动插入,见 04-ARC详解


MRC 是什么(简要介绍)

MRCManual Reference Counting:由开发者手动调用 retainreleaseautorelease 来增加或减少对象的引用计数,从而决定对象何时被释放。在 iOS 5 之前是 Objective-C 的主流方式;理解 MRC 的规则与配对原则,是理解 ARC 和自动释放池的基础。下文的「引用计数原理」与「加一/减一规则」同时适用于 MRC 与 ARC,ARC 只是把这些调用交给编译器自动插入。


一、引用计数原理

1.1 基本思想

  • 每个堆上对象维护一个引用计数(retain count):表示当前有多少处「引用」正在使用该对象。
  • 引用计数为 0 时,对象不再被任何引用使用,系统销毁对象并回收内存
  • +1:新增加一处引用(如持有、拷贝得到新指针);-1:减少一处引用(如不再持有、释放)。

1.2 规则小结

事件 引用计数变化
创建对象(alloc/new/copy/mutableCopy) +1,创建者持有
retain +1
release -1
autorelease 将「稍后 -1」交给当前 AutoreleasePool 处理

1.3 流程图:对象生命周期

flowchart LR
    A[alloc/new 等] --> B[rc=1]
    B --> C{有 retain?}
    C -->|是| D[rc+=1]
    D --> C
    C -->|否| E{有 release/autorelease?}
    E -->|是| F[rc-=1]
    F --> G{rc==0?}
    G -->|是| H[dealloc 回收]
    G -->|否| E

二、MRC 下的「加一」与「减一」

2.1 引用计数 +1 的操作

方法/操作 说明
alloc 分配内存并返回对象,调用者拥有,rc 初始为 1
new 等价于 alloc + init,调用者拥有
copy / mutableCopy 得到新对象,调用者拥有新对象(rc=1)
retain 对已有对象调用,表示多一处引用,rc+1

2.2 引用计数 -1 的操作

方法/操作 说明
release 立即减少引用计数,rc-1;若 rc 变为 0 则 dealloc
autorelease 将对象加入当前 AutoreleasePool,在池 drain 时对该对象 release(延迟 -1)

2.3 配对原则(MRC 核心)

  • 谁让引用计数 +1,谁就要在合适时机让引用计数 -1(自己 release 或 autorelease)。
  • 只 +1 不 -1 → 泄漏;若多 -1 或对已释放对象再访问 → 野指针/崩溃。

三、伪代码与算法说明

3.1 retain / release 语义(伪代码)

函数 retain(obj):
    若 obj == nil: 返回 nil
    obj.retainCount += 1
    返回 obj

函数 release(obj):
    若 obj == nil: 返回
    obj.retainCount -= 1
    若 obj.retainCount == 0:
        调用 obj.dealloc
        释放对象内存

3.2 autorelease 语义(伪代码)

函数 autorelease(obj):
    若 obj == nil: 返回 nil
    将 obj 加入当前线程的 AutoreleasePool 栈顶
    返回 obj
// 当 AutoreleasePool pop/drain 时,对池内每个对象调用 release

3.3 MRC 下典型写法示例(Objective-C)

// 创建并持有:alloc 后 rc=1,需要在不使用时 release
NSObject *obj = [[NSObject alloc] init];
// ... 使用 obj ...
[obj release];

// 通过方法返回「已 autorelease」的对象:调用者不拥有,不需 release
- (NSString *)name {
    return [[[NSString alloc] initWithFormat:@"name"] autorelease];
}

四、所有权与命名约定(MRC 时代)

  • 方法名以 alloc / new / copy / mutableCopy 开头:返回的对象调用者拥有,需负责 release 或 autorelease。
  • 其他返回对象的方法:默认约定返回 autorelease 对象,调用者不拥有,不应 release(除非先 retain)。

五、与 ARC 的衔接

ARC 仍基于同一套引用计数规则,只是 retain/release/autorelease编译器在编译期自动插入;开发者通过 strong/weak 等修饰符表达所有权,编译器据此生成对应的 retain/release。详见 04-ARC详解


参考文献

01-研究系统框架@Web@iOS | JavaScriptCore 框架:从使用到原理解析

2026年3月1日 20:10

JavaScriptCore 框架:从使用到原理解析

JavaScript 越来越多地出现在我们客户端开发的视野中,从 React Native 到 JSPatch,JavaScript 与客户端相结合的技术开始变得魅力无穷。本文主要讲解 iOS 中的 JavaScriptCore 框架,正是它为 iOS 提供了执行 JavaScript 代码的能力。未来的技术日新月异,JavaScript 与 iOS 正在碰撞出新的激情。

JavaScriptCoreJavaScript虚拟机,为 JavaScript 的执行提供底层资源。


📋 目录


一、JavaScript

在讨论JavaScriptCore之前,我们首先必须对JavaScript有所了解。

1. JavaScript干啥的?

  • 说的高大上一点:一门基于原型、函数先行的高级编程语言,通过解释执行,是动态类型的直译语言。是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程。
  • 说的通俗一点:主要用于网页,为其提供动态交互的能力。可嵌入动态文本于HTML页面,对浏览器事件作出响应,读写HTML元素,控制cookies等。
  • 再通俗一点:抢月饼,button.click()。(PS:请谨慎使用while循环)

img

2. JavaScript起源与历史

  • 1990年底,欧洲核能研究组织(CERN)科学家Tim Berners-Lee,在互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。
  • 1994年12月,Netscape 发布了一款面向普通用户的新一代的浏览器Navigator 1.0版,市场份额一举超过90%。
  • 1995年,Netscape公司雇佣了程序员Brendan Eich开发这种嵌入网页的脚本语言。最初名字叫做Mocha,1995年9月改为LiveScript。
  • 1995年12月,Netscape公司与Sun公司达成协议,后者允许将这种语言叫做JavaScript。

3. JavaScript与ECMAScript

  • “JavaScript”是Sun公司的注册商标,用来特制网景(现在的Mozilla)对于这门语言的实现。网景将这门语言作为标准提交给了ECMA——欧洲计算机制造协会。由于商标上的冲突,这门语言的标准版本改了一个丑陋的名字“ECMAScript”。同样由于商标的冲突,微软对这门语言的实现版本取了一个广为人知的名字“Jscript”。
  • ECMAScript作为JavaScript的标准,一般认为后者是前者的实现。

4. Java和JavaScript

img

《雷锋和雷峰塔》

Java 和 JavaScript 是两门不同的编程语言 一般认为,当时 Netscape 之所以将 LiveScript 命名为 JavaScript,是因为 Java 是当时最流行的编程语言,带有 “Java” 的名字有助于这门新生语言的传播。

二、 JavaScriptCore

1. 浏览器演进

  • 演进完整图

upload.wikimedia.org/wikipedia/c…

  • WebKit分支

现在使用WebKit的主要两个浏览器Sfari和Chromium(Chorme的开源项目)。WebKit起源于KDE的开源项目Konqueror的分支,由苹果公司用于Sfari浏览器。其一条分支发展成为Chorme的内核,2013年Google在此基础上开发了新的Blink内核。

img

2. WebKit排版引擎

webkit是sfari、chrome等浏览器的排版引擎,各部分架构图如下

img

  • webkit Embedding API是browser UI与webpage进行交互的api接口;
  • platformAPI提供与底层驱动的交互, 如网络, 字体渲染, 影音文件解码, 渲染引擎等;
  • WebCore它实现了对文档的模型化,包括了CSS, DOM, Render等的实现;
  • JSCore是专门处理JavaScript脚本的引擎;

3. JavaScript引擎

  • JavaScript引擎是专门处理JavaScript脚本的虚拟机,一般会附带在网页浏览器之中。第一个JavaScript引擎由布兰登·艾克在网景公司开发,用于Netscape Navigator网页浏览器中。JavaScriptCore就是一个JavaScript引擎。
  • 下图是当前主要的还在开发中的JavaScript引擎

img

4. JavaScriptCore组成

JavaScriptCore主要由以下模块组成:

  • Lexer 词法分析器,将脚本源码分解成一系列的Token
  • Parser 语法分析器,处理Token并生成相应的语法树
  • LLInt 低级解释器,执行Parser生成的二进制代码
  • Baseline JIT 基线JIT(just in time 实施编译)
  • DFG 低延迟优化的JIT
  • FTL 高通量优化的JIT

关于更多JavaScriptCore的实现细节,参考 trac.webkit.org/wiki/JavaSc…

5. JavaScriptCore 框架与历史

JavaScriptCore 是一个 C++ 实现的开源项目(WebKit 的一部分)。历史上,JSC 长期作为 Safari / WebKit 的内置 JS 引擎;自 iOS 7.0 / OS X 10.9 起,Apple 将 JavaScriptCore 以系统框架 JavaScriptCore.framework 的形式开放给开发者,使其可在 Objective-C 或基于 C 的程序中执行 JavaScript 代码,并向 JS 环境中插入自定义对象,而无需依赖 UIWebView。这为 Hybrid 应用、热更新、脚本引擎等场景提供了统一的底层能力。

JavaScriptCore.h 中,我们可以看到:

#ifndef JavaScriptCore_h
#define JavaScriptCore_h

#include <JavaScriptCore/JavaScript.h>
#include <JavaScriptCore/JSStringRefCF.h>

#if defined(__OBJC__) && JSC_OBJC_API_ENABLED

#import "JSContext.h"
#import "JSValue.h"
#import "JSManagedValue.h"
#import "JSVirtualMachine.h"
#import "JSExport.h"

#endif

#endif /* JavaScriptCore_h */

这里已经很清晰地列出了JavaScriptCore的主要几个类:

  • JSContext
  • JSValue
  • JSManagedValue
  • JSVirtualMachine
  • JSExport

接下来我们会依次讲解这几个类的用法。

6. Hello World!

这段代码展示了如何在 Objective-C 中执行一段 JavaScript 代码,并且获取返回值并转换成 OC 数据打印:

// 创建虚拟机
JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];

//创建上下文
JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];

//执行JavaScript代码并获取返回值
JSValue *value = [context evaluateScript:@"1+2*3"];

// 转换成 OC 数据并打印
NSLog(@"value = %d", [value toInt32]);
// Output: value = 7

Swift 等价写法:

import JavaScriptCore

let vm = JSVirtualMachine()!
let context = JSContext(virtualMachine: vm)!
let value = context.evaluateScript("1 + 2 * 3")!
print("value =", value.toInt32())  // value = 7

三、 JSVirtualMachine

一个JSVirtualMachine的实例就是一个完整独立的JavaScript的执行环境,为JavaScript的执行提供底层资源。

这个类主要用来做两件事情:

  1. 实现并发的 JavaScript 执行
  2. JavaScript 和 Objective-C 桥接对象的内存管理

看下头文件 JSVirtualMachine.h 里有什么:

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSVirtualMachine : NSObject

/* 创建一个新的完全独立的虚拟机 */
(instancetype)init;

/* 对桥接对象进行内存管理 */
- (void)addManagedReference:(id)object withOwner:(id)owner;

/* 取消对桥接对象的内存管理 */
- (void)removeManagedReference:(id)object withOwner:(id)owner;

@end

每一个JavaScript上下文(JSContext对象)都归属于一个虚拟机(JSVirtualMachine)。每个虚拟机可以包含多个不同的上下文,并允许在这些不同的上下文之间传值(JSValue对象)。

然而,每个虚拟机都是完整且独立的,有其独立的堆空间和垃圾回收器(garbage collector ),GC无法处理别的虚拟机堆中的对象,因此你不能把一个虚拟机中创建的值传给另一个虚拟机。

img

线程和JavaScript的并发执行

JavaScriptCore API都是线程安全的。你可以在任意线程创建JSValue或者执行JS代码,然而,所有其他想要使用该虚拟机的线程都要等待。

  • 如果想并发执行JS,需要使用多个不同的虚拟机来实现。
  • 可以在子线程中执行JS代码。

通过下面这个 demo 来理解这个并发机制:

JSContext *context = [[CustomJSContext alloc] init];
JSContext *context1 = [[CustomJSContext alloc] init];
JSContext *context2 = [[CustomJSContext alloc] initWithVirtualMachine:[context virtualMachine]];
NSLog(@"start");
dispatch_async(queue, ^{
    while (true) {
        sleep(1);
        [context evaluateScript:@"log('tick')"];
    }
});
dispatch_async(queue1, ^{
    while (true) {
        sleep(1);
        [context1 evaluateScript:@"log('tick_1')"];
    }
});
dispatch_async(queue2, ^{
    while (true) {
        sleep(1);
        [context2 evaluateScript:@"log('tick_2')"];
    }
});
[context evaluateScript:@"sleep(5)"];
NSLog(@"end");

context和context2属于同一个虚拟机。

context1属于另一个虚拟机。

三个线程分别异步执行每秒1次的js log,首先会休眠1秒。

在context上执行一个休眠5秒的JS函数。

首先执行的应该是休眠5秒的JS函数,在此期间,context所处的虚拟机上的其他调用都会处于等待状态,因此tick和tick_2在前5秒都不会有执行。

而context1所处的虚拟机仍然可以正常执行tick_1

休眠5秒结束后,tick和tick_2才会开始执行(不保证先后顺序)。

实际运行输出的 log 是:

start
tick_1
tick_1
tick_1
tick_1
end
tick
tick_2

四、 JSContext

一个JSContext对象代表一个JavaScript执行环境。在native代码中,使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问native的对象、方法、函数。

img

1. JSContext执行JS代码

  • 调用evaluateScript函数可以执行一段top-level 的JS代码,并可向global对象添加函数和对象定义
  • 其返回值是JavaScript代码中最后一个生成的值

API Reference

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:
        (JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL     NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;
@property (copy) void(^exceptionHandler)(JSContext *context, JSValue
    *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);


@end

2. JSContext访问JS对象

一个JSContext对象对应了一个全局对象(global object)。例如web浏览器中中的JSContext,其全局对象就是window对象。在其他环境中,全局对象也承担了类似的角色,用来区分不同的JavaScript context的作用域。全局变量是全局对象的属性,可以通过JSValue对象或者context下标的方式来访问。

示例代码:

JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];

NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);
// Output: a = 7, a = 7, a = 7

这里列出了三种访问JavaScript对象的方法

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式

设置属性也是对应的。

API Reference

/* 为 JSContext 提供下标访问元素的方式 */
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end



/* 例如:以下代码在JavaScript中创建了一个实现是Objective-C block的function */
context[@"makeNSColor"] = ^(NSDictionary *rgb){
    float r = [rgb[@"red"] floatValue];
    float g = [rgb[@"green"] floatValue];
    float b = [rgb[@"blue"] floatValue];
    return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f)         alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeNSColor({red:12, green:23, blue:67})"];

五、 JSValue

一个JSValue实例就是一个JavaScript值的引用。使用JSValue类在JavaScript和native代码之间转换一些基本类型的数据(比如数值和字符串)。你也可以使用这个类去创建包装了自定义类的native对象的JavaScript对象,或者创建由native方法或者block实现的JavaScript函数。

每个JSValue实例都来源于一个代表JavaScript执行环境的JSContext对象,这个执行环境就包含了这个JSValue对应的值。每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有(retain),这个JSContext就会一直存活。通过调用JSValue的实例方法返回的其他的JSValue对象都属于与最始的JSValue相同的JSContext。

img

每个JSValue都通过其JSContext间接关联了一个特定的代表执行资源基础的JSVirtualMachine对象。你只能将一个JSValue对象传给由相同虚拟机管理(host)的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发一个Objective-C异常。

img

1. JSValue类型转换

JSValue提供了一系列的方法将native与JavaScript的数据类型进行相互转换:

img

2. NSDictionary与JS对象

NSDictionary 对象以及其包含的 keys 与 JavaScript 中的对应名称的属性相互转换。key 所对应的值也会递归地进行拷贝和转换。

[context evaluateScript:@"var color = {red:230, green:90, blue:100}"];

//js->native 给你看我的颜色
JSValue *colorValue = context[@"color"];
NSLog(@"r=%@, g=%@, b=%@", colorValue[@"red"], colorValue[@"green"], colorValue[@"blue"]);
NSDictionary *colorDic = [colorValue toDictionary];
NSLog(@"r=%@, g=%@, b=%@", colorDic[@"red"], colorDic[@"green"], colorDic[@"blue"]);

//native->js 给你点颜色看看
context[@"color"] = @{@"red":@(0), @"green":@(0), @"blue":@(0)};
[context evaluateScript:@"log('r:'+color.red+'g:'+color.green+' b:'+color.blue)"];
// Output:
// r=230, g=90, b=100
// r=230, g=90, b=100
// r:0 g:0 b:0

可见,JS中的对象可以直接转换成Objective-C中的NSDictionary,NSDictionary传入JavaScript也可以直接当作对象被使用。

3. NSArray与JS数组

NSArray 对象与 JavaScript 中的 array 相互转换。其子元素也会递归地进行拷贝和转换。

[context evaluateScript:@"var friends = ['Alice','Jenny','XiaoMing']"];

//js->native 你说哪个是真爱?
JSValue *friendsValue = context[@"friends"];
NSLog(@"%@, %@, %@", friendsValue[0], friendsValue[1], friendsValue[2]);
NSArray *friendsArray = [friendsValue toArray];
NSLog(@"%@, %@, %@", friendsArray[0], friendsArray[1], friendsArray[2]);

//native->js 我觉得 XiaoMing 不错,给你再推荐个 Jimmy
context[@"girlFriends"] = @[friendsArray[2], @"Jimmy"];
[context evaluateScript:@"log('girlFriends :'+girlFriends[0]+' '+girlFriends[1])"];
// Output: Alice, Jenny, XiaoMing / girlFriends : XiaoMing Jimmy

4. Block/函数和JS function

Objective-C中的block转换成JavaScript中的function对象。参数以及返回类型使用相同的规则转换。

将一个代表native的block或者方法的JavaScript function进行转换将会得到那个block或方法。

其他的JavaScript函数将会被转换为一个空的dictionary。因为JavaScript函数也是一个对象。

5. OC对象和JS对象

对于所有其他 native 的对象类型,JavaScriptCore 都会创建一个拥有 constructor 原型链的 wrapper 对象,用来反映 native 类型的继承关系。默认情况下,native 对象的属性和方法并不会导出给其对应的 JavaScript wrapper 对象。通过 JSExport 协议可选择性地导出属性和方法。下面第六节对 JSExport 与原生对象导出做详细讲解。


六、JSExport 与原生对象导出

JSExport 是 JavaScriptCore 框架中的协议,用于将 Objective-C/Swift 的类(属性与方法)选择性导出给 JavaScript,使 JS 代码可以像调用普通对象一样调用原生对象 [1][2]。

6.1 作用与机制

  • 遵循 JSExport 的协议中声明的属性和方法,会在将 native 对象注入到 JSContext(如 context[@"bridge"] = nativeObject)时,自动暴露为 JS 侧的属性和函数。
  • 若类未实现 JSExport 或未在协议中声明,则对应属性/方法不会出现在 JS 中;这样可控制「桥接面」,避免暴露内部实现 [1][2]。

6.2 使用示例(概念)

@protocol MyPointExport <JSExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
- (NSString *)description;
@end

@interface MyPoint : NSObject <MyPointExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
@end

MyPoint 实例赋给 context[@"point"] 后,在 JS 中可访问 point.xpoint.y 并调用 point.description()
注意:若在 Block 或导出方法中再次使用 JSValueJSContext,需注意线程与内存管理(见第七节 JSManagedValue)[1][2]。

Swift 中的等价写法(通过 JSContext 注入遵循 JSExport 的类):

import JavaScriptCore

@objc protocol PointExport: JSExport {
    var x: Double { get set }
    var y: Double { get set }
    func description() -> String
}

class Point: NSObject, PointExport {
    @objc var x: Double
    @objc var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
    func description() -> String { "Point(\(x), \(y))" }
}

// 注入到 context
let context = JSContext()!
context.setObject(Point(x: 1, y: 2), forKeyedSubscript: "point" as NSString)
context.evaluateScript("point.x; point.description()")

6.3 与 Block 注入的对比

方式 适用场景
context[@"fn"] = ^(id arg){ ... } 单次或简单逻辑,直接暴露为 JS 函数
JSExport 协议 + 原生对象 需要暴露多个方法/属性、保持对象身份与状态的「桥接对象」

七、JSManagedValue 与内存管理

7.1 为何需要 JSManagedValue

  • JSValueJSContext强引用JSContext 又挂在 JSVirtualMachine 上。
  • 若在 堆上的 OC 对象(如某 ViewController 的 property)中直接强引用 JSValue,而该 JSValue 通过某种方式(例如被注入到 context 的全局对象)又引用回该 OC 对象,会形成 OC ↔ JS 的循环引用,导致 Context 与 OC 对象均无法释放 [1][2]。

7.2 JSManagedValue 的职责

JSManagedValueJSValue 的包装类,用于在「被 OC 堆对象持有」的场景下,以条件保留的方式引用 JS 值,并可与 JSVirtualMachineaddManagedReference:withOwner: / removeManagedReference:withOwner: 配合,让虚拟机在合适的时机断开或保留对 native 对象的引用,从而打破循环、避免 JSContext 无法释放 [1][2]。

7.3 使用要点(概念)

  • 当需要把 JSValue(或从 JS 传回的函数/对象)存为 OC 对象的成员变量时,应使用 JSManagedValue 包装,并以 owner 注册到 JSVirtualMachine;在 owner 析构或不再需要时调用 removeManagedReference:withOwner: [1][2]。
  • 仅临时在栈上使用 JSValue(如 evaluateScript 的返回值在方法内使用后不再持有)时,一般无需 JSManagedValue。

八、关键概念图示与流程

8.1 VM、Context、Value 关系

flowchart TB
  subgraph VM1[JSVirtualMachine 1]
    C1[JSContext 1]
    C2[JSContext 2]
  end
  subgraph VM2[JSVirtualMachine 2]
    C3[JSContext 3]
  end
  C1 --> V1[JSValue]
  C2 --> V2[JSValue]
  C1 -.->|可传值| C2
  C1 -.->|不可跨 VM| C3

同一 JSVirtualMachine 下多个 JSContext 可共享、传递 JSValue;不同 VM 之间不能传递 JSValue [3]。

8.2 JavaScriptCore 引擎执行层级(概念)

源码经 Lexer → Parser 得到语法树并生成字节码后,由下至上的执行/编译层级可概括为:

flowchart LR
  A[源码] --> B[Lexer]
  B --> C[Parser / AST]
  C --> D[字节码]
  D --> E[LLInt 解释器]
  E --> F[Baseline JIT]
  F --> G[DFG JIT]
  G --> H[FTL JIT]
  • LLInt:低级解释器,低延迟启动。
  • Baseline JIT:首次 JIT,兼顾分析与回退。
  • DFG:基于数据流的优化 JIT。
  • FTL:更高优化层(历史上曾用 LLVM/B3 后端)[4][5]。

更多实现细节见 WebKit JavaScriptCore Wiki


九、应用场景与最佳实践

9.1 典型应用场景

场景 说明
Hybrid 应用 在 App 内执行 JS 脚本、调用原生能力(如弹窗、定位、支付),JavaScriptCore 提供 OC/Swift 与 JS 的双向桥接 [1][2]
React Native / 类 RN 方案 早期 RN 等方案在 iOS 上依赖 JSC 执行 JS bundle;JSC 提供 VM、Context、Value 等能力 [3]
JSPatch 等热修复 通过下发 JS 脚本并在 JSC 中执行,动态调用原生类与方法,实现热更新(需注意安全与审核政策)[3]
WKWebView 与 Web 页面 WKWebView 内部使用系统 WebKit,其 JS 引擎与 Safari 一致;独立使用 JSC 时无需 WebView 即可执行 JS [1][2]
规则引擎 / 脚本配置 将业务规则或配置写成 JS,由原生在 JSC 中执行并取结果,便于迭代与 A/B 测试

9.2 最佳实践要点

  • 线程:同一 VM 下多线程会串行等待;需并发执行 JS 时使用多个 JSVirtualMachine [3]。
  • 异常:设置 context.exceptionHandler,在 JS 抛错时记录或上报,避免静默失败 [3]。
  • 内存:在 OC 堆对象中持有 JS 值时使用 JSManagedValue + add/removeManagedReference,避免循环引用 [1][2]。
  • 安全:执行来自网络或不可信来源的 JS 时,需做沙箱与权限控制;避免将敏感 API 无限制暴露给 JS [3]。

十、伪代码与算法说明

10.1 执行脚本并取返回值(概念)

function evaluateScript(script: String) -> JSValue:
  parse script -> AST
  generate bytecode from AST
  execute bytecode (via LLInt or JIT tier)
  return last expression value as JSValue

10.2 将 Native 对象注入 Context(概念)

function setObject(object: Any, forKey key: String):
  if object is Block or conforms to JSExport:
    create JS wrapper (function or object with exported properties/methods)
  else:
    create generic wrapper preserving native type hierarchy
  set wrapper on context.globalObject[key]

10.3 JS 调用 Native Block 时(概念)

JavaScript 侧,调用通过 context[@"key"] 注入的 Block,与调用普通函数一致:

// 假设 Native 已注入:context["makeColor"] = ^(NSDictionary *rgb) { ... }
var color = makeColor({ red: 12, green: 23, blue: 67 });

底层流程(伪代码):

当 JS 调用 context 中注册的 Block 时:
  1. JSC 将 JS 参数按类型转换为 OC 对象(NSNumber/NSString/NSDictionary/NSArray 等)
  2. 调用 Block,传入转换后的参数
  3. 将 Block 返回值按类型转换为 JSValue 并返回给 JS

参考文献

[1] Apple. JavaScriptCore Framework. iOS / macOS Developer Documentation.
[2] 掘金 / 博客. iOS 与 JS 交互开发知识总结JavaScriptCore 初探 等.
[3] 本文原稿与常见 JSC 教程(JSVirtualMachine、JSContext、JSValue、并发与内存).
[4] WebKit. Introducing the WebKit FTL JIT. webkit.org/blog/3362/i…
[5] WebKit. JavaScriptCore - Deep Dive. docs.webkit.org/Deep%20Dive…
[6] trac.webkit.org. JavaScriptCore. trac.webkit.org/wiki/JavaSc…
[7] 美团技术团队. 深入理解 JSCore. blog.csdn.net/MeituanTech…

01-研究系统框架@Web@iOS | JavaScriptCore 框架:从使用到原理解析

JavaScriptCore 框架:从使用到原理解析

JavaScript 越来越多地出现在我们客户端开发的视野中,从 React Native 到 JSPatch,JavaScript 与客户端相结合的技术开始变得魅力无穷。本文主要讲解 iOS 中的 JavaScriptCore 框架,正是它为 iOS 提供了执行 JavaScript 代码的能力。未来的技术日新月异,JavaScript 与 iOS 正在碰撞出新的激情。

JavaScriptCoreJavaScript虚拟机,为 JavaScript 的执行提供底层资源。


📋 目录


一、JavaScript

在讨论JavaScriptCore之前,我们首先必须对JavaScript有所了解。

1. JavaScript干啥的?

  • 说的高大上一点:一门基于原型、函数先行的高级编程语言,通过解释执行,是动态类型的直译语言。是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程。
  • 说的通俗一点:主要用于网页,为其提供动态交互的能力。可嵌入动态文本于HTML页面,对浏览器事件作出响应,读写HTML元素,控制cookies等。
  • 再通俗一点:抢月饼,button.click()。(PS:请谨慎使用while循环)

img

2. JavaScript起源与历史

  • 1990年底,欧洲核能研究组织(CERN)科学家Tim Berners-Lee,在互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。
  • 1994年12月,Netscape 发布了一款面向普通用户的新一代的浏览器Navigator 1.0版,市场份额一举超过90%。
  • 1995年,Netscape公司雇佣了程序员Brendan Eich开发这种嵌入网页的脚本语言。最初名字叫做Mocha,1995年9月改为LiveScript。
  • 1995年12月,Netscape公司与Sun公司达成协议,后者允许将这种语言叫做JavaScript。

3. JavaScript与ECMAScript

  • “JavaScript”是Sun公司的注册商标,用来特制网景(现在的Mozilla)对于这门语言的实现。网景将这门语言作为标准提交给了ECMA——欧洲计算机制造协会。由于商标上的冲突,这门语言的标准版本改了一个丑陋的名字“ECMAScript”。同样由于商标的冲突,微软对这门语言的实现版本取了一个广为人知的名字“Jscript”。
  • ECMAScript作为JavaScript的标准,一般认为后者是前者的实现。

4. Java和JavaScript

img

《雷锋和雷峰塔》

Java 和 JavaScript 是两门不同的编程语言 一般认为,当时 Netscape 之所以将 LiveScript 命名为 JavaScript,是因为 Java 是当时最流行的编程语言,带有 “Java” 的名字有助于这门新生语言的传播。

二、 JavaScriptCore

1. 浏览器演进

  • 演进完整图

upload.wikimedia.org/wikipedia/c…

  • WebKit分支

现在使用WebKit的主要两个浏览器Sfari和Chromium(Chorme的开源项目)。WebKit起源于KDE的开源项目Konqueror的分支,由苹果公司用于Sfari浏览器。其一条分支发展成为Chorme的内核,2013年Google在此基础上开发了新的Blink内核。

img

2. WebKit排版引擎

webkit是sfari、chrome等浏览器的排版引擎,各部分架构图如下

img

  • webkit Embedding API是browser UI与webpage进行交互的api接口;
  • platformAPI提供与底层驱动的交互, 如网络, 字体渲染, 影音文件解码, 渲染引擎等;
  • WebCore它实现了对文档的模型化,包括了CSS, DOM, Render等的实现;
  • JSCore是专门处理JavaScript脚本的引擎;

3. JavaScript引擎

  • JavaScript引擎是专门处理JavaScript脚本的虚拟机,一般会附带在网页浏览器之中。第一个JavaScript引擎由布兰登·艾克在网景公司开发,用于Netscape Navigator网页浏览器中。JavaScriptCore就是一个JavaScript引擎。
  • 下图是当前主要的还在开发中的JavaScript引擎

img

4. JavaScriptCore组成

JavaScriptCore主要由以下模块组成:

  • Lexer 词法分析器,将脚本源码分解成一系列的Token
  • Parser 语法分析器,处理Token并生成相应的语法树
  • LLInt 低级解释器,执行Parser生成的二进制代码
  • Baseline JIT 基线JIT(just in time 实施编译)
  • DFG 低延迟优化的JIT
  • FTL 高通量优化的JIT

关于更多JavaScriptCore的实现细节,参考 trac.webkit.org/wiki/JavaSc…

5. JavaScriptCore 框架与历史

JavaScriptCore 是一个 C++ 实现的开源项目(WebKit 的一部分)。历史上,JSC 长期作为 Safari / WebKit 的内置 JS 引擎;自 iOS 7.0 / OS X 10.9 起,Apple 将 JavaScriptCore 以系统框架 JavaScriptCore.framework 的形式开放给开发者,使其可在 Objective-C 或基于 C 的程序中执行 JavaScript 代码,并向 JS 环境中插入自定义对象,而无需依赖 UIWebView。这为 Hybrid 应用、热更新、脚本引擎等场景提供了统一的底层能力。

JavaScriptCore.h 中,我们可以看到:

#ifndef JavaScriptCore_h
#define JavaScriptCore_h

#include <JavaScriptCore/JavaScript.h>
#include <JavaScriptCore/JSStringRefCF.h>

#if defined(__OBJC__) && JSC_OBJC_API_ENABLED

#import "JSContext.h"
#import "JSValue.h"
#import "JSManagedValue.h"
#import "JSVirtualMachine.h"
#import "JSExport.h"

#endif

#endif /* JavaScriptCore_h */

这里已经很清晰地列出了JavaScriptCore的主要几个类:

  • JSContext
  • JSValue
  • JSManagedValue
  • JSVirtualMachine
  • JSExport

接下来我们会依次讲解这几个类的用法。

6. Hello World!

这段代码展示了如何在 Objective-C 中执行一段 JavaScript 代码,并且获取返回值并转换成 OC 数据打印:

// 创建虚拟机
JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];

//创建上下文
JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];

//执行JavaScript代码并获取返回值
JSValue *value = [context evaluateScript:@"1+2*3"];

// 转换成 OC 数据并打印
NSLog(@"value = %d", [value toInt32]);
// Output: value = 7

Swift 等价写法:

import JavaScriptCore

let vm = JSVirtualMachine()!
let context = JSContext(virtualMachine: vm)!
let value = context.evaluateScript("1 + 2 * 3")!
print("value =", value.toInt32())  // value = 7

三、 JSVirtualMachine

一个JSVirtualMachine的实例就是一个完整独立的JavaScript的执行环境,为JavaScript的执行提供底层资源。

这个类主要用来做两件事情:

  1. 实现并发的 JavaScript 执行
  2. JavaScript 和 Objective-C 桥接对象的内存管理

看下头文件 JSVirtualMachine.h 里有什么:

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSVirtualMachine : NSObject

/* 创建一个新的完全独立的虚拟机 */
(instancetype)init;

/* 对桥接对象进行内存管理 */
- (void)addManagedReference:(id)object withOwner:(id)owner;

/* 取消对桥接对象的内存管理 */
- (void)removeManagedReference:(id)object withOwner:(id)owner;

@end

每一个JavaScript上下文(JSContext对象)都归属于一个虚拟机(JSVirtualMachine)。每个虚拟机可以包含多个不同的上下文,并允许在这些不同的上下文之间传值(JSValue对象)。

然而,每个虚拟机都是完整且独立的,有其独立的堆空间和垃圾回收器(garbage collector ),GC无法处理别的虚拟机堆中的对象,因此你不能把一个虚拟机中创建的值传给另一个虚拟机。

img

线程和JavaScript的并发执行

JavaScriptCore API都是线程安全的。你可以在任意线程创建JSValue或者执行JS代码,然而,所有其他想要使用该虚拟机的线程都要等待。

  • 如果想并发执行JS,需要使用多个不同的虚拟机来实现。
  • 可以在子线程中执行JS代码。

通过下面这个 demo 来理解这个并发机制:

JSContext *context = [[CustomJSContext alloc] init];
JSContext *context1 = [[CustomJSContext alloc] init];
JSContext *context2 = [[CustomJSContext alloc] initWithVirtualMachine:[context virtualMachine]];
NSLog(@"start");
dispatch_async(queue, ^{
    while (true) {
        sleep(1);
        [context evaluateScript:@"log('tick')"];
    }
});
dispatch_async(queue1, ^{
    while (true) {
        sleep(1);
        [context1 evaluateScript:@"log('tick_1')"];
    }
});
dispatch_async(queue2, ^{
    while (true) {
        sleep(1);
        [context2 evaluateScript:@"log('tick_2')"];
    }
});
[context evaluateScript:@"sleep(5)"];
NSLog(@"end");

context和context2属于同一个虚拟机。

context1属于另一个虚拟机。

三个线程分别异步执行每秒1次的js log,首先会休眠1秒。

在context上执行一个休眠5秒的JS函数。

首先执行的应该是休眠5秒的JS函数,在此期间,context所处的虚拟机上的其他调用都会处于等待状态,因此tick和tick_2在前5秒都不会有执行。

而context1所处的虚拟机仍然可以正常执行tick_1

休眠5秒结束后,tick和tick_2才会开始执行(不保证先后顺序)。

实际运行输出的 log 是:

start
tick_1
tick_1
tick_1
tick_1
end
tick
tick_2

四、 JSContext

一个JSContext对象代表一个JavaScript执行环境。在native代码中,使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问native的对象、方法、函数。

img

1. JSContext执行JS代码

  • 调用evaluateScript函数可以执行一段top-level 的JS代码,并可向global对象添加函数和对象定义
  • 其返回值是JavaScript代码中最后一个生成的值

API Reference

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:
        (JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL     NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;
@property (copy) void(^exceptionHandler)(JSContext *context, JSValue
    *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);


@end

2. JSContext访问JS对象

一个JSContext对象对应了一个全局对象(global object)。例如web浏览器中中的JSContext,其全局对象就是window对象。在其他环境中,全局对象也承担了类似的角色,用来区分不同的JavaScript context的作用域。全局变量是全局对象的属性,可以通过JSValue对象或者context下标的方式来访问。

示例代码:

JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];

NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);
// Output: a = 7, a = 7, a = 7

这里列出了三种访问JavaScript对象的方法

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式

设置属性也是对应的。

API Reference

/* 为 JSContext 提供下标访问元素的方式 */
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end



/* 例如:以下代码在JavaScript中创建了一个实现是Objective-C block的function */
context[@"makeNSColor"] = ^(NSDictionary *rgb){
    float r = [rgb[@"red"] floatValue];
    float g = [rgb[@"green"] floatValue];
    float b = [rgb[@"blue"] floatValue];
    return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f)         alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeNSColor({red:12, green:23, blue:67})"];

五、 JSValue

一个JSValue实例就是一个JavaScript值的引用。使用JSValue类在JavaScript和native代码之间转换一些基本类型的数据(比如数值和字符串)。你也可以使用这个类去创建包装了自定义类的native对象的JavaScript对象,或者创建由native方法或者block实现的JavaScript函数。

每个JSValue实例都来源于一个代表JavaScript执行环境的JSContext对象,这个执行环境就包含了这个JSValue对应的值。每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有(retain),这个JSContext就会一直存活。通过调用JSValue的实例方法返回的其他的JSValue对象都属于与最始的JSValue相同的JSContext。

img

每个JSValue都通过其JSContext间接关联了一个特定的代表执行资源基础的JSVirtualMachine对象。你只能将一个JSValue对象传给由相同虚拟机管理(host)的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发一个Objective-C异常。

img

1. JSValue类型转换

JSValue提供了一系列的方法将native与JavaScript的数据类型进行相互转换:

img

2. NSDictionary与JS对象

NSDictionary 对象以及其包含的 keys 与 JavaScript 中的对应名称的属性相互转换。key 所对应的值也会递归地进行拷贝和转换。

[context evaluateScript:@"var color = {red:230, green:90, blue:100}"];

//js->native 给你看我的颜色
JSValue *colorValue = context[@"color"];
NSLog(@"r=%@, g=%@, b=%@", colorValue[@"red"], colorValue[@"green"], colorValue[@"blue"]);
NSDictionary *colorDic = [colorValue toDictionary];
NSLog(@"r=%@, g=%@, b=%@", colorDic[@"red"], colorDic[@"green"], colorDic[@"blue"]);

//native->js 给你点颜色看看
context[@"color"] = @{@"red":@(0), @"green":@(0), @"blue":@(0)};
[context evaluateScript:@"log('r:'+color.red+'g:'+color.green+' b:'+color.blue)"];
// Output:
// r=230, g=90, b=100
// r=230, g=90, b=100
// r:0 g:0 b:0

可见,JS中的对象可以直接转换成Objective-C中的NSDictionary,NSDictionary传入JavaScript也可以直接当作对象被使用。

3. NSArray与JS数组

NSArray 对象与 JavaScript 中的 array 相互转换。其子元素也会递归地进行拷贝和转换。

[context evaluateScript:@"var friends = ['Alice','Jenny','XiaoMing']"];

//js->native 你说哪个是真爱?
JSValue *friendsValue = context[@"friends"];
NSLog(@"%@, %@, %@", friendsValue[0], friendsValue[1], friendsValue[2]);
NSArray *friendsArray = [friendsValue toArray];
NSLog(@"%@, %@, %@", friendsArray[0], friendsArray[1], friendsArray[2]);

//native->js 我觉得 XiaoMing 不错,给你再推荐个 Jimmy
context[@"girlFriends"] = @[friendsArray[2], @"Jimmy"];
[context evaluateScript:@"log('girlFriends :'+girlFriends[0]+' '+girlFriends[1])"];
// Output: Alice, Jenny, XiaoMing / girlFriends : XiaoMing Jimmy

4. Block/函数和JS function

Objective-C中的block转换成JavaScript中的function对象。参数以及返回类型使用相同的规则转换。

将一个代表native的block或者方法的JavaScript function进行转换将会得到那个block或方法。

其他的JavaScript函数将会被转换为一个空的dictionary。因为JavaScript函数也是一个对象。

5. OC对象和JS对象

对于所有其他 native 的对象类型,JavaScriptCore 都会创建一个拥有 constructor 原型链的 wrapper 对象,用来反映 native 类型的继承关系。默认情况下,native 对象的属性和方法并不会导出给其对应的 JavaScript wrapper 对象。通过 JSExport 协议可选择性地导出属性和方法。下面第六节对 JSExport 与原生对象导出做详细讲解。


六、JSExport 与原生对象导出

JSExport 是 JavaScriptCore 框架中的协议,用于将 Objective-C/Swift 的类(属性与方法)选择性导出给 JavaScript,使 JS 代码可以像调用普通对象一样调用原生对象 [1][2]。

6.1 作用与机制

  • 遵循 JSExport 的协议中声明的属性和方法,会在将 native 对象注入到 JSContext(如 context[@"bridge"] = nativeObject)时,自动暴露为 JS 侧的属性和函数。
  • 若类未实现 JSExport 或未在协议中声明,则对应属性/方法不会出现在 JS 中;这样可控制「桥接面」,避免暴露内部实现 [1][2]。

6.2 使用示例(概念)

@protocol MyPointExport <JSExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
- (NSString *)description;
@end

@interface MyPoint : NSObject <MyPointExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
@end

MyPoint 实例赋给 context[@"point"] 后,在 JS 中可访问 point.xpoint.y 并调用 point.description()
注意:若在 Block 或导出方法中再次使用 JSValueJSContext,需注意线程与内存管理(见第七节 JSManagedValue)[1][2]。

Swift 中的等价写法(通过 JSContext 注入遵循 JSExport 的类):

import JavaScriptCore

@objc protocol PointExport: JSExport {
    var x: Double { get set }
    var y: Double { get set }
    func description() -> String
}

class Point: NSObject, PointExport {
    @objc var x: Double
    @objc var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
    func description() -> String { "Point(\(x), \(y))" }
}

// 注入到 context
let context = JSContext()!
context.setObject(Point(x: 1, y: 2), forKeyedSubscript: "point" as NSString)
context.evaluateScript("point.x; point.description()")

6.3 与 Block 注入的对比

方式 适用场景
context[@"fn"] = ^(id arg){ ... } 单次或简单逻辑,直接暴露为 JS 函数
JSExport 协议 + 原生对象 需要暴露多个方法/属性、保持对象身份与状态的「桥接对象」

七、JSManagedValue 与内存管理

7.1 为何需要 JSManagedValue

  • JSValueJSContext强引用JSContext 又挂在 JSVirtualMachine 上。
  • 若在 堆上的 OC 对象(如某 ViewController 的 property)中直接强引用 JSValue,而该 JSValue 通过某种方式(例如被注入到 context 的全局对象)又引用回该 OC 对象,会形成 OC ↔ JS 的循环引用,导致 Context 与 OC 对象均无法释放 [1][2]。

7.2 JSManagedValue 的职责

JSManagedValueJSValue 的包装类,用于在「被 OC 堆对象持有」的场景下,以条件保留的方式引用 JS 值,并可与 JSVirtualMachineaddManagedReference:withOwner: / removeManagedReference:withOwner: 配合,让虚拟机在合适的时机断开或保留对 native 对象的引用,从而打破循环、避免 JSContext 无法释放 [1][2]。

7.3 使用要点(概念)

  • 当需要把 JSValue(或从 JS 传回的函数/对象)存为 OC 对象的成员变量时,应使用 JSManagedValue 包装,并以 owner 注册到 JSVirtualMachine;在 owner 析构或不再需要时调用 removeManagedReference:withOwner: [1][2]。
  • 仅临时在栈上使用 JSValue(如 evaluateScript 的返回值在方法内使用后不再持有)时,一般无需 JSManagedValue。

八、关键概念图示与流程

8.1 VM、Context、Value 关系

flowchart TB
  subgraph VM1[JSVirtualMachine 1]
    C1[JSContext 1]
    C2[JSContext 2]
  end
  subgraph VM2[JSVirtualMachine 2]
    C3[JSContext 3]
  end
  C1 --> V1[JSValue]
  C2 --> V2[JSValue]
  C1 -.->|可传值| C2
  C1 -.->|不可跨 VM| C3

同一 JSVirtualMachine 下多个 JSContext 可共享、传递 JSValue;不同 VM 之间不能传递 JSValue [3]。

8.2 JavaScriptCore 引擎执行层级(概念)

源码经 Lexer → Parser 得到语法树并生成字节码后,由下至上的执行/编译层级可概括为:

flowchart LR
  A[源码] --> B[Lexer]
  B --> C[Parser / AST]
  C --> D[字节码]
  D --> E[LLInt 解释器]
  E --> F[Baseline JIT]
  F --> G[DFG JIT]
  G --> H[FTL JIT]
  • LLInt:低级解释器,低延迟启动。
  • Baseline JIT:首次 JIT,兼顾分析与回退。
  • DFG:基于数据流的优化 JIT。
  • FTL:更高优化层(历史上曾用 LLVM/B3 后端)[4][5]。

更多实现细节见 WebKit JavaScriptCore Wiki


九、应用场景与最佳实践

9.1 典型应用场景

场景 说明
Hybrid 应用 在 App 内执行 JS 脚本、调用原生能力(如弹窗、定位、支付),JavaScriptCore 提供 OC/Swift 与 JS 的双向桥接 [1][2]
React Native / 类 RN 方案 早期 RN 等方案在 iOS 上依赖 JSC 执行 JS bundle;JSC 提供 VM、Context、Value 等能力 [3]
JSPatch 等热修复 通过下发 JS 脚本并在 JSC 中执行,动态调用原生类与方法,实现热更新(需注意安全与审核政策)[3]
WKWebView 与 Web 页面 WKWebView 内部使用系统 WebKit,其 JS 引擎与 Safari 一致;独立使用 JSC 时无需 WebView 即可执行 JS [1][2]
规则引擎 / 脚本配置 将业务规则或配置写成 JS,由原生在 JSC 中执行并取结果,便于迭代与 A/B 测试

9.2 最佳实践要点

  • 线程:同一 VM 下多线程会串行等待;需并发执行 JS 时使用多个 JSVirtualMachine [3]。
  • 异常:设置 context.exceptionHandler,在 JS 抛错时记录或上报,避免静默失败 [3]。
  • 内存:在 OC 堆对象中持有 JS 值时使用 JSManagedValue + add/removeManagedReference,避免循环引用 [1][2]。
  • 安全:执行来自网络或不可信来源的 JS 时,需做沙箱与权限控制;避免将敏感 API 无限制暴露给 JS [3]。

十、伪代码与算法说明

10.1 执行脚本并取返回值(概念)

function evaluateScript(script: String) -> JSValue:
  parse script -> AST
  generate bytecode from AST
  execute bytecode (via LLInt or JIT tier)
  return last expression value as JSValue

10.2 将 Native 对象注入 Context(概念)

function setObject(object: Any, forKey key: String):
  if object is Block or conforms to JSExport:
    create JS wrapper (function or object with exported properties/methods)
  else:
    create generic wrapper preserving native type hierarchy
  set wrapper on context.globalObject[key]

10.3 JS 调用 Native Block 时(概念)

JavaScript 侧,调用通过 context[@"key"] 注入的 Block,与调用普通函数一致:

// 假设 Native 已注入:context["makeColor"] = ^(NSDictionary *rgb) { ... }
var color = makeColor({ red: 12, green: 23, blue: 67 });

底层流程(伪代码):

当 JS 调用 context 中注册的 Block 时:
  1. JSC 将 JS 参数按类型转换为 OC 对象(NSNumber/NSString/NSDictionary/NSArray 等)
  2. 调用 Block,传入转换后的参数
  3. 将 Block 返回值按类型转换为 JSValue 并返回给 JS

参考文献

[1] Apple. JavaScriptCore Framework. iOS / macOS Developer Documentation.
[2] 掘金 / 博客. iOS 与 JS 交互开发知识总结JavaScriptCore 初探 等.
[3] 本文原稿与常见 JSC 教程(JSVirtualMachine、JSContext、JSValue、并发与内存).
[4] WebKit. Introducing the WebKit FTL JIT. webkit.org/blog/3362/i…
[5] WebKit. JavaScriptCore - Deep Dive. docs.webkit.org/Deep%20Dive…
[6] trac.webkit.org. JavaScriptCore. trac.webkit.org/wiki/JavaSc…
[7] 美团技术团队. 深入理解 JSCore. blog.csdn.net/MeituanTech…

01-HarmonyOS底层原理|HarmonyOS的各个渲染框架和HarmonyOS图层渲染原理

HarmonyOS 底层原理:各个渲染框架与图层渲染原理

前言


概述

本文主要对 HarmonyOS 页面渲染原理 展开讨论。在讨论本文主题之前,我们需要先了解 HarmonyOS,然后进行一定的知识铺垫,先带大家简单回顾一下 计算机图形渲染原理。若您不想了解 HarmonyOS 的系统背景,可以从第二节「铺垫知识」开始。若您也有一定的 计算机图形学基础,可以忽略前期的知识准备,直接从本文的第三节开始阅读。

本文总共有以下几个章节:


📋 目录


一、HarmonyOS 简述

HarmonyOS 系统由中国的华为公司发行。它作为首款完全自主国产智能移动终端搭载系统,自诞生以来就备受关注,至今为止已经迭代了 3+ 代。国内很多电子发烧友都想进一步了解 HarmonyOS,在此过程中也提出了一些疑问:HarmonyOS 是否是 Android 系统的套皮?(换言之就是怀疑:HarmonyOS 是否是以安卓操作系统为底座,修改了上层的 UI 图形显示界面的系统)。华为公司在多次系统发布会也对 HarmonyOS 的定位、它的设计等各方面做出了介绍。在本文中我们首先从以下几个方面来认识发烧友们的质疑是否可靠:

  • 系统定位
  • 内核对比
  • 运行速度

鸿蒙(HarmonyOS):一款面向万物互联时代的、全新的分布式操作系统。在传统的单设备系统能力基础上,HarmonyOS 提出了基于同一套系统能力、适配多种终端形态的分布式理念,能够支持手机、平板、智能穿戴、智慧屏、车机等多种终端设备,提供全场景(移动办公、运动健康、社交通信、媒体娱乐等)业务能力。

1.1 鸿蒙系统和 Android 系统的定位不同

华为官方对于 HarmonyOS 系统定位的介绍视频 我们可以得知:

Android 和 HarmonyOS 两款产品的研发初衷完全不一样,根本就不在同一个赛道上。安卓系统面向的是手机端,而鸿蒙系统面向的是这些年比较新的概念物联网,致力于利用其 5G 世界领先的技术,优先布局和打造一个超级终端、万物互联的生态。

安卓(Android): 是一种基于 Linux 内核(不包含 GNU 组件)的自由及开放源代码的操作系统,主要使用于移动设备,如智能手机和平板电脑,由美国 Google 公司和开放手机联盟领导及开发。

鸿蒙(HarmonyOS): 是一款面向万物互联时代的、全新的分布式操作系统。在传统的单设备系统能力基础上,HarmonyOS 提出了基于同一套系统能力、适配多种终端形态的分布式理念,能够支持手机、平板、智能穿戴、智慧屏、车机等多种终端设备,提供全场景(移动办公、运动健康、社交通信、媒体娱乐等)业务能力。

1.2 鸿蒙系统和 Android 系统的内核不同

安卓(Android): 基于 Linux 的宏内核设计。宏内核包含了操作系统绝大多数的功能和模块,而且这些功能和模块都具有最高的权限,只要一个模块出错,整个系统就会崩溃,这也是安卓系统容易崩溃的原因。

  • 系统开发难度低。

鸿蒙(HarmonyOS): 基于微内核设计。微内核仅包括了操作系统必要的功能模块(任务管理、内存分配等),处在核心地位具有最高权限;其他模块不具有最高权限,也就是说其他模块出现问题,对于整个系统的运行是没有阻碍的。

  • 微内核稳定性很高。
  • 鸿蒙系统包含了两个内核:
    • Linux 内核
    • LiteOS 内核
  • 内核子系统:HarmonyOS 采用多内核设计,支持针对不同资源受限设备选用适合的 OS 内核。
    • 内核抽象层(KAL,Kernel Abstract Layer) 通过屏蔽多内核差异,对上层提供基础的内核能力,包括进程/线程管理、内存管理、文件系统、网络管理和外设管理等。
  • 驱动子系统:硬件驱动框架(HDF)是 HarmonyOS 硬件生态开放的基础,提供统一外设访问能力和驱动开发、管理框架。

关于鸿蒙系统内核的介绍,我们也可以通过 官方视频 的介绍来进一步认识。

HarmonyOS 底层内核空间 以 【Linux Kernel】作为基石。上层用户空间由 Native 系统库虚拟机运行环境框架层 组成,通过系统调用(Syscall) 连通系统的 内核空间用户空间

对于用户空间主要采用 C++ 和 Java 代码编写,通过 JNI 技术 打通用户空间的 Java 层Native 层(C++/C),从而连通整个系统。

我们今天就以 HarmonyOS 渲染原理为主题,对 HarmonyOS 系统的渲染框架和渲染流水线展开讨论,以为后期在项目实施过程中做技术选型做知识储备!!那就让我们进入今天的正题吧!!!

1.3 鸿蒙系统和 Android 系统的运行速度对比

安卓(Android): 基于 Java 语言编码。Java 语言有个很大的缺点是其不能直接与底层操作系统通信,需要通过虚拟机(JVM)充当中间转换的角色,这是每一个 Java 开发人员都知道的知识点。虽然 Java 语言由于虚拟机的优化、编译器的优化、热点代码等技术使得其越来越快,但是无法直接与操作系统互相通信一直影响着其性能的突破。

鸿蒙(HarmonyOS): 鸿蒙的开发也可以采用 Java 语言,官方也推荐使用 Java 语言开发,但是 华为针对 Java 语言的这种特性,研发了方舟编译器,通过方舟编译器编译的软件可以直接与底层操作系统通信,方舟编译器在这一层面做到了取代虚拟机。通过方舟编译器转换为操作系统能够读懂的机器语言,这样就可以跳过虚拟机解释这一步骤,当然这是肯定对机器的内存要求比较高,应该也存在启动后无法继续优化等问题。

1.4 方舟编译器简单介绍

华为方舟编译器作为一款全新的编译器可以显著提高手机的运行速度,它 不采用现有编译器边解释边执行的模式,而是将这种动态编译改为静态编译,可以做到全程执行机器码,进而高效运行程序,大大缩短程序响应时间

方舟编译器的优势

  • 多语言联合:将同一应用中的不同语言代码联合编译、联合优化,消除语言间的性能「鸿沟」,降低开发者的优化成本
  • 轻量运行时:通过编译器的语言实现能力和优化能力增强,应用运行时的开销更小
  • 软硬件协同:编译器与芯片实现软硬件协同优化,充分发挥硬件能效,应用体验更佳
  • 多平台支持:支持面向多样化的终端设备平台进行编译和运行,根据设备特征提供便捷的开发与部署策略,提高开发效率


二、铺垫知识

HarmonyOS 系统的图形渲染原理其实在核心部分都是和 计算机图形学 的计算机图形渲染原理一样的。所以我们在了解 HarmonyOS 的 视图系统 和其 2D、3D 渲染框架渲染流水线 之前,我们需要进入笔者的这篇文章:计算机图形渲染原理 进行一定的知识准备。

链接 附带的文章中,我们可以了解到「智能硬件 的 CPU、GPU 的设计理念以及两者之间的性能差异」、「计算机图形渲染芯片 GPU 的诞生史」、「围绕 GPU 工作的 3D 图形渲染库(OpenGL、DirectX 等)、图形学相关的专业术语和 OpenGL 工作的渲染流水线」、「屏幕成像的电子束 CRT 扫描原理」、「屏幕成像原理」等诸多相关的核心要点。

您若是不想关注 CPU、GPU,直接了解移动设备的屏幕成像原理,也可以阅读笔者这一份专门为移动而写的简约版:移动终端屏幕成像与卡顿
在这篇文章中,我们可以分别从两个维度去关注:第一个就是 系统成像遇到的 Bug 问题,第二个就是 解决问题的解决方案。几个要点可以简单归纳为:

  • 问题:「屏幕撕裂 Screen Tearing」、「掉帧 Jank」、视图成像切换衔接失误导致的画面空白
  • 解决方案:「Vsync」、「Double Buffering」、「Triple Buffering」

总结:我们这里主要关注屏幕成像的整个渲染流水线,以便于我们后面对 HarmonyOS 的图像渲染原理展开讨论:

① 获取图层渲染数据 → ② GPU 加工成像素数据 → ③ 帧缓冲器(存储像素信息)→ ④ 视频控制器读取缓存 → ⑤ 数模转换、显示器显示

我们今天的主题就是主要关注第一个环节。入手点分为几个:

  • HarmonyOS 系统的**视图层(Layer)视图窗口(Window)**以及系统中的各个图形渲染框架(2D/3D)
  • HarmonyOS 系统的渲染流水线
  • HarmonyOS 系统的事件机制

下面用一张流程图概括从「应用绘制」到「屏幕显示」的通用流水线(与第二节铺垫知识对应):

flowchart LR
  subgraph 应用与框架
    A[应用/ArkUI 绘制]
    B[图层数据]
  end
  subgraph 系统与硬件
    C[GPU 光栅化]
    D[帧缓冲]
    E[视频控制器]
    F[显示器]
  end
  A --> B --> C --> D --> E --> F

三、HarmonyOS 的视图层和视图窗口

本节在不删减原有结构的前提下,对 HarmonyOS 的 窗口(Window)窗口层级视图层(Layer)Surface 等概念做系统性补充,便于与后文「渲染框架与流水线」衔接。相关表述综合自华为/开放原子官方文档、开发者社区与项目实践 [1][2][3][4]。

3.1 窗口子系统与窗口类型

HarmonyOS 的窗口模块(窗口子系统)负责在同一块物理屏幕上提供多个应用界面的显示与交互,其核心职责包括 [2]:

  • 提供应用系统界面的窗口对象
  • 组织不同窗口的显示关系,维护窗口的叠加层次位置属性
  • 提供窗口动效交互
  • 指导输入事件分发

窗口在类型上可分为两大类 [2]:

类型 说明
系统窗口 完成系统特定功能的窗口,如音量条、壁纸、通知栏、状态栏、导航栏等
应用窗口 应用主窗口:显示应用主界面,在任务管理界面中显示;应用子窗口:弹窗、悬浮窗等辅助窗口,生命周期跟随主窗口

应用主窗口与子窗口在尺寸上有约束:宽度范围 [320, 2560] vp,高度范围 [240, 2560] vp(具体以当前版本文档为准)[1]。

3.2 窗口层级与 WindowType

窗口的前后叠加关系WindowTypepriority(优先级) 共同决定 [4]:

  • BelowApp:底层,如桌面、壁纸等,priority = 0
  • App:中间层,应用主窗口(priority = 0)、应用子窗口(priority = 1)等
  • AboveApp:上层,如锁屏(priority = 114)、状态栏(priority = 110)等

同一 WindowType 下,priority 值越大,层级越高,越靠近用户 [4]。窗口模式(WindowMode)可配置为全屏、分屏主/副、悬浮等(如 WINDOW_MODE_FULLSCREENWINDOW_MODE_FLOATING 等)[4]。

flowchart TB
  subgraph AboveApp
    L[锁屏 priority=114]
    S[状态栏 priority=110]
  end
  subgraph App
    M[应用主窗口 priority=0]
    C[应用子窗口 priority=1]
  end
  subgraph BelowApp
    D[桌面/壁纸 priority=0]
  end
  L --> S --> M --> C --> D

系统侧由 WindowManagerService(WMS) 负责窗口的创建、销毁、布局、层级与焦点管理;DisplayManagerService(DMS) 管理 Display 与 Screen 的映射关系。Screen 表示物理屏幕,Display 表示逻辑屏幕,Window 依附于某个 Display [4]。

3.3 UIAbility 与 WindowStage

在应用开发模型中,窗口生命周期与 UIAbilityWindowStage 绑定 [3][5]:

  • UIAbility 是应用组件的一种,代表一个「界面能力」的抽象;一个 UIAbility 可拥有一个主窗口及若干子窗口。
  • WindowStage 在 UIAbility 创建时被建立,负责该 Ability 下窗口的创建与生命周期维护
  • onWindowStageCreate 回调中,应用加载 UI 界面(如 ArkUI 页面),主窗口在此阶段被创建并展示。

因此,从「界面」到「窗口」的链条为:UIAbility → WindowStage → Window(s);渲染框架则基于这些窗口提供的 Surface 进行绘制与合成。

3.4 视图层(Layer)与 Surface

在图形栈中,窗口对应可绘制的表面(Surface)。应用或 ArkUI 将 UI 内容绘制到与窗口绑定的 Surface 上,形成图层(Layer)数据;多个 Layer 由系统的 GPU 合成器(如 Rosen / Render Service) 按 z-order 合成为最终一帧,再送入帧缓冲,经 VSync显示控制器输出到屏幕 [1][2][6]。

  • Surface:可绘制的缓冲区抽象,对应窗口的绘图目标;应用侧通过 Canvas、Skia/OpenGL 等接口向 Surface 提交绘制命令或像素。
  • Layer:可理解为某一层绘制结果(或某棵视图树对应的渲染结果);多层叠加后经合成器合成为一帧。

OpenHarmony 文档与社区资料中常出现 RSSurfaceRSWindow 等接口,用于创建和管理可绘制的表面与窗口,与上述概念对应 [6]。


四、HarmonyOS 的各个渲染框架和渲染流水线

本节系统性地介绍 HarmonyOS / OpenHarmony 的图形栈分层ArkUI 声明式框架2D/3D 渲染框架Measure-Layout-Draw 渲染管线以及 Rosen(Render Service)合成,并给出从应用层到屏幕的完整流水线概览。内容综合自华为/开放原子官方文档、InfoQ 等技术文章及开发者社区 [1][2][6][7][8][9]。

4.1 图形栈整体架构

OpenHarmony 采用自研的图形栈,按分层抽象可分为 [6][7]:

层次 内容说明
接口层 向应用提供 NDK 等能力,包括 WebGL、Native Canvas、OpenGL 指令级支持等
框架层 Render Service(RS)、Drawing、Animation、Effect、显示与内存管理等
引擎层 2D 图形库、3D 图形引擎等

华为开发者官网将 ArkGraphics 2D 作为 HarmonyOS 上二维图形绘制、渲染与显示的核心模块,采用 API 层 — 服务层 — 硬件适配层 的三层架构,支持 ArkTS 与 C/C++ 开发 [1]。整体上,应用 UI 框架(如 ArkUI) 调用 2D/3D 图形 API,由 RS 进行合成与 VSync 调度,最终输出到屏幕。

flowchart TB
  subgraph 应用层
    ArkUI[ArkUI / ArkUI JS]
  end
  subgraph 图形栈
    API[API 层 / ArkGraphics 2D 等]
    RS[Render Service / Rosen]
    Draw[Drawing / 2D 引擎]
    Eng[3D 引擎]
  end
  subgraph 硬件
    GPU[GPU]
    Disp[显示控制器]
  end
  ArkUI --> API --> Draw
  ArkUI --> Eng
  Draw --> RS
  Eng --> RS
  RS --> GPU --> Disp

4.2 ArkUI 框架与声明式渲染

ArkUI 是 HarmonyOS 上主推的 声明式 UI 框架,面向 1+8+N 多设备,支持 ArkUI JS(类 Web/小程序范式)与 ArkUI eTS(声明式 + 方舟编译器)两套开发范式 [7][8]。从渲染角度看,ArkUI 可概括为 [7][8][9]:

  • 声明层:通过 build() 描述 UI 结构,用 @State / @Prop / @Link 等装饰器管理状态,遵循 UI = f(State) 的声明式范式。
  • 节点层:将声明式描述转化为内部可计算的节点树(Component 树、Alignment 树、Render 树等),支持细粒度更新,避免整树重算。
  • 渲染管线层:在 VSync 驱动下,经历 Measure → Layout → Draw,最终通过统一的渲染引擎(如 Skia 或华为自研引擎)将内容绘制到 Surface [7][8]。

ArkUI 采用前后端分离:前端为声明式 DSL(eTS 或类 Web),后端为 C++ 编写的声明式后端引擎,包含布局、动画、多态组件、自绘制渲染管线等;底层使用统一的框架层渲染引擎(当前文档多提及 Skia,华为亦在自研替代方案)[7][8]。

4.3 2D 与 3D 渲染框架

HarmonyOS 在应用层可归纳为两类典型渲染路线 [1][9]:

方式 适用场景 说明
ArkUI + Canvas / 内置组件 常规 UI、轻量 2D 动效、小游戏 使用 ArkGraphics 2D、Canvas 等 API,由框架完成 Measure-Layout-Draw
XComponent + Native(OpenGL ES) 复杂 3D、高性能图形、游戏 通过 XComponent 获得 Native 层 Surface,直接调用 OpenGL ES,细粒度控制

ArkGraphics 2D 提供画布操作、图元绘制(几何、图片、文本)、文本模块、可变帧率、Vsync、Window 管理等能力 [1]。3D 渲染则依赖系统图形子系统(含 Rosen/RS)提供的 Native 缓冲区与 OpenGL ES/Vulkan 等接口,实现完整渲染管线控制 [9]。

4.4 渲染管线:Measure、Layout、Draw

ArkUI 的 UI 渲染管线与常见移动端框架一致,分为三个阶段 [8][9]:

  1. Measure(测量):系统询问每个组件的尺寸需求,父容器根据子元素约束与自身约束计算每个节点的宽高。
  2. Layout(布局):根据测量结果与布局规则(如 Column、Row、Flex)确定每个组件在父容器中的位置(x, y)。
  3. Draw(绘制):将组件的几何、图片、文本等绘制到 Surface 对应的缓冲区,最终由 RS 合成并送显。

伪代码(概念)

function renderFrame():
  for each node in renderTree (from root to leaf):
    node.measure()   // 测量宽高
  for each node in renderTree (from root to leaf):
    node.layout()    // 确定 x, y
  for each node in renderTree (in draw order):
    node.draw()      // 绘制到 Surface
  submitToRenderService()

自定义 NDK 组件可通过 onMeasure / onLayout / onDraw 等回调接入该管线;测量与布局相关的 API(如 measureNodelayoutNodesetMeasuredSizesetLayoutPosition)需在对应的 ARKUI_NODE_CUSTOM_EVENT_ON_MEASUREARKUI_NODE_CUSTOM_EVENT_ON_LAYOUT 回调中使用 [9]。

flowchart LR
  M[Measure 测量]
  L[Layout 布局]
  D[Draw 绘制]
  RS[Render Service]
  M --> L --> D --> RS

4.5 Rosen / Render Service 与合成

Rosen 是 OpenHarmony 的 GPU 合成与显示服务,在架构上类似 Android 的 SurfaceFlinger,负责 [6][7]:

  • 管理 RSSurfaceRSWindow 等可绘制表面与窗口;
  • 接收各应用/窗口提交的图层数据,按 z-order 与可见性进行 GPU 合成
  • DisplayManager 配合,分发 VSync 信号,实现帧同步与双缓冲/三缓冲,减少撕裂与掉帧。

可通过系统调试命令(如 hidumper -s RenderService)查看 RS 状态、屏幕、节点、FPS 等信息 [7]。图层数据经 RS 合成后写入帧缓冲,再由视频控制器读取并输出到物理屏幕,与第二节「铺垫知识」中的流水线一致。

4.6 从应用层到屏幕的完整流水线

将上述各节串联,从「应用 UI」到「屏幕显示」的完整流水线可概括为:

  1. 应用层:ArkUI(或 Native UI)根据状态构建/更新 Component 树 → Render 树,在 VSync 触发下执行 Measure → Layout → Draw
  2. 绘制输出:Draw 阶段将内容绘制到各窗口对应的 Surface,生成**图层(Layer)**缓冲区。
  3. 合成Render Service(Rosen) 收集所有窗口的 Layer,按层级与区域进行 GPU 合成,输出一帧到帧缓冲
  4. 显示视频控制器VSync 同步下读取帧缓冲,经数模转换输出到显示器

整体与第二节给出的「① 获取图层渲染数据 → ② GPU 加工 → ③ 帧缓冲 → ④ 视频控制器 → ⑤ 显示器」一致,HarmonyOS 在「①」环节通过 ArkUI、ArkGraphics 2D、Rosen 等框架与服务实现了从视图到图层的完整链路。

flowchart TB
  subgraph 应用
    A[ArkUI build/update]
    B[Measure / Layout / Draw]
  end
  subgraph 系统图形
    C[Surface / Layer]
    D[Render Service 合成]
    E[帧缓冲]
  end
  subgraph 硬件
    F[VSync]
    G[显示器]
  end
  A --> B --> C --> D --> E --> F --> G

五、总结

通过前面的介绍,我们基本知道了:

  • HarmonyOS 的定位是面向万物互联的分布式操作系统,与 Android 在定位、内核(微内核 vs 宏内核)、运行速度(方舟编译器) 等方面存在差异;底层以 Linux 内核为基石,用户空间通过 JNI 等连通 Java 与 Native。
  • 铺垫知识 部分强调了计算机图形渲染原理与移动端屏幕成像(Vsync、多缓冲)的通用流水线,本文主题聚焦该流水线的第一个环节:视图层、窗口与渲染框架。
  • 视图层与窗口:HarmonyOS 通过窗口子系统(WMS、DMS)管理系统窗口应用窗口(主窗口/子窗口),窗口层级由 WindowType + priority 决定;UIAbility / WindowStage 负责应用侧窗口生命周期;Surface / Layer 是绘制与合成的载体。
  • 渲染框架与流水线:图形栈分为接口层、框架层(含 RS)、引擎层ArkUI 提供声明式 UI 与 Measure–Layout–Draw 管线;2DArkGraphics 2D 为主,3D 通过 XComponent + OpenGL ES 等实现;Rosen(Render Service) 负责图层合成与 VSync,最终与帧缓冲、显示控制器一起完成从应用到屏幕的完整成像链路。

本篇文章,没有解决的问题如下:

  • HarmonyOS 系统事件机制(输入事件从硬件到应用的分发路径、与窗口/焦点的关系)的详细梳理;
  • ArkUI 与 Flutter / SwiftUI 在渲染管线与性能上的对比分析;
  • 更多性能调优卡顿排查在 HarmonyOS 上的具体工具与步骤(如 RS 的 hidumper、ArkUI 的布局与绘制耗时分析)。
  • ……

参考

  • 见文末 参考文献

六、文章推荐


相关阅读(共计 14 篇文章)

iOS 相关专题
webApp 相关专题
跨平台开发方案相关专题
阶段性总结:Native、WebApp、跨平台开发三种方案性能比较
Android、HarmonyOS 页面渲染专题
小程序页面渲染专题
总结

参考文献

[1] 华为开发者. 图形绘制概览 / ArkGraphics 2D(HarmonyOS 文档). developer.huawei.com/consumer/cn…
[2] HarmonyOS 应用窗口管理(Stage 模型)等. 博客园 / 华为云社区.
[3] 深入理解 HarmonyOS UIAbility:生命周期、WindowStage 与启动模式. 华为云社区. bbs.huaweicloud.com/blogs/41689…
[4] OpenHarmony 窗口子系统基本概念与流程分析. 掘金. juejin.cn/post/751099…
[5] 深入解析 HarmonyOS 5 UIAbility 组件:从核心架构到实战应用. CSDN.
[6] 深入解析 OpenHarmony:图层渲染与合成 SurfaceBuffer 实践指南. 百度云. cloud.baidu.com/article/327…
[7] OpenHarmony 实战开发——图形框架解析. 腾讯云开发者. cloud.tencent.com/developer/a…
[8] InfoQ. HarmonyOS ArkUI 框架的实现原理和落地实践. www.infoq.cn/article/tsa…
[9] HarmonyOS 开发者社区 / CSDN. ArkUI 渲染管线、Measure/Layout/Draw、自定义组件 NDK 等.
[10] 掘金. 鸿蒙 HarmonyOS 实战 - 窗口管理. juejin.cn/post/741784…

07-Debug调试@iOS-其它调试方式指导SOP补充

本文在「网络/蓝牙/UI/调试器/崩溃」等专题之外,对 LLDB/GDB 常用操作、Xcode 调试技巧、Chisel/Reveal/FLEX 等其它 iOS 调试方式做 SOP 与要点补充,便于日常查阅与落地使用。


📋 目录


一、LLDB 与 GDB

LLDB 是 Xcode 默认调试器;与 GDB 的命令对应关系可参考:lldb 与 gdb 命令对比

1.1 常用 Debug 快捷键

功能 命令
暂停/继续 Cmd + Ctrl + Y
断点失效/生效 Cmd + Y
控制台显示/隐藏 Cmd + Shift + Y
光标切换到控制台 Cmd + Shift + C
清空控制台 Cmd + K
Step Over F6
Step Into F7
Step Out F8

1.2 技巧一:格式化输出数据

1、封装 log 函数

// Swift 版
func DLog<T>(message: T, file: String = #file, method: String = #function, line: Int = #line) {
    #if DEBUG
        print("\((file as NSString).lastPathComponent) : \(line), \(method)  \(message)")
    #endif
}
// OC 版
#ifdef DEBUG
#define DLog(fmt, ...) NSLog((@"<%s : %d> %s  " fmt), [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __PRETTY_FUNCTION__, ##__VA_ARGS__);
#else
#define DLog(...)
#endif

2、代替 NSLog,打印对象的内部属性

在 LLDB 中可使用 po(print object)打印对象描述;若需更细粒度,可结合 exprframe variable。Xcode 控制台在断点停下时,对变量使用「右键 → Print Description of …」或输入 po 变量名 即可查看内部属性。

1.3 技巧二:条件断点(condition)

设置断点后,可为断点添加触发条件,只有条件为真时才暂停,便于在循环或高频调用处精确定位。

注意:在条件表达式中调用 Objective-C 方法时,需强转返回值类型,否则可能报错:

// 正确
(BOOL)[pId isEqualToString:@"short-videopage"]

// 报错:error: no known method '-isEqualToString:'; cast the message send to the method's return type
[pId isEqualToString:@"short-videopage"]

1.4 技巧三:运行中修改变量的值(expr & call)

在断点处可通过 Expression (expr)Debug → Debug Workflow → Evaluate Expression 修改变量或调用方法,无需重新运行。例如:调试登录时临时改 token/登录状态,或调试 UI 时改某控件的颜色、frame。

在调试登录相关的 bug 时,非常方便,不用担心经常输密码,还输错的尴尬

调试 UI,改变指定控件的颜色

1.5 技巧四:符号断点(Add Symbolic Breakpoint)

通过 Add Symbolic Breakpoint 可对符号名(函数/方法)下断点,无需指定具体文件行号。适合在陌生项目中快速了解执行路径,例如对所有 viewDidLoad 下断点,观察页面加载顺序。

Symbol 填写格式

语言/风格 写法说明
C 语言 methodName 只需写函数名,不用写后面的 ()
Objective-C [ClassName methodName],ClassName 为类名,methodName 为方法名(不区分类方法/实例方法)
Swift ClassName.methodName
  • Module:模块筛选,避免不同库中同名方法/函数冲突。
  • Condition:触发条件。可写表达式(如第一个参数不能为 nil);参数可用 $arg3$arg4 等表示(如 $arg3 == nil)。也可调用返回 BOOL 的类方法。
    样例:找出给 [UIImage imageNamed:] 传 nil 的调用。Symbol 设为 [UIImage imageNamed:],Condition 设为 $arg3 == nil,运行中一旦传 nil 就会触发断点。

如何查某个函数的符号:在该函数处打普通断点,运行到断点后,在堆栈信息中查看对应帧的显示格式,即可得到 Symbol 应填的格式。

1.6 技巧五:全局异常断点(Add Exception Breakpoint)

添加 Exception Breakpoint 后,当发生 Objective-C / C++ 异常(或可选 Swift 异常)时,调试器会在抛出处暂停,便于快速定位未捕获异常。

1.7 技巧六:查看整体 UI 层级结构(debug view hierarchy)

Xcode 菜单 Debug → View Debugging → Capture View Hierarchy 可捕获当前界面的视图层级并做 3D 展示与选择。若机器配置较低、卡顿明显,可改用 Chiselpviews 等命令在控制台输出层级文本(参见本文第二节)。

1.8 技巧七:开启僵尸模式(EXC_BAD_ACCESS)

EXC_BAD_ACCESS 常表示向已释放对象发消息。开启 Zombie Objects 后,这类访问会被系统标记,Xcode 可据此在诊断中给出对象类型与释放相关信息,便于定位野指针。

开启步骤Edit Scheme → Run → Diagnostics,勾选 Enable Zombie Objects

1.9 技巧八:查看 frame 的值

在 LLDB 中打印 UIViewframe 等属性时,若直接 p self.view.frame 可能报「property 'frame' not found」。可先 导入 UIKit 模块,再打印:

(lldb) p self.view.frame
error: property 'frame' not found on object of type 'UIView *'
error: 1 errors parsing expression
(lldb) e @import UIKit
(lldb) p self.view.frame
(CGRect) $0 = (origin = (x = 0, y = 0), size = (width = 375, height = 667))

或使用强制转换:

print (CGRect)[view frame]
(CGRect) $1 = (origin = (x = 0, y = 0), size = (width = 200, height = 100))

1.10 技巧九:监听所有点击事件(UIControl、Touch、Gesture)

方法:覆写 UIApplication

通过自定义 UIApplication 子类并重写 sendEvent:,可在事件派发前统一拦截,用于统计、调试或行为分析。

.h 文件:

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface CustomApplication : UIApplication

@end

NS_ASSUME_NONNULL_END

.m 文件:

#import "CustomApplication.h"

@implementation CustomApplication
- (void)sendEvent:(UIEvent *)event {
    [super sendEvent:event];
}
@end

main.m 文件:

#import <UIKit/UIKit.h>
#import "AppDelegate.h"
#import "CustomApplication.h"

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

方法执行与事件次数

一次事件可能会执行三次函数:-(void)sendEvent:(UIEvent *)event,三次的 force 有区别


一次事件可能会执行两次函数:-(void)sendEvent:(UIEvent *)event,两次的 force 没区别


响应者链条

1、若是 UIControl 事件,继承自 UIResponder 的控件(如 UIButton)消息传递链(倒序)如下图所示


2、若是 UIGestureRecognizer 手势事件,继承自 UIResponder 的控件(如 UIView)消息传递链(倒序)如下图所示


3、若 UIControl 和 UIGestureRecognizer 同时存在,优先级关系如下图所示


二、其它工具:Chisel

Chisel 是 Facebook(Meta)开源的 LLDB 命令集合,在 Xcode 调试时提供如 pviewspvcfvvisualizebmessage 等高层命令,便于查看视图层级、查找视图、可视化图片、对方法下符号断点等。

安装

具体参考官方 README

使用 Homebrew 安装:

brew update
brew install chisel

安装完成后,将下面一行加入 ~/.lldbinit,Xcode 启动时才会加载 Chisel:

# Intel Mac 常见路径
command script import /usr/local/opt/chisel/libexec/fbchisellldb.py

# Apple Silicon (M1/M2/M3) 常见路径
# command script import /opt/homebrew/opt/chisel/libexec/fbchisellldb.py

若你当前使用的是旧版路径 fblldb.py,且能正常加载,可保留;新版本仓库中入口一般为 fbchisellldb.py,以官方 README 为准。

常用命令

在 LLDB 中查看完整命令列表与说明:

(lldb) help

更完整的 Chisel 命令说明与原理见本目录:05-Debug调试@调试器-Chisel LLDB调试工具:从原理到实践

参考Chisel - LLDB 命令插件,让调试更 Easy


三、其它工具:Reveal

RevealiOS 界面调试 的桌面应用,可连接模拟器或真机,实时查看与修改视图层级、约束、属性等,使用上往往比 Xcode 自带的 View Debugging 更流畅、功能更丰富。软件为商业收费,提供试用(如 30 天),试用期过后需购买授权。

集成方式约有多种(Framework、CocoaPods、Swift Package 等),详情见 Reveal 官网集成指南


四、其它工具:FLEX

FLEX 是 Flipboard 开源的 应用内调试工具集,以第三方库形式集成到 App 中。运行时通过调用 [[FLEXManager sharedManager] showExplorer]; 即可调出调试工具栏,无需连接 Mac,适合真机或脱机场景。

主要功能包括

  • 查看、修改 views
  • 查看任意对象的属性
  • 动态修改属性
  • 动态调用实例方法与类方法
  • 查看网络请求过程
  • 添加模拟的键盘快捷键
  • 查看系统日志
  • 从堆中获取任意对象
  • 查看沙盒中的文件
  • 查看文件系统中的 SQLite / Realm 数据库
  • 在模拟器中触发 3D Touch
  • 查看应用中所有的类
  • 快速获取常用对象(如 [UIApplication sharedApplication]、App Delegate、key window 的 root view controller 等)
  • 动态查看 NSUserDefaults 中的值

参考文献

06-Debug调试@崩溃-iOS崩溃日志分析与反解(符号化):从原理到实践

📋 目录


一、概述与历史演进

1.1 什么是崩溃日志与符号化

当 iOS/macOS 应用因未捕获异常、非法内存访问、系统终止等原因退出时,操作系统会生成一份崩溃报告(crash report),记录进程终止时的状态:异常类型、终止原因、各线程的调用栈(backtrace)、已加载的二进制镜像(binary images)等 [1][2]。其中调用栈以内存地址形式呈现符号化(symbolication) 即把这些地址替换为可读的函数名与源码行号,使开发者能定位到具体代码位置 [2][3]。

  • 未符号化:栈帧显示为 0x1022cbfa80x1022c0000 + 49064 等,难以直接对应源码。
  • 已符号化:栈帧显示为 Line.updateRectForExistingPoint(_:) (in TouchCanvas) + 656ViewController.touchesEstimatedPropertiesUpdated(_:) (in TouchCanvas) + 304,可直接对应到工程中的类与方法 [2]。

符号化依赖与崩溃时运行二进制一一对应的 dSYM(Debug Symbol)文件及正确的加载地址(load address);只有 Build UUID 一致 的二进制与 dSYM 才能正确反解 [3][4]。

1.2 历史与格式演进

时期/变化 说明
传统 .crash 文本格式 早期至 iOS 14,崩溃报告多为纯文本:Incident IdentifierProcessException TypeThread 0 CrashedBinary Images 等字段,便于人工阅读与 grep [1][5]
iOS 15 / macOS 12 起 .ips 系统改为将崩溃数据存为 JSON,文件扩展名为 .ips;首行为 IPS 元数据对象,其余为崩溃报告数据对象。Console 等工具将 JSON 转成可读展示 [6][7]
Xcode Organizer 与自动符号化 从 App Store / TestFlight 收集的崩溃若在上传时包含符号,Xcode 的 Crashes 组织器可自动符号化;本地需自行提供 dSYM [2][8]
TN2151 与现行文档 Apple 早年的 Technical Note TN2151: Understanding and Analyzing Application Crash Reports 仍被广泛引用;现行说明已迁移至 Adding identifiable symbol names to a crash report 等官方文档 [2][3]

1.3 典型应用场景

  • 线上/TestFlight 崩溃定位:用户或测试反馈崩溃,从 Xcode Organizer 或邮件拿到 .ips/.crash,用对应版本的 dSYM 符号化后根据异常类型与栈顶帧排查。
  • 内存与稳定性问题:EXC_BAD_ACCESS、EXC_CRASH (SIGABRT)、Watchdog 等,结合 Exception Type、Termination Reason、Last Exception Backtrace 分析。
  • 审核或论坛反馈:App Review 或用户提供的 .txt/.crash,重命名为 .crash 后在 Xcode 中拖入 Device Logs 或使用 symbolicatecrash/atos 反解 [2][8]。
  • 多版本/多架构管理:为每个分发版本保留 Archive 与 dSYM,用 UUID 匹配正确 dSYM 进行符号化 [3][4]。

二、核心概念与崩溃报告结构

2.1 崩溃报告包含哪些信息

无论 .crash 文本还是 .ips 中的 JSON,一份完整崩溃报告通常包含 [1][6][9]:

部分 含义
Header / 元数据 进程名、Bundle ID、版本、设备/系统、时间、Incident Identifier、CrashReporter Key 等,用于区分环境与用户
Exception / 异常信息 异常类型(如 EXC_BAD_ACCESS、EXC_BREAKPOINT、EXC_CRASH)、信号(SIGSEGV、SIGABRT 等)、Exception Codes、Exception Message/Subtype
Termination 若进程被系统或其它进程终止,会包含 Termination Reason、namespace、code、indicator 等 [6]
Threads / 线程与栈 各线程的 backtrace(frames)、触发崩溃的线程标记、部分场景下的 threadState(寄存器)
Last Exception Backtrace 语言层异常(如 Objective-C/Swift 未捕获异常)时的专用栈,便于区分“谁抛出了异常” [9]
Binary Images 进程内已加载的二进制列表:名称、路径、UUID、加载地址(base)、大小;符号化与 atos 依赖此处的 UUID 与 base

2.2 栈帧(Frame)与 Backtrace

  • Frame 0:崩溃发生时正在执行的函数(或最内层调用)。
  • Frame 1, 2, …:调用者链,从内到外;通常从栈顶向下读,先看 Frame 0 与自家 App 的帧,再结合系统帧理解调用链 [9]。
  • 每帧包含:二进制名运行时地址(或 imageOffset + 对应 image 的 base)、符号化后为 函数名 + 偏移(+ 行号视工具而定) [6][7]。

三、崩溃报告格式:.crash 与 .ips

3.1 传统 .crash 文本格式(概要)

常见字段包括 [1][5]:

  • Incident IdentifierCrashReporter Key
  • ProcessIdentifierVersionCode Type
  • Exception TypeException CodesException SubtypeCrashed Thread
  • Thread 0 Crashed: 下列出各帧:序号 二进制名 地址 符号或 基址+偏移
  • Binary Images: 下列出各镜像的地址范围、名称、UUID(括号内,常为小写无连字符)

Xcode 的 Device Logs 要求文件扩展名为 .crash;若拿到的是 .txt 或其它扩展名,需重命名为 .crash 再拖入,才能正确触发符号化 [2]。

3.2 .ips:JSON 双对象结构(iOS 15+)

.ips 文件由两段 JSON 组成 [6][7]:

  1. 第一行IPS 元数据对象(单行一个 JSON 对象)。
  2. 其余内容崩溃报告数据对象(当 bug_type == "309" 时表示崩溃报告)。

解析逻辑要点(与官方示例一致)[6]:

  • 先读第一行解析 metadata。
  • metadata["bug_type"] == "309",再把剩余部分解析为 report。
  • 报告中的地址、码值等在 JSON 中多为十进制,需按需转为十六进制以便阅读或传给 atos [6][7]。

3.2.1 IPS 元数据常用键

Key 类型 说明
name String 进程可执行文件名
bug_type String 日志类型,309 表示崩溃报告;288 表示 stackshot 等 [6]
bundleID String Bundle 标识符
build_version String 构建版本号
incident_id String 报告唯一 ID
platform Number 平台(1=macOS, 2=iOS, 3=tvOS, 4=watchOS, 6=Mac Catalyst, 7=iOS Simulator 等)[6]
timestamp String 日志系统记录时间

3.2.2 崩溃报告对象常用键

Key 类型 说明
exception Dictionary typesignalcodessubtypemessage 等 [6]
faultingThread Number 崩溃线程在 threads 数组中的下标
threads Array 各线程对象,含 framesidqueuetriggeredthreadState 等 [6][7]
usedImages Array Binary images:basesizenamepathuuidarchsource 等 [7]
captureTimeprocLaunch String 崩溃时间、进程启动时间
lastExceptionBacktrace Array 语言层异常栈 [6]
bundleInfoosVersionstoreInfo Dictionary 包信息、系统版本、商店信息等

3.2.3 Frames 与 Binary Images(用于符号化)

  • frames 中每帧:imageIndex(对应 usedImages 下标)、imageOffset(相对该镜像的偏移)、symbolsymbolLocation(符号化后才有)[7]。
  • usedImages 中每项:base(加载地址)、uuid(Build UUID,用于匹配 dSYM)、namepatharch [7]。
    符号化时:运行时地址 = base + imageOffset;atos 需要 -l base 和该镜像对应的 dSYM [2][3]。

四、dSYM 与符号化原理

4.1 dSYM 是什么

dSYM(Debug Symbol File) 是 Xcode 生成的调试符号包,与编译出的二进制一一对应:包含函数名、行号、变量等 DWARF 调试信息,不随 App 分发,仅用于调试与崩溃反解 [3][4]。每个可执行体(主程序、Extension、Framework)各有自己的 dSYM;二进制与 dSYM 通过 Build UUID 绑定,只有 UUID 完全一致才能正确符号化 [3][4]。

4.2 生成与归档 dSYM

在 Xcode 中 [4]:

  • Build Settings → Debug Information Format 设为 DWARF with dSYM File(Release 与需分析崩溃的构建建议一致)。
  • Generate Debug Symbols 建议保持 YES

归档(Archive)时,Xcode 会把该次构建的所有二进制与 dSYM 收进 .xcarchive;上传 App Store/TestFlight 时可勾选上传符号,便于在 Crashes 组织器中自动符号化 [2][4]。必须为每个对外分发的版本保留对应 Archive,否则无法为该版本崩溃找到匹配 dSYM [4]。

4.3 符号化的本质

  • 崩溃报告里记录的是运行时地址(或 image 的 base + offset)。
  • 编译器在生成二进制时,会把符号与地址的对应关系写入 dSYM(DWARF)。
  • 符号化工具(Xcode、symbolicatecrash、atos)根据 UUID 找到对应 dSYM,再根据 load address(base) 把运行时地址换算成“镜像内偏移”,在 dSYM 中查找函数与行号并写回报告或输出 [2][3]。

因此:UUID 不一致(例如换了 Xcode 版本或编译选项重新构建)、缺少 dSYMload address 错误,都会导致无法符号化或结果错误。


五、获取崩溃报告

5.1 从 Xcode 与 App Store Connect

  • Xcode → Window → Organizer → Crashes:可看到已同步的崩溃报告(来自 TestFlight/App Store 用户且已开启诊断共享)。若上传时包含符号,此处多为已符号化 [2][8]。
  • Xcode → Window → Devices and Simulators → 选中设备 → View Device Logs:可把本机或用户导出的 .crash/.ips 拖入 Device Logs 列表,由 Xcode 自动尝试符号化(需本机有对应 dSYM 或系统符号)[2]。

5.2 从设备本地导出(用户/测试人员操作)

iOS / iPadOS [8]:

  1. 设置 → 隐私与安全性 → 分析与改进 → 分析数据(Analytics Data)。
  2. 找到以应用名为前缀的崩溃日志(名称常以 _ 开头),点进后通过“分享”以邮件等方式发给开发者。

macOS [8]:

  1. 打开 Console.app → 左侧选择本机 → Crash Reports。
  2. 找到对应应用的崩溃报告,右键 → Reveal in Finder,可复制或通过邮件发送。

5.3 调试时生成完整崩溃报告

若在 Xcode 中调试时发生崩溃,调试器会先接管,系统不会立即写盘。需要“完整崩溃报告”时:Debug → Detach(或 LLDB 中执行 detach),让进程继续运行直至退出,系统再生成报告;再按 5.2 方式在设备或 Mac 上找到该报告 [8]。


六、符号化操作 SOP

6.1 前置检查:UUID 一致

符号化前必须确认:崩溃报告里该二进制的 UUIDdSYM 的 UUID 一致。

  • 从报告中找 Binary Images 里该镜像的 UUID(.ips 的 usedImages[].uuid;.crash 常在小括号内,小写无连字符)。
  • 在终端执行 [2][3]:
dwarfdump --uuid <PathToDSYM>/Contents/Resources/DWARF/<BinaryName>
dwarfdump --uuid <PathToBinary>

两者一致才可用该 dSYM 符号化该二进制。

6.2 用 Xcode 符号化(推荐)

  1. 扩展名:确保报告为 .crash(.ips 若 Xcode 支持可直接拖,否则可先导出为 .crash 或保留 .ips 用命令行)。
  2. 打开 Devices and Simulators → 选中设备 → View Device Logs
  3. 将崩溃报告文件拖入左侧日志列表。
  4. 若本机 Spotlight 可搜到对应 UUID 的 dSYM(例如在 ~/Library/Developer/Xcode/Archives 或项目 DerivedData),Xcode 会自动符号化;符号化后栈中会显示函数名与行号 [2]。

若未符号化或仅部分符号化:多为缺少匹配 dSYM系统框架符号缺失(需连接过对应系统版本的设备,让 Xcode 拉取系统符号)[2]。

6.3 用 symbolicatecrash 命令行

位置(随 Xcode 安装)[2][3]:

/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash

环境变量(必须)[2][3]:

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer

用法示例 [2][3]:

symbolicatecrash /path/to/crash.crash /path/to/App.dSYM > symbolicated.txt
# 或指定 dSYM 目录
symbolicatecrash -d /path/to/dSYMs -o symbolicated.txt /path/to/crash.crash
  • 支持一次传入多个 dSYM 或目录,工具会按报告中的 UUID 自动匹配。
  • 输入必须是系统生成的完整崩溃报告(含 Binary Images),否则无法解析。

6.4 用 atos 单地址符号化

适用于:只有若干地址、或在 LLDB/脚本中对单帧反解。

公式 [2][3][7]:报告中某帧的运行时地址 = 该镜像在报告中的 base(load address) + 该帧的 imageOffset(.ips 中)或从 “base+offset” 形式中读出。

命令形式 [2][3]:

atos -arch <arch> -o <PathToDSYM>/Contents/Resources/DWARF/<BinaryName> -l <LoadAddress> <Address1> [Address2 ...]

示例(Binary 名为 TouchCanvas,arm64,base 0x1022c0000)[2]:

atos -arch arm64 -o TouchCanvas.app.dSYM/Contents/Resources/DWARF/TouchCanvas -l 0x1022c0000 0x00000001022df754
# 输出示例:ViewController.touchesEstimatedPropertiesUpdated(_:) (in TouchCanvas) + 304
  • -o 必须是 dSYM 包内的 DWARF 文件路径,不能只写 .dSYM 包路径 [2]。
  • -l 必须是该次运行中该镜像的 load address,在 Binary Images 中查。

6.5 用 Spotlight 查找本机 dSYM(按 UUID)

若已知 Binary 在报告中的 Build UUID(如 9cc89c5e55163f4ab40c5821e99f05c6),可转为标准格式(大写、8-4-4-4-12)再查 [2]:

mdfind "com_apple_xcode_dsym_uuids == 9CC89C5E-5516-3F4A-B40C-5821E99F05C6"

若返回路径,说明本机有该 dSYM,可再 dwarfdump --uuid 核对后用于 Xcode 或 symbolicatecrash。

6.6 符号化 SOP 速查

步骤 操作
1 拿到 .ips 或 .crash;若是 .txt,重命名为 .crash 以便 Xcode 识别
2 在报告底部 Binary Images 中确认主程序/Extension 的 UUID 与 arch
3 mdfind "com_apple_xcode_dsym_uuids == <UUID>" 或 Archive 路径找到对应 dSYM,dwarfdump --uuid 核对
4 优先在 Xcode Device Logs 中拖入报告,由 Xcode 自动符号化
5 若需命令行:设置 DEVELOPER_DIR,用 symbolicatecrash 传入报告与 dSYM(或目录),输出到文件
6 若仅有个别地址:从 Binary Images 取 base,用 atos -arch -o -l 反解

七、异常类型与诊断要点

7.1 常见异常类型(Exception Type)

以下为 Apple 文档中常见类型与含义摘要 [10][11]:

异常类型 典型含义与排查方向
EXC_BAD_ACCESS (SIGSEGV) 非法或越界内存访问(野指针、已释放对象、栈溢出等)[10]
EXC_BAD_ACCESS (SIGBUS) 错位访问、指针认证失败等 [10]
EXC_BREAKPOINT (SIGTRAP) 陷阱指令触发;Swift 中常见于强制解包 nil、断言失败、fatalError 等 [10][11]
EXC_CRASH (SIGABRT) 进程调用 abort() 或断言失败;常见于 NSException 未捕获、Objective-C 异常、断言 [10]
EXC_CRASH (SIGKILL) 被系统终止:如 Watchdog、内存压力、用户强退等 [10]
EXC_CRASH (SIGTERM) 软件终止信号 [10]
EXC_GUARD 违反受保护资源(如文件描述符 guard)[10]
EXC_RESOURCE 超过资源限制(CPU 时间、内存等)[10]
EXC_ARITHMETIC 算术异常(如除零、浮点错误)[10]

7.2 诊断时的使用方式

  • 先看 Exception Type / signal 判断大类:内存问题、断言/abort、系统杀进程等。
  • 结合 Exception Message / Termination ReasonLast Exception Backtrace(若有)缩小范围。
  • Crashed ThreadFrame 0 与自家 App 的栈帧是首要关注点;系统库帧可帮助理解调用链(例如是否在 present popover、主线程卡顿等)[9]。

八、崩溃分析流程与解读

8.1 分析顺序建议(基于 Apple 文档 [9])

  1. 确认报告已充分符号化:至少自家 App 的栈帧要有函数名与行号;否则先按第六章完成符号化。
  2. 从用户视角找入口:根据栈中与业务相关的帧,推断用户当时在使用什么功能(例如某个 VC、某个 present)。
  3. 看 Header:设备型号、系统版本、App 版本、启动时间与崩溃时间(运行时长)、是否 TestFlight、主 App 还是 Extension 等,用于复现环境与分组。
  4. 看异常信息:Exception Type、Exception Codes、Termination Reason,判断是内存、断言、Watchdog 等哪一类。
  5. 看崩溃线程 backtrace:从 Frame 0 往上看,先关注自家代码;再结合 Last Exception Backtrace(若有)看“谁抛出了异常”。
  6. 看其它线程:是否有大量相似等待、是否涉及不该在非主线程调用的 API(如 UI)等 [9]。
  7. 复杂内存/寄存器问题:可结合 threadState 与 atos 对 PC/LR 等地址符号化,或参考 [Investigating memory access crashes] 等专项文档。

8.2 分组与复现

  • 多份报告可按 相同 Exception Type + 相同栈顶帧相同 Termination Reason 分组,便于判断是否为同一根因、是否可稳定复现 [9]。
  • 用 Header 中的 CrashReporter Key / Beta Identifier 区分不同用户/设备,评估影响面。

8.3 系统符号与 Binary Images

  • 若系统库帧未符号化,需在与报告系统版本一致的设备上连接 Xcode,让 Xcode 拉取该系统版本的符号;或在本机已有对应版本符号时,Xcode 才能反解系统帧 [2]。
  • Binary Images 可用来确认:主程序与各 Framework/Extension 的 UUID是否缺少预期加载的库(如动态加载的 framework)[9]。

九、关键概念图示与流程

9.1 崩溃报告生成与符号化数据流

flowchart LR
    subgraph 设备
        A[App 崩溃]
        B[系统收集状态]
        C[.ips / .crash]
    end
    subgraph 开发机
        D[dSYM]
        E[Xcode / symbolicatecrash / atos]
        F[符号化报告]
    end
    A --> B --> C
    C --> E
    D --> E
    E --> F

9.2 符号化匹配关系

flowchart TB
    subgraph 崩溃报告
        U1[Binary Images: UUID, base, name]
        F1[Frames: imageIndex, imageOffset]
    end
    subgraph 本地
        DSYM[dSYM: UUID, DWARF]
    end
    U1 -->|UUID 一致| DSYM
    F1 -->|base + imageOffset = 运行时地址| atos
    DSYM --> atos[atos / symbolicatecrash]
    atos --> out[函数名 + 行号]

9.3 从获取到分析的流程

flowchart TD
    A[获取 .ips / .crash] --> B{是否已符号化?}
    B -->|否| C[按 UUID 找 dSYM]
    C --> D[Xcode 拖入 或 symbolicatecrash/atos]
    D --> E[得到符号化报告]
    B -->|是| E
    E --> F[看 Exception Type]
    F --> G[看 Crashed Thread 栈顶与自家帧]
    G --> H[结合 Last Exception / Termination]
    H --> I[定位代码与复现路径]

十、伪代码与算法说明

10.1 判断报告是否已符号化

根据 Apple 文档 [2]:若 backtrace 中每一帧都包含可读函数名(而非“基址+偏移”或纯地址),则视为已完全符号化;若仅部分帧有函数名,为部分符号化;若全是地址或“基址+偏移”,为未符号化

对于报告中的每个线程 thread:
  对于 thread 的每个帧 frame:
    若 frame 仅包含 "0x... + 数字" 或 "基址 + 偏移" 且无函数名:
      返回 "未符号化"
若 存在任一 frame 无函数名:
  返回 "部分符号化"
否则:
  返回 "已符号化"

10.2 atos 使用的地址关系

报告中某帧的运行时地址load address(base)imageOffset 关系 [2][7]:

运行时地址 = base(Binary Images 中该镜像的 base) + imageOffset(该帧相对该镜像的偏移)

atos 内部会用“运行时地址 - base”得到相对偏移,在 dSYM 的 DWARF 中查找对应符号与行号。因此 -l 必须传入该次运行的 base(从同一份报告的 Binary Images 读取)。

10.3 UUID 匹配与 dSYM 查找

1. 从崩溃报告 Binary Images 中取出目标镜像的 uuid 字符串(可能为小写、无连字符)。
2. 转为标准格式:32 字符,8-4-4-4-12,大写,连字符分隔。
3. 使用 mdfind "com_apple_xcode_dsym_uuids == <UUID>" 得到候选 dSYM 路径。
4. 对候选路径执行 dwarfdump --uuid <dSYM内DWARF路径>,与报告中的 uuid(忽略大小写与连字符)比较。
5. 一致则该 dSYM 可用于该二进制;否则需从 Archive 或构建产物中取正确版本。

十一、应用场景与最佳实践

11.1 构建与归档

  • Release/分发构建 统一使用 DWARF with dSYM File,并保留每次分发的 Archive(含 dSYM)[4]。
  • 上传 App Store/TestFlight 时勾选上传符号,便于 Organizer 中自动符号化 [2][4]。

11.2 第三方崩溃统计与符号上传

若使用 Firebase Crashlytics、Bugly、Sentry 等,需按各平台文档在构建阶段上传 dSYM(或符号表),以便其服务端对上报的堆栈做符号化 [12]。例如 Firebase 要求在 Xcode Build Phases 中配置上传脚本与 dSYM 路径 [12]。

11.3 .ips 与脚本化处理

  • .ips 为 JSON,便于用脚本解析:取 bug_type==309threadsusedImagesexception 等,批量提取 UUID、faultingThread、栈顶帧等 [6][7]。
  • 若需对大量报告做“是否可符号化”检查,可解析 usedImages 中的 uuid,与本地或符号服务器中的 dSYM UUID 列表比对。

11.4 常见崩溃模式与专项文档

11.5 官方文档与延伸阅读

资源 用途
Adding identifiable symbol names to a crash report 符号化步骤、Xcode/atos、UUID、mdfind [2]
Analyzing a crash report 分析顺序、Header/Exception/Backtrace/寄存器 [9]
Interpreting the JSON format of a crash report .ips 结构、metadata、report 各键 [6][7]
Understanding the exception types in a crash report 异常类型与信号含义 [10]
Acquiring crash reports and diagnostic logs 获取途径、设备导出、Organizer [8]
Building your app to include debugging information dSYM 生成、上传与归档 [4]
Identifying the cause of common crashes 常见崩溃模式与排查思路
Investigating memory access crashes 内存访问崩溃深入分析

参考文献

[1] Apple. Understanding and Analyzing Application Crash Reports (TN2151).
[2] Apple. Adding identifiable symbol names to a crash report. developer.apple.com/documentati…
[3] Apple. Symbolicating iPhone App Crash Reports (Stack Overflow / 社区实践).
[4] Apple. Building your app to include debugging information. developer.apple.com/documentati…
[5] 阿里云. 苹果官方文档:理解和分析ios应用崩溃日志. developer.aliyun.com/article/239…
[6] Apple. Interpreting the JSON format of a crash report. developer.apple.com/documentati…
[7] Apple. Interpreting the JSON format of a crash report — Binary images, Frames, Convert numeric values.
[8] Apple. Acquiring crash reports and diagnostic logs. developer.apple.com/documentati…
[9] Apple. Analyzing a crash report. developer.apple.com/documentati…
[10] Apple. Understanding the exception types in a crash report. developer.apple.com/documentati…
[11] RY's Blog. EXC_BREAKPOINT when forced unwrapping optional in Swift.
[12] Firebase. 在 Crashlytics 信息中心内获取易于理解的崩溃报告(Apple 平台) / Get deobfuscated crash reports.
[13] Apple. Identifying the cause of common crashes. developer.apple.com/documentati…
[14] Apple. Investigating memory access crashes. developer.apple.com/documentati…

04-Debug调试@UI-Lookin UI调试工具:从原理到实践

Lookin UI 调试工具:从原理到实践

📋 目录


一、概述与历史演进

1.1 工具简介

Lookin 是一款免费的 macOS 端 iOS 视图调试应用,与 LookinServer(嵌入 iOS 工程的 Framework)配合使用,可查看与修改 iOS App 内的 UI 对象——包括视图层级结构、视图与控件属性、布局与约束等,功能定位类似 Xcode 自带的 UI Inspector 或商业软件 Reveal [1][2][3]。

Lookin 由 QMUI 团队(曾隶属微信读书等产品)开源并维护:LookinServer 为 iOS 端 SDK(GitHub: QMUI/LookinServer),Lookin 为 macOS 端桌面应用(GitHub: hughkli/Lookin)。官网为 lookin.work/;集成后支持模拟器与真机,并可在无 Mac 连接时通过 App 内摇一摇等方式使用内置调试界面 [1][2][4][5]。

1.2 历史与版本脉络

时期/版本 事件
开源发布 Lookin / LookinServer 以免费、开源形式发布,填补 Xcode UI Inspector 能力有限、Reveal 收费等需求 [1][2]
仓库与官网 iOS 端 QMUI/LookinServer、Mac 端 hughkli/Lookin;官网 lookin.work 提供集成指南与 FAQ [1][2]
集成方式演进 支持 CocoaPods(ObjC / Swift 子库)、Swift Package Manager手动集成(Framework + Run Script)[1][2][4]
1.0.6+ 安全要求 禁止在 Release/App Store 构建中集成 LookinServer不要使用早于 1.0.6 的版本,旧版存在严重 Bug 可能导致线上事故 [2][6]
文档与技巧 官方与社区提供「自定义信息展示」「更多成员变量」「Swift 优化」等进阶文档(如字节飞书文档汇总)[2]

1.3 典型应用场景

  • 视图层级与结构排查:查看完整 UI 树(含屏幕外、hidden 视图)、UITableViewCell 的 indexPath、嵌套层级与折叠级别,定位视图遮挡、层级错误或约束冲突。
  • 属性查看与修改:实时查看 frame、bounds、backgroundColor、alpha、约束等;在 Mac 端或控制台修改属性并立即在设备上生效,用于快速验证布局与样式。
  • 导出与协作:将当前页面的 UI 信息导出为文件,脱离 Xcode 与设备单独查看或分享给他人分析。
  • 方法监听与堆栈:监听指定方法调用并打印堆栈,辅助定位触发时机与调用链。
  • 无 Mac 场景:在真机上通过摇一摇等触发 App 内 Lookin 界面,不依赖 Mac 连接即可做基础审查。

二、核心原理与架构

2.1 双端架构:LookinServer(iOS)与 Lookin(macOS)

Lookin 采用 「iOS 端 SDK + macOS 端桌面应用」 的 C/S 式架构 [1][2][4][5]:

  • LookinServer(iOS):以 Framework 形式嵌入目标 App(仅 Debug 配置)。在 App 进程内通过 Objective-C Runtime反射 获取当前 UI 层级与视图属性,将数据序列化后通过进程间/网络通信发送给 Mac 端。
  • Lookin(macOS):在 Mac 上运行,发现并连接同一网络或通过 USB 转发的 iOS 设备/模拟器上的 LookinServer,接收序列化数据后反序列化并渲染为 2D 层级树3D 视图,支持属性编辑与指令回传。

因此,不集成 LookinServer 的 App 无法被 Mac 版 Lookin 连接;集成后,模拟器或真机与 Mac 需处于可发现/可通信环境(如本机模拟器、同网段或 USB 连接)[1][2][4]。

2.2 视图信息的获取与序列化(概念)

iOS 的 UI 层级根植于 UIWindow / UIViewController 及其 view 层级。LookinServer 在 App 进程内 [4][5]:

  1. 遍历视图树:从 keyWindow(或指定 window)的 rootViewController 出发,递归访问 view.subviews,得到整棵视图树;可配置折叠深度、是否包含 hidden 视图等。
  2. 提取属性:对每个 UIView(及其子类)利用 Runtime 读取属性(如 frame、bounds、backgroundColor、layer 信息、约束等),以及自定义的 LKS_Config 等扩展属性。
  3. 序列化:将树形结构及属性编码为可在进程间或网络上传输的格式(如自定义二进制或 JSON),并发送给 Mac。
  4. Mac 端反序列化与展示:Lookin 收到数据后重建树结构,在 2D 面板展示层级、在 3D 视图展示空间关系,并支持点击选中、属性面板编辑。

修改回写:用户在 Mac 上修改某视图属性(如 frame、backgroundColor)时,Lookin 将修改指令发回 LookinServer,LookinServer 在 App 主线程对对应视图执行 setter(如 view.frame = ...),实现实时生效 [4][5]。

2.3 三种使用模式:2D、3D、Export

  • Lookin_2D:在 Mac 或 App 内以树形列表形式展示视图层级,点击节点可查看/编辑属性,对应「审查元素」。
  • Lookin_3D:将视图层级以三维空间形式展示,便于观察重叠、遮挡与 z-order。
  • Lookin_Export:将当前 UI 快照(层级与属性)导出为文件,可在未连接设备时用 Lookin 打开查看 [1][4][5]。
    触发方式:除在 Mac 或 App 内点击对应入口外,可通过代码发送通知触发,例如(Objective-C): [[NSNotificationCenter defaultCenter] postNotificationName:@"Lookin_Export" object:nil];
    同理可触发 Lookin_2DLookin_3D [1][5]。

2.4 数据流概览

flowchart LR
    subgraph iOS App
        A[UIWindow / ViewController]
        B[LookinServer]
        C[Runtime / 视图树遍历]
    end
    subgraph 传输
        D[序列化]
        E[IPC / 网络]
    end
    subgraph Mac
        F[Lookin]
        G[反序列化 / 2D·3D 展示]
        H[属性编辑回写]
    end
    A --> C
    C --> B
    B --> D
    D --> E
    E --> F
    F --> G
    G --> H
    H --> E
    E --> B

三、获取、安装与集成

3.1 前置条件

项目 说明
Mac 安装 Lookin 桌面应用(从官网或 GitHub Release 下载)[1][2]
Xcode 用于编译运行 iOS 工程;LookinServer 仅需在 Debug 配置下集成 [1][2]
iOS 项目 支持 Objective-C 或 Swift;Swift 项目需使用 Swift 子库或 SPM [2]

3.2 获取 Lookin 桌面应用

3.3 集成 LookinServer 到 iOS 项目(官方方式 [1][2])

重要仅在 Debug 配置下集成不要使用早于 1.0.6 的版本 [2][6]。

通过 CocoaPods
  • Objective-C 项目:在 Podfile 中增加
    pod 'LookinServer', :configurations => ['Debug']
    然后 pod install
  • Swift 项目
    pod 'LookinServer', :subspecs => ['Swift'], :configurations => ['Debug']

若项目使用自定义 xcconfig,需将所有 Debug 相关配置名列入 configurations,例如:
pod 'LookinServer', :configurations => ['Debug', 'Debug-Staging']
否则 Release 或其它配置可能误链 LookinServer,存在上线风险 [4][7]。

通过 Swift Package Manager
  • 在 Xcode 中:File → Add Package Dependencies,填入
    https://github.com/QMUI/LookinServer
    并选择仅在 Debug 配置下链接该依赖。
手动集成
  • LookinServer 仓库 获取 LookinServer.framework(或源码),加入工程。
  • 通过 Run Script 在构建时按配置条件嵌入;需定义宏(如 SHOULD_COMPILE_LOOKIN_SERVER)确保 Release 不包含。详见 官网集成指南Run Script 说明 [2][4]。

3.4 集成后验证

  1. Debug 配置编译并运行到模拟器真机(真机与 Mac 需同网或通过 USB 等可发现方式)。
  2. 打开 Mac 上的 Lookin,应能自动发现并列出当前运行中的 App。
  3. 选择对应设备与 App,连接成功后即可看到 2D 层级与 3D 视图;若无法发现,请检查网络、防火墙及是否确为 Debug 包且已包含 LookinServer。

四、使用流程与操作步骤

4.1 基本使用流程(Mac + 模拟器/真机)

步骤 操作 说明
1 Debug 配置运行 iOS App 模拟器或真机均可;真机与 Mac 需可通信(同网或 USB)
2 打开 Mac 上的 Lookin 从应用程序或官网下载的 app 启动
3 在 Lookin 中选择设备与 App 列表中选中当前运行中的 App,建立连接
4 选择 2D3D 模式 2D:树形层级与属性面板;3D:空间关系与遮挡
5 在层级树或 3D 视图中选中视图 右侧或面板中显示该视图属性,可编辑并实时生效
6 (可选)使用控制台或方法监听 执行代码或监听方法调用与堆栈
7 (可选)导出 使用 Export 将当前 UI 快照导出为文件,便于离线查看或分享

4.2 无 Mac 连接:App 内使用

  • 在已集成 LookinServer 的 App 中,可通过摇一摇(或配置的其它手势)调起 Lookin 内置的调试界面 [3][5]。
  • 或通过代码发送通知触发 2D/3D/Export:
    • Lookin_2D:审查元素
    • Lookin_3D:3D 视图
    • Lookin_Export:导出文件
      这样可在真机或他人设备上不连 Mac 也能做基础 UI 审查与导出。

4.3 导出(Lookin_Export)

  • 在 Mac 或 App 内触发 Export 后,当前页面的 UI 层级与属性会保存为 Lookin 可识别的文件格式。
  • 导出文件可在未连接设备时用 Lookin 打开,用于归档、协作或问题复现 [1][5]。

五、功能体系与数据表示

5.1 功能总览

功能 说明
视图层级展示 树形结构展示 UI 层级,支持折叠级别、显示 hidden 视图、屏幕外视图;可显示 UITableViewCell 的 indexPath 等 [1][4]
属性查看与编辑 查看 frame、bounds、backgroundColor、alpha、约束等;在 Mac 或控制台修改后实时回写到 App [1][4][5]
2D 审查 对应 Lookin_2D,以列表+属性面板形式审查元素
3D 视图 对应 Lookin_3D,以三维形式展示视图堆叠与遮挡关系
导出 将当前 UI 快照导出为文件,脱离设备与 Xcode 查看 [1][5]
控制台 输入代码访问当前选中视图或类,执行方法或读取属性 [1][4]
方法监听 监听特定方法调用并打印堆栈,辅助定位调用链 [1][4]
自定义展示 通过 LKS_Config 等接口在 Lookin 中展示自定义信息或更多成员变量 [2]

5.2 与 Xcode UI Inspector 的差异(概念)

  • 范围:Lookin 可展示比 Xcode UI Inspector 更大范围的视图(不限于当前屏幕可见区域),且可配置折叠与 hidden 视图 [1][4]。
  • 形态:Lookin 提供独立的 Mac 应用与 2D/3D/Export 多种形态;Xcode 的 UI Inspector 嵌入在 Debug 会话中。
  • 集成:Lookin 需主动集成 LookinServer;Xcode 对任意 Debug 运行中的 App 均可使用 UI Inspector,但功能相对简单。

六、关键概念图示与流程

6.1 双端与数据流

flowchart TB
    subgraph iOS
        APP[App 进程]
        RT[Runtime / 视图树]
        LKS[LookinServer]
    end
    subgraph 传输
        S[序列化]
        C[连接]
    end
    subgraph macOS
        LK[Lookin]
        UI[2D / 3D / 属性面板]
    end
    APP --> RT
    RT --> LKS
    LKS --> S
    S --> C
    C --> LK
    LK --> UI
    UI -->|编辑回写| C
    C --> LKS

6.2 使用流程简图

sequenceDiagram
    participant U as 用户
    participant M as Mac Lookin
    participant I as iOS App + LookinServer

    U->>M: 打开 Lookin,选择设备与 App
    M->>I: 建立连接
    I->>I: 遍历视图树,序列化
    I->>M: 发送 UI 数据
    M->>U: 展示 2D/3D 与属性
    U->>M: 编辑属性或触发 Export
    M->>I: 回写修改或请求导出
    I->>M: 确认或返回导出文件

七、应用场景与最佳实践

7.1 视图层级与布局调试

  • 使用 2D 检查嵌套层级、view 的父子关系与同层顺序,结合 3D 查看重叠与遮挡。
  • 利用「显示 hidden 视图」「折叠级别」减少噪音,快速定位目标 view;通过属性面板查看 frame、constraints、autoresizing 等,判断布局异常原因。

7.2 属性实时修改与验证

  • 在属性面板直接改 frame、backgroundColor、alpha 等,无需改代码重新运行,适合快速验证样式与布局假设。
  • 注意:修改仅对当前运行实例生效,不会写入源码;需将确认后的值同步到代码或约束中。

7.3 导出与协作

  • 对难以复现的 UI 问题,使用 Export 导出当前页面快照,将文件发给同事或在未连接设备时用 Lookin 打开分析。
  • 建议在问题复现后立即导出,避免界面变化导致快照与问题现场不一致。

7.4 官方文档与进阶技巧导读

内容 链接或入口
官网与集成 lookin.work集成指南CocoaPods手动集成 Run Script
LookinServer 仓库 GitHub QMUI/LookinServer(iOS 端)
Lookin Mac 应用 GitHub hughkli/Lookin
演示项目 官网提供的 QMUI-Demo 等,可快速体验
进阶 官方与社区文档:在 Lookin 中展示自定义信息、展示更多成员变量、Swift 优化等(见 LookinServer README 中的飞书/字节文档链接)[2]

7.5 安全与版本规范

  • 仅 Debug 集成:通过 :configurations => ['Debug'] 或 SPM/手动时的配置条件,确保 Release/App Store 包不包含 LookinServer [2][6]。
  • 版本:使用 1.0.6 及以上 版本,避免旧版严重 Bug 导致线上风险 [2][6]。
  • 自定义 xcconfig:若存在多种 Debug 配置,务必在 Pod 的 configurations 中全部列出,防止误打到非 Debug 包 [4][7]。

八、伪代码与算法说明

8.1 视图树遍历与属性收集(概念)

函数 collect_view_hierarchy(root, options):
  nodes = []
  函数 visit(view, depth):
    若 options.include_hidden 为假 且 view.hidden 为真: 返回
    若 depth > options.max_depth: 返回
    node = 新建节点()
    node.view_class = view.class
    node.frame = view.frame
    node.bounds = view.bounds
    node.alpha = view.alpha
    node.hidden = view.hidden
    node.backgroundColor = view.backgroundColor
    // 通过 Runtime 读取更多属性、约束等
    node.children = []
    for subview in view.subviews:
      child = visit(subview, depth + 1)
      if child: node.children.append(child)
    nodes.append(node)
    return node
  visit(root, 0)
   return 根节点

8.2 序列化与连接(概念)

函数 send_to_mac(tree):
  将 tree 编码为可传输格式(如二进制或 JSON)
  通过已建立的连接(如 socket / 本地通信)发送到 Lookin Mac 端
  Mac 端反序列化后重建树结构,渲染 2D 树与 3D 视图

8.3 属性修改回写(概念)

函数 apply_edit(view_id, property_key, value):
  LookinServer 在 App 主线程根据 view_id 找到对应 UIView 实例
  根据 property_key 调用对应 setter,例如 setFrame: / setBackgroundColor:
  视图更新后,可选地再次同步当前状态到 Mac

九、与其它 UI 调试工具的对比

维度 Lookin Xcode UI Inspector Reveal
费用 免费、开源 随 Xcode 免费 商业收费
集成方式 需集成 LookinServer(Debug) 无需集成,Debug 运行即可 需集成 Reveal SDK 或 Reveal Loader
视图范围 可超出一屏、含 hidden、可折叠 以当前层级为主 完整层级、多窗口
2D/3D 2D 树 + 3D 视图 + Export 以层级与属性为主 2D/3D、时间线等
属性修改 支持实时回写 支持部分修改 支持
控制台/方法监听 支持控制台与方法监听 依赖 LLDB/控制台 部分版本支持
无 Mac 使用 支持 App 内摇一摇等 不适用 依赖 Reveal App

Lookin 适合需要免费、开源、可定制视图范围与 2D/3D/Export 能力的团队;Xcode UI Inspector 适合快速随 Debug 使用;Reveal 适合对商业支持与高级功能有需求的场景。


参考文献

[1] Lookin 官网. Lookin - Free macOS app for iOS view debugging. lookin.work/
[2] QMUI. LookinServer. GitHub. github.com/QMUI/Lookin…
[3] hughkli. Lookin (macOS app). GitHub. github.com/hughkli/Loo…
[4] 腾讯云开发者社区 / IM Geek / GitCode 等. Lookin 原理与集成(Runtime、序列化、双端通信、CocoaPods/SPM/手动集成).
[5] 简书. 使用 Lookin 调试 iOS App UI. www.jianshu.com/p/ec5c7e0e7…
[6] LookinServer 官方. 不要使用早于 1.0.6 的版本;不要在 Release 集成. GitHub README 与 Feishu 说明.
[7] GitCode 博客. LookinServer 集成:自定义 xcconfig 配置时的注意事项.
[8] Apple. UI Inspector. Xcode 文档.
[9] Reveal. Reveal - iOS UI Debugger. revealapp.com/

05-Debug调试@调试器-Chisel LLDB调试工具:从原理到实践

📋 目录


一、概述与历史演进

1.1 工具简介

ChiselFacebook(Meta) 开源的 LLDB 命令集合,用于辅助调试 iOS 与 macOS 应用。它通过 Python 脚本 调用 LLDB 的 Scripting Bridge API(SB API) 扩展调试器能力,在不修改 Xcode 或 LLDB 本体的前提下,为开发者提供大量高层调试命令——如递归打印视图/控制器层级、在 Mac 上可视化 UIImage/UIView、按类名查找视图、对方法设置符号断点、查看响应链与约束等 [1][2][3]。

与仅使用 LLDB 内置的 pobtframe variable 等相比,Chisel 的命令更贴近 UIKit/AppKit 与日常 UI 调试场景,可显著减少手写表达式与重复操作。Chisel 与 Derek Selander 的 LLDB 扩展项目齐名,被广泛视为 iOS 开发者的标配调试增强工具 [1][3]。

仓库GitHub - facebook/chisel许可证:MIT。

1.2 历史与版本脉络

时期/事件 说明
Facebook 开源 Chisel 由 Facebook 工程师开发并开源,作为内部 iOS 调试的增强工具集 [1][2]
LLDB 与 Python 依赖 LLDB 的 Python 脚本SB API:通过 command script import 加载 fbchisellldb.py,在调试会话中注册自定义命令 [1][2]
Homebrew 分发 支持 brew install chisel 安装,安装后需在 ~/.lldbinit 中配置 command script import 路径 [1][2]
架构差异 Intel Mac:常见路径为 /usr/local/opt/chisel/libexec/fbchisellldb.pyApple Silicon (M1+):为 /opt/homebrew/opt/chisel/libexec/fbchisellldb.py [2]
objc.io 推荐 Chisel 官方 README 推荐阅读 Ari Grant 的 Dancing in the Debugger — A Waltz with LLDB(objc.io 第 19 期),以理解 LLDB 与 Chisel 的配合 [2]

1.3 典型应用场景

  • 视图/控制器层级排查:断点暂停后使用 pviewspvc 快速查看 keyWindow 的视图树与 ViewController 栈,定位层级或 present 关系问题。
  • 视图定位与可视化:用 fv/fvc 按类名或正则查找视图/控制器并将地址拷到剪贴板;用 visualize 将 UIImage/UIView/CALayer 等在 Mac 的 Preview 中打开,便于检查图片或布局。
  • 临时显示/隐藏与边框show/hideborder/unbordermask/unmaskflicker 在不继续执行的情况下修改视图可见性或描边,辅助确认视图位置与遮挡关系。
  • 断点与监视bmessage 对类或其子类上的方法设置符号断点(无需关心具体实现类);wivar 对实例变量设置 watchpoint,便于追踪成员变化。
  • 响应链、约束与数据presponder 打印响应链;paltracealamborder 等辅助 Auto Layout 调试;pcurlpjsonpdata 等方便网络与数据调试。

二、核心原理与架构

2.1 LLDB 与 Python 脚本扩展

LLDB(Low Level Debugger)是 Apple 在 Xcode 中采用的底层调试器,支持 C、C++、Objective-C、Swift 等。除内置命令外,LLDB 提供 Python 脚本接口:在调试会话中可通过 command script import <path> 加载 Python 模块,该模块可调用 LLDB Python API(SB API) 访问调试目标(进程、线程、帧、变量、表达式求值等),并调用 debugger.HandleCommand()SBCommandInterpreter 注册自定义命令 [2][4][5]。

Chisel 的入口脚本为 fbchisellldb.py:被 import 后,会加载 commands/ 目录下各 Python 模块,每个模块通过 FBCommand 基类(或等价接口)定义命令的 namedescriptionrun 以及可选的参数/选项;最终这些命令被注册到当前 LLDB 的 command interpreter,在 (lldb) 提示符下可直接输入使用 [1][2]。

2.2 Chisel 的代码结构(概念)

  • fbchisellldb.py:入口,负责加载各子模块并注册命令。
  • fbchisellldbbase.py 等:基类与公共逻辑(如 FBCommand、参数解析、raw-input 等)。
  • commands/:按功能拆分的命令实现,例如:
    • FBPrintCommands.py:pviews、pvc、pclass、pmethods、presponder、pcurl、pjson 等打印类命令。
    • FBDisplayCommands.py:border、unborder、show、hide、mask、unmask、caflush、dismiss、present 等显示与视图操作。
    • FBFindCommands.py:fv、fvc、taplog、vs 等查找与交互。
    • FBDebugCommands.py:bmessage、binside、wivar、mwarning 等断点与监视。
    • FBVisualizationCommands.py:visualize。
    • FBAutoLayoutCommands.py:paltrace、alamborder、alamunborder。
    • 以及 Accessibility、Component、Invocation、TextInput 等 [1][2]。

命令实现中通过 LLDB SB API 获取当前 target、frame、变量,并执行表达式(如 [UIApplication sharedApplication]keyWindowsubviews)以遍历视图层级或修改属性;部分命令会将数据(如图像)通过 LLDB 传回 Mac 并在本地用 Preview 等打开 [2][4]。

2.3 数据流与执行位置

Chisel 的命令在 开发机(Mac) 上的 LLDB 进程中执行,但 表达式求值 发生在 被调试进程(iOS 模拟器或真机上的 App)中。例如 pviews 会在目标进程中执行获取 keyWindow 与递归 description 的代码,结果回传到 LLDB 并打印到控制台;visualize 则会把目标进程中的 UIImage 等数据提取出来,在 Mac 上写入临时文件并用 Preview 打开 [2][4]。

flowchart LR
    subgraph Mac
        X[Xcode / LLDB]
        C[Chisel Python]
        P[Preview / 剪贴板]
    end
    subgraph 目标进程
        A[iOS/macOS App]
    end
    X --> C
    C -->|SB API 求值| A
    A -->|返回值/数据| C
    C --> X
    C --> P

三、获取与安装

3.1 前置条件

项目 说明
Mac 运行 macOS,已安装 Xcode 及命令行工具
LLDB 随 Xcode 提供;Chisel 在调试会话中通过 command script import 加载
Python LLDB 内置 Python 绑定,无需单独安装 Python;Homebrew 安装的 Chisel 会使用系统或 LLDB 自带 Python

3.2 通过 Homebrew 安装(推荐 [2])

brew update
brew install chisel

安装后,Chisel 的脚本通常位于:

  • Intel Mac/usr/local/opt/chisel/libexec/fbchisellldb.py
  • Apple Silicon (M1+)/opt/homebrew/opt/chisel/libexec/fbchisellldb.py

3.3 配置 ~/.lldbinit

~/.lldbinit 不存在,可创建并编辑:

touch ~/.lldbinit
open ~/.lldbinit

~/.lldbinit 中增加一行(路径按实际架构二选一):

# Intel Mac
command script import /usr/local/opt/chisel/libexec/fbchisellldb.py

# Apple Silicon (M1+)
# command script import /opt/homebrew/opt/chisel/libexec/fbchisellldb.py

保存后,下次启动 Xcode 并进入调试会话 时,Chisel 命令会自动加载。若已打开 Xcode,可先在 LLDB 中执行 command source ~/.lldbinit 重新加载 [2]。

3.4 从源码安装

facebook/chisel 克隆或下载后,在 ~/.lldbinit 中写:

command script import /path/to/chisel/fbchisellldb.py

/path/to/chisel 替换为本地 Chisel 仓库路径 [2]。

3.5 验证安装

在 Xcode 中运行任意 iOS/macOS 工程,断点命中后,在 LLDB 控制台输入:

(lldb) help

在输出末尾的「user-defined commands」中应能看到 Chisel 提供的命令(如 pviewspvcfvborder 等)。也可直接执行:

(lldb) pviews

若输出了当前 keyWindow 的视图层级,则安装与配置正确 [2]。


四、命令体系与使用流程

4.1 命令分类概览

类别 代表命令 用途
视图/控制器层级 pviews、pvc 递归打印 keyWindow 的 view / view controller 描述
查找 fv、fvc、fa11y、vs 按类名/正则/无障碍标签查找视图或控制器,或交互式搜索
可视化 visualize 在 Mac Preview 中打开 UIImage、UIView、CALayer 等
显示/边框/遮罩 show、hide、border、unborder、mask、unmask、flicker 临时显示/隐藏视图、加边框、加遮罩、闪烁
渲染 caflush、slowanim、unslowanim 刷新 Core Animation、慢速动画
断点与监视 bmessage、binside、wivar 方法符号断点、库内偏移断点、实例变量 watchpoint
打印 presponder、pclass、pmethods、pproperties、pcurl、pjson、pdata、pblock、pinvocation、pivar 等 响应链、继承关系、方法列表、属性、curl、JSON、NSData、Block、调用信息、实例变量
Auto Layout paltrace、alamborder、alamunborder 约束追踪、歧义约束边框
ViewController present、dismiss present / dismiss 指定 VC
其它 mwarning、setinput、settext、taplog、pcomponents、dcomponents、rcomponents 等 模拟内存警告、输入文本、点击日志、Component 相关

完整列表可在 LLDB 中执行 help 查看,或参阅 Chisel Wiki [1][2]。

4.2 基本使用流程

  1. 在 Xcode 中为 iOS 或 macOS 项目设置断点(或运行后点击暂停)。
  2. 断点命中或暂停后,在 LLDB 控制台 输入 Chisel 命令;多数命令支持 raw-input(即命令后可直接写表达式,如 fv UITableViewborder 0x12345678);可执行 help raw-input 查看说明。
  3. 查看输出或效果(控制台打印、剪贴板、Preview 窗口等);若需修改命令行为,可查阅 help <command>
  4. 继续执行(如 continue)或单步调试,结合其它 LLDB 命令(pobtframe variable)完成排查。

五、常用命令详解与 SOP

5.1 视图与控制器层级

命令 语法与说明 典型用法
pviews pviews [--up] [--depth=depth] [view] 无参数时递归打印 keyWindow 的视图层级;--up 只打印从指定 view 到 window 的上层;--depth 限制深度;传入 view 则从该 view 开始 [2][6]
pvc pvc [viewController] 递归打印 keyWindow 的 ViewController 层级(含 present 关系);iOS 常用,macOS 不支持 [2][6]

SOP:布局或层级异常时,先 pviews 看整棵树,再用 fv <ClassName> 找到目标 view 地址,用 border <addr>mask <addr> 在界面上标出位置;若关心 VC 栈则用 pvc

5.2 查找与可视化

命令 语法与说明 典型用法
fv fv <classNameRegex> 在 keyWindow 的视图树中按类名正则查找,第一个匹配的 view 地址会写入剪贴板;后续可用 border (id)[剪贴板] 或直接 border <addr> [2][6]
fvc fvc [--name=classNameRegex] [--view=view] 按 ViewController 类名正则查找,或将拥有某 view 的 VC 打印出来 [2][6]
visualize visualize <expr> UIImage、CGImageRef、UIView、CALayer、NSData(图像)、UIColor、CIColor、CIImage、CGColorRef、CVPixelBuffer 等在 Preview.app 中打开;expr 为对象表达式 [2][6]

SOP:需要确认某视图是否在层级中或位置时:fv MyCustomView → 粘贴地址 → border (id)0x...visualize (UIView *)0x...

5.3 显示、边框与遮罩

命令 语法与说明 典型用法
show / hide show <view/layer>hide <view/layer> 不继续执行即可在设备/模拟器上显示或隐藏该 view/layer,便于确认是谁在遮挡 [2][6]
border / unborder border [--color=] [--width=] [--depth=] <view/layer> 给 view/layer 画边框;color、width、depth 可选;unborder 移除 [2][6]
mask / unmask mask [--color=] [--alpha=] <view/layer> 在 view/layer 上叠加半透明矩形,标出范围;unmask 移除 [2][6]
flicker flicker <view> 快速显示再隐藏一次,用于快速定位视图位置 [2][6]

5.4 断点与监视

命令 语法与说明 典型用法
bmessage bmessage "<expr>" 类或其子类上对方法设符号断点;expr 如 -[MyView setFrame:]+[MyClass sharedInstance]-[0xabcd1234 setFrame:];Chisel 会沿继承链找到实际实现该 selector 的类并设条件断点 [2][6]
wivar wivar <object> <ivarName> 对对象的实例变量watchpoint,该 ivar 被写入时断下 [2][6]

5.5 响应链、约束与数据

命令 语法与说明 典型用法
presponder presponder [responder] 从指定 responder 起向上打印 响应链 [2][6]
paltrace paltrace [view] 打印 Auto Layout 的调试 trace,默认 keyWindow [2][6]
alamborder / alamunborder alamborder [--color=] [--width=]alamunborder 布局歧义的 view 加边框;需 raw-input [2][6]
pcurl pcurl [--embed-data] <NSURLRequest> NSURLRequest 转成 curl 命令,便于在终端重放 [2][6]
pjson pjson [--plain] <NSDictionary/NSArray> JSON 形式打印字典或数组 [2][6]

5.6 命令速查表

场景 推荐命令
看当前界面视图树 pviews
看 ViewController 栈 pvc
按类名找 view 并标出 fv <Regex> → border <addr>
在 Mac 上看图/看 view visualize <expr>
临时隐藏某 view hide <view>
给 view 加边框 border [选项] <view>
对某类方法下断点 bmessage "-[ClassName method:]"
监视某对象 ivar 变化 wivar <obj> <ivarName>
看响应链 presponder [responder]
看约束问题 paltrace;alamborder
把请求变 curl pcurl <request>

六、关键概念图示与流程

6.1 Chisel 在调试会话中的位置

flowchart TB
    subgraph 开发机
        X[Xcode]
        L[LLDB]
        I[~/.lldbinit]
        C[Chisel Python]
    end
    subgraph 目标
        A[iOS/macOS App 进程]
    end
    X --> L
    I -->|command script import| L
    L --> C
    C -->|SB API / 表达式求值| L
    L --> A
    A -->|结果/数据| L
    L --> C

6.2 典型调试流程(视图问题)

sequenceDiagram
    participant D as 开发者
    participant L as LLDB
    participant C as Chisel
    participant A as App

    D->>L: 断点命中 / 暂停
    D->>L: pviews
    L->>C: 执行 pviews
    C->>A: 求值 keyWindow / 递归 description
    A->>C: 返回字符串
    C->>L: 输出到控制台
    D->>L: fv MyView
    C->>A: 查找并取地址
    C->>D: 地址拷到剪贴板
    D->>L: border (id)0x...
    C->>A: 设置 layer border
    A->>D: 界面显示边框

七、自定义命令与开发工作流

7.1 自定义命令接口(概念 [2])

Chisel 支持在本地添加自定义命令,供个人或团队使用。基本方式:

  1. 编写一个 Python 文件,定义继承自 fbchisellldbbase.FBCommand 的类,实现:
    • name(self):命令名
    • description(self):简短描述
    • run(self, arguments, options):命令逻辑;内部可调用 lldb.debugger.HandleCommand() 执行 LLDB 命令,或使用 SB API 获取 frame、变量、求值表达式等。
  2. ~/.lldbinit 中先 command script import Chisel 的 fbchisellldb.py,再调用 loadCommandsInDirectory 加载自定义命令所在目录 [2]。

示例(来自 Chisel README):打印 keyWindow 的 windowLevel:

#!/usr/bin/python
# 示例:自定义命令
import lldb
import fbchisellldbbase as fb

def lldbcommands():
    return [ PrintKeyWindowLevel() ]

class PrintKeyWindowLevel(fb.FBCommand):
    def name(self):
        return 'pkeywinlevel'
    def description(self):
        return 'Print the window level of the key window.'
    def run(self, arguments, options):
        lldb.debugger.HandleCommand('p (CGFloat)[(id)[(id)[UIApplication sharedApplication] keyWindow] windowLevel]')

更多参数与选项可参考 Chisel 内置命令(如 borderpinvocation)的实现;官方 README 的 Custom CommandsContributing 提供了贡献与扩展说明 [2]。

7.2 开发工作流(调试 Chisel 命令本身 [2])

  1. 写好命令脚本并放到某目录。
  2. ~/.lldbinit 中配置 loadCommandsInDirectory 加载该目录。
  3. 启动 LLDB(或 Xcode 调试),断点命中后执行 command source ~/.lldbinit 重新加载。
  4. 运行正在开发的命令,观察行为。
  5. 修改命令代码后,可使用 script reload(modulename) 重载模块,无需重启 Xcode,再重复 4–5 直至满意。

八、伪代码与算法说明

8.1 pviews 类命令的递归描述(概念)

函数 print_view_hierarchy(view, depth, max_depth):
  若 max_depth 已设定且 depth >= max_depth: 返回
  缩进 = 根据 depth 生成
  输出 缩进 + view 的 description(类名、frame 等)
  for subview in view.subviews:
    print_view_hierarchy(subview, depth + 1, max_depth)

实际实现中,Chisel 通过 LLDB 在目标进程中执行 Objective-C 表达式获取 keyWindowrootViewController.viewsubviews 等,并在本地拼接输出 [2][4]。

8.2 fv 查找视图(概念)

函数 find_view_matching(regex):
  window = 求值 "[UIApplication sharedApplication].keyWindow"
  results = 在 window 的子树中递归查找 view.class 与 regex 匹配的 view
  若 results 非空:
    将 results[0] 的地址写入剪贴板
    返回 results[0]
  否则 返回 nil

8.3 bmessage 符号断点(概念)

函数 bmessage(expr):
  # expr 如 "-[MyView setFrame:]"
  解析出 class(或 instance)与 selector
  遍历 class 及其子类(或 instance 的类及其子类),查找实际实现该 selector 的类
  在该类的实现上设置断点(或条件断点,使仅当 receiver 匹配时断下)

这样无需关心 setFrame: 是在 MyView 还是其子类中实现,都能在调用时断下 [2][6]。


九、应用场景与最佳实践

9.1 UI 层级与布局

  • 先用 pviewspvc 把握整体结构;再用 fv + bordermask 在界面上标出目标 view,确认 frame 与遮挡关系。
  • Auto Layout 异常时用 paltrace 看约束冲突/歧义;用 alamborder 在歧义 view 上画边框便于对照。

9.2 图片与渲染

  • visualize 将 UIImage、CALayer、UIView 等导出到 Preview,检查内容与尺寸;配合 pviews 找到持有该 image 的 view。
  • 若界面未刷新,可尝试 caflush 强制 Core Animation 刷新。

9.3 断点与数据

  • 对「谁调用了某方法」不清晰时,用 bmessage 在该方法上设断点,运行到断点后看 btpinvocation(x86)等。
  • 对 NSURLRequest 用 pcurl 转为 curl 在终端重放;对 NSDictionary/NSArray 用 pjson 查看结构。

9.4 官方文档与资源导读

资源 链接 说明
Chisel 仓库 GitHub facebook/chisel 源码、README、CONTRIBUTING、安装与自定义命令
命令列表 Chisel Wiki 各命令的 Syntax、Arguments、Options 与实现文件
LLDB 与 Chisel 综述 Dancing in the Debugger — A Waltz with LLDB(objc.io #19) Ari Grant 撰文,理解 LLDB 与 Chisel 的配合 [2][7]
LLDB Python API LLDB Python API SB API、自定义命令接口
LLDB 自定义命令教程 Writing Custom Commands 官方扩展 LLDB 的教程

9.5 注意事项

  • Chisel 命令依赖当前暂停的 target 与 frame;若未暂停或 target 不对,部分命令会失败。
  • raw-input:多数命令的最后一个参数可直接写表达式(如 view 地址或类名),无需用引号包裹整条表达式;详见各命令 help
  • 真机调试时,visualize 等需要将数据从设备传回 Mac,大图或复杂层级可能略慢。

十、与其它调试工具的对比

维度 Chisel 纯 LLDB (po/bt/expr) Lookin / Reveal
形态 LLDB 命令集合(Python) 调试器内置命令与表达式 独立 Mac 应用 + App 内/网络
集成 配置 ~/.lldbinit 即可,无需改工程 Lookin 需集成 LookinServer;Reveal 需 SDK 或 Loader
视图层级 pviews/pvc 文本输出;fv + border 等辅助 需手写 po 与递归表达式 图形化 2D/3D 树与属性面板
可视化 visualize 在 Preview 中看图/view 在 Lookin/Reveal 内直接看
断点/监视 bmessage、wivar 等 breakpoint set、watchpoint 等需手写 不提供
适用场景 断点调试时快速查层级、改显示、下断点、看数据 通用底层调试 专注 UI 结构审查与属性修改

Chisel 与 LLDB 内置能力互补:在保持「断点 + 控制台」工作流的前提下,用少量命令完成视图、VC、约束、请求等常见调试任务;与 Lookin/Reveal 相比,无需改工程、无需额外进程,但视图展示为文本与简单边框/遮罩,而非完整图形化树 [1][2][3]。


参考文献

[1] 掘金等. LLDB 命令库 Chisel 介绍(Facebook、Python、SB API、与 Derek Selander 对比).
[2] Facebook. Chisel. GitHub. github.com/facebook/ch…
[3] 西门桃桃. LLDB;LearnLLDB 等. Chisel 命令用法总结.
[4] LLDB. Python APIWriting Custom CommandsImplementing Standalone Scripts. lldb.llvm.org/python_api.…lldb.llvm.org/use/tutoria…
[5] LLDB. Scripting Bridge API. lldb.llvm.org/resources/s…
[6] Facebook. Chisel Wiki (Commands). github.com/facebook/ch…
[7] objc.io. Dancing in the Debugger — A Waltz with LLDB (Ari Grant). Issue 19. www.objc.io/issue-19/ll…

02-Debug调试@网络-Wireshark网络抓包工具:从原理到实践

Wireshark 网络抓包工具:从原理到实践

📋 目录


一、概述与历史演进

1.1 工具简介

Wireshark 是一款开源的网络协议分析器(Network Protocol Analyzer),支持实时抓包(Live Capture)离线分析(Offline Analysis),可对数百种协议进行深度解析(Deep Inspection),运行于 Windows、Linux、macOS 等平台,被业界与教育机构广泛用于网络排障、安全分析、协议学习与性能调优 [1][2]。

与 Charles、Fiddler 等应用层代理不同,Wireshark 工作在网卡/驱动层,可捕获本机及经本机转发的原始报文(含二层以太网帧、IP、TCP/UDP 及各类应用层协议),不依赖应用配置代理,适用于全栈协议分析与非 HTTP(S) 流量 [3]。

1.2 历史与版本脉络

时期 事件
1997 年底 Gerald Combs 为解决工作中的网络问题并学习网络知识,开始编写 Ethereal(Wireshark 前身)[1][4]
1998 年 7 月 Ethereal 0.2.0 首次发布;Gilbert Ramirez、Guy Harris、Richard Sharpe 等贡献底层解析器与协议支持 [4]
2006 年 项目迁移基础设施并更名为 Wireshark [1][4]
2008 年 Wireshark 1.0 发布,标志着「最低可用功能」完成;首届 SharkFest 开发者与用户大会举办 [4]
2015 年 Wireshark 2.0 发布,采用全新 UI [4]
2023 年 项目由 Wireshark Foundation(美国 501(c)(3) 非营利组织)接管,负责基础设施、SharkFest 与网络教育推广 [4]

社区贡献模式以「所需协议驱动」为主:开发者复制现有解析器、实现新协议后回馈上游,使 Wireshark 支持的协议数量持续增长(如 4.x 版本已支持数千种协议、数十万字段)[1][2][5]。

1.3 典型应用场景

  • 网络排障:定位连接超时、丢包、重传、RST、DNS 解析失败等,结合协议栈与时间轴分析根因。
  • 协议学习与逆向:查看真实报文结构、字段含义、状态机行为(如 TCP 握手/挥手、TLS 握手)。
  • 安全与取证:检测异常流量、分析攻击载荷、配合 TLS 密钥日志解密 HTTPS 以审计内容(需合规授权)。
  • 性能分析:统计往返时延、重传率、吞吐量,配合 IO 图形化与专家信息系统(Expert Info)。
  • 嵌入式与物联网:抓取串口/蓝牙/BLE 等经适配器转换后的报文,或配合远程抓包(SSH、rpcapd)分析设备侧流量。

二、核心原理与架构

2.1 抓包在 OS 中的位置

抓包需要网卡驱动或内核模块将流经网卡的报文复制一份交给用户态。在 Unix/Linux/macOS 上,Wireshark 使用 libpcap:应用通过 libpcap 打开设备或文件,由 libpcap 与内核交互(如 Linux 的 PF_PACKET、BPF 过滤器),把满足条件的报文拷贝到用户空间 [6][7]。在 Windows 上,早期依赖 WinPcap(基于 libpcap 1.0.0,支持至 Windows 8,已停止维护);现代 Wireshark(3.0+)默认使用 Npcap:由 Nmap 项目维护,采用 NDIS 6 Light-Weight Filter 驱动,支持环回(loopback)抓包、原始 802.11、x86/x64/ARM,并随 Wireshark 安装包分发;相比 WinPcap 性能与安全性更优 [6][8]。

flowchart TB
    subgraph 用户态
        W[Wireshark / tshark]
        L[libpcap / Npcap]
    end
    subgraph 内核
        K[内核网络栈 / NDIS]
        D[抓包驱动]
    end
    subgraph 硬件
        N[网卡]
    end
    N --> K
    K --> D
    D --> L
    L --> W

2.2 抓包过滤器与显示过滤器的分工

  • 抓包过滤器(Capture Filter):在抓包前由驱动/内核或 libpcap 应用,只将符合条件的报文写入捕获文件或交给 Wireshark,未匹配的报文直接丢弃。语法为 Berkeley Packet Filter (BPF),与 tcpdump、WinDump 等一致;抓包过程中不可更改 [5][9]。
  • 显示过滤器(Display Filter):在已抓取的报文上做二次过滤,仅影响界面展示与统计,不改变捕获文件内容;可随时修改,基于 Wireshark 自有的字段与协议树 [5][10]。

因此:抓包过滤器用于减负与聚焦(如只抓某主机或某端口),在抓包前设置且抓包过程中不可修改,可减少落盘与内存占用;显示过滤器用于分析时的精筛(如只看 HTTP 请求、某状态码、某字段值),不改变捕获文件内容,仅隐藏包列表中的报文,可随时修改。二者语法不同:例如「某主机 Telnet」抓包过滤写 tcp port 23 and host 10.0.0.5,显示过滤写 tcp.port == 23 and ip.addr == 10.0.0.5 [9][17][18]。

2.3 协议解析(Dissection)与协议树

每个报文进入 Wireshark 后,由解析器(Dissector)按协议栈逐层解析:先由 Frame 解析器处理捕获元数据(时间戳、长度等),再依次调用数据链路层(如 Ethernet)、网络层(IP/ARP)、传输层(TCP/UDP)、应用层(HTTP、TLS、DNS 等)解析器,形成协议树。解析器可内置或通过插件加载;支持协议与字段的完整列表可通过「View → Internals → Supported Protocols」查看,显示过滤器可基于任意已注册字段 [5][11]。


三、抓包过滤器(Capture Filter)与 BPF

3.1 BPF 语法概述(官方语法 [9][12])

抓包过滤器采用 libpcap 过滤器语言(即 BPF),与 tcpdump、WinDump 等使用同一语法。形式为若干原语(primitive) 通过 and / or 连接,并可加 not

[not] primitive [and|or [not] primitive ...]

原语限定符(qualifier) + ID 组成。根据 pcap-filter man pageWireshark User's Guide §4.10,常见原语包括:

原语 含义 示例
[src|dst] host 按主机 IP 或主机名过滤 host 10.0.0.5src host 192.168.1.1;不写 src/dst 时表示源或目的任一匹配即可
ether [src|dst] host 按以太网(MAC)地址过滤 ether host aa:bb:cc:dd:ee:ff
gateway host 以 host 为网关的报文(以太网源/目的为 host,但 IP 源/目的不是 host) gateway 192.168.1.1
[src|dst] net [mask|len] 按网络号过滤,可写掩码或 CIDR 长度 net 192.168.0.0/24net 192.168.0.0 mask 255.255.255.0
[tcp|udp] [src|dst] port 按 TCP/UDP 端口过滤;tcp/udp 须在 src/dst 前 tcp port 80udp dst port 53
portrange 端口范围(libpcap 0.9.1+) tcp portrange 1501-1549
less | greater length 按报文长度 ≤ 或 ≥ 某值 greater 128less 64
ip | ether proto 按 IP 或以太网层协议类型过滤 ip proto 6(TCP)、ether proto 0x888e(EAPOL)
ether | ip broadcast | multicast 广播或组播 not broadcast and not multicast
relop 按字节或字节范围选择(复杂表达式) 见 pcap-filter man page

注意:抓包过滤器不是显示过滤器;前者在抓包前应用、语法更受限,后者在已抓包上过滤、可随时修改 [17]。

3.2 常用抓包过滤器示例(官方与 Wiki [9][12][17])

host 172.18.5.4                    # 与某 IP 双向流量
src host 192.168.1.1 / dst host 192.168.1.1   # 仅源或仅目的
net 192.168.0.0/24                 # 某网段;或 net 192.168.0.0 mask 255.255.255.0
src net 192.168.0.0/24             # 源网段
tcp port 23 and host 10.0.0.5      # 发往/来自 10.0.0.5 的 Telnet
tcp port 23 and not src host 10.0.0.5  # Telnet 且源非 10.0.0.5
port 53                            # DNS(TCP+UDP)
port not 53 and not arp             # 排除 DNS 与 ARP
tcp portrange 1501-1549             # TCP 端口区间
ether host aa:bb:cc:dd:ee:ff        # 以太网地址
ether proto 0x888e                  # 仅 EAPOL
ip                                # 仅 IPv4,可排除 ARP/STP 等
not broadcast and not multicast     # 仅单播
host www.example.com and not (port 80 or port 25)  # 排除 HTTP/SMTP
dst host ff02::1                   # IPv6 全节点组播(如 RA)

完整语法见 pcap-filter man pageWireshark Wiki CaptureFilters [9][12][17]。

3.3 抓包前设置与 Capture Options 界面(官方 [9])

  • 入口:菜单 Capture → Options…(或主工具栏对应项),打开 「Capture Options」 对话框 [19]。
  • 抓包过滤器输入位置:在 Input 标签页中,Interface 表格里每块网卡有一列 Capture Filter;可双击该列编辑该接口的 BPF;也可在表格上方「Capture filter for selected interfaces」为多块接口统一设置。设置完成后点击 Start 开始抓包。
  • Input 标签页:除 Capture Filter 外,还可配置每块接口的 Promiscuous(混杂模式)、Snaplen(每包捕获字节数)、Buffer(内核缓冲区大小)、Link-layer header type(链路层类型)、Monitor mode(无线 802.11 原始头,可能断网)等;悬停或展开接口可看到其 IPv4/IPv6 地址。
  • Output 标签页:可设置 Capture to a permanent file(保存路径、pcapng 默认格式)、Create a new file automatically(按时间/时长/大小/包数切换文件)、Ring buffer(多文件循环)。
  • Options 标签页Update list of packets in real-time(实时更新包列表)、Automatically scroll during live capture(自动滚动)、Name Resolution(解析 MAC/网络/传输层名称)、Stop capture automatically after…(按时长/大小/包数自动停止)。
  • Compile Selected BPFs:可查看当前 BPF 编译后的字节码,便于理解与排错。
  • 自动排除远程会话流量:当 Wireshark 在远程环境运行(如 SSH、X11、终端服务器)时,会检测环境变量并自动生成一条抓包过滤器以排除远程连接流量,减少无关包。检测变量包括:SSH_CONNECTIONSSH_CLIENTREMOTEHOSTDISPLAY(X11)、SESSIONNAME(终端服务器);Windows 下会检测是否在 Remote Desktop Services 环境 [9][17]。

Linux 提示:开启 BPF JIT 可加速过滤:echo 1 >/proc/sys/net/core/bpf_jit_enable(需 root);持久化可借助 sysfsutils [9]。


四、显示过滤器(Display Filter)

显示过滤器用于在已抓取的报文上精确控制显示哪些包;与抓包过滤器不同,可随时修改且基于协议树字段。完整语法见 User's Guide §6.4;协议与字段列表见 View → Internals → Supported ProtocolsDisplay Filter Reference [10][18][20]。

4.1 按协议/字段过滤与比较运算符(官方 [10])

  • 最简单:在显示过滤器栏输入协议名(如 tcp)或字段名(如 http.request),只显示包含该协议或该字段的报文。
  • 比较运算符User's Guide Table 6.6):
英文/别名 C 风格 含义 示例
eq / any_eq == 相等(多值字段时任一匹配即成立) ip.src == 10.0.0.5
ne / all_ne != 不相等(多值字段时全部不匹配才成立;Wireshark 3.6+ 语义) ip.src != 10.0.0.5
all_eq === 相等(多值字段时全部匹配才成立) ip.src === 10.0.0.5
any_ne !== 不相等(多值字段时任一不匹配即成立) ip.src !== 10.0.0.5
gt / lt / ge / le > < >= <= 大于/小于/大于等于/小于等于 frame.len > 100frame.len le 0x100
contains 协议、字段或切片包含某值 sip.To contains "a1762"udp contains 81:60:03
matches ~ 协议或文本字段匹配 Perl 兼容正则 http.host matches "acme\\.(org|com|net)"

注意ip.addrtcp.port 等为多值字段(同时含源与目的);== 表示「任一匹配」,要排除某地址应写 !(ip.addr == 10.43.54.65) 而非 ip.addr != 10.43.54.65(后者语义为「至少一个不等于」,易误用)[18]。

示例:

ip.addr == 192.168.0.1
ip.src == 10.0.0.5 and tcp.flags.fin
frame.len > 100
http.request.uri contains "api"
http.host matches "acme\.(org|com|net)"
tcp.flags.syn == 1
tcp.flags & 0x02

4.2 字段类型(官方 [10])

类型 说明与示例
无符号/有符号整数 可 8/16/24/32/64 位;可写十进制、八进制(0)、十六进制(0x)、二进制(0b)。例:ip.len le 1500ip.len le 0x5dc
布尔 1 或 True、0 或 False。字段存在即参与过滤;要匹配 SYN 置位须写 tcp.flags.syn == 1
以太网地址 6 字节,分隔符可为 :.-。例:eth.dst == ff:ff:ff:ff:ff:ff
IPv4 ip.addr == 192.168.0.1;支持 CIDR:ip.addr == 129.111.0.0/16
IPv6 ipv6.addr == ::1,也可匹配子网
字符串 双引号;可用 \xhh\ddd 转义。例:http.request.uri == "https://www.wireshark.org/";原始字符串前缀 rR 使反斜杠按字面处理
日期时间 字符串格式,如 frame.time == "Sep 26, 2004 23:18:04.954975"frame.time < "2022-01-01";小数秒可选,无时区后缀

4.3 逻辑、集合与算术

  • 逻辑Table 6.7):and(&&)、or(||)、not(!)、xor(^^);子序列用 []
  • 集合(Membership)field in { 值1, 值2 } 或范围 field in {443, 4430..4434};等价于多个 == 的 or,但集合对单字段求值,避免多值字段歧义。例:tcp.port in {80, 443, 8080}http.request.method in {"HEAD", "GET"}ip.addr in {10.0.0.5..10.0.0.9, 192.168.1.1..192.168.1.9}
  • 算术+-(减号前需空格)、*/%&(按位与)。例:frame.cap_len < { 14 + ip.hdr_len + tcp.hdr_len } 可找被截断的 TCP 选项。

4.4 切片、层操作符与 @ 操作符(官方 [10])

  • 切片(Slice):在字段或协议名后加 [范围]。范围格式:n:m(从偏移 n 起长度 m)、n-m(从 n 到 m inclusive)、:m(从头到 m)、n:(从 n 到结尾)、单字节 [n];负偏移表示从末尾算起。例:eth.src[0:3] == 00:00:83frame[-4:](最后 4 字节)、frame[-4:4] == 0.1.2.3。字符串切片按 UTF-8 码点边界。
  • 层操作符 #:限定到协议栈某一层。例:ip.addr#2 == 192.168.30.40 只匹配第二层 IP(如隧道内层);tcp.port#[2-4] 表示第 2、3、4 层。
  • @ 操作符:用 @ 前缀表示按原始字节比较,不经过解码。例:@browser.comment == 73:74:72:69:6e:67:... 用于有解码错误时的精确匹配。

4.5 函数(官方 [10])

函数 说明 示例
upper / lower 字符串大小写转换 lower(http.server) contains "apache"
len 字符串或字节长度(字节数) len(http.request.uri) > 100
count 帧中某字段出现次数 count(ip.addr) > 2
string 将字段转为字符串(可与 matches 等配合) string(frame.number) matches "[13579]$"
vals 将字段转为「值字符串」(若有定义) 用于与枚举名比较
dec / hex 整数转十进制/十六进制字符串
float / double 转为浮点;double 可处理时间(自 epoch 秒)
max / min 参数中的最大/最小值 max(tcp.srcport, tcp.dstport) <= 1024
abs 绝对值

4.6 字段引用(Field References)

${proto.field} 表示当前选中报文中该字段的值,用于动态过滤。例:自当前包起前 5 分钟:frame.time_relative >= ${frame.time_relative} - 300;或 HTTP 且目的 IP 等于当前帧 DNS A 记录:http && ip.dst eq ${dns.a} [10]。

4.7 正则与多值字段注意点

  • matches 的字符串会先经 Wireshark 解析再交给 PCRE,转义可能需双重(如括号 \\();用原始字符串 r"..." 可减少问题 [10]。
  • 协议名歧义:如 fc 可能被解析为协议 Fibre Channel 或十六进制 0xFC;用 .fc 强制协议名、:fc 强制字节序列 [10]。
  • 协议更名:如 bootp → dhcp,旧名可能仍可用但会提示 deprecated;新写过滤器建议用新名 [10]。

五、关键概念图示与流程

5.1 从网卡到界面的数据流

flowchart LR
    subgraph 捕获
        A[网卡] --> B[驱动/Npcap]
        B --> C[BPF 抓包过滤]
        C --> D[捕获缓冲区/文件]
    end
    subgraph 解析与展示
        D --> E[Frame 解析器]
        E --> F[各层 Dissector]
        F --> G[协议树]
        G --> H[显示过滤器]
        H --> I[包列表/详情/字节]
    end

5.2 抓包过滤器 vs 显示过滤器

flowchart TD
    subgraph 抓包阶段
        P1[所有经过网卡的报文] --> CF{抓包过滤器 BPF}
        CF -->|匹配| P2[写入捕获]
        CF -->|不匹配| P3[丢弃]
    end
    subgraph 分析阶段
        P2 --> DF{显示过滤器}
        DF -->|匹配| Q1[列表中显示]
        DF -->|不匹配| Q2[隐藏,仍存在于文件]
    end

5.3 协议解析栈(概念)

flowchart TB
    F[Frame] --> E[Ethernet]
    E --> I[IP / ARP]
    I --> T[TCP / UDP / ICMP]
    T --> A[HTTP / TLS / DNS / ...]
    A --> B[应用数据]

六、应用场景与实战

6.1 HTTP/HTTPS 调试

  • HTTP:显示过滤 httphttp.requesthttp.response.code == 404;右键报文 → Follow → HTTP Stream 可查看完整请求/响应体 [13]。
  • HTTPS:默认仅能看到 TLS 握手与加密载荷。若需查看明文,需提供会话密钥:在浏览器或 curl 侧设置 SSLKEYLOGFILE 环境变量导出密钥日志,在 Wireshark 中 Edit → Preferences → Protocols → TLS → (Pre)-Master-Secret log filename 指向该文件,重新抓包或重放后即可解密(依赖 TLS 1.2 等支持密钥导出;PFS 场景下必须用密钥日志,仅私钥不足)[14][15]。

6.2 TLS 握手与证书问题

  • 过滤:tlsip.addr == 1.2.3.4 and tls,查看 Client Hello / Server Hello / Certificate / Alert 等。
  • 典型问题:版本或套件不匹配、证书链不完整、主机名不匹配、Alert 告警等,可在 Expert Info 与 TLS 解析树中定位 [14][16]。

6.3 连接与性能问题

  • TCP:过滤 tcp,关注 SYN/ACK/RST/FIN、重传、窗口;统计 → 往返时延、流图,辅助判断延迟与丢包。
  • DNS:过滤 dns,查看请求与响应、响应码与解析结果,排查解析失败或污染。

6.4 TLS 解密配置步骤(摘要)

  1. 导出密钥日志:在客户端(浏览器/curl)设置环境变量 SSLKEYLOGFILE 指向一可写文件;完成 TLS 会话后,该文件会包含 NSS 格式的会话密钥。
  2. Wireshark 配置:Edit → Preferences → Protocols → TLS → 在「(Pre)-Master-Secret log filename」中指定上述文件路径。
  3. 抓包或重放:重新建立 TLS 连接并抓包,或对已有 pcap 重新打开;若密钥匹配,应用层载荷会以明文显示在 TLS 解析树下。
  4. 限制:仅持有服务器私钥无法解密使用 ECDHE/DHE 的会话(PFS);必须依赖客户端导出的密钥日志 [14][15][16]。

6.5 企业与实践参考

  • Cisco 等厂商文档中常推荐使用 Wireshark 进行 TLS 与通用网络故障排查,并说明如何配合密钥日志解密 HTTPS [16]。
  • 实践中常采用「先抓包过滤缩小范围 + 再显示过滤精查 + Follow Stream / 统计 / 专家信息」的组合流程;大流量环境优先用 BPF 减少落盘与内存占用。

6.6 典型通讯场景抓包与数据包分析 SOP

以下针对 Socket TCP、Socket UDP、音视频直播、WebRTC P2P 等典型通讯场景,给出从抓包配置 → 显示过滤 → 关键字段解读 → 调试与排错的完整 SOP,便于按场景落地使用。


6.6.1 Socket TCP 抓包与分析 SOP

场景说明:基于 TCP 的 Socket 通讯(自定义协议、长连接、游戏/IM 等),需观察连接建立、数据传输、重传、挥手与 RST 等。

阶段 操作 说明
1. 抓包前配置 若已知对端 IP 或端口,在 Capture Options → Capture Filter 中设置 BPF,减少无关流量 例:host 192.168.1.100tcp port 9000tcp portrange 8000-8010;未知时可先不设过滤,抓包后用显示过滤器精查
2. 选择接口 选择实际收发流量的网卡(有线/无线/环回) 本机压测选 loopback;真机/远程选对应物理或虚拟接口
3. 开始抓包 启动抓包后,在客户端/服务端触发 TCP 连接与数据收发 建议在问题复现前开始抓,复现后尽快停止,便于定位时间窗口
4. 显示过滤 在显示过滤器栏输入表达式,精确定位目标流 常用:tcp.port == 9000ip.addr == 192.168.1.100tcp.stream eq 0(按流索引);组合示例:tcp and ip.addr == 192.168.1.100 and tcp.port == 9000
5. 定位单条连接 在包列表中选中该连接任意一包 → 右键 → Follow → TCP Stream 弹出窗口显示该连接重组后的双向数据(可看明文或十六进制);窗口内可切换「仅显示/仅请求/仅响应」与编码方式
6. 分析连接状态 查看 TCP 握手与挥手 握手:过滤 tcp.flags.syn == 1 and tcp.flags.ack == 0 找 SYN,再找对应 SYN+ACK、ACK;挥手:过滤 tcp.flags.fin == 1tcp.flags.rst == 1异常:大量 tcp.analysis.retransmissiontcp.analysis.fast_retransmission 表示重传,需结合 RTT 与丢包排查
7. 统计与 RTT Statistics → Flow GraphStatistics → Round-Trip Time Flow Graph 可看时序;RTT 可看往返时延分布;Statistics → Conversations → TCP 可看每条连接的字节数、包数,辅助判断是否有半开、僵死连接

关键字段与调试要点

  • tcp.flags:syn、ack、fin、rst、push 等;RST 表示连接被重置,需结合前后包分析是哪一端发起。
  • tcp.seq / tcp.ack:序列号与确认号,用于判断丢包、乱序与重传。
  • tcp.len: payload 长度;0 表示纯 ACK 或控制包。
  • tcp.window_size_value:接收窗口,过小可能限制吞吐。
  • Expert InfoAnalyze → Expert Information 可汇总重传、重复 ACK、零窗口等,便于快速定位问题。

常见问题排查

  • 连接超时/建连失败:过滤 SYN,看是否有 SYN 无 SYN+ACK(对端未响应或防火墙拦截),或 SYN+ACK 无 ACK(本机未回 ACK)。
  • 数据丢包/应用收不到:看是否有重传(tcp.analysis.retransmission)、对端 RST(tcp.flags.rst == 1)、或中间设备分片/MTU 问题(看 IP 分片与 ICMP 不可达)。
  • 连接被重置:过滤 tcp.flags.rst == 1,看 RST 来自哪一侧、在哪个 seq 之后,结合应用日志判断是服务端主动关闭、超时还是异常断开。

6.6.2 Socket UDP 抓包与分析 SOP

场景说明:基于 UDP 的 Socket 通讯(DNS、QUIC、游戏、音视频 RTP、自定义协议等),无连接状态,需按五元组或 payload 特征过滤。

阶段 操作 说明
1. 抓包前配置 若已知端口或主机,在 Capture Filter 中设置 BPF 例:udp port 53(DNS)、udp port 5000host 10.0.0.1 and udp;UDP 流量大时可加 udp 避免抓过多 TCP
2. 选择接口 同 TCP,选实际收发流量的接口 本机/容器/远程按需选择
3. 开始抓包 触发 UDP 收发后抓包 UDP 无握手,需在业务触发期间抓取
4. 显示过滤 按端口、IP、长度等过滤 常用:udp.port == 5000udp and ip.addr == 192.168.1.100udp.length > 100;若 Wireshark 解析了上层协议(如 DNS、RTP),可用 dnsrtp
5. 按流重组(若支持) 部分协议支持「Follow → UDP Stream」或按 RTP 重组 对裸 UDP 可右键 → Follow → UDP Stream 看该五元组下双向 payload;RTP 可用 Telephony → RTP → Stream Analysis
6. 分析 payload 查看 Packet Bytes 或解析后的应用层字段 UDP 无重传标识,需结合应用逻辑判断丢包;可统计同一目的端口包数/字节与时间间隔,判断发送频率与是否被丢弃

关键字段与调试要点

  • udp.srcport / udp.dstport:源/目的端口,区分服务与流。
  • udp.length:UDP 段总长度(含 8 字节头);大包需关注是否 IP 分片(ip.fragments)。
  • ip.addr:确认五元组,便于区分多路流。
  • 无连接状态:不能像 TCP 那样用「流」概念直接看握手;需通过时间序、端口、payload 模式关联请求与响应(若协议有请求/响应结构)。

常见问题排查

  • 收不到包:确认抓包接口正确、BPF 未过滤掉目标端口;对端是否真的发送(可在对端或中间设备抓包对比)。
  • 丢包:UDP 本身不保证可靠,Wireshark 只能看到「到达本机网卡」的包;若应用层发现丢包,可对比包序号(若协议带序号)或统计包数。
  • 分片:过滤 ip.fragmentsip.flags.mf,看是否有多片;分片丢失会导致重组失败,应用收不到完整报文。

6.6.3 音视频直播场景抓包与分析 SOP

场景说明:音视频直播涉及多种传输方式——基于 TCP 的 HTTP(HLS、HTTP-FLV、DASH 等)、基于 UDP 的 RTP/RTCP、RTSP 控制 + RTP 承载、以及 WebRTC(见 6.6.4)。此处覆盖 RTP/RTCP、RTSP 及 HTTP 直播拉流。

阶段 操作 说明
1. 抓包前配置 按协议类型选用 BPF,缩小范围 RTP/RTCPudp portrange 5000-6000 或已知端口;RTSPtcp port 554udp port 554HLS/HTTP-FLVtcp port 80 or tcp port 443,或先不过滤在显示层再筛
2. 开始抓包 在播放端开始拉流/播放的同时启动抓包 确保从「起播」或「卡顿/花屏发生前」开始,便于做时序与丢包分析
3. 显示过滤 按协议与端口过滤 RTPrtprtp.payload_type == 96(H.264 常见);RTCPrtcpRTSPrtspHLShttp.request.uri contains ".m3u8"http.request.uri contains ".ts"HTTP-FLVhttp.request.uri contains ".flv"
4. RTP 流分析 Telephony → RTP → RTP StreamsStatistics → Flow Graph RTP Streams 可列出所有 RTP 流,选中某流后可 Analyze;可看丢包数、抖动、 delta 等;Follow → RTP Stream 可看 payload 与解码尝试(若支持)
5. RTCP 分析 过滤 rtcp,查看 SR/RR、丢包率与抖动报告 RTCP 携带接收端统计(丢包、抖动),可与 RTP 侧对比,判断是网络丢包还是对端发送问题
6. RTSP 分析 过滤 rtsp,右键 Follow → TCP Stream(若 RTSP 走 TCP) 看 OPTIONS、DESCRIBE、SETUP、PLAY 等信令与 SDP;可确认媒体端口、编码格式与 URL
7. HTTP 直播(HLS/DASH/FLV) 过滤 http,按 URI 或 Host 筛 看 .m3u8/.mpd 请求与 200 响应、.ts/.m4s 分片请求顺序与状态码;Follow → HTTP Stream 看完整请求/响应;关注 4xx/5xx 与超时

关键字段与调试要点

  • RTPrtp.ssrcrtp.seqrtp.timestamprtp.payload_type;丢包会导致 seq 不连续或 RTCP 报告高丢包率。
  • RTCP:SR(Sender Report)含发送端 NTP/RTP 时间与包数/字节数;RR(Receiver Report)含丢包数、最高接收 seq、抖动。
  • RTSP:CSeq、Session、Transport(含端口与 RTP/RTCP 端口对)。
  • HTTP 直播:状态码、Content-Length、Range 请求;分片请求间隔与响应时间可辅助判断卡顿是否与拉流延迟有关。

常见问题排查

  • 花屏/卡顿:先看 RTP 或 HTTP 分片是否有丢包或重传;再看 RTCP RR 的丢包率与抖动;最后看应用层是否频繁重试或切换码率。
  • 无法起播:RTSP 检查 DESCRIBE/SETUP/PLAY 是否均 200、SDP 与 Transport 端口是否可用;HTTP 检查 m3u8/mpd 与首条分片是否 200、CDN 是否可达。
  • 延迟大:看 RTP 时间戳与接收时间差、HTTP 分片请求间隔;缓冲与 GOP 大小也会影响延迟,需结合播放器与服务器配置。

6.6.4 WebRTC P2P 场景抓包与分析 SOP

场景说明:WebRTC 包含 信令(多通过 HTTP/WebSocket)、媒体(SRTP/SRTCP over UDP)、以及 NAT 穿透用的 STUN/TURN。抓包可分析 ICE 候选、DTLS 握手、SRTP 流与信令交互;媒体内容为加密,解密需密钥(见下)。

阶段 操作 说明
1. 抓包前配置 建议先宽抓再显示过滤;若已知端口可收窄 常用 BPF:udp(STUN/RTP 多为 UDP)或 tcp port 443 or tcp port 80(信令);WebRTC 端口动态,常不事先过滤端口
2. 开始抓包 在浏览器或 App 中完成「加入房间/呼叫」至「建立音视频」全过程 从点击「开始」前即开始抓,便于捕获完整 ICE 与 DTLS
3. 显示过滤(信令) 信令多为 HTTPS/WSS httptls 过滤信令域名;若已配 SSLKEYLOGFILE 并解密 TLS,可看到 WebSocket 或 HTTP 上的 SDP/ICE 等
4. 显示过滤(STUN) STUN 用于 NAT 探测与保活 stunstun.type;可看 Binding Request/Response、XOR-MAPPED-ADDRESS(即 NAT 映射地址)
5. 显示过滤(DTLS) WebRTC 媒体使用 DTLS 协商密钥,再以 SRTP 传媒体 dtls;可看 Client Hello/Server Hello、Certificate、Finished;无法仅凭私钥解密 DTLS,需在端点导出 DTLS/SRTP 密钥(见下)
6. 显示过滤(RTP/媒体) 解密前仅能见 SRTP 密文;解密后可识别为 RTP 解密前:udp.port == 9xxx(媒体端口在 SDP 中);解密后:可用 rtp 过滤并做 RTP 流分析
7. 解密 WebRTC 媒体(可选) 在 Chrome/Chromium 中启用 SSL 日志,导出密钥供 Wireshark 解密 SRTP Chrome 启动参数加 --ssl-key-log-file=<path> 并指定路径;Wireshark:Edit → Preferences → Protocols → TLS,在 (Pre)-Master-Secret log filename 填该路径;部分版本需在 Protocols → RTP 中启用「Decrypt SRTP」并依赖 DTLS 密钥;Chrome 的 NSS 格式密钥日志对 DTLS 有效,解密后可见 RTP 流

关键字段与调试要点

  • STUNstun.type(0x0001 Binding Req、0x0101 Binding Resp);stun.att.xor_mapped_address 为服务器看到的客户端公网地址,用于 ICE 候选。
  • SDP(在信令中)m=video/m=audioc=IN IP4a=rtcp-muxa=ice-ufrag/a=ice-pwda=fingerprint(DTLS);可确认媒体端口、ICE 与 DTLS 参数。
  • ICE:在信令中交换 candidate(host/srflx/relay);抓包可验证 candidate 是否与 STUN 响应一致、是否走 TURN(relay)。
  • DTLS:握手成功后才会有 SRTP;若 DTLS 失败,无媒体流或报错。

常见问题排查

  • P2P 不通、仅 TURN 能通:看 STUN Binding 是否有响应;若只有 relay candidate 可用,说明 NAT 对称或策略限制,需 TURN 中继。
  • 无音频/无视频:看信令中 SDP 是否含对应 m= 与 codec;看 DTLS 是否握手成功;看是否有对应端口的 UDP 包(防火墙/安全组可能拦媒体端口)。
  • 媒体解密失败:确认密钥日志在握手已配置、浏览器确实写入了该文件;Wireshark 需同时支持 TLS 密钥日志与 RTP 的 SRTP 解密(部分版本需在 RTP 偏好中勾选解密选项)。

WebRTC 抓包与解密流程简图

flowchart LR
    subgraph 抓包
        A[抓 UDP/TCP] --> B[显示过滤 STUN/DTLS/TLS]
        B --> C[信令看 SDP/ICE]
    end
    subgraph 解密
        D[浏览器 ssl-key-log] --> E[Wireshark TLS 密钥]
        E --> F[DTLS/SRTP 解密]
    end
    C --> G[分析候选与媒体端口]
    F --> G

6.6.5 场景与过滤器速查表
场景 建议抓包过滤(BPF) 常用显示过滤 分析入口
Socket TCP tcp port 端口host IP tcp.port == 端口tcp.stream eq 流索引 Follow TCP Stream、Expert Info、Flow Graph
Socket UDP udp port 端口host IP and udp udp.port == 端口udp.length > 0 Follow UDP Stream、Packet Bytes
音视频 RTP udp portrange 5000-6000 rtprtcprtp.payload_type == 96 Telephony → RTP Streams、RTP Stream Analysis
RTSP tcp port 554udp port 554 rtsp Follow TCP Stream(信令)、RTP 同音视频
HLS/HTTP 直播 tcp port 80 or tcp port 443 http.request.uri contains ".m3u8"http.request.uri contains ".ts" Follow HTTP Stream、看状态码与顺序
WebRTC 信令 tcp port 443 http/tls(解密后看 WSS/SDP) Follow HTTP Stream、看 SDP/ICE
WebRTC STUN/媒体 udp stundtlsrtp(解密后) STUN 看 XOR-MAPPED;RTP 同音视频

七、高级应用与扩展

7.1 官方文档与界面操作导读

内容 官方入口 说明
抓包 Capture → Options;User's Guide Ch.4 接口选择、Capture Filter、Output/Options 标签
抓包过滤语法 §4.10 Filtering while capturingWiki CaptureFilters BPF 原语、自动远程过滤
显示过滤语法 §6.4 Building Display Filter ExpressionsWiki DisplayFilters 比较/逻辑/切片/函数/字段引用
协议与字段列表 View → Internals → Supported ProtocolsDisplay Filter Reference 各协议可过滤字段名
跟随流 右键包 → Follow → TCP/TLS/HTTP Stream 见下
统计 Statistics 菜单 Conversations、Endpoints、Protocol Hierarchy、RTT、IO Graph
专家信息 Analyze → Expert Information 重传、重复 ACK、错误等汇总
开发/解析器 Developer's Guide 编写 Dissector、插件

7.2 跟随流(Follow Stream)

对 TCP、TLS、HTTP 等协议,右键报文选择 Follow → TCP Stream / TLS Stream / HTTP Stream,可在一个窗口中看到该连接上的重组应用数据(明文或解密后),便于分析单会话内容 [13]。

7.3 统计与 IO 图

  • Statistics:Conversations、Endpoints、Protocol Hierarchy、Round-Trip Time 等,用于宏观把握流量与延迟。
  • IO Graphs:按时间轴绘制报文数、字节数或自定义显示过滤器计数,便于观察突发、重传与趋势。

7.4 命令行 tshark

tshark 为 Wireshark 的命令行版本,使用相同的抓包与显示过滤器,适合脚本化与 CI 环境,例如:

tshark -i eth0 -f "tcp port 80" -w capture.pcapng
tshark -r capture.pcapng -Y "http.request" -T fields -e http.request.uri

7.5 自定义解析器与插件

新协议可通过编写 Dissector(C 或 Lua)注册到 Wireshark,实现 proto_register_XXXproto_reg_handoff_XXX,将协议与字段挂入协议树并参与显示过滤;详见 Wireshark Developer's Guide [11]。


八、伪代码与算法说明

8.1 抓包过滤器(BPF)求值概念

BPF 在内核或用户态对每个报文执行布尔表达式求值,仅当结果为真时交付给上层;原语通常对应「偏移 + 长度 + 掩码 + 比较」,例如「IPv4 且目的端口为 80」会编译为对帧内特定偏移处字节的测试。完整语义见 BPF 论文与 man page [12]。

8.2 显示过滤器求值

对每条已解析的报文,根据当前显示过滤器表达式遍历协议树:若字段存在且满足比较/逻辑/集合条件则保留显示,否则隐藏。字段类型与运算符需匹配(如整数用 ==、字符串用 contains/matches),类型不匹配会导致过滤无效或报错。多值字段(如 ip.addr 同时有源与目的)下,== 表示「任一匹配即成立」(any_eq),若需「全部匹配」可使用 ===(all_eq)[10]。

8.3 协议解析器调用顺序(概念)

对每个捕获的 frame:
  1. Frame dissector 写入时间戳、长度等元数据
  2. 根据链路层类型(如 Ethernet type)选择下一层解析器
  3. 递归:每个解析器解析本层头部,根据「下一层协议」字段(如 IP 的 protocol、TCP 的 port)调用子解析器
  4. 直至无子协议或数据结束,协议树与字段注册完成,供显示过滤器使用

参考文献

[1] Wireshark. About Wireshark. www.wireshark.org/about.html
[2] Wireshark. Wireshark User's Guide. www.wireshark.org/docs/wsug_h…
[3] 与 Charles 等代理工具的差异:Charles 为应用层代理,Wireshark 为底层抓包与协议解析。
[4] Wireshark. 1.4. A Brief History Of Wireshark. www.wireshark.org/docs/wsug_h…
[5] Wireshark. Filtering while capturing / Building Display Filter Expressions. User's Guide.
[6] Wireshark Wiki. libpcap. wiki.wireshark.org/libpcap
[7] Wireshark Wiki. Packet capture. wiki.wireshark.org/CaptureSetu…
[8] Npcap. Npcap: Windows Packet Capture Library & Driver. npcap.org/
[9] Wireshark. 4.10. Filtering while capturing. www.wireshark.org/docs/wsug_h…
[10] Wireshark. 6.4. Building Display Filter Expressions. www.wireshark.org/docs/wsug_h…
[11] Wireshark. Chapter 9. Packet Dissection. Developer's Guide. www.wireshark.org/docs/wsdg_h…
[12] tcpdump. pcap-filter man page. www.tcpdump.org/manpages/pc…
[13] Wireshark. Following Protocol Streams. User's Guide.
[14] Wireshark Wiki. TLS. wiki.wireshark.org/TLS
[15] SSLTrust / 第三方. Wireshark troubleshoot network SSL TLS.
[16] Cisco Community. Troubleshoot TLS using Wireshark. community.cisco.com/t5/security…
[17] Wireshark Wiki. CaptureFilters. wiki.wireshark.org/CaptureFilt…
[18] Wireshark Wiki. DisplayFilters. wiki.wireshark.org/DisplayFilt…
[19] Wireshark. 4.5. The "Capture Options" Dialog Box. User's Guide. www.wireshark.org/docs/wsug_h…
[20] Wireshark. Display Filter Reference. www.wireshark.org/docs/dfref/

01-Debug调试@网络-Charles网络抓包工具:从原理到实践

📋 目录


一、概述与历史演进

1.1 工具简介

Charles 是一款面向 Windows、macOS、LinuxWeb 调试代理(Web Debugging Proxy) 应用,由 Karl von Randow 创建,自 2002 年发布至今,由 XK72 维护 [1][2]。其核心能力包括:拦截、记录、修改与重放 HTTP/HTTPS 流量,支持带宽限速、断点调试、请求/响应重写与本地/远程映射,被广泛用于 Web 与移动端接口调试、前后端联调、弱网与异常场景测试 [1][3][4]。

1.2 历史与版本脉络

时期 事件
2002 Charles 首次发布,以 Java 实现,跨平台运行
3.x 引入 SSL Proxying(HTTPS 中间人解密)、各平台根证书安装流程
3.10+ Charles 根证书改为每台安装独立生成,需重新信任
3.11.4+ 支持 iOS App Transport Security (ATS)
2018 Charles for iOS 上架 App Store,支持在设备端抓包 [2]
2024–2025 4.6.x 稳定版;Charles 5.0 发布,新 UI、Apple Silicon/Windows on ARM、新会话格式 .chlz [2][5]

1.3 典型应用场景

  • 接口调试:查看请求 URL、Method、Header、Body 与响应状态、Body(JSON/XML 等),定位参数与返回错误。
  • HTTPS 明文查看:通过 SSL Proxying 将加密流量解密为明文,便于分析 API 内容。
  • 弱网与限速:Throttling 模拟带宽、延迟、丢包,验证加载、超时与降级逻辑。
  • Mock 与联调:Map Local / Map Remote / Rewrite 用本地或备用环境响应替代线上,或修改请求/响应内容。
  • 断点调试:在请求发出前或响应返回前暂停,修改后再放行或中止,用于测试异常与边界。

二、核心原理

2.1 代理与中间人

Charles 作为 HTTP/HTTPS 代理 运行在本机(默认端口 8888)。客户端(浏览器、App)将代理设置为 127.0.0.1:8888 后,发往目标的 HTTP(S) 请求会先发到 Charles,再由 Charles 转发到真实服务器;响应同样经 Charles 再回到客户端。因此 Charles 处于「客户端 ↔ Charles ↔ 服务端」的中间人位置,可完整查看与修改双向流量 [3][4]。

flowchart LR
    subgraph 客户端
        C[Browser / App]
    end
    subgraph 代理层
        P[Charles :8888]
    end
    subgraph 服务端
        S[Origin Server]
    end
    C -->|1. 请求| P
    P -->|2. 转发请求| S
    S -->|3. 响应| P
    P -->|4. 返回响应| C

2.2 数据流与记录

  • 记录:Charles 在转发前后记录请求与响应的 URL、方法、头、体;对 HTTPS 需开启 SSL Proxying 并安装根证书后才能解密并记录明文。
  • 结构视图:按 Host 或 Path 聚合展示会话,便于按接口查看;支持搜索、过滤与导出会话(.chls/.chlz)。
  • 证书与解密:HTTPS 解密依赖「客户端信任 Charles 根证书 + Charles 对指定 Host 启用 SSL Proxying」,详见第三节。

三、HTTPS 与 SSL 代理

3.1 为何 HTTPS 需要特殊处理

HTTPS 在 TCP 之上建立 TLS/SSL 加密通道,端到端加密后,代理若只做「透传」,无法看到应用层明文。Charles 要查看或修改内容,必须作为 TLS 中间人:与客户端建立一条 TLS 连接,与服务器建立另一条 TLS 连接,在中间以明文处理数据 [4][6]。

3.2 SSL Proxying(中间人)原理

Charles 的 SSL Proxying 本质是受控的「中间人」行为 [6][7]:

  1. 客户端 → Charles:客户端发起 HTTPS 请求到 host:443,因系统代理指向 Charles,TCP 连接实际建到 Charles;TLS 握手时,Charles 转发服务器真实证书,而是用 Charles 根证书签发一张「伪造」的站点证书(Subject 等与目标 host 匹配),下发给客户端。
  2. 客户端验证:客户端校验证书链。若未安装/信任 Charles 根证书,会报「不受信任的 CA」;安装并信任 Charles 根证书后,客户端认为该站点证书合法,与 Charles 完成 TLS 握手,后续应用层数据以 Charles 与客户端协商的密钥加密。
  3. Charles → 服务端:Charles 再以真实客户端身份向目标服务器发起 HTTPS,使用服务器真实证书完成 TLS,获得与服务器的明文通信。
  4. 结果:Charles 同时拥有「客户端 ↔ Charles」与「Charles ↔ 服务端」的解密能力,可记录、修改请求与响应后再转发。
    简要对应关系 [16]:客户端向服务器发起 HTTPS 请求 → Charles 拦截并伪装成客户端向服务器请求 → 服务器返回 CA 证书给「客户端」(实为 Charles)→ Charles 用本地根证书签发一张与目标站点匹配的证书,替换后发给客户端 → 客户端用 Charles 公钥加密对称密钥发给 Charles → Charles 用私钥解密得到对称密钥,再用服务器公钥加密发给服务器 → 此后 Charles 同时持有两端密钥,可解密、修改后再转发。关键前提:客户端必须信任 Charles 根证书,否则会报证书不受信任。
sequenceDiagram
    participant C as 客户端
    participant P as Charles
    participant S as 服务器

    C->>P: 建立连接 (代理)
    P->>C: 返回 Charles 签发的站点证书
    Note over C: 校验证书(需信任 Charles 根证书)
    C->>P: 加密请求 (客户端↔Charles 密钥)
    P->>S: 建立 TLS,获取服务器证书
    P->>S: 加密请求 (Charles↔服务器 密钥)
    S->>P: 加密响应
    P->>P: 解密并可选修改
    P->>C: 用客户端密钥加密后返回

3.3 配置要点(官方建议 [6][7][8])

  • 启用 SSL Proxying:Proxy → SSL Proxying Settings,勾选 Enable SSL Proxying,并在列表中加入要解密的主机(Host + Port,如 *:443api.example.com:443)。不在此列表中的 Host,Charles 对 HTTPS 只做透传,不解密。
  • 按地址启用 SSL Proxying(重要):若只做了全局「Enable SSL Proxying」却未把具体要抓的域名加入列表,该域名的 HTTPS 仍会以密文显示。操作方式二选一即可 [15][16]:
    • 方式一:在 Charles 会话列表(Structure/Sequence)中,右键目标 Host 或该域名下的某条请求 → SSL Proxying → Enable SSL Proxying,Charles 会自动将该 Host:443 加入 SSL Proxying 列表。
    • 方式二:Proxy → SSL Proxying Settings → Add,手动填写 Host(如 api.example.com)与 Port(如 443)。
      因此:具体的 HTTPS 抓包,必须在「被抓包的那个地址」上启用 SSL Proxying,否则无法看到明文。
  • 安装并信任根证书:Help → SSL Proxying → Install Charles Root Certificate(或 Save 后手动导入)。安装后需在系统/浏览器中将该证书设为受信任的根 CA(如 macOS 钥匙串中设为「始终信任」),否则客户端仍会报错。
  • 关闭解密:若不需要查看 HTTPS 明文,可在 Proxy Preferences 中关闭 SSL Proxying,Charles 将直接转发 TLS 流量,不进行解密与记录明文。

3.4 HTTPS 解密流程(算法级描述)

算法概念:Charles 作为 TLS 中间人

1. 客户端向 host:443 发起 TLS ClientHello(因系统代理,连接至 Charles)。
2. Charles 向真实服务器发起 TLS 连接,完成与服务器的握手,获得服务器证书与会话密钥 K2。
3. Charles 用本地 Charles 根证书的私钥,为「host」签发一张新证书 cert_fake,Subject 等与服务器证书一致或兼容。
4. Charles 向客户端返回 cert_fake,客户端用已信任的 Charles 根证书验证 cert_fake,通过则与 Charles 完成握手,得到客户端与 Charles 的会话密钥 K1。
5. 客户端发送的 HTTPS 请求用 K1 加密,Charles 用 K1 解密得到明文请求;Charles 用 K2 加密后转发给服务器。
6. 服务器响应用 K2 加密,Charles 用 K2 解密得到明文响应;Charles 可修改后再用 K1 加密返回客户端。
7. 因此 Charles 在「请求」与「响应」两段均具备明文读写能力,用于记录、Breakpoint 修改、Rewrite、Map Local 等。

3.5 证书与安全注意

  • Charles 根证书拥有对任意域签发证书的能力,一旦被信任,可被用于窃听或篡改 HTTPS。因此仅应在本机调试环境安装,不要在生产或个人敏感环境中长期信任。
  • 从 Charles 3.10 起,根证书为每台机器/每次安装独立生成,旧设备导出的根证书不能直接用于新环境,需在新环境重新安装并信任 [8]。

3.6 HTTPS 抓包配置处理(操作清单)

要成功对 HTTPS 流量进行抓包并看到明文,必须同时满足 Charles 端客户端/本机 两处配置;缺一不可。下面给出统一的操作清单与排查要点。

3.6.1 Charles 端必须完成的配置

顺序 操作 说明
打开 Proxy → SSL Proxying Settings 若未打开过,先进入该窗口
勾选 Enable SSL Proxying 不勾选则所有 HTTPS 仅透传,不解密
SSL Proxying Locations 列表中为要抓的域名添加条目 Host:填域名或 *(如 * 表示任意;api.example.com 表示仅该域名);Port:一般为 443。未在列表中的 Host 不会被解密,界面中仍显示为密文

按地址启用的两种方式(任选其一即可):

  • 方式 A(推荐):抓包时在会话列表(Structure / Sequence)中,右键目标 Host 或该域名下任意一条请求 → SSL Proxying → Enable SSL Proxying,Charles 会自动把该 Host:443 加入上述列表。
  • 方式 B:在 SSL Proxying Settings 窗口点击 Add,手动填写 HostPort(443);可用 * 表示任意主机。

3.6.2 客户端/本机必须完成的配置

抓包对象是,就要在的系统或环境中安装并信任 Charles 根证书:

抓包对象 证书安装与信任位置
本机浏览器 本机:Help → SSL Proxying → Install Charles Root Certificate;安装后在系统钥匙串/证书存储中将该证书设为受信任的根 CA(如 macOS 钥匙串访问 → 找到 Charles Proxy CA → 展开「信任」→ 使用此证书时:始终信任
iOS 真机 App/浏览器 手机:Safari 打开 chls.pro/ssl 安装描述文件;设置 → 通用 → 关于本机 → 证书信任设置 中对该 Charles 证书启用「完全信任
Android / HarmonyOS 设备 设备浏览器打开 chls.pro/ssl 安装证书;Android 7+ 自研 App 还需在工程中配置 Network Security Configuration 信任用户证书(见 §6.3)
iOS 模拟器 Charles:Help → SSL Proxying → Install Charles Root Certificate in iOS Simulators(需先关闭模拟器再执行)

未安装或未信任根证书时,客户端会报「证书不受信任」「连接不是私密连接」等错误,HTTPS 握手失败,无法抓包。

3.6.3 配置自检与常见问题

  • Charles 里能看到请求,但 Response 是乱码或显示为加密数据
    → 说明代理已生效,但未对该 Host 启用 SSL Proxying。按 3.6.1 在 SSL Proxying Locations 中添加该 Host:443,或右键该请求/Host → Enable SSL Proxying。

  • 客户端报证书错误、无法打开页面或 App 请求失败
    → 说明未在该客户端环境安装或信任 Charles 根证书。按 3.6.2 在对应设备/本机完成安装并在「证书信任设置」或系统凭据中设为信任。

  • *已添加 :443 仍有个别域名看不到明文
    → 少数情况下需确认该请求确实走了 Charles 代理(系统/App 代理指向 Charles);若为自研 App,检查是否开启了证书锁定(Certificate Pinning),若开启则需在调试版本中关闭或信任 Charles。

  • 换电脑或重装 Charles 后,手机/本机之前装的证书报错
    → Charles 3.10+ 根证书每台机器独立生成,需在当前运行 Charles 的电脑上重新执行「Install Charles Root Certificate on a Mobile Device」,并在设备上重新访问 chls.pro/ssl 安装新证书并信任。

3.6.4 配置处理流程简图

flowchart LR
    subgraph Charles端
        A1[Enable SSL Proxying]
        A2[Add Host:443 或 右键启用]
    end
    subgraph 客户端
        B1[安装 Charles 根证书]
        B2[信任该证书]
    end
    A1 --> A2
    B1 --> B2
    A2 --> C[HTTPS 可解密]
    B2 --> C

总结:HTTPS 抓包 = Charles 端对目标 Host 启用 SSL Proxying + 在抓包对象所在环境安装并信任 Charles 根证书;两者都做对后,即可在 Charles 中看到该域名的请求/响应明文并进行修改、断点等操作。


四、功能体系与工具链

4.1 功能总览

功能 说明 典型用途
Proxy 记录 自动记录经 Charles 的 HTTP(S) 请求与响应 日常抓包、接口排查
SSL Proxying 对指定 Host 解密 HTTPS,以明文展示与修改 查看/改写 API 内容
Breakpoints 按 URL 匹配在请求/响应前后暂停,可编辑后放行或中止 改参数、改响应、模拟失败
Map Local 将匹配的请求的响应替换为本地文件内容 用本地 JSON/HTML 做 Mock
Map Remote 将匹配的请求重定向到另一 Host/Path 将线上接口指到测试/预发
Rewrite 按规则修改请求/响应头或体(如替换字符串) 改 Host、Token、部分 JSON
Throttling 限制带宽、延迟、丢包、MTU 等 弱网、高延迟、不稳定网络
Compose / Repeat 手动编辑请求并发送、重放已有请求 接口重放、压力与回归

4.2 Breakpoints(断点)[9]

  • 作用:在请求发出前或响应返回前拦截,在 Charles 中查看并编辑内容,再选择 Execute(应用修改并继续)、Abort(中止并返回错误)或 Cancel(放弃修改并原样通过)。
  • 配置:Proxy → Breakpoint Settings,添加 Location,用协议、Host、端口、路径模式匹配 URL,支持通配符;每个断点可单独勾选「Request」「Response」或两者。
  • 流程概念
请求发出 → 若匹配 Request 断点 → 暂停 → 编辑 → Execute/Abort/Cancel
         → 若未匹配或已放行 → 转发到服务器
响应返回 → 若匹配 Response 断点 → 暂停 → 编辑 → Execute/Abort/Cancel
         → 若未匹配或已放行 → 返回客户端

4.3 Map Local 与 Map Remote [10][11][15]

  • Map Local:当请求的 URL 与设定规则匹配时,Charles 向服务器发请求,而是用本地文件内容作为响应体返回。适用于静态资源或 JSON 等;服务端动态逻辑不会执行。
    典型用法 [15]:① 用本地 JSON 文件充当某接口的返回值(如难以复现的首充、活动接口);② 用本地 JS 调试线上页面:将线上站点的 https://www.example.com/js/main.js 映射到本机 /Users/xxx/project/js/main.js,在浏览器直接访问线上 URL 即可看到本地修改在「线上环境」下的效果,适合本地环境不完整、必须依赖线上环境联调时。
  • Map Remote:将匹配的请求重定向到另一地址(可不同 Host/Path),例如把 https://api.prod.com/v1/* 映射到 https://api.test.com/v1/*,便于用测试环境替代生产。
    典型用法 [15]:本地开发时接口写为带域名的 https://www.example.com/api/getData(避免跨域),在 Charles 中配置「将 https://localhost/api/* 或本机某路径映射到 https://www.example.com/」,这样本地请求会实际转发到线上或测试环境;配合 Rewrite 注入登录 Cookie 后,可带登录态访问测试/线上接口。

4.4 Rewrite [11][15]

  • 按规则对请求或响应的 Header / Body 做字符串级替换(如键名、域名、Token)。与 Map Local/Remote 相比,不替换整份内容,只做局部修改;规则可基于 URL 匹配与通配符。
    典型用法 [15]:
    • 模拟登录态:在 Tools → Rewrite 中添加规则,对指定 URL 集合做 Add Header,将已登录环境下的 Cookie 填入请求头,即可在本地或无登录环境访问需登录的接口(Cookie 有过期时间,需定期更新)。
    • 解决响应乱码:部分响应使用 Brotli(br) 编码时,Charles 可能无法正确解码导致 Body 乱码;可在 Rewrite 中修改请求头 Accept-Encoding,去掉 br,让服务端返回 gzip/deflate,Charles 即可正常显示 JSON 等内容。
    • 其他:添加/修改请求参数、修改响应状态码或部分 JSON 字段等。

4.5 Throttling(带宽与弱网模拟)[4][12]

  • 作用:模拟慢速、高延迟、丢包等,使客户端「以为」处于弱网环境,用于验证加载、超时、错误提示与降级策略。
  • 启用:Proxy → Start Throttling(或快捷键);Proxy → Throttle Settings 配置。
  • 常见参数
    • Bandwidth:上行/下行带宽上限(如 256 kbps)。
    • Latency:往返延迟(如 500 ms)。
    • Reliability:丢包率。
    • MTU:最大传输单元。
  • 仅限部分 Host:在 Throttle Settings 中勾选「Only for selected hosts」并添加 Host,可只对指定接口限速,避免影响其他操作。

五、配置与使用场景

5.1 本机浏览器抓包(macOS / Windows)

  1. 启动 Charles,默认监听 8888。
  2. 系统代理:Charles 可自动设置系统 HTTP/HTTPS 代理为 127.0.0.1:8888(Proxy → macOS Proxy / Windows Proxy);关闭 Charles 时通常可恢复原设置。请求路径如下:
flowchart LR
    A[浏览器] -->|系统代理 127.0.0.1:8888| B[Charles]
    B --> C[目标服务器]
    C --> B
    B --> A
  1. HTTPS:按第三节安装并信任根证书,在 SSL Proxying Settings 中添加需解密的主机(如 *:443)。
  2. 浏览器访问任意 HTTP(S) 站点,在 Charles 的 Structure 或 Sequence 视图中查看会话。

5.2 仅抓部分域名或接口

  • SSL Proxying Settings:只添加需要解密的 Host,其他 HTTPS 不解密。
  • Proxy → Recording Settings:Include 中只填需要记录的 Host/Path,或 Exclude 排除无关域名,减少噪音。

5.3 Mock 与联调流程(概念)

1. 在 Map Local 中添加规则:例如 Host=api.example.com, Path=/v1/config → 本地文件 config.json
2. 客户端请求 https://api.example.com/v1/config → Charles 匹配规则 → 直接返回 config.json 内容
3. 或使用 Map Remote:将 api.example.com 映射到 api.test.com,请求被转发到测试环境
4. 或使用 Rewrite:将响应体中的 "env":"prod" 替换为 "env":"test"

5.4 弱网测试流程(概念)

1. Proxy → Throttle Settings,设置 Bandwidth / Latency / Reliability 等
2. 可选:Only for selected hosts,添加待测接口的 Host
3. Proxy → Start Throttling
4. 在 App 或页面中触发请求,观察加载时间、超时与错误处理
5. 测试结束后 Stop Throttling

六、各平台网络抓包配置 SOP

以下按 iOS、AOS(Android)、HOS(HarmonyOS)、WebAPP(浏览器/Web 端) 四类平台,给出 Charles 抓包的标准操作步骤(SOP),便于按平台查阅与排错。

6.1 通用前提与平台对照

项目 说明
网络 终端设备与运行 Charles 的电脑处于同一局域网(同一 Wi‑Fi 或同网段)。
端口 电脑防火墙放行 8888 入站,或临时关闭防火墙测试。
Charles 已启动并监听 8888;需抓 HTTPS 时在 Charles 内对目标 Host 启用 SSL Proxying(见 §3.3)。
平台 代理配置位置 证书安装方式 HTTPS 特别说明
iOS 设置 → Wi‑Fi → 当前网络 → 配置代理 浏览器打开 chls.pro/ssl → 安装描述文件 → 证书信任设置 需在「证书信任设置」中勾选 Charles
AOS (Android) 设置 → WLAN → 当前网络 → 代理 浏览器打开 chls.pro/ssl 安装;Android 7+ 自研 App 需 NSC 信任用户证书 见 §6.3
HOS (HarmonyOS) 设置 → WLAN → 当前网络 → 代理 同 AOS,浏览器 chls.pro/ssl;自研应用可能需网络安全配置 与 Android 类似,新系统可能需应用内代理/证书配置
WebAPP 系统代理或浏览器代理指向本机 127.0.0.1:8888 本机安装 Charles 根证书并信任(Help → Install Charles Root Certificate) 依赖系统/浏览器信任 Charles 根证书

6.2 iOS 抓包配置 SOP [8][13]

适用于 iPhone / iPad 真机iOS 模拟器

6.2.1 iOS 真机

步骤 操作 说明
1 电脑:Charles 已启动 → Help → SSL Proxying → Install Charles Root Certificate on a Mobile Device 弹出框内会显示代理地址(如 192.168.x.x:8888)与证书下载页 chls.pro/ssl,记下电脑 IP 与 8888 端口
2 手机:设置 → Wi‑Fi → 点击当前已连接网络右侧 (i) → 配置代理 → 选择 手动 服务器:填电脑 IP;端口:8888;保存
3 手机:用 Safari 打开 chls.pro/ssl 按提示下载并安装「Charles Proxy CA」描述文件,若提示需在设置中确认则前往「设置 → 已下载描述文件」安装
4 手机:设置 → 通用关于本机证书信任设置 找到 Charles Proxy CA,打开「完全信任」开关;未信任则 HTTPS 会报证书错误
5 Charles:Proxy → SSL Proxying Settings → 勾选 Enable,在列表中 Add 需解密的 Host(如 *:443api.xxx.com:443);或抓包时在会话列表右键目标 Host/请求 → SSL Proxying → Enable SSL Proxying 不在此列表的域名 HTTPS 仍为密文 [15][16]
6 在手机中打开要抓的 App 或 Safari,正常发起请求 Charles 的 Structure / Sequence 中应出现对应会话;HTTPS 需完成步骤 4、5 才能看到明文

注意:若 App 启用 ATS 且不信任用户安装的 CA,需在自研 App 的 Info.plist 中配置 ATS 例外或使用已信任 Charles 的调试包。

6.2.2 iOS 模拟器

步骤 操作 说明
1 关闭所有 iOS 模拟器实例 确保证书安装到当前模拟器运行时
2 Charles:Help → SSL Proxying → Install Charles Root Certificate in iOS Simulators 证书会安装到当前已安装的模拟器系统中
3 启动模拟器;在 macOS 上勾选 Proxy → macOS Proxy(或 Windows Proxy) 模拟器继承系统代理,流量走本机 Charles
4 在 Charles 的 SSL Proxying Settings 中为需抓取的 Host 启用 SSL Proxying 同真机步骤 5
5 在模拟器内打开 Safari 或目标 App 发起请求 抓包与真机一致

6.3 AOS(Android)抓包配置 SOP [8]

适用于 Android 手机 / 平板(含 Android 7+ 自研 App 的证书信任配置)。

步骤 操作 说明
1 电脑:Charles 已启动;Help → SSL Proxying → Install Charles Root Certificate on a Mobile Device,记下 电脑 IP8888 同 iOS
2 手机:设置 → WLAN(或 网络和互联网 → Wi‑Fi)→ 长按当前连接网络 → 修改网络 / 高级选项代理手动 主机名:电脑 IP;端口:8888;保存
3 手机:用系统浏览器打开 **chls.pro/ssl**,下载并安装 Charles 根证书 按系统提示完成「安装到凭据存储」等步骤
4 Android 7.0+ (N):系统默认不信任用户安装的 CA。若抓的是自研 App,需在工程中增加 Network Security Configuration,在 debug 下信任用户证书: 仅影响调试包,正式包可不引用或仅 debug 引用

res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <debug-overrides>
    <trust-anchors>
      <certificates src="user" />
      <certificates src="system" />
    </trust-anchors>
  </debug-overrides>
</network-security-config>

AndroidManifest.xml<application> 中增加:

android:networkSecurityConfig="@xml/network_security_config"

| 5 | Charles:Proxy → SSL Proxying Settings → 为需抓取的 Host 添加 *:443 或具体域名:443;或右键会话中的 Host → Enable SSL Proxying | 同 iOS | | 6 | 在手机中打开目标 App 或浏览器发起请求 | 若仍报证书错误,检查步骤 3、4 是否完成;第三方 App 无法改 NSC 时可能无法抓其 HTTPS |


6.4 HOS(HarmonyOS)抓包配置 SOP

适用于 华为 / 荣耀等搭载 HarmonyOS 的设备;代理与证书流程与 Android 类似,部分机型路径可能为「设置 → WLAN → 当前网络 → 代理」 [17]。

步骤 操作 说明
1 电脑:Charles 已启动;记下电脑 IP 与 8888 端口(Help → Install Charles Root Certificate on a Mobile Device 可查看) 同 iOS / AOS
2 手机:设置 → WLAN → 当前连接网络 → 代理(或 高级 / 更多)→ 选 手动 主机/服务器:电脑 IP;端口:8888
3 手机:浏览器打开 **chls.pro/ssl**,下载并安装 Charles 根证书 按系统提示安装到「凭据存储」等;部分 HarmonyOS 版本需在「设置 → 安全 → 加密与凭据」中确认用户证书已安装
4 自研 HarmonyOS 应用:若抓包时 HTTPS 仍报证书错误,需在应用内配置信任用户证书(视 SDK/API 版本而定;API 10+ 支持 usingProxycaPath 等)或使用系统提供的网络安全配置能力 与 AOS 的 NSC 思路类似,具体以华为开发者文档为准
5 Charles:SSL Proxying Settings 中为需解密的 Host 添加条目,或右键会话 → Enable SSL Proxying 同其他平台
6 在设备上打开目标 App 或浏览器发起请求 确认代理与证书均生效

6.5 WebAPP(浏览器 / Web 端)抓包配置 SOP

适用于 桌面浏览器(Chrome / Edge / Safari / Firefox) 以及 移动端浏览器 访问的 Web 页面;核心是「本机或当前设备使用 Charles 作为代理 + 信任 Charles 根证书」。

6.5.1 桌面端(本机浏览器)

步骤 操作 说明
1 电脑:启动 Charles,确认监听 8888 默认 Proxy → macOS Proxy / Windows Proxy 会设置系统代理为 127.0.0.1:8888
2 本机证书:Help → SSL Proxying → Install Charles Root Certificate;安装后在系统钥匙串(macOS)或证书管理(Windows)中将 Charles 根证书设为受信任的根 CA 否则浏览器访问 HTTPS 会报不安全
3 Charles:Proxy → SSL Proxying Settings → Enable SSL Proxying,Add 需解密的 Host(如 *:443)或抓包时右键目标 Host → Enable SSL Proxying 同移动端
4 在浏览器中访问目标 Web 或 WebAPP 流量经 Charles,Structure / Sequence 中可查看与修改请求/响应

仅对当前浏览器走代理(不改系统代理)时:安装浏览器代理扩展(如 SwitchyOmega),将 HTTP/HTTPS 代理指向 127.0.0.1:8888,并确保该浏览器信任本机已安装的 Charles 根证书。

6.5.2 移动端浏览器(手机/平板访问 Web)

  • iOS:按 §6.2.1 配置代理与证书后,在 Safari 或其他浏览器中访问的 H5/Web 请求会经 Charles。
  • AOS / HOS:按 §6.3 / §6.4 配置代理与证书后,在系统浏览器或 Chrome 等中访问的页面请求会经 Charles;若仅浏览器抓包、不涉及 App,通常只需系统代理 + 安装并信任 Charles 证书即可。

6.6 流程小结

flowchart TD
    A[设备与电脑同网 + Charles 已启动] --> B[设备端配置代理: 电脑IP:8888]
    B --> C[设备/本机安装并信任 Charles 根证书]
    C --> D[Charles 内对目标 Host 启用 SSL Proxying]
    D --> E[发起请求]
    E --> F[Charles 记录并可选解密/修改]

七、最佳实践与注意事项

  • 仅调试环境使用:Charles 根证书权限极大,只在开发/测试机器上安装并信任,用毕可关闭系统代理或停用 Charles。
  • 最小化 SSL Proxying 范围:只对需要查看的 Host 开启 SSL Proxying,避免不必要的解密与隐私风险。
  • 弱网:优先使用「Only for selected hosts」限速,减少对整机其他请求的影响。
  • 敏感数据:会话中可能包含 Token、Cookie、账号信息,保存 .chls/.chlz 或截图时注意脱敏与保管。
  • 合规:仅对自有或已授权的应用与接口抓包,勿用于未授权的第三方服务或用户数据。

延伸阅读(掘金系列)

以下文章从 iOS/Android 抓包、前端联调、HTTPS 原理与 Charles 功能教程等角度做了补充说明,可按需查阅。

序号 标题 链接 内容概要
01 iOS Charles 抓包 掘金 iOS 端 Charles 抓包配置与证书安装
02 Android 端 Charles 抓包 掘金 Android 代理与证书配置、高版本信任用户 CA
03 Charles 前端应用 掘金 Rewrite 模拟登录 Cookie、Map Remote/Local、去掉 br 解决乱码
04 史上最强 Charles 抓包 掘金 Charles 功能与抓包场景综合介绍
05 Charles 从入门到精通 掘金 入门到进阶功能与操作教程
06 Charles 功能介绍和使用教程 掘金 功能说明与使用步骤
07 HTTPS 与 Charles 掘金 HTTP/HTTPS 安全、TLS 握手与 Charles 中间人原理
08 为了学会 Charles,我拼命了 掘金 简介、iPhone/Chrome 配置、Repeat/Compose/Rewrite/Map 简介
09 最明白的 Charles 教程(一) 掘金 Charles 基础与界面说明
10 最明白的 Charles 教程(二) 掘金 抓包与过滤操作
11 最明白的 Charles 教程(三) 掘金 进阶功能与场景
12 最明白的 Charles 教程(四) 掘金 综合实战与排错

参考文献

[1] Charles Proxy. Overview / Features. www.charlesproxy.com/overview/fe…
[2] Wikipedia. Charles (software). en.wikipedia.org/wiki/Charle…
[3] Charles Proxy. Documentation – Welcome. www.charlesproxy.com/documentati…
[4] XK72. Charles Proxy. www.charlesproxy.com/
[5] Charles Proxy. Version History. www.charlesproxy.com/documentati…
[6] Charles Proxy. SSL Proxying. www.charlesproxy.com/documentati…
[7] Charles Proxy. SSL Certificates. www.charlesproxy.com/documentati…
[8] Charles Proxy. SSL Certificates – iOS / Android / Java / Chrome. 官方文档 Install Charles Root Certificate 各平台说明.
[9] Charles Proxy. Breakpoints Tool. www.charlesproxy.com/documentati…
[10] Charles Proxy. Map Remote. www.charlesproxy.com/documentati…
[11] Charles Proxy. Map Local;Stack Overflow. Charles Proxy Rewrite vs Map Local.
[12] Charles Proxy. Throttling;Donny Wals. Throttle network speeds for a specific host in Charles;Mobot. Charles Proxy for Network Throttling.
[13] Detroit Labs. A Guide To Charles Proxy for Mobile Development;Medium. Setting Up Charles Proxy with iOS Devices and Emulators;Charles. iOS Getting Started.
[14] CSDN / 技术博客. 使用 Charles 抓取 HTTPS 数据及原理分析(中间人、证书链).
[15] 掘金. charles 前端应用. juejin.cn/post/684490… Remote、Map Local 实战).
[16] 掘金. HTTPS 与 Charles为了学会 Charles,我拼命了. juejin.cn/post/684490… 握手与 Charles 证书替换、按地址启用 SSL Proxying).
[17] HarmonyOS 网络抓包与 Charles 代理证书配置(代理 + chls.pro/ssl + 证书信任);华为开发者文档 @ohos.net.http(API 10+ 代理与证书参数).

其它参考

❌
❌