iOS 开发中的 JSPatch

由浅入深,从原理到源码,全面解析 JSPatch 的设计思想、实现机制与关键技术细节


一、JSPatch 是什么?

1.1 定位与目标

JSPatch 是一个 iOS 动态更新框架,由 bang590 开源。其核心能力是:在 App 内引入极小的引擎后,用 JavaScript 调用任意 Objective-C 接口,并可替换原生方法实现,从而实现:

  • 热修复:下发 JS 脚本修复线上 Bug,无需发版、无需审核
  • 动态能力:为项目动态添加模块或替换原生逻辑

1.2 与 Apple 审核政策的关系

⚠️ 重要说明:Apple 开发者协议 3.3.2 明确禁止「下载、安装或执行未包含在应用中的可执行代码」。JSPatch 通过 JS 间接调用 Runtime、替换方法 IMP,被认为绕过审核、改变应用行为,已被 Apple 明确禁止上架使用。
本文仅从原理分析、技术学习与架构设计角度展开,不鼓励在正式上架 App 中接入。

1.3 技术栈位置

1
2
3
4
5
6
7
┌─────────────────────────────────────────────────────────────┐
│ JSPatch 技术栈层次 │
├─────────────────────────────────────────────────────────────┤
│ JS 脚本层 │ require / defineClass / 业务补丁逻辑 │
│ 桥接层 │ __c() 元函数、JPEngine、JPBoxing、类型转换 │
│ 系统层 │ JavaScriptCore、Objective-C Runtime │
└─────────────────────────────────────────────────────────────┘

二、基础原理:为什么 JS 能调用 OC?

2.1 根本原因:Objective-C 的动态性

JSPatch 能通过 JS 调用和改写 OC 方法的根本原因是:Objective-C 是动态语言。在 OC 中,类与方法的查找、调用、替换都在运行时通过 Objective-C Runtime 完成,而不是在编译期写死。

因此可以:

  • 通过类名/方法名字符串反射得到类和方法
  • 替换某个类的方法实现(IMP)
  • 动态注册新类、添加方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反射调用
Class class = NSClassFromString("UIViewController");
id vc = [[class alloc] init];
SEL sel = NSSelectorFromString("viewDidLoad");
[vc performSelector:sel];

// 替换方法实现
static void newViewDidLoad(id slf, SEL sel) {}
class_replaceMethod(class, @selector(viewDidLoad), (IMP)newViewDidLoad, "v@:");

// 动态注册类
Class cls = objc_allocateClassPair(superCls, "JPObject", 0);
class_addMethod(cls, selector, implement, typedesc);
objc_registerClassPair(cls);

结论:JSPatch 的基本原理就是——JS 把类名、方法名、参数等以字符串/结构化数据传给 OC,OC 通过 Runtime 接口完成「查找类 → 查找方法 → 调用/替换」

2.2 整体数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
JS: require('UIView').alloc().init()


__c() 元函数:解析调用链,得到 类名/方法名/参数/调用者


OC 桥接层:JPEngine 接收参数,类型转换,构造 NSInvocation


Runtime:objc_msgSend / NSInvocation 调用,返回结果


结果经 JPBoxing/包装后回传 JS,继续链式调用或使用

三、方法调用:从 JS 到 OC 的完整链路

下面以一段典型代码为例,拆解「JS 调用 OC」的五个环节:

1
2
3
4
require('UIView')
var view = UIView.alloc().init()
view.setBackgroundColor(require('UIColor').grayColor())
view.setAlpha(0.5)

涉及:require 机制 → JS 接口设计 → 消息传递 → 对象持有/转换 → 类型转换

3.1 require:在 JS 中「引入」OC 类

require('UIView') 的作用是:在 JS 全局作用域 上创建一个同名变量,指向一个表示 OC 类的 JS 对象。该对象用 __clsName 保存类名,并标记「这是 OC 类」。

1
2
3
4
5
6
7
8
var _require = function(clsName) {
if (!global[clsName]) {
global[clsName] = {
__clsName: clsName
}
}
return global[clsName]
}

于是 require('UIView') 之后,全局有:

1
UIView === { __clsName: "UIView" }

后续 UIView.alloc() 等调用,都基于这个对象进行。

3.2 JS 接口设计:如何让 UIView.alloc() 不报错?

3.2.1 问题:JS 没有「未定义方法」的转发机制

在 JS 中,若对象没有 alloc 属性,调用 UIView.alloc() 会直接抛错。不像 OC/Lua/Ruby 有「方法缺失 → 转发」的机制。

早期思路是:在 require 时向 OC 要该类(及父类)的全部方法名,在 JS 对象上为每个方法名挂一个函数,函数内部再调 OC。这样 JS 上就有 allocinit 等「真实存在」的属性。

问题在于:一个类就有几百个方法,还要沿继承链汇总,内存暴涨,且要维护 OC→JS 的方法列表同步,难以接受。

3.2.2 方案:正则替换 + __c() 元函数(关键优化)

不改变「JS 语法」,但在 OC 执行 JS 脚本之前,用正则把所有方法调用统一替换成对 __c() 的调用,从而在 JS 侧实现「任意方法名 → 统一入口」的转发:

1
2
3
4
5
// 替换前
UIView.alloc().init()

// 替换后(示意)
UIView.__c('alloc')().__c('init')()

再给 JS 的 Object.prototype 增加 __c 方法,使任意对象(类对象、实例对象)都能走到同一套逻辑:

1
2
3
4
5
6
7
8
9
10
Object.defineProperty(Object.prototype, '__c', {
value: function(methodName) {
if (!this.__obj && !this.__clsName) return this[methodName].bind(this);
var self = this;
return function() {
var args = Array.prototype.slice.call(arguments);
return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper);
};
}
});
  • 若调用者是 OC 类(有 __clsName):把类名、方法名、参数传给 OC,由 OC 通过 Runtime 调类方法。
  • 若调用者是 OC 实例(有 __obj):把对象指针、方法名、参数传给 OC,调实例方法。

这样不需要在 JS 上枚举任何 OC 方法,内存占用大幅下降,是 JSPatch 中最重要的一步优化。

3.3 消息传递:JS 与 OC 如何互传数据?

OC 端在启动 JSPatch 时会创建 JavaScriptCoreJSContext,并在 context 上挂载 OC 实现的 Block/方法。JS 调这些方法时,参数和返回值会由 JavaScriptCore 自动在 JS 与 OC 类型之间转换(如 NSArray ↔ Array、NSString ↔ string、NSNumber ↔ number 等)。

因此,_methodFunc 只需把「类名 / 对象 / 方法名 / 参数列表」通过 context 上暴露给 JS 的函数传给 OC;OC 用 Runtime 完成调用后,再把返回值通过同一机制回传给 JS。

3.4 对象持有与转换:OC 对象在 JS 侧的表示

  • 类对象:在 JS 里就是 { __clsName: "UIView" },不涉及 OC 对象生命周期。
  • 实例对象:OC 的 id 若直接以指针形式交给 JS,JS 无法「理解」这个指针,但可以再把它传回 OC。
    为了在 JS 里识别「这是一个 OC 实例」,OC 在把对象返回给 JS 前会做一层包装,例如:
1
2
3
static NSDictionary *_wrapObj(id obj) {
return @{@"__obj": obj};
}

在 JS 侧就变成:

1
{ __obj: [OC 对象指针] }

这样在 __c() 里可以通过「是否有 __obj」判断调用者是 OC 实例,并取出 __obj 与方法名、参数一起传回 OC,完成实例方法调用。

对象生命周期:当 JS 侧有变量引用该包装对象时,OC 对象引用计数 +1;JS 侧释放后 -1,由 OC/JS 共同管理。

3.5 类型转换:参数与返回值的 OC 类型

OC 侧实际调用是通过 NSInvocation 完成的。要正确调用并拿到返回值,需要:

  1. 根据 OC 方法的 NSMethodSignature 得到每个参数的类型,把 JS 传过来的对象(如 NSNumber、NSDictionary)转成对应类型(如 intfloatCGRect 等)再传入。
  2. 根据返回值类型NSInvocation 取出返回值,再包装成 JS 可用的对象(或 JPBoxing 等)传回 JS。

例如 view.setAlpha(0.5):JS 传的是 NSNumber,OC 根据 setAlpha: 的签名得知参数是 float,于是把 NSNumber 转为 float 再调用。


四、方法替换(热修复的核心)

4.1 基础思路:替换 IMP

OC 的类方法列表里,每个方法对应一个 Method(SEL + 类型编码 + IMP)。通过 Runtime 可以:

  • 保留原 IMP:给类新增一个方法(如 ORIGviewDidLoad),其 IMP 指向原来的实现。
  • 替换原方法的 IMP:把 viewDidLoad 的 IMP 改成自定义函数,在自定义函数里调 JS 传入的实现,并在需要时再调 ORIGviewDidLoad

以替换 UIViewControllerviewDidLoad 为例(无参数情况):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void viewDidLoadIMP(id slf, SEL sel) {
// 从 JS 侧取到的函数并调用
JSValue *jsFunction = ...;
[jsFunction callWithArguments:nil];
}

Class cls = NSClassFromString(@"UIViewController");
SEL selector = @selector(viewDidLoad);
Method method = class_getInstanceMethod(cls, selector);
IMP imp = method_getImplementation(method);
char *typeDescription = (char *)method_getTypeEncoding(method);

// 原实现保留到 ORIGviewDidLoad
class_addMethod(cls, @selector(ORIGviewDidLoad), imp, typeDescription);
// viewDidLoad 指向新实现
class_replaceMethod(cls, selector, (IMP)viewDidLoadIMP, typeDescription);

这样,所有对 viewDidLoad 的调用都会走到 viewDidLoadIMP,进而执行 JS 逻辑;JS 里可通过 self.ORIGviewDidLoad() 调回原实现。

4.2 有参数时的问题:通用 IMP 如何拿到所有参数?

需要一个通用 IMP,能对「任意方法、任意参数个数与类型」都拿到参数并传给 JS。这里就出现了 32 位与 64 位 的差异。

4.2.1 32 位:va_list 取参(已不可用于 64 位)

最初用可变参数实现:

1
2
3
4
5
6
7
8
static void commonIMP(id slf, ...) {
va_list args;
va_start(args, slf);
NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:selector];
// 根据 methodSignature 的 typeEncoding 用 va_arg 逐个取出参数,组成 NSArray
// 再 [jsFunction callWithArguments:list];
va_end(args);
}

arm64 上,va_list 的 ABI 与 32 位不同,无法用上述方式正确取参,会 crash,因此 64 位必须换方案。

4.2.2 64 位:利用消息转发与 NSInvocation

OC 在「找不到方法实现」时会走消息转发链路,最终会到 -forwardInvocation:,此时会拿到一个 NSInvocation,其中已经包含了本次调用的 selector、参数类型、参数值、返回值类型。因此可以:

  1. 把要替换的方法的 IMP 改为 _objc_msgForward,这样一旦调用该方法,就会直接进入转发流程,最终进入 -forwardInvocation:
  2. 重写该类的 -forwardInvocation::在实现里判断「若是我们替换的方法」,则从 NSInvocation 里解出所有参数,调用我们新增的 _JPxxx 方法(该方法内部再调 JS);否则调原来的 ORIGforwardInvocation:,保证其他转发逻辑不受影响。
  3. 新增 ORIGviewWillAppear:_JPviewWillAppear::前者指向原 IMP,后者是「从 NSInvocation 取参并调 JS」的桥接实现。

这样在 64 位上就能通用地拿到任意方法的参数并交给 JS,无需依赖 va_list

4.3 返回值是 struct 时的注意点:_objc_msgForward_stret

部分架构下,当方法返回值是「较大的 struct」时,用的是 objc_msgSend_stret 的调用约定(返回值通过内存指针传回),若仍用 _objc_msgForward 会出错。此时需要改用 _objc_msgForward_stret
是否「special struct return」没有公开 API,JSPatch 通过 NSMethodSignaturedebugDescription 是否包含 "is special struct return? YES" 来判断,在非 arm64 上对这类方法使用 _objc_msgForward_stret

4.4 新增方法、Protocol、Property

  • 新增方法:OC 侧通过 class_addMethod 动态添加,参数与返回值类型先统一为 id(因为新增方法主要给 JS 用)。若类声明实现了某 Protocol,则从 Protocol 的方法描述里取类型信息,保证与 Protocol 一致(如 tableView:sectionForSectionIndexTitle:atIndex: 等)。
  • Property:已有属性直接通过 getter/setter 方法在 JS 里按普通方法调用即可。动态新增成员则用 objc_setAssociatedObject / objc_getAssociatedObject 模拟(因为 class_addIvar 只能在类注册前使用,无法给已有类加 ivar)。

4.5 self 与 super

  • self:在 defineClass 的实例方法执行前,把「当前实例」写入一个 JS 全局变量(如 self),方法执行完后清空,这样在 JS 里写的 self 就指向当前 OC 实例。
  • superself.super()__c() 里做特殊处理,返回一个带 __isSuper: 1 标记的对象。OC 侧若发现是 super 调用,则取父类该方法的 IMP,为当前类临时加一个方法(如 SUPER_viewDidLoad)指向该 IMP,再转调该方法,从而模拟 OC 的 super 语义。

五、扩展能力:Struct 与 C 函数

5.1 Struct 支持

JS 与 OC 之间不能直接传 C struct,需要序列化/反序列化。JSPatch 的做法是:

  • 内置:对常用类型如 NSRange、CGRect、CGSize、CGPoint 等做专门转换。
  • 可扩展:在 JS 里通过 defineStruct 声明 struct 的「名字、类型串、字段名」,OC 根据类型串按内存布局逐字段读写,再封装成 NSDictionary 与 JS 互传。这样新增 struct 不需要改 OC 代码,只需在 JS 声明布局即可(依赖当前 ABI 下 struct 内存布局稳定)。

5.2 C 函数支持

C 函数无法通过 Runtime 反射调用,因此采用「在 JSContext 上挂 OC Block 包装」的方式:在 context 上暴露与 C 函数同名的 JS 可调接口,内部转调 C 函数,并做好指针等类型转换。为避免引擎体积和启动时一次性注册过多 C 函数,设计了 JPExtension 机制:通过 +main:(JSContext *)formatJSToOC / formatOCToJS 等接口,让扩展按需注册 C 函数,JS 端通过 require('JPEngine').addExtensions(['JPMemory']) 等方式按需加载。


六、关键实现细节

6.1 JPBoxing:避免可变集合被 JavaScriptCore 自动转换

NSMutableArray / NSMutableDictionary / NSMutableString 从 OC 返回给 JS 时,JavaScriptCore 会强制转成 JS 的 Array / Object / String,导致「回到 OC 时无法再调原生的可变方法」。
解决办法:不直接返回这些对象,而是用 JPBoxing 包装一层(把 OC 对象放在 Boxing 的 property 里),返回 Boxing 实例给 JS。JS 再把这个 Boxing 传回 OC 时,OC 从 Boxing 里取出原对象,即可继续调可变方法。同时,为规则统一,NSArray/NSDictionary/NSString 也采用「默认以指针形式在 JS 侧存在,需要再调 .toJS() 转成纯 JS 类型」的策略。

6.2 nil / NSNull 的区分与链式调用

  • nil 与 NSNull:JS 的 null/undefined 传到 OC 时统一变成 nil;若需要明确表示 NSNull,在 JS 里使用全局变量 nsnull,OC 侧据此区分。
  • 链式调用:OC 里 [[obj returnNil] doSomething] 是安全的(对 nil 发消息不崩溃),但 JS 里 null 没有方法,无法写 require("JPObject").returnNil().hash()。JSPatch 用 false 表示 OC 返回的 nil:在 JS 里 false 也是对象可调方法,同时 if (!obj) 仍可用来判断「是否为 nil」。这样链式调用在 JS 侧也能安全进行。唯一的小坑是:若 OC 参数类型是 NSNumber* 而 JS 传 false,OC 会得到 nil 而非 NSNumber,需要业务侧注意。

6.3 下划线 _ 的歧义

OC 方法名用 : 分隔参数,JSPatch 在 JS 里用 单个下划线 _ 连接多参数方法名,例如:

  • setObject:forKey:setObject_forKey_

若 OC 方法名里本身带下划线(如 set_object:forKey:),就会与「参数分隔符」混淆。约定:OC 方法名中的字面下划线在 JS 里用双下划线 __ 表示,例如 set__object_forKey_。这样 OC 的 _ 与 JSPatch 的「参数分隔」可以区分开。

6.4 内存与 ARC

  • 从 NSInvocation 取参数/返回值:若用 id arg; [invocation getArgument:&arg atIndex:i];,ARC 会在退出作用域时对 arg 做 release,但 getArgument:atIndex: 并不会自动做 retain,容易造成 double release。解决方式是用 __unsafe_unretained__weak,或通过 void * + __bridge 明确所有权。
  • alloc / new / copy / mutableCopy 返回值:按 OC 约定,这些方法返回的对象调用方持有,retainCount 已 +1。从 NSInvocation 取返回值时,若 selector 是这类方法,需用 __bridge_transfer 把所有权交给 ARC,否则会泄漏。

七、核心模块与源码结构

模块 / 文件 职责
JPEngine 初始化 JSContext、注入 require/defineClass 等全局方法,执行脚本入口;提供 OC 侧与 JS 的桥接入口(如接收类名、方法名、参数并调用 Runtime)。
JPBoxing 包装 OC 对象(含 NSMutableArray/Dictionary/String、C 指针、Class 等),避免被 JavaScriptCore 自动转换或无法在 JS 侧标识类型。
JPLoader 负责从网络/本地加载、解密、执行 JS 补丁;版本管理、条件执行等。
JPExtension (JPExtension) 扩展接口:暴露 JSContext 与类型转换方法,供 C 函数、自定义 Struct 等扩展按需注册。
JS 脚本预处理 正则替换方法调用为 __c('methodName') 形式,以及 defineClass 中 self/super 的注入等。

源码阅读顺序建议:JPEngine 初始化与注入 → JS 中的 __c_methodFunc → OC 侧根据类名/对象/方法名调用 Runtime(含 NSInvocation)→ 方法替换(forwardInvocation + ORIG/JP 前缀)→ JPBoxing 与类型转换

关键调用链(OC 侧)

1
2
3
4
5
6
7
JS 调用 UIView.alloc().init()
→ _methodFunc 被调用,参数 [className="UIView", methodName="alloc", args=[]]
→ 通过 JSContext 注册的桥接函数进入 OC(如 callSelector:selectorName:arguments:...)
→ JPEngine 内根据 className 取 Class,根据 selectorName 取 SEL,组装 NSInvocation
→ 设置 target、arguments,invoke
→ 返回值经 formatOCToJS / JPBoxing 包装后回传 JS
→ JS 侧得到包装对象 { __obj: 实例 },再调用 .__c('init')() 继续链式调用

方法替换调用链(64 位)

1
2
3
4
5
6
OC 代码调用 [vc viewWillAppear:YES]
→ viewWillAppear: 的 IMP 已被改为 _objc_msgForward
→ 进入消息转发,最终到 forwardInvocation:
→ 自定义 forwardInvocation 实现中:从 NSInvocation 解出参数,调 _JPviewWillAppear:(BOOL)
→ _JPviewWillAppear: 内部把参数打包,通过 JSContext 调 JS 里 defineClass 定义的 viewWillAppear
→ 若 JS 里调 self.ORIGviewWillAppear(),则 OC 再调 ORIGviewWillAppear:,即原实现

八、设计思想总结

  1. 用字符串与 Runtime 打通 JS 与 OC
    不依赖预编译或代码生成,完全依赖「类名/方法名 + Runtime 反射 + NSInvocation」,使任意 OC 接口都能被 JS 调用和替换。

  2. 用「正则替换 + 元函数」规避 JS 语言限制
    JS 没有「未定义方法转发」,通过脚本预处理把方法调用统一成 __c('methodName'),用一层元函数模拟「消息转发」,避免在 JS 侧枚举海量方法,兼顾内存与实现复杂度。

  3. 区分 32/64 位与返回值类型
    32 位用 va_list 取参,64 位用 forwardInvocation + NSInvocation;对 special struct return 用 _objc_msgForward_stret,体现对 ABI 与底层调用约定的细致处理。

  4. 用包装类型统一「跨引擎对象」
    JPBoxing、__obj/__clsName 等,把「OC 对象/类在 JS 侧的句柄」标准化,便于在 __c() 中统一分支(类方法 / 实例方法 / super)。

  5. 扩展点清晰
    Struct 用类型串 + 键名在 JS 侧声明;C 函数通过 JPExtension 按需注册,既控制体积又保持能力可扩展。


九、合规性与替代方案

维度 说明
Apple 态度 3.3.2 禁止未包含在应用内的可执行代码的下载与执行;JSPatch 通过 JS 调 Runtime 替换方法,被视为违规。
现状 作者已不再维护,新上架 App 不建议使用。
替代思路 热修:RN/Weex/Flutter 等脚本层 OTA;紧急修复:服务端降级、开关、兜底逻辑;架构上减少对「运行时替换原生实现」的依赖。

十、小结

JSPatch 通过 Objective-C Runtime + JavaScriptCore,用「类名/方法名 + 参数」在 JS 与 OC 之间架起桥梁,并用 正则替换 + __c() 元函数 在 JS 侧实现无需枚举方法的调用转发;方法替换在 64 位上依赖 消息转发与 NSInvocation 通用地获取参数。再配合 JPBoxing、nil 用 false 表示、Struct/C 函数扩展 等细节,在技术上演进出一套完整的热修方案。理解其原理有助于掌握 Runtime、消息转发、JS–Native 桥接与 ABI 等知识;在实际项目中则应优先采用符合当前审核政策的热更新与架构方案。


本文基于 JSPatch 官方 Wiki、作者博客及公开技术资料整理,仅用于学习与原理分析。

iOS 开发中的性能优化

由浅入深,从基本概念到源码解析,再到实际项目应用,带你全面掌握 iOS 性能优化之道


一、什么是性能优化?

1.1 为什么性能很重要?

在移动端,性能直接关系到用户体验:

指标 用户感知 业务影响
启动速度 3 秒内无法进入应用,约 77% 用户会放弃 流失、留存下降
界面卡顿 掉帧、滑动不跟手 评价差、卸载
内存占用 应用被系统强杀、白屏 体验中断、投诉
耗电发热 续航变短、设备发烫 用户反感

苹果对 App Store 的审核和推荐也会考虑应用质量,性能是重要维度之一。

1.2 性能优化的核心目标

  • :启动快、响应快、界面流畅
  • :省内存、省电、省流量
  • :不崩溃、不卡死、不白屏

1.3 性能优化的「黄金法则」

先测量,再优化;先瓶颈,再细节。

盲目优化往往事倍功半。正确的做法是:用工具定位瓶颈,再针对性地优化。


二、性能指标与测量工具

2.1 关键指标

指标 说明 理想值
FPS 帧率,60fps 为流畅 ≥ 55fps
主线程耗时 单次任务在主线程的耗时 < 16ms(一帧)
启动时间 冷启动/热启动到首屏可交互 冷启动 < 2s
内存占用 常驻内存、峰值内存 视业务而定,避免持续增长
CPU 占用 主线程 CPU 占比 空闲时尽量低

2.2 官方工具:Instruments

Instruments 是 Xcode 自带的性能分析工具套件:

  • Time Profiler:CPU 耗时分析,定位主线程卡顿
  • Allocations:内存分配追踪
  • Leaks:内存泄漏检测
  • Core Animation:离屏渲染、图层混合检测
  • Energy Log:耗电分析
  • Network:网络请求分析

2.3 第三方工具与库

工具 用途 特点
YYFPSLabel 实时 FPS 显示 开发阶段监控
MLeaksFinder 内存泄漏检测 无侵入、自动化
Matrix(微信) 综合性能监控 线上 APM
DoraemonKit 开发调试面板 多维度自检

2.4 简单 FPS 监控实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 基于 CADisplayLink 的 FPS 监控
class FPSMonitor {
private var displayLink: CADisplayLink?
private var lastTime: CFTimeInterval = 0
private var count: Int = 0
var fpsUpdate: ((Int) -> Void)?

func start() {
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .common)
}

@objc private func tick(_ link: CADisplayLink) {
if lastTime == 0 {
lastTime = link.timestamp
return
}
count += 1
let delta = link.timestamp - lastTime
if delta >= 1.0 {
let fps = Int(round(Double(count) / delta))
fpsUpdate?(fps)
count = 0
lastTime = link.timestamp
}
}

func stop() {
displayLink?.invalidate()
displayLink = nil
}
}

三、UI 与渲染优化

3.1 离屏渲染(Offscreen Rendering)

离屏渲染 是指 GPU 在当前屏幕缓冲区之外新开缓冲区进行渲染,再合成到主缓冲区的过程。额外的缓冲区和上下文切换会带来性能开销。

常见触发离屏渲染的属性:

属性 说明
cornerRadius + masksToBounds 圆角裁剪
shadow(阴影) 需要额外 Pass 计算
mask(遮罩) 蒙版合成
group opacity 组透明度
edge antialiasing 抗锯齿

优化方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 容易触发离屏渲染
imageView.layer.cornerRadius = 10
imageView.layer.masksToBounds = true

// ✅ 方案一:只对需要圆角的内容做裁剪,避免整层
imageView.layer.cornerRadius = 10
imageView.layer.masksToBounds = true
imageView.clipsToBounds = true // 对 UIImageView 而言,用 clipsToBounds 配合 contentMode

// ✅ 方案二:用贝塞尔路径 + CAShapeLayer 做圆角(iOS 9+ 可考虑)
let path = UIBezierPath(roundedRect: bounds, cornerRadius: 10)
let mask = CAShapeLayer()
mask.path = path.cgPath
layer.mask = mask // 仍可能离屏,需实测

// ✅ 方案三:直接用圆角图片(切图或 Core Graphics 绘制)
// 在子线程绘制圆角图片,主线程只做 display

阴影优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 阴影 + 裁剪容易离屏
view.layer.shadowOpacity = 0.5
view.layer.cornerRadius = 10
view.layer.masksToBounds = true // 与 shadow 冲突

// ✅ 分到两个 layer:容器负责阴影,子 layer 负责圆角
let containerView = UIView()
containerView.layer.shadowOpacity = 0.5
containerView.layer.shadowRadius = 4
containerView.layer.shadowOffset = .zero

let contentView = UIView()
contentView.layer.cornerRadius = 10
contentView.layer.masksToBounds = true
contentView.frame = containerView.bounds
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
containerView.addSubview(contentView)

3.2 图层混合(Layer Blending)

当多个图层叠在一起且存在透明像素时,GPU 需要进行混合计算。减少透明区域和图层数量可以降低开销。

优化建议:

  • 给不透明的视图设置 layer.opaque = true(或 isOpaque = true
  • 避免不必要的半透明叠加
  • 减少视图层级
1
2
3
// 已知不透明时
view.layer.opaque = true
view.backgroundColor = .white // 明确不透明色

3.3 TableView / CollectionView 优化

列表是 App 中最常见的性能瓶颈场景。

核心思路:

  1. Cell 复用:使用 dequeueReusableCell,避免重复创建
  2. 减少主线程工作:图片解码、复杂计算放到子线程
  3. 按需加载:快速滑动时减少或暂停非可见 Cell 的加载
  4. 高度缓存UITableViewAutomaticDimension 会反复计算,可缓存高度

示例:Cell 配置优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ❌ 在主线程做重活
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MyCell
let model = dataSource[indexPath.row]
cell.imageView?.image = UIImage(contentsOfFile: model.imagePath) // 同步读盘 + 解码
cell.label.text = heavyCompute(model) // 复杂计算
return cell
}

// ✅ 异步加载图片 + 计算放子线程
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MyCell
let model = dataSource[indexPath.row]
cell.tag = indexPath.row
cell.label.text = nil
cell.imageView?.image = nil

DispatchQueue.global().async {
let image = self.loadImage(path: model.imagePath)
let text = self.heavyCompute(model)
DispatchQueue.main.async {
if cell.tag == indexPath.row {
cell.imageView?.image = image
cell.label.text = text
}
}
}
return cell
}

预加载与 RunLoop 空闲优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 利用 RunLoop 在空闲时预加载
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = ...
preloadIfNeeded(at: indexPath)
return cell
}

private func preloadIfNeeded(at indexPath: IndexPath) {
let maxIndex = min(indexPath.row + 5, dataSource.count - 1)
for i in (indexPath.row + 1)...maxIndex {
if !imageCache.isCached(for: dataSource[i].imagePath) {
DispatchQueue.global().async {
_ = self.loadImage(path: self.dataSource[i].imagePath)
}
}
}
}

3.4 图片加载与解码优化

图片解码是 CPU 密集型操作,大图在主线程解码会导致卡顿。

1
2
3
4
5
6
7
8
9
10
11
// 在子线程解码
func decodeImage(_ image: UIImage) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(image.size, true, 0)
image.draw(at: .zero)
let decoded = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return decoded
}

// 或使用 ImageIO 指定解码选项
// 对于网络图片,使用 SDWebImage / Kingfisher 等库,它们会在后台解码

四、内存优化

4.1 内存管理基础

  • 引用计数:OC 使用 MRC/ARC,Swift 使用 ARC
  • AutoreleasePool:自动释放池,延迟 release
  • 循环引用:block、delegate、闭包持有 self 未使用 weak 导致

4.2 AutoreleasePool 与 RunLoop

主线程 RunLoop 每次循环会创建并销毁一次 @autoreleasepool,因此临时对象会在一次循环结束释放。子线程若没有 RunLoop,需要手动加 @autoreleasepool,否则临时对象会堆积到线程结束。

1
2
3
4
5
6
7
8
9
10
// 子线程大量创建临时对象时
dispatch_async(dispatch_get_global_queue(0, 0), ^{
@autoreleasepool {
for (int i = 0; i < 10000; i++) {
// 创建大量临时对象
NSString *temp = [NSString stringWithFormat:@"item_%d", i];
[array addObject:temp];
}
}
});

objc4 源码中的 AutoreleasePoolPage 结构(简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// objc4 源码简化
class AutoreleasePoolPage {
magic_t const magic;
id *next; // 下一个可存放 autorelease 对象的地址
pthread_t const thread; // 所属线程
AutoreleasePoolPage * const parent;
AutoreleasePoolPage *child;
// ...
static void *operator new(size_t size) {
return malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE);
}
id *add(id obj) {
// 将 obj 加入当前 page,next 指向下一个空位
// ...
}
static void releaseAll() {
// 从 last 到 next 逆序 release
}
};

4.3 循环引用与 weak/strong

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ block 强引用 self,self 强引用持有 block 的成员
class ViewController: UIViewController {
var onComplete: (() -> Void)?
func setup() {
onComplete = {
self.doSomething() // 强引用 self
}
}
}

// ✅ weak self
onComplete = { [weak self] in
self?.doSomething()
}

// ✅ weak + strong 避免 block 执行期间 self 被释放
onComplete = { [weak self] in
guard let self = self else { return }
self.doSomething()
}

4.4 大对象与图片内存

一张 1000×1000 的 RGBA 图片,解码后约占 约 4MB 内存。使用 UIImage(named:) 会缓存,大图慎用。

1
2
3
4
5
6
7
8
9
// 大图使用 imageWithContentsOfFile 或 UIImage(contentsOfFile:) 避免缓存
let image = UIImage(contentsOfFile: path)

// 或使用 ImageIO 进行缩略图解码,减少内存
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceThumbnailMaxPixelSize: 200
]
// 只解码缩略图尺寸,而非整张大图

五、启动优化

5.1 启动阶段

阶段 说明 可优化点
pre-main dyld 加载、ObjC 初始化、+load、C++ 静态构造 减少 +load、精简动态库
post-main main 到首屏可交互 异步化、延迟加载

5.2 pre-main 优化

1
2
# 测量 pre-main 时间:Edit Scheme → Run → Arguments → Environment Variables
# 添加 DYLD_PRINT_STATISTICS = 1
  • 减少动态库数量:合并动态库,能用静态库则用静态库
  • 减少 +load:把逻辑迁移到 +initialize 或首屏使用再初始化
  • 减少 ObjC 类/方法数量:删除无用代码,用 Swift 替代部分 OC

5.3 post-main 优化

1
2
3
4
5
6
7
8
9
10
11
// 串行改并行
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) -> Bool {
// 可并行的初始化
DispatchQueue.global().async { initAnalytics() }
DispatchQueue.global().async { initCrashReporter() }
DispatchQueue.global().async { initNetworkConfig() }

// 必须主线程且阻塞首屏的,尽量后置或精简
setupWindow()
return true
}

延迟加载:

1
2
3
4
5
6
7
8
// 非首屏必需的模块,等首屏展示后再初始化
DispatchQueue.main.async {
self.window?.rootViewController = MainTabBarController()
DispatchQueue.main.async {
// 首屏渲染完成后再做
initThirdPartySDK()
}
}

六、网络与 I/O 优化

6.1 网络请求优化

  • 合并请求、减少请求次数
  • 使用 HTTP/2 多路复用
  • 合理设置超时与重试
  • 大文件使用断点续传
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 请求合并示例(伪代码)
class RequestMerger {
private var pendingRequests: [String: [CompletionHandler]] = [:]
private var inflight: [String: URLSessionTask] = [:]

func fetch(key: String, completion: @escaping (Data?) -> Void) {
if let task = inflight[key] {
// 合并到同一请求的回调
pendingRequests[key, default: []].append(completion)
return
}
let task = URLSession.shared.dataTask(with: url) { data, _, _ in
let handlers = self.pendingRequests.removeValue(forKey: key) ?? []
DispatchQueue.main.async {
handlers.forEach { $0(data) }
}
}
task.resume()
inflight[key] = task
}
}

6.2 文件 I/O 优化

  • 避免在主线程做大量读写
  • 小文件合并、大文件分片
  • 使用 mmap 映射大文件
  • 合理使用 Data(contentsOf:) 与流式读取
1
2
3
4
5
6
7
8
9
10
11
// 大文件流式读取
if let stream = InputStream(fileAtPath: path) {
stream.open()
defer { stream.close() }
let bufferSize = 1024 * 64
var buffer = [UInt8](repeating: 0, count: bufferSize)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: bufferSize)
// 处理 buffer
}
}

七、多线程与 GCD 优化

7.1 主线程减压

任何耗时操作都不应阻塞主线程超过 16ms(约一帧)。

1
2
3
4
5
6
DispatchQueue.global(qos: .userInitiated).async {
let result = expensiveComputation()
DispatchQueue.main.async {
self.updateUI(with: result)
}
}

7.2 线程爆炸与串行化

过多并发会导致线程爆炸,反而不利于性能。可使用串行队列 + 多队列分组:

1
2
3
// 为不同任务类型使用不同队列,避免单一队列过长
let imageQueue = DispatchQueue(label: "com.app.image", qos: .userInitiated)
let dbQueue = DispatchQueue(label: "com.app.db", qos: .utility)

7.3 避免锁竞争

1
2
3
4
5
6
7
8
9
10
11
12
13
// 读多写少场景,可用 dispatch_barrier 优化
class ThreadSafeArray<Element> {
private var array: [Element] = []
private let queue = DispatchQueue(label: "com.app.safe", attributes: .concurrent)

func append(_ element: Element) {
queue.async(flags: .barrier) { self.array.append(element) }
}

var last: Element? {
queue.sync { array.last }
}
}

八、实际项目应用案例

8.1 案例一:电商首页 Feed 列表卡顿

现象:首页信息流快速滑动时明显卡顿,FPS 掉到 40 以下。

排查

  1. Time Profiler 发现 cellForRow 内存在 UIImage(contentsOfFile:) 同步解码
  2. Core Animation 发现 Cell 内圆角 + 阴影组合触发离屏渲染

优化措施

  1. 图片改为异步加载 + 子线程解码,使用 Kingfisher 的 downsamplingImageProcessor
  2. 圆角改为用 UIBezierPath 绘制圆角图,或用 cornerRadius 仅作用在 imageView.layer
  3. 高度缓存,避免 UITableViewAutomaticDimension 反复计算

效果:滑动 FPS 稳定在 58–60。


8.2 案例二:App 冷启动超 3 秒

现象:从点击图标到首屏出现超过 3 秒。

排查

  1. 通过 DYLD_PRINT_STATISTICS 发现 pre-main 约 1.2s
  2. 发现 20+ 个动态库、多个 +load 中做了同步网络请求和大量注册

优化措施

  1. 合并部分动态库,能静态链接的改为静态
  2. 移除 +load 中的网络请求和耗时逻辑,改为首屏展示后异步初始化
  3. 路由注册从「启动全量注册」改为「首次使用时按需注册」

效果:pre-main 降至约 0.6s,整体冷启动约 1.8s。


8.3 案例三:内存持续增长被系统强杀

现象:在某个二级页面反复进出多次后,App 被系统强杀。

排查

  1. Allocations 发现每次进入页面,ViewModelNetworkManager 持续增长
  2. Leaks 未报明显泄漏,但 MLeaksFinder 提示 ViewController 未释放

根因

  • NetworkManager 持有请求的 closureclosure 捕获了 ViewController
  • ViewController 又持有 NetworkManager 的 delegate,形成循环引用

优化措施

  1. 所有回调使用 [weak self],并在回调内 guard let self
  2. NetworkManager 的 delegate 改为 weak
  3. 请求完成后主动置空 completion,避免长生命周期持有

效果:反复进出页面,内存稳定回收,不再被强杀。


九、性能优化清单(自检表)

类别 检查项
UI 是否避免不必要的离屏渲染?图层是否过多?是否在子线程解码图片?
列表 Cell 是否复用?高度是否缓存?是否做了预加载?
内存 是否存在循环引用?大图是否控制解码尺寸?
启动 动态库数量是否可控?+load 是否精简?是否延迟非必要初始化?
网络 是否合并请求?超时和重试是否合理?
线程 耗时操作是否在子线程?是否存在锁竞争或线程爆炸?

十、小结

性能优化是一个持续的过程,需要:

  1. 建立指标体系:用 FPS、启动时间、内存等量化指标
  2. 善用工具:Instruments、APM、自研监控
  3. 由瓶颈入手:先解决主要矛盾,再优化细节
  4. 平衡取舍:在开发成本、可维护性和性能之间找平衡
  5. 回归验证:每次改动后做回归测试,避免引入新问题

掌握原理、熟练使用工具、结合业务实践,才能在真实项目中持续提升 App 的性能与体验。

iOS 开发中的多线程

由浅入深,从基本概念到源码解析,带你全面理解 iOS 并发编程


一、为什么需要多线程?

1.1 单线程的局限

在移动应用中,主线程(Main Thread/UI Thread) 负责:

  • 处理用户交互(点击、滑动等)
  • 更新 UI
  • 处理 RunLoop 事件

如果耗时操作(网络请求、大文件读写、复杂计算)在主线程执行,会导致:

  • 界面卡顿:主线程被阻塞,无法及时响应触摸
  • ANR(Application Not Responding):系统可能强制终止「无响应」的 App
  • 糟糕的用户体验
1
2
3
4
5
6
// ❌ 错误示例:主线程执行网络请求
func loadData() {
let url = URL(string: "https://api.example.com/data")!
let data = try? Data(contentsOf: url) // 阻塞主线程!
self.tableView.reloadData()
}

1.2 多线程的核心思想

将耗时任务放到子线程执行,完成后回到主线程更新 UI:

1
2
3
主线程:响应用户 → 派发任务到子线程 → 继续处理 UI
子线程:执行耗时任务 → 完成 → 通知主线程
主线程:收到结果 → 更新 UI

二、iOS 多线程技术栈

2.1 技术对比

技术 抽象层次 使用场景 学习曲线
Thread 底层,直接操作线程 需要精细控制线程生命周期
GCD 任务队列,无需管理线程 绝大多数异步任务
Operation 面向对象,可取消/依赖/优先级 复杂任务编排 中低

2.2 选择建议

  • 首选 GCD:简单异步、串行/并发队列、延迟执行、一次性执行
  • Operation:需要取消、依赖关系、暂停恢复、进度回调
  • Thread:极少需要,仅当必须直接操作线程时使用

三、GCD(Grand Central Dispatch)详解

3.1 核心概念

GCD 是苹果提供的并发编程框架,基于 C 库 libdispatch。它采用「任务 + 队列」模型:

  • 任务(Block/Closure):要执行的代码块
  • 队列(Queue):存放任务,按规则调度到线程执行
1
2
3
4
5
6
7
8
9
// 基本用法
DispatchQueue.global().async {
// 子线程执行
let result = doHeavyWork()
DispatchQueue.main.async {
// 主线程更新 UI
self.updateUI(result)
}
}

3.2 队列类型

队列 类型 说明
主队列 串行 DispatchQueue.main,主线程执行,用于 UI 更新
全局队列 并发 DispatchQueue.global(qos:),系统管理线程池
自定义串行队列 串行 同一时刻只执行一个任务
自定义并发队列 并发 可同时执行多个任务
1
2
3
4
5
6
7
8
9
// 串行队列:任务按顺序执行
let serialQueue = DispatchQueue(label: "com.app.serial")

// 并发队列:任务可并发执行
let concurrentQueue = DispatchQueue(label: "com.app.concurrent", attributes: .concurrent)

// QoS(服务质量)优先级
DispatchQueue.global(qos: .userInitiated).async { } // 用户发起,需快速响应
DispatchQueue.global(qos: .background).async { } // 后台任务,可慢速

3.3 QoS 优先级

QoS 说明 典型场景
.userInteractive 用户交互,最高优先级 动画、即时反馈
.userInitiated 用户发起,高优先级 加载数据、点击后处理
.default 默认 无特别需求
.utility 实用型 下载、导入
.background 后台 同步、预加载

3.4 常用 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 异步执行
queue.async { }

// 同步执行(会阻塞当前线程直到任务完成)
queue.sync { }

// 延迟执行
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { }

// 只执行一次(如单例)
var token: Int = 0
DispatchQueue.once(&token) {
// 全局只执行一次
}

// DispatchWorkItem:可取消的任务
let workItem = DispatchWorkItem { print("working") }
queue.async(execute: workItem)
workItem.cancel() // 取消(若已开始则无法取消)

3.5 dispatch_barrier:读写锁场景

在自定义并发队列中,barrier 可保证「屏障前的任务完成后,再执行屏障任务,屏障完成后再执行屏障后的任务」:

1
2
3
4
5
6
7
8
9
let concurrentQueue = DispatchQueue(label: "com.app.db", attributes: .concurrent)

// 读:可并发
concurrentQueue.async { self.readFromCache() }

// 写:barrier 保证独占
concurrentQueue.async(flags: .barrier) {
self.writeToCache(newValue)
}

四、NSOperation 与 NSOperationQueue

4.1 为什么需要 Operation?

GCD 虽然强大,但在以下场景不够灵活:

  • 需要取消尚未执行的任务
  • 需要任务依赖(A 完成后再执行 B)
  • 需要暂停/恢复队列
  • 需要进度完成回调

Operation 提供了面向对象的方式解决这些问题。

4.2 基本用法

1
2
3
4
5
6
7
8
9
10
11
// 使用 BlockOperation
let op = BlockOperation {
print("执行任务")
}
op.completionBlock = { print("任务完成") }

let queue = OperationQueue()
queue.addOperation(op)

// 设置最大并发数
queue.maxConcurrentOperationCount = 3

4.3 任务依赖

1
2
3
4
5
6
7
8
let opA = BlockOperation { downloadImage() }
let opB = BlockOperation { resizeImage() }
let opC = BlockOperation { uploadImage() }

opB.addDependency(opA) // B 依赖 A
opC.addDependency(opB) // C 依赖 B

queue.addOperations([opA, opB, opC], waitUntilFinished: false)

4.4 自定义 Operation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ImageLoadOperation: Operation {
let url: URL
var image: UIImage?

init(url: URL) {
self.url = url
super.init()
}

override var isAsynchronous: Bool { true }

override func main() {
guard !isCancelled else { return }
if let data = try? Data(contentsOf: url) {
image = UIImage(data: data)
}
}
}

4.5 取消与暂停

1
2
3
queue.cancelAllOperations()   // 取消所有未执行任务
queue.isSuspended = true // 暂停队列(不执行新任务)
queue.isSuspended = false // 恢复

五、线程同步与线程安全

5.1 为什么需要同步?

多个线程同时访问共享资源(变量、文件、网络连接)时,若未做同步,会出现:

  • 数据竞争:读写交错,结果不可预期
  • 脏读:读到未写入完成的数据
  • 崩溃:如数组在遍历时被另一线程修改
1
2
3
4
5
6
// ❌ 非线程安全
var counter = 0
DispatchQueue.concurrentPerform(iterations: 1000) { _ in
counter += 1 // 多线程同时写,结果可能远小于 1000
}
print(counter) // 可能输出 523、687 等

5.2 锁机制

5.2.1 NSLock

1
2
3
4
5
6
7
8
9
let lock = NSLock()
var counter = 0

DispatchQueue.concurrentPerform(iterations: 1000) { _ in
lock.lock()
defer { lock.unlock() }
counter += 1
}
print(counter) // 1000

5.2.2 os_unfair_lock(高性能,iOS 10+)

1
2
3
4
var unfairLock = os_unfair_lock()
os_unfair_lock_lock(&unfairLock)
// 临界区
os_unfair_lock_unlock(&unfairLock)

5.2.3 NSRecursiveLock(可重入锁)

同一线程可多次加锁,用于递归或嵌套调用:

1
2
3
4
5
6
7
8
let recursiveLock = NSRecursiveLock()
func recursiveMethod(_ n: Int) {
recursiveLock.lock()
defer { recursiveLock.unlock() }
if n > 0 {
recursiveMethod(n - 1)
}
}

5.2.4 @synchronized(Objective-C)

1
2
3
@synchronized(self) {
// 临界区
}

底层使用 objc_sync_enter / objc_sync_exit,基于对象做锁。

5.3 信号量(Semaphore)

控制并发数量或实现生产者-消费者

1
2
3
4
5
6
7
8
9
let semaphore = DispatchSemaphore(value: 3)  // 最多 3 个并发

for i in 0..<10 {
DispatchQueue.global().async {
semaphore.wait() // 资源 -1,若为 0 则等待
defer { semaphore.signal() } // 资源 +1
doWork(i)
}
}

5.4 原子操作(Atomic)

对于简单类型,可使用原子属性。Swift 中常用 NSLock + 属性封装,或使用 objc_setAssociatedObject 的原子选项。atomic 属性只保证 getter/setter 原子,不保证复合操作(如 count++)的原子性。

5.5 避免死锁

死锁:两个或多个线程互相等待对方释放资源。

1
2
3
4
// ❌ 死锁示例:主队列同步执行
DispatchQueue.main.sync {
print("永远不会执行") // 主线程等待自己,死锁
}
1
2
3
4
5
6
7
// ❌ 死锁:串行队列嵌套同步
let queue = DispatchQueue(label: "serial")
queue.async {
queue.sync {
print("死锁") // 外层等内层,内层等外层
}
}

原则:避免在同一串行队列中嵌套 sync 调用。


六、Swift 并发(async/await)

6.1 从回调到 async/await

传统异步代码容易产生「回调地狱」:

1
2
3
4
5
6
7
8
// 回调嵌套
loadUser(id: 1) { user in
loadPosts(userId: user.id) { posts in
loadComments(postId: posts[0].id) { comments in
// 层层嵌套...
}
}
}

Swift 5.5 引入 async/await,写法更清晰:

1
2
3
4
5
6
func loadUserData() async throws {
let user = try await loadUser(id: 1)
let posts = try await loadPosts(userId: user.id)
let comments = try await loadComments(postId: posts[0].id)
await MainActor.run { self.updateUI(comments) }
}

6.2 Task 与 MainActor

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建异步任务
Task {
let result = await fetchData()
await MainActor.run {
self.label.text = result
}
}

// MainActor:保证在主线程执行
@MainActor
class ViewController: UIViewController {
func updateUI() { } // 自动在主线程
}

6.3 与 GCD 的桥接

1
2
3
4
5
6
7
8
9
// GCD 转 async
func withCheckedContinuation() async {
await withCheckedContinuation { continuation in
DispatchQueue.global().async {
let result = doWork()
continuation.resume(returning: result)
}
}
}

七、RunLoop 与线程

每个线程都有唯一的 RunLoop。子线程默认不启动 RunLoop,若使用 performSelector:onThread:NSTimer,需要手动 run

1
2
3
4
5
6
class WorkerThread: Thread {
override func main() {
RunLoop.current.add(Port(), forMode: .default)
RunLoop.current.run() // 进入事件循环
}
}

主线程的 RunLoop 由系统自动运行,通常无需关心。


八、GCD 底层原理(libdispatch 简析)

8.1 队列与线程池

  • GCD 维护全局线程池,根据队列类型和系统负载动态创建/复用线程
  • 串行队列:通常绑定一个线程,任务在该线程顺序执行
  • 并发队列:任务可分发到线程池中的多个线程

8.2 任务提交流程(简化)

1
2
3
4
5
6
7
dispatch_async(queue, block)

├─ 将 block 封装为 dispatch_continuation_t

├─ 将任务加入队列的 FIFO 链表

└─ 若有空闲 worker 线程,则唤醒执行;否则根据需要创建新线程

8.3 主队列特殊性

主队列任务一定在主线程执行,通过 RunLoop 的 Source1(Mach Port)唤醒主线程处理。

8.4 源码参考

libdispatch 开源:https://github.com/apple/swift-corelibs-libdispatch

核心结构体(简化):

1
2
3
4
5
6
7
8
9
struct dispatch_queue_s {
// 队列类型:串行 / 并发
// 目标队列、任务链表等
};

struct dispatch_continuation_s {
void (*dc_func)(void *); // block 的执行函数
void *dc_ctxt; // block 捕获的上下文
};

九、实战示例

9.1 图片异步加载与缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ImageLoader {
private let cache = NSCache<NSString, UIImage>()
private let queue = DispatchQueue(label: "com.app.imageloader", attributes: .concurrent)

func loadImage(url: URL, completion: @escaping (UIImage?) -> Void) {
let key = url.absoluteString as NSString
if let cached = cache.object(forKey: key) {
DispatchQueue.main.async { completion(cached) }
return
}
queue.async {
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else {
DispatchQueue.main.async { completion(nil) }
return
}
self.cache.setObject(image, forKey: key)
DispatchQueue.main.async { completion(image) }
}
}
}

9.2 多接口并发请求,统一回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func loadDashboardData(completion: @escaping (DashboardData?) -> Void) {
let group = DispatchGroup()
var user: User?
var orders: [Order]?
var error: Error?

group.enter()
fetchUser { result in
user = try? result.get()
group.leave()
}

group.enter()
fetchOrders { result in
orders = (try? result.get()) ?? []
group.leave()
}

group.notify(queue: .main) {
if let u = user, let o = orders {
completion(DashboardData(user: u, orders: o))
} else {
completion(nil)
}
}
}

9.3 线程安全的单例

1
2
3
4
5
6
7
8
final class DataManager {
static let shared: DataManager = {
let instance = DataManager()
return instance
}()

private init() { }
}

若需「懒加载 + 线程安全」,可使用 dispatch_once 的 Swift 封装,或在 Swift 中依赖 static let 的天然懒加载与线程安全。


十、实际项目中的应用案例

10.1 列表图片预加载

UITableView / UICollectionView 滚动时,预加载即将出现的 cell 所需图片,放到后台队列解码,再回主线程赋值,避免主线程卡顿。

1
2
3
4
5
6
7
8
9
10
11
func prefetchImage(at indexPath: IndexPath) {
let url = urls[indexPath.row]
DispatchQueue.global(qos: .utility).async {
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return }
DispatchQueue.main.async {
self.imageCache.setObject(image, forKey: url.absoluteString as NSString)
self.collectionView.reloadItems(at: [indexPath])
}
}
}

10.2 大文件分片上传

将大文件切分为多个 chunk,通过 OperationQueue 控制并发数,每个 Operation 上传一个 chunk,全部完成后组装结果。

1
2
3
4
5
6
7
8
let uploadQueue = OperationQueue()
uploadQueue.maxConcurrentOperationCount = 3
for chunk in chunks {
let op = BlockOperation { uploadChunk(chunk) }
uploadQueue.addOperation(op)
}
uploadQueue.waitUntilAllOperationsAreFinished()
mergeChunks()

10.3 搜索防抖

用户输入时,取消上一次未完成的搜索任务,延迟 300ms 再发起新请求:

1
2
3
4
5
6
7
8
9
var searchWorkItem: DispatchWorkItem?
func searchTextDidChange(_ text: String) {
searchWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.performSearch(text)
}
searchWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: workItem)
}

10.4 后台数据同步

App 进入后台时,使用 beginBackgroundTask 申请有限时间,在后台队列执行同步逻辑,完成后结束 task:

1
2
3
4
5
6
7
8
9
10
11
12
func syncInBackground() {
var taskID: UIBackgroundTaskIdentifier = .invalid
taskID = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(taskID)
}
DispatchQueue.global(qos: .utility).async {
performSync()
DispatchQueue.main.async {
UIApplication.shared.endBackgroundTask(taskID)
}
}
}

十一、常见问题与最佳实践

11.1 主线程检查

开发阶段可用断言检查 UI 是否在主线程更新:

1
assert(Thread.isMainThread, "UI must be updated on main thread")

11.2 避免循环引用

在闭包中使用 self 时注意 [weak self]

1
2
3
4
5
6
7
DispatchQueue.global().async { [weak self] in
guard let self = self else { return }
let result = self.doWork()
DispatchQueue.main.async { [weak self] in
self?.updateUI(result)
}
}

11.3 合理选择 QoS

不要滥用 .userInteractive,避免后台任务抢占用户交互资源。

11.4 优先使用 Swift 并发

新项目优先考虑 async/await + Task,结构更清晰,可组合性更好。


十二、总结

场景 推荐方案
简单异步、延迟执行 GCD
复杂任务依赖、取消、暂停 NSOperation
新项目、网络/IO 密集型 async/await
控制并发数 信号量 或 OperationQueue.maxConcurrentOperationCount
读写分离、缓存 dispatch_barrier
线程安全访问共享资源 NSLock / os_unfair_lock

多线程能提升体验,但也会带来复杂度和潜在问题。理解原理、选对工具、注意同步与线程安全,是写出高质量并发代码的关键。

iOS 开发中的 Runtime

由浅入深,从基本概念到源码解析,带你全面理解 Objective-C 运行时机制


一、什么是 Runtime?

1.1 从「面向对象」说起

在 C++ 这类静态语言中,方法的调用在编译期就确定了,编译器会把方法调用翻译成确定的函数地址。而 Objective-C 是一门「消息型」语言,方法调用在编译期只是生成了「发消息」的代码,真正要调用哪个方法,要到运行期才能确定。

这种「推迟到运行时再决定」的能力,就是 Runtime 的精髓。

1.2 Runtime 是什么?

Runtime 是 Objective-C 的运行时系统,是一套用 C 和汇编实现的底层库。它负责:

  • 类与对象的创建、布局
  • 方法的查找、派发(Message Dispatch)
  • 消息传递机制(Message Passing)
  • 动态添加/修改类、方法、属性
  • 方法交换(Method Swizzling)
  • KVO、KVC、Block 等特性的底层支撑

可以理解为:Runtime 是 Objective-C 的「操作系统」,没有它,OC 就无法运行。

1.3 两种版本

版本 说明 适用场景
Legacy Runtime 较早版本 32 位 macOS、老设备
Modern Runtime 当前主流 64 位 macOS、iOS、模拟器

Modern Runtime 支持:非 fragile instance variables、属性自动合成、Objective-C 2.0 语法等。


二、核心概念

2.1 对象(Object)与类(Class)

在 C 中,结构体就是「数据 + 布局」。在 OC 中,对象 本质上是一个指向结构体的指针,结构体第一个成员是指向 类对象(Class) 的指针 isa

1
2
3
4
5
6
7
8
9
10
11
12
13
// 简化理解
struct objc_object {
Class isa; // 指向所属类
};

struct objc_class {
Class isa; // 类对象的 isa 指向元类(metaclass)
Class superclass; // 父类
// ... 方法列表、属性列表等
};

typedef struct objc_object *id;
typedef struct objc_class *Class;
  • 实例对象isa → 类对象
  • 类对象isa → 元类(metaclass)
  • 元类isa → 根元类,superclass → 父类的元类

2.2 消息传递(Message Passing)

OC 中「调用方法」实际上是「发消息」:

1
2
3
[person sayHello];
// 等价于
objc_msgSend(person, @selector(sayHello));

objc_msgSend 是 Runtime 提供的 C 函数,流程大致为:

  1. 检查 receiver 是否为 nil(若为 nil,直接返回,不崩溃)
  2. receiverisa 指向的类中查找方法
  3. 若未找到,沿 superclass 链向上查找
  4. 找到后跳转执行实现(IMP)
  5. 若最终未找到,进入「消息转发」流程

2.3 SEL、IMP、Method

类型 说明 示例
SEL 方法选择器,方法名的唯一标识 @selector(sayHello)
IMP 函数指针,方法的实际实现 void (*)(id, SEL, ...)
Method 方法结构体,包含 SEL 和 IMP struct objc_method
1
2
3
4
5
6
7
typedef struct objc_method *Method;

struct objc_method {
SEL method_name;
char *method_types;
IMP method_imp;
};

三、消息查找与转发

3.1 方法查找流程(快速查找 + 慢速查找)

1
2
3
4
5
6
7
8
9
10
objc_msgSend(receiver, sel)

├─ 1. 检查 receiver 是否为 nil

├─ 2. 从类/父类缓存中查找 (objc_cache) —— 快速路径

└─ 3. 缓存未命中 → 调用 lookUpImpOrForward 慢速查找
├─ 在当前类 method_list 中查找
├─ 沿 superclass 链向上查找
└─ 若仍未找到 → 进入动态方法解析

3.2 动态方法解析(Dynamic Method Resolution)

在找不到方法时,Runtime 会先给类一次「补救」机会:

1
2
3
4
5
6
7
8
9
10
11
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(dynamicMethod)) {
class_addMethod([self class], sel, (IMP)dynamicMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}

void dynamicMethodIMP(id self, SEL _cmd) {
NSLog(@"动态添加的方法被调用了");
}

3.3 消息转发(Message Forwarding)

若动态解析也返回 NO,则进入转发流程:

  1. Fast Forwarding- (id)forwardingTargetForSelector:(SEL)aSelector

    • 返回一个能响应该 Selector 的对象,消息将转给该对象
    • 不修改方法签名,性能较好
  2. Normal Forwarding- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector + - (void)forwardInvocation:(NSInvocation *)anInvocation

    • 返回方法签名,再在 forwardInvocation: 中处理
    • 可灵活重定向、修改参数、多对象分发等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Fast Forwarding
- (id)forwardingTargetForSelector:(SEL)aSelector {
if (aSelector == @selector(sayHello)) {
return self.backupObject; // 转给其他对象处理
}
return [super forwardingTargetForSelector:aSelector];
}

// Normal Forwarding
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if (aSelector == @selector(sayHello)) {
return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
return [super methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
if (anInvocation.selector == @selector(sayHello)) {
[anInvocation invokeWithTarget:self.backupObject];
} else {
[super forwardInvocation:anInvocation];
}
}

3.4 流程小结

1
2
3
4
5
6
7
8
9
10
11
12
13
查找 IMP

├─ 1. 缓存命中 → 直接调用

├─ 2. 当前类及父类 method_list 中找到 → 写入缓存并调用

├─ 3. 未找到 → resolveInstanceMethod / resolveClassMethod

├─ 4. 解析失败 → forwardingTargetForSelector

├─ 5. 返回 nil → methodSignatureForSelector + forwardInvocation

└─ 6. 仍无法处理 → doesNotRecognizeSelector: crash

四、Runtime 源码结构

4.1 主要头文件

1
2
3
4
5
6
7
8
objc4 源码(可在 opensource.apple.com 获取):
├── objc-runtime.mm # 运行时初始化、类加载
├── objc-msg-x86_64.s # objc_msgSend 汇编实现(x86_64)
├── objc-msg-arm64.s # objc_msgSend 汇编实现(ARM64)
├── objc-class.mm # 类结构、方法列表
├── objc-object.mm # 对象、isa、关联对象
├── message.mm # 消息查找、转发
└── runtime.mm # 动态创建类、方法等

4.2 objc_object 与 isa

1
2
3
4
5
6
7
8
9
// objc-private.h (简化)
struct objc_object {
private:
isa_t isa; // 非指针时存储类地址;Tagged Pointer 优化时存更多信息
public:
Class getIsa();
void initIsa(Class cls);
// ...
};

4.3 objc_class 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// objc-runtime-new.h (简化)
struct objc_class : objc_object {
Class superclass;
cache_t cache; // 方法缓存
class_data_bits_t bits; // 方法列表、属性、协议等

class_rw_t *data() const {
return bits.data();
}

const method_array_t methods() const;
const property_array_t properties() const;
const protocol_array_t protocols() const;
};

4.4 方法缓存(cache_t)

为提高查找效率,每个类都有方法缓存。查找顺序:先查缓存,再查方法列表

1
2
3
4
5
6
7
8
9
10
11
// 简化理解
struct cache_t {
bucket_t *buckets; // 哈希表
mask_t mask; // 容量 - 1
mask_t occupied; // 已缓存数量
};

struct bucket_t {
SEL sel;
IMP imp;
};

五、常用 Runtime API

5.1 类与对象

1
2
3
4
5
6
7
8
9
10
11
// 获取类
Class cls = [MyObject class];
Class cls2 = object_getClass(obj);

// 创建实例
id obj = class_createInstance(cls, 0);

// 判断类型
BOOL isMeta = class_isMetaClass(cls);
BOOL isMember = [obj isMemberOfClass:[MyObject class]];
BOOL isKind = [obj isKindOfClass:[NSObject class]];

5.2 方法操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取方法
Method m = class_getInstanceMethod(cls, @selector(sayHello));

// 获取 SEL 和 IMP
SEL sel = method_getName(m);
IMP imp = method_getImplementation(m);

// 交换实现
Method m1 = class_getInstanceMethod(cls, @selector(methodA));
Method m2 = class_getInstanceMethod(cls, @selector(methodB));
method_exchangeImplementations(m1, m2);

// 添加方法
class_addMethod(cls, @selector(newMethod), (IMP)newMethodIMP, "v@:");

5.3 属性与成员变量

1
2
3
4
5
6
7
8
9
10
11
// 获取属性列表
unsigned int count;
objc_property_t *properties = class_copyPropertyList(cls, &count);

// 获取成员变量
Ivar *ivars = class_copyIvarList(cls, &count);
for (unsigned int i = 0; i < count; i++) {
Ivar ivar = ivars[i];
const char *name = ivar_getName(ivar);
const char *type = ivar_getTypeEncoding(ivar);
}

5.4 动态创建类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建新类
Class newClass = objc_allocateClassPair([NSObject class], "MyDynamicClass", 0);

// 添加实例变量(需在 allocate 之后、register 之前)
class_addIvar(newClass, "title", sizeof(NSString *), log2(sizeof(NSString *)), @encode(NSString *));

// 添加方法
class_addMethod(newClass, @selector(doSomething), (IMP)doSomethingIMP, "v@:");

// 注册类
objc_registerClassPair(newClass);

// 使用
id instance = [[newClass alloc] init];

六、Method Swizzling(方法交换)

6.1 基本原理

通过 method_exchangeImplementations 交换两个方法的 IMP,使调用 A 时实际执行 B 的实现。常用于:

  • 无侵入式 Hook 系统或第三方方法
  • 统计埋点、日志
  • 修复 Bug、兼容旧版本

6.2 基础写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 交换 viewDidLoad 实现
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class cls = [UIViewController class];
SEL originalSel = @selector(viewDidLoad);
SEL swizzledSel = @selector(swizzled_viewDidLoad);

Method originalMethod = class_getInstanceMethod(cls, originalSel);
Method swizzledMethod = class_getInstanceMethod(cls, swizzledSel);

method_exchangeImplementations(originalMethod, swizzledMethod);
});
}

- (void)swizzled_viewDidLoad {
NSLog(@"ViewController viewDidLoad 被调用");
[self swizzled_viewDidLoad]; // 交换后,这里实际调用原 viewDidLoad
}

6.3 安全写法:处理子类未实现的情况

若子类未重写 viewDidLoadclass_getInstanceMethod 会拿到父类的方法。直接交换会导致:父类方法被交换,影响所有子类。更安全的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class cls = [UIViewController class];
SEL originalSel = @selector(viewDidLoad);
SEL swizzledSel = @selector(swizzled_viewDidLoad);

Method originalMethod = class_getInstanceMethod(cls, originalSel);
Method swizzledMethod = class_getInstanceMethod(cls, swizzledSel);

BOOL didAdd = class_addMethod(cls, originalSel,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAdd) {
class_replaceMethod(cls, swizzledSel,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
});
}

6.4 实际应用:全局统计页面访问

1
2
3
4
5
6
7
8
9
10
11
// UIViewController+PageTrack.m
+ (void)load {
[self swizzleInstanceMethod:@selector(viewDidAppear:)
withMethod:@selector(track_viewDidAppear:)];
}

- (void)track_viewDidAppear:(BOOL)animated {
[self track_viewDidAppear:animated];
// 统计逻辑
[Analytics trackPageView:NSStringFromClass([self class])];
}

七、关联对象(Associated Objects)

7.1 为什么需要关联对象?

分类(Category)不能添加实例变量,但我们有时需要给已有类「挂」一些额外数据。关联对象可以在不修改原类的情况下,为实例绑定键值对

7.2 API

1
2
3
4
5
6
7
8
// 设置
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy);

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

// 移除
void objc_removeAssociatedObjects(id object); // 移除该对象所有关联,慎用

7.3 内存策略(policy)

Policy 对应属性修饰符 说明
OBJC_ASSOCIATION_ASSIGN assign 弱引用
OBJC_ASSOCIATION_RETAIN_NONATOMIC nonatomic, strong 强引用,非原子
OBJC_ASSOCIATION_RETAIN atomic, strong 强引用,原子
OBJC_ASSOCIATION_COPY_NONATOMIC nonatomic, copy 拷贝,非原子
OBJC_ASSOCIATION_COPY atomic, copy 拷贝,原子

7.4 示例:为 UIButton 绑定 Block

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// UIButton+Block.h
- (void)addAction:(void (^)(UIButton *sender))block forControlEvents:(UIControlEvents)events;

// UIButton+Block.m
#import <objc/runtime.h>

static const void *kButtonBlockKey = &kButtonBlockKey;

- (void)addAction:(void (^)(UIButton *))block forControlEvents:(UIControlEvents)events {
objc_setAssociatedObject(self, kButtonBlockKey, block, OBJC_ASSOCIATION_COPY_NONATOMIC);
[self addTarget:self action:@selector(block_buttonTapped:) forControlEvents:events];
}

- (void)block_buttonTapped:(UIButton *)sender {
void (^block)(UIButton *) = objc_getAssociatedObject(self, kButtonBlockKey);
if (block) block(sender);
}

八、实际项目中的应用案例

8.1 场景一:无侵入埋点

通过 Method Swizzling Hook viewDidAppear:viewDidDisappear:,自动统计页面停留时长,无需在每个 VC 里手动写埋点代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// PageAnalytics.m
- (void)tracked_viewDidAppear:(BOOL)animated {
[self tracked_viewDidAppear:animated];
self.pageEnterTime = CACurrentMediaTime();
[Tracking logEvent:@"page_enter" properties:@{@"page": NSStringFromClass([self class])}];
}

- (void)tracked_viewDidDisappear:(BOOL)animated {
NSTimeInterval duration = CACurrentMediaTime() - self.pageEnterTime;
[Tracking logEvent:@"page_leave" properties:@{
@"page": NSStringFromClass([self class]),
@"duration": @(duration)
}];
[self tracked_viewDidDisappear:animated];
}

8.2 场景二:防崩溃( unrecognized selector)

利用消息转发,在 forwardInvocation: 中统一处理未实现的方法调用,记录日志并优雅降级,避免 Crash。

1
2
3
4
5
6
7
8
9
10
11
12
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
NSMethodSignature *sig = [super methodSignatureForSelector:aSelector];
if (!sig) {
sig = [NSMethodSignature signatureWithObjCTypes:"v@:"]; // 兜底签名
}
return sig;
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
[CrashGuard logUnrecognizedSelector:anInvocation.selector onObject:self];
// 可选择上报、降级处理等
}

8.3 场景三:字典转模型(JSON → Model)

遍历类的属性列表,根据属性名从字典中取值并赋值,实现自动 JSON 转 Model。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+ (instancetype)modelWithDictionary:(NSDictionary *)dict {
id model = [[self alloc] init];
unsigned int count;
objc_property_t *properties = class_copyPropertyList([self class], &count);
for (unsigned int i = 0; i < count; i++) {
const char *name = property_getName(properties[i]);
NSString *key = [NSString stringWithUTF8String:name];
id value = dict[key];
if (value && ![value isKindOfClass:[NSNull class]]) {
[model setValue:value forKey:key];
}
}
free(properties);
return model;
}

8.4 场景四:KVO 防崩溃

KVO 常见崩溃:未配对 removeObserver、重复 remove、对象已释放。可通过 Hook addObserver:forKeyPath:options:context:removeObserver:forKeyPath:,用关联对象维护观察者链表,在 dealloc 时自动移除,实现「自释放」KVO。

8.5 场景五:调试时打印对象属性

利用 class_copyIvarListclass_copyPropertyList 遍历所有属性,valueForKey: 取值并拼接成字符串,方便调试时查看对象完整状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (NSString *)debugDescription {
NSMutableString *desc = [NSMutableString stringWithFormat:@"<%@: %p>\n", [self class], self];
unsigned int count;
Ivar *ivars = class_copyIvarList([self class], &count);
for (unsigned int i = 0; i < count; i++) {
Ivar ivar = ivars[i];
const char *name = ivar_getName(ivar);
NSString *key = [NSString stringWithUTF8String:name];
id value = [self valueForKey:key];
[desc appendFormat:@" %@ = %@\n", key, value];
}
free(ivars);
return desc;
}

九、注意事项与最佳实践

9.1 线程安全

  • objc_msgSend 等查找流程内部有锁,但动态修改类结构(如 class_addMethod)需注意线程安全
  • Method Swizzling 建议在 +load 中、单次执行完成,避免并发问题

9.2 Swizzling 陷阱

  • 只在 +load 中执行,且用 dispatch_once 保证只执行一次
  • 注意子类未实现父类方法时的交换范围
  • 避免多个库对同一方法重复 Swizzling,易产生难以排查的 Bug

9.3 性能考虑

  • Runtime 动态特性有开销,高频路径慎用
  • 方法缓存使大部分调用命中缓存,性能可接受
  • 关联对象查找为哈希查找,量不大时影响较小

9.4 Swift 与 Runtime

  • Swift 类继承自 NSObject 时,仍可使用 Runtime
  • 纯 Swift 类(不继承 NSObject)使用更静态的派发方式,Runtime 能力受限
  • 若需在 Swift 中使用 Runtime,需将类/方法标记为 @objcdynamic

十、总结

主题 要点
本质 OC 是消息型语言,方法调用 = 发消息,由 Runtime 在运行期决定实际执行的 IMP
查找 缓存 → 当前类 → 父类 → 动态解析 → 快速转发 → 完整转发 → 崩溃
Swizzling 交换 IMP,实现无侵入 Hook,注意子类与并发
关联对象 为实例动态绑定数据,弥补 Category 不能加实例变量的限制
应用 埋点、防崩溃、JSON 转模型、调试工具等

理解 Runtime,不仅有助于排查「消息转发」「KVO 崩溃」等问题,还能在需要时写出更灵活、可扩展的架构。建议结合 objc4 源码 和实际项目实践,逐步加深理解。

SDWebImage 源码

由浅入深,从基本概念到源码解析,带你全面掌握图片加载框架的设计与实现


一、什么是 SDWebImage?

1.1 为什么需要图片加载库?

在 iOS 开发中,展示网络图片是极其常见的需求。如果自己实现,需要处理:

  • 异步下载:不能阻塞主线程
  • 缓存策略:内存缓存 + 磁盘缓存,避免重复下载
  • 图片解码:在主线程解码大图会导致卡顿
  • 复用与取消:列表滑动时,旧请求应及时取消,避免错乱
  • 格式支持:JPEG、PNG、GIF、WebP 等

SDWebImage 将这些能力封装成一套成熟方案,被广泛应用于 App 中。

1.2 SDWebImage 简介

SDWebImage 是一个异步图片下载与缓存库,支持 iOS、macOS、watchOS、visionOS。核心特性包括:

特性 说明
异步下载 基于 NSURLSession,不阻塞主线程
内存 + 磁盘缓存 支持自定义缓存策略、过期时间
后台解码 避免主线程解码导致的卡顿
渐进式加载 支持 JPEG 等格式的渐进显示
动图支持 GIF、APNG、WebP 动画
缩略图解码 大图可只解码指定尺寸,节省内存
协议化设计 v5.x 起核心组件可插拔、可替换

1.3 版本演进要点

版本 主要变化
4.x Block 回调、FLAnimatedImageView
5.x 协议化:Loader、Cache、Coder 均可自定义;新增 View Indicator、Image Transform
5.6+ 完善协议体系,架构更清晰

二、核心架构与原理

2.1 整体数据流

一次完整的图片加载流程大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
用户调用 sd_setImageWithURL:


┌───────────────────────────────────────┐
│ UIView+WebCache (便捷入口) │
│ sd_internalSetImageWithURL:... │
└───────────────┬───────────────────────┘


┌───────────────────────────────────────┐
│ SDWebImageManager (调度中心) │
│ loadImageWithURL:options:... │
└───────────────┬───────────────────────┘

┌───────────┼───────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Cache │ │ Loader │ │ Coder │
│ 查找缓存 │ │ 下载图片 │ │ 解码图片 │
└─────────┘ └─────────┘ └─────────┘


显示到 UIImageView

2.2 协议化设计(v5.x 核心)

v5.x 将核心能力抽象为协议,实现可替换、可扩展:

协议 默认实现 职责
SDImageCache SDImageCache 内存 + 磁盘缓存
SDImageLoader SDWebImageDownloader 网络/本地图片加载
SDImageCoder SDImageCodersManager 图片编解码
SDWebImageCacheSerializer - 自定义缓存序列化
SDWebImageIndicator SDWebImageActivityIndicator 加载状态指示器

这种设计让开发者可以:

  • 替换默认下载器(如接入自研 CDN SDK)
  • 使用自定义缓存(如接入 YYCache、PINCache)
  • 支持新图片格式(实现 Coder 协议即可)

2.3 主线程检测的演进

SDWebImage 中使用 dispatch_main_async_safe 确保 UI 更新在主线程/主队列执行。v5.x 对主线程检测做了改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 旧版:用 [NSThread isMainThread] 判断
#define dispatch_main_async_safe(block)\
if ([NSThread isMainThread]) { block(); } else {\
dispatch_async(dispatch_get_main_queue(), block);\
}

// 新版:用 dispatch_queue 标签判断
#define dispatch_main_async_safe(block)\
if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == \
dispatch_queue_get_label(dispatch_get_main_queue())) {\
block();\
} else {\
dispatch_async(dispatch_get_main_queue(), block);\
}

原因:主队列 ≠ 主线程。某些场景下,非主队列的任务可能在主线程执行,若依赖 isMainThread,在依赖「主队列」的框架(如 VektorKit)中会出现问题。主队列上的任务一定在主线程执行,反之则需用队列标签判断。


三、源码解析

3.1 入口:UIView + WebCache

所有 View 的图片设置最终汇聚到 sd_internalSetImageWithURL:...

1
2
3
4
5
6
7
- (void)sd_internalSetImageWithURL:(nullable NSURL *)url
placeholderImage:(nullable UIImage *)placeholder
options:(SDWebImageOptions)options
context:(nullable SDWebImageContext *)context
setImageBlock:(nullable SDSetImageBlock)setImageBlock
progress:(nullable SDImageLoaderProgressBlock)progressBlock
completed:(nullable SDInternalCompletionBlock)completedBlock;

核心步骤(精简):

  1. 取消旧任务sd_cancelImageLoadOperationWithKey:,避免同一 View 的多次请求冲突
  2. 设置占位图:立即显示 placeholder
  3. 调用 ManagerloadImageWithURL:options:context:progress:completed:
  4. 保存 Operation:将返回的 id<SDWebImageOperation> 存入 sd_operationDictionary,便于取消

sd_operationDictionary 使用 NSMapTable,key 强引用、value 弱引用,因为 Operation 由 Manager 的 runningOperations 持有,这里仅作取消用。

3.2 SDWebImageManager:调度中心

Manager 负责串联 Cache、Loader、Coder,核心逻辑在 loadImageWithURL:options:context:progress:completed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 生成缓存 Key
NSString *key = [self cacheKeyForURL:url context:context];

// 2. 先查缓存(可选跳过)
if (!(options & SDWebImageFromLoaderOnly)) {
[self callQueryCacheOperationForKey:key ... completed:^(UIImage *cachedImage, NSData *cachedData, SDImageCacheType cacheType) {
if (cachedImage) {
// 命中缓存,直接回调
completedBlock(cachedImage, cachedData, nil, cacheType, YES, url);
return;
}
// 3. 未命中,执行下载
[self callLoadOperationWithURL:url ... completed:^(UIImage *image, NSData *data, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
// 4. 下载完成,写入缓存并回调
if (image && (options & SDWebImageCacheMemoryOnly)) {
[self.imageCache storeImage:image forKey:key ...];
}
completedBlock(image, data, error, cacheType, finished, imageURL);
}];
}];
}

流程:查缓存 → 未命中则下载 → 下载完成写缓存 → 回调

3.3 SDWebImageDownloader:下载器

下载器负责发起网络请求,支持并发数、超时、Request/Response 修改等配置:

1
2
3
4
5
6
// 核心下载接口
- (nullable SDWebImageDownloadToken *)downloadImageWithURL:(nullable NSURL *)url
options:(SDWebImageDownloaderOptions)options
context:(nullable SDWebImageContext *)context
progress:(nullable SDWebImageDownloaderProgressBlock)progressBlock
completed:(nullable SDWebImageDownloaderCompletedBlock)completedBlock;

URL 复用:同一 URL 的多次请求会复用同一个 SDWebImageDownloaderOperation,通过 addHandlersForProgress:completed: 累积多个回调,下载完成后依次执行,避免重复下载。

1
2
3
4
5
6
7
8
9
10
NSOperation<SDWebImageDownloaderOperation> *operation = [self.URLOperations objectForKey:url];
if (operation) {
@synchronized (operation) {
downloadOperationCancelToken = [operation addHandlersForProgress:progressBlock completed:completedBlock];
}
} else {
operation = [self createDownloaderOperationWithUrl:url options:options context:context];
[self.URLOperations setObject:operation forKey:url];
// ...
}

可插拔扩展

  • SDWebImageDownloaderRequestModifier:修改 Request(如加 Header)
  • SDWebImageDownloaderResponseModifier:修改 Response(如校验 MIME-Type)
  • SDWebImageDownloaderDecryptor:解密(如 Base64)

3.4 SDImageCache:缓存

缓存采用内存 + 磁盘二层结构:

1
2
3
4
5
6
7
8
9
10
11
12
// 查询缓存
- (void)queryImageForKey:(NSString *)key
options:(SDImageCacheOptions)options
context:(nullable SDWebImageContext *)context
completion:(nullable SDImageCacheQueryCompletionBlock)completionBlock;

// 存储
- (void)storeImage:(UIImage *)image
imageData:(NSData *)imageData
forKey:(NSString *)key
cacheType:(SDImageCacheType)cacheType
completion:(nullable SDWebImageNoParamsBlock)completionBlock;
  • 内存缓存:基于 NSCache,受内存压力和系统策略自动回收
  • 磁盘缓存:默认使用 NSFileManager 存储到 Library/Caches/default,可配置自定义路径

缓存 Key 默认为 URL 的绝对字符串,可通过 SDWebImageContextcacheKeyFilter 自定义。

3.5 解码与变换

为避免主线程解码导致卡顿,SDWebImage 在后台队列解码:

1
2
3
// 解码在 SDImageIOCoder / SDImageGIFCoder 等中完成
// 通过 SDImageCoder 协议统一
- (UIImage *)decodedImageWithData:(NSData *)data options:(SDImageCoderOptions *)options;

v5.x 支持 Image Transform:下载后可对图片做缩放、旋转、圆角等处理,结果再缓存,避免重复计算。


四、示例

4.1 基础用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#import <SDWebImage/SDWebImage.h>

// 最简单的用法
[self.imageView sd_setImageWithURL:[NSURL URLWithString:@"https://example.com/photo.jpg"]];

// 带占位图和完成回调
[self.imageView sd_setImageWithURL:url
placeholderImage:[UIImage imageNamed:@"placeholder"]
completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
if (error) {
NSLog(@"加载失败: %@", error);
} else {
NSLog(@"加载成功,来源: %@", cacheType == SDImageCacheTypeMemory ? @"内存" :
cacheType == SDImageCacheTypeDisk ? @"磁盘" : @"网络");
}
}];

4.2 进度与选项

1
2
3
4
5
6
7
[self.imageView sd_setImageWithURL:url
placeholderImage:placeholder
options:SDWebImageRetryFailed | SDWebImageProgressiveLoad
progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
CGFloat progress = expectedSize > 0 ? (CGFloat)receivedSize / expectedSize : 0;
self.progressView.progress = progress;
} completed:nil];

常用 options

  • SDWebImageRetryFailed:失败后重试
  • SDWebImageProgressiveLoad:渐进式加载
  • SDWebImageRefreshCached:忽略缓存强制刷新
  • SDWebImageFromLoaderOnly:只从网络加载,不查缓存

4.3 预加载

1
2
3
4
5
6
7
SDWebImagePrefetcher *prefetcher = [SDWebImagePrefetcher sharedImagePrefetcher];
prefetcher.maxConcurrentPrefetches = 4;
[prefetcher prefetchURLs:imageURLs progress:^(NSUInteger no, NSUInteger total) {
NSLog(@"预加载进度: %lu/%lu", (unsigned long)no, (unsigned long)total);
} completed:^(NSUInteger finishedCount, NSUInteger skippedCount) {
NSLog(@"完成: %lu, 跳过: %lu", (unsigned long)finishedCount, (unsigned long)skippedCount);
}];

4.4 自定义缓存 Key

1
2
3
4
5
6
SDWebImageContext *context = @{
SDWebImageContextCacheKeyFilter: [SDWebImageCacheKeyFilter cacheKeyFilterWithBlock:^NSString * _Nullable(NSURL * _Nullable url) {
return [url.absoluteString stringByAppendingString:@"_suffix"]; // 自定义 key
}]
};
[self.imageView sd_setImageWithURL:url placeholderImage:nil context:context];

4.5 图片变换(圆角、缩放)

1
2
3
4
[self.imageView sd_setImageWithURL:url
placeholderImage:placeholder
options:0
context:@{SDWebImageContextImageTransformer: [SDImageRoundCornerTransformer transformerWithRadius:10 corners:UIRectCornerAllCorners borderWidth:1 borderColor:[UIColor whiteColor]]}];

五、实际项目中的应用案例

5.1 列表图片加载与复用

场景:TableView/CollectionView 中加载头像或商品图。

做法

  • 使用 sd_setImageWithURL: 即可,SDWebImage 会自动取消不可见 cell 的请求
  • 可选 SDWebImageAvoidAutoSetImage,在 completed 中手动设置,便于加入过渡动画
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
MyCell *cell = [tableView dequeueReusableCellWithIdentifier:@"MyCell"];
NSURL *avatarURL = self.avatars[indexPath.row];
[cell.avatarView sd_setImageWithURL:avatarURL
placeholderImage:[UIImage imageNamed:@"default_avatar"]
options:SDWebImageAvoidAutoSetImage
completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
if (image) {
[UIView transitionWithView:cell.avatarView duration:0.2 options:UIViewAnimationOptionTransitionCrossDissolve animations:^{
cell.avatarView.image = image;
} completion:nil];
}
}];
return cell;
}

5.2 详情页大图预加载

场景:从列表进入详情时,希望大图尽快展示。

做法:在列表滑动到某条数据时,对详情大图 URL 做预加载:

1
2
3
4
5
6
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *detailImageURL = self.detailImageURLs[indexPath.row];
[[SDWebImagePrefetcher sharedImagePrefetcher] prefetchURLs:@[[NSURL URLWithString:detailImageURL]]];
// 再 push 到详情页
[self.navigationController pushViewController:detailVC animated:YES];
}

5.3 统一添加请求头(如 Token)

场景:图片 URL 需要鉴权 Header。

做法:实现 SDWebImageDownloaderRequestModifier

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface MyRequestModifier : NSObject <SDWebImageDownloaderRequestModifier>
@end

@implementation MyRequestModifier
- (NSURLRequest *)modifiedRequestWithRequest:(NSURLRequest *)request {
NSMutableURLRequest *mutable = [request mutableCopy];
[mutable setValue:[MyAuthManager shared].token forHTTPHeaderField:@"Authorization"];
return [mutable copy];
}
@end

// 配置
SDWebImageDownloader *downloader = [SDWebImageManager sharedManager].imageLoader;
downloader.requestModifier = [[MyRequestModifier alloc] init];

5.4 加载状态指示器

场景:图片加载时显示 UIActivityIndicator。

1
2
self.imageView.sd_imageIndicator = SDWebImageActivityIndicator.grayIndicator;
[self.imageView sd_setImageWithURL:url];

或自定义实现 SDWebImageIndicator 协议,适配项目 UI 规范。

5.5 自定义缓存路径与策略

场景:头像与普通图片使用不同缓存目录和过期策略。

1
2
3
4
5
6
7
// 头像缓存:30 天
SDImageCache *avatarCache = [[SDImageCache alloc] initWithNamespace:@"avatar" diskCacheDirectory:[NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject stringByAppendingPathComponent:@"AvatarCache"]]];
avatarCache.config.maxDiskAge = 30 * 24 * 60 * 60;

// 通过 Context 指定使用 avatarCache
SDWebImageContext *context = @{SDWebImageContextCustomCache: avatarCache};
[self.avatarView sd_setImageWithURL:avatarURL placeholderImage:nil context:context];

六、小结

SDWebImage 通过协议化设计清晰的职责划分,将图片加载、缓存、解码、展示等环节解耦,既易用又易扩展。掌握其架构与源码,有助于:

  • 合理选用 optionscontext,优化体验与性能
  • 在需要时自定义 Loader、Cache、Coder,适配业务
  • 理解异步加载、缓存、取消等通用模式,迁移到其他场景

建议结合官方 GitHubWiki 阅读源码,逐层从 Category → Manager → Loader/Cache 跟踪调用链,会有更深理解。

iOS 开发中的 ReactiveCocoa (RAC)

由浅入深,从基本概念到源码解析,带你全面掌握 Swift 响应式编程


一、什么是响应式编程?

1.1 从命令式到声明式

传统的命令式编程中,我们通过直接修改状态和调用方法来驱动程序执行:

1
2
3
4
5
6
7
8
9
// 命令式:监听文本框变化
textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)

func textDidChange() {
let text = textField.text ?? ""
if text.count >= 3 {
searchUsers(text)
}
}

响应式编程则将数据流视为核心概念,用声明式的方式描述「当数据变化时该做什么」:

1
2
3
4
5
6
7
// 响应式:声明数据流关系
textField.reactive.continuousTextValues
.filter { ($0?.count ?? 0) >= 3 }
.debounce(0.3, on: QueueScheduler.main)
.observeValues { [weak self] text in
self?.searchUsers(text ?? "")
}

1.2 ReactiveCocoa 与 ReactiveSwift

  • ReactiveSwift:纯 Swift 实现的响应式核心库,提供 Signal、SignalProducer 等基础类型
  • ReactiveCocoa:基于 ReactiveSwift,为 Cocoa/Cocoa Touch 提供 UI 绑定、扩展和便捷 API

两者关系可以理解为:ReactiveSwift 是引擎,ReactiveCocoa 是上层封装。

1.3 为什么选择 RAC?

  • 统一抽象:将 delegate、回调、通知、KVO、Target-Action 等统一为「事件流」
  • 可组合:通过 map、filter、combineLatest 等操作符组合数据流
  • 减少状态:用数据流替代分散的中间变量
  • 声明式:代码更贴近「业务意图」,易于阅读和维护

二、核心概念

2.1 事件 (Event)

Event 是事件流中的最小传输单元,类似一次性的直播流中的一帧:

1
2
3
4
5
6
public enum Event<Value, Error: Swift.Error> {
case value(Value) // 携带一个值
case failed(Error) // 失败(携带错误)
case completed // 正常完成
case interrupted // 被中断
}

一个典型的流:若干个 .value,最后以 .completed.failed 结束;.interrupted 表示订阅被取消。

2.2 观察者 (Observer)

Observer 负责向事件流发送事件:

1
2
3
4
5
6
let (signal, observer) = Signal<String, Never>.pipe()

// observer 用于发送事件
observer.send(value: "Hello")
observer.send(value: "World")
observer.sendCompleted()

2.3 可销毁对象 (Disposable)

订阅信号会返回 Disposable,用于取消订阅、释放资源:

1
2
3
4
5
6
let disposable = signalProducer.start { value in
print(value)
}

// 不再需要时取消订阅
disposable.dispose()

2.4 核心类型总览

类型 描述 类比
Signal 热信号,单向事件流,所有者控制发送 直播画面
SignalProducer 冷信号,延迟执行,每次 start 创建新流 点播视频
Property 可观察的「有值」盒子,永不失败 播放进度条
Action 串行执行、可启用/禁用的操作 自动售货机
Lifetime 观察的生命周期,用于自动取消 观看时间段

三、Signal 与 SignalProducer

3.1 Signal:热信号

  • 热信号:无论有没有观察者,事件都会持续发送
  • 所有者完全控制何时发送、发送什么
  • 观察者只能订阅,不能影响流的产生
1
2
3
4
5
6
7
8
9
10
// 创建 Signal 的方式:pipe
let (signal, observer) = Signal<String, Never>.pipe()

signal.observeValues { value in
print("收到: \(value)")
}

observer.send(value: "A") // 输出: 收到: A
observer.send(value: "B") // 输出: 收到: B
observer.sendCompleted()

3.2 SignalProducer:冷信号

  • 冷信号:只有在 start 时才真正执行
  • 每次 start 都会创建新的 Signal,重新跑一遍逻辑
  • 适合网络请求、文件读取等「按需执行」的场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let producer = SignalProducer<String, Never> { observer, _ in
print("开始执行") // 每次 start 都会打印
observer.send(value: "Hello")
observer.send(value: "World")
observer.sendCompleted()
}

let d1 = producer.startWithValues { print("观察者1: \($0)") }
// 输出: 开始执行
// 输出: 观察者1: Hello
// 输出: 观察者1: World

let d2 = producer.startWithValues { print("观察者2: \($0)") }
// 再次输出: 开始执行
// 输出: 观察者2: Hello
// 输出: 观察者2: World

3.3 热信号 vs 冷信号

特性 Signal (热) SignalProducer (冷)
执行时机 由发送者决定 start 时执行
多订阅 共享同一流 每个订阅独立执行
典型场景 按钮点击、通知 网络请求、文件读取

四、Property 与 Action

4.1 Property:可观察的盒子

Property 表示「始终有一个当前值」且「永不失败」的流,适合表示 UI 状态、配置等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// MutableProperty:可读可写
let username = MutableProperty<String>("")

// 读取当前值
print(username.value)

// 监听变化
username.signal.observeValues { newValue in
print("用户名变为: \(newValue)")
}

// 更新值
username.value = "张三"
username.modify { $0 = "李四" }

4.2 Action:串行操作

Action 将「输入 → 输出」封装为可复用、可启用/禁用的操作,且保证串行执行(同一时间只处理一个请求):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let searchAction = Action<String, [User], NSError> { keyword in
return searchAPI(keyword) // 返回 SignalProducer
}

// 绑定启用条件(例如:输入非空)
let enabledSearch = Action(state: viewModel.keyword, enabledIf: { $0.count > 0 }) { _, keyword in
return searchAPI(keyword)
}

// 执行
searchAction.apply("Swift").startWithResult { result in
switch result {
case .success(let users):
self.users = users
case .failure(let error):
self.showError(error)
}
}

五、常用操作符

5.1 转换类

map:转换每个值

1
2
3
signal
.map { $0.uppercased() }
.observeValues { print($0) }

filter:过滤值

1
2
3
signal
.filter { $0.count >= 3 }
.observeValues { print($0) }

reduce:聚合为单个值(在 completed 时发出)

1
2
3
signal
.reduce(0) { $0 + $1 }
.observeValues { print("总和: \($0)") }

5.2 组合类

combineLatest:合并多个流的最新值

1
2
3
4
let combined = Signal.combineLatest(usernameSignal, passwordSignal)
combined.observeValues { (user, pwd) in
loginButton.isEnabled = user.count > 0 && pwd.count >= 6
}

zip:按顺序一一配对

1
2
let zipped = Signal.zip(numbersSignal, lettersSignal)
// (1,A), (2,B), (3,C)...

5.3 扁平化 (flatten)

merge:内层多个流的值按到达顺序全部输出

concat:按顺序消费内层流,前一个完成后才订阅下一个

latest:只保留「最新」的内层流,常用于搜索联想(新关键词来时取消旧请求)

1
2
3
4
5
6
7
searchKeywordSignal
.flatMap(.latest) { keyword in
searchAPI(keyword) // 新关键词会取消上一次请求
}
.observeValues { users in
self.updateUI(users)
}

5.4 错误处理

1
2
3
4
producer
.flatMapError { _ in SignalProducer(value: []) } // 失败时返回空数组
.retry(upTo: 3) // 失败重试 3 次
.mapError { CustomError.wrapped($0) } // 转换错误类型

六、源码与实现原理

6.1 Signal 的核心结构

Signal 通过闭包保存「如何向观察者推送事件」的逻辑,内部维护观察者列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Signal 的核心:Generator 闭包
// 当有观察者订阅时,闭包被调用,传入 Observer
public init(_ generator: (Observer) -> Disposable?)

// pipe 创建方式
public static func pipe() -> (Signal, Signal.Observer) {
var observer: Signal.Observer!
let signal = Signal { innerObserver in
observer = innerObserver
return nil
}
return (signal, observer)
}

observer.send(value:) 被调用时,所有已注册的观察者都会收到该值。

6.2 SignalProducer 的延迟执行

SignalProducer 保存的是「如何创建 Signal」的闭包,而不是 Signal 本身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 每次 start 时:
// 1. 创建一个新的 Signal(通过 pipe 或类似机制)
// 2. 执行 generator 闭包,将 observer 传入
// 3. 闭包内的逻辑开始执行,向 observer 发送事件
public func start(_ observer: Observer) -> Disposable {
let (signal, pipeObserver) = Signal.pipe()
let disposable = CompositeDisposable()
disposable += signal.observe(observer)
disposable += self.startWithSignal { signal, innerDisposable in
pipeObserver.observe(signal)
disposable += innerDisposable
}
return disposable
}

因此每次 start 都会触发一次完整的「创建 + 执行」过程。

6.3 操作符的链式调用

mapfilter 等操作符本质是创建新的 Signal/SignalProducer,内部订阅上游并转换后传给下游:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// map 的简化逻辑
extension Signal {
public func map<U>(_ transform: @escaping (Value) -> U) -> Signal<U, Error> {
return Signal { observer in
return self.observe { event in
switch event {
case let .value(value):
observer.send(value: transform(value))
case .completed:
observer.sendCompleted()
case let .failed(error):
observer.send(error: error)
case .interrupted:
observer.sendInterrupted()
}
}
}
}
}

七、实战示例

7.1 登录表单校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class LoginViewModel {
let username = MutableProperty("")
let password = MutableProperty("")

var canLogin: Property<Bool> {
return Property(
initial: false,
then: Signal.combineLatest(username.signal, password.signal)
.map { user, pwd in
user.count >= 3 && pwd.count >= 6
}
)
}

let loginAction: Action<(String, String), User, Error>

init() {
loginAction = Action(enabledIf: canLogin) { [weak self] _, input in
return loginAPI(username: input.0, password: input.1)
}
}
}

// ViewController 中绑定
viewModel.canLogin.signal
.observeValues { [weak loginButton] canLogin in
loginButton?.isEnabled = canLogin
}

loginButton.reactive.pressed = CocoaAction(viewModel.loginAction) { _ in
(viewModel.username.value, viewModel.password.value)
}

7.2 搜索联想(防抖 + 取消旧请求)

1
2
3
4
5
6
7
8
9
10
11
12
13
searchTextField.reactive.continuousTextValues
.filter { ($0 ?? "").count >= 2 }
.debounce(0.3, on: QueueScheduler.main)
.flatMap(.latest) { [weak self] keyword -> SignalProducer<[User], Error> in
guard let keyword = keyword, !keyword.isEmpty else {
return .init(value: [])
}
return self?.searchAPI(keyword) ?? .empty
}
.observe(on: UIScheduler())
.startWithValues { [weak self] users in
self?.updateSearchResults(users)
}

7.3 多数据源合并展示

1
2
3
4
5
6
7
8
9
10
11
12
13
let localUsers = loadLocalUsers()   // SignalProducer<[User], Never>
let remoteUsers = fetchRemoteUsers() // SignalProducer<[User], Error>

SignalProducer.combineLatest(
localUsers.flatMapError { _ in .init(value: []) },
remoteUsers.flatMapError { _ in .init(value: []) }
)
.map { local, remote in
mergeAndDeduplicate(local: local, remote: remote)
}
.startWithValues { [weak self] users in
self?.tableView.reload(with: users)
}

八、ReactiveCocoa 的 Cocoa 扩展

ReactiveCocoa 为 UIKit 提供了 reactive 命名空间下的扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
// UIControl 事件
button.reactive.controlEvents(.touchUpInside)
.observeValues { _ in print("点击") }

// UITextField 文本
textField.reactive.continuousTextValues
.observeValues { print($0 ?? "") }

// 双向绑定(需要导入 ReactiveCocoa)
label.reactive.text <~ viewModel.title // 单向:ViewModel -> Label

// CocoaAction 绑定按钮
button.reactive.pressed = CocoaAction(viewModel.submitAction)

九、最佳实践与注意事项

  1. 避免循环引用:在闭包中使用 [weak self],在适当时机 dispose 订阅
  2. 主线程更新 UI:使用 .observe(on: UIScheduler()) 确保 UI 更新在主线程
  3. 合理选择热/冷信号:异步任务、网络请求用 SignalProducer;UI 事件、通知用 Signal
  4. 错误类型:尽量使用 Never 表示不会失败的流,便于组合
  5. 利用 Lifetime:在 ViewController/View 销毁时通过 Lifetime 自动取消订阅

十、总结

概念 要点
Event 事件流的传输单元:value / failed / completed / interrupted
Signal 热信号,由所有者控制,多订阅共享
SignalProducer 冷信号,延迟执行,每次 start 独立运行
Property 有当前值、不失败的流,适合状态
Action 串行、可启用/禁用的操作封装
操作符 map、filter、combineLatest、flatMap 等组合与转换流
实践 表单校验、搜索联想、多源合并、UI 绑定

掌握这些概念后,你就能用响应式思维重构业务逻辑,写出更清晰、更易维护的 Swift/iOS 代码。


参考资源