由 布多(budo) 发布于 2025-05-10
前言
block 是 iOS 开发中一个非常常用的特性,它允许我们将执行逻辑和数据封装成一个代码块,并将其作为函数参数、返回值进行传递。这种设计使得 block 在实现回调、异步操作、链式调用等场景时变得异常优雅和高效。block 最引人注目的特性在于它能够捕获并持有外部变量,这使得它在处理异步任务、事件响应等场景中表现出色,同时也为开发者提供了极大的灵活性。
本文将和大家一起深入剖析 block 的底层实现原理,从内部数据结构、内存管理机制到使用注意事项等多个维度,帮助读者全面理解这一技术的精髓,从而在实际开发中更好地运用 block 特性。
关于 block 的数据结构是开源的,在 libclosure 库中,感兴趣的可以去看一下。
block 的本质
int main(int argc, const char * argv[]) { |
上面这段代码展示了 block 的基本用法。为了深入理解 block 的底层实现原理,我们可以使用 clang 编译器将其转换为 C++ 代码。通过分析转换后的代码,我们可以清晰地看到 block 在底层的具体结构,包括其内部的数据组织方式、函数指针的存储位置以及内存布局等关键细节。
使用
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc -fobjc-runtime=ios-18.0.0 main.m
命令可以将 main.m 文件内的代码转换为 C++ 代码。 注意:转换后的 C++ 代码并不是真正执行的代码,仅供参考。
// block 的结构体。 |
通过分析转换后的代码,我们可以得出以下结论:
block 本质上是一个 ObjC 对象。因为在 ObjC 中,任何以
isa
指针作为首成员的结构体都可以被视为一个对象。从代码中可以看到,__block_impl
结构体的第一个成员就是void *isa
。block 的创建过程实际上是在初始化一个
__main_block_impl_XXX
结构体实例。这个结构体主要包含了:__block_impl
结构体(主要包含 isa 指针和函数指针);__main_block_desc_0
结构体指针(主要包含 block 相关的描述信息)。
block 的执行过程:
- 系统会将 block 的代码块编译成一个全局函数(如
__main_block_func_0
); - 这个全局函数的地址会被保存在
__block_impl
的FuncPtr
成员中; - 当调用 block 时,实际上是通过
FuncPtr
找到这个全局函数调用,并把 block 本身作为参数传递给这个函数。
- 系统会将 block 的代码块编译成一个全局函数(如
变量捕获机制
当 block 捕获外部变量时,编译器会根据捕获变量的类型和修饰符,生成不同的 block 结构体。这种设计使得 block 能够正确地管理不同类型变量的内存和生命周期。主要分为以下几种捕获情况:
- 捕获全局变量;
- 捕获局部变量(分为基本类型和对象类型);
- 捕获静态变量。
另外还有关于捕获 __block 修饰的变量,这个我们会在后面的 __block 修饰符详解 章节详细讨论。
int global_age = 30; |
这段代码展示了 block 捕获外部变量的几种典型场景:全局变量(global_age)、局部基本类型变量(age)、局部对象类型变量(per 和 weakPer)以及静态变量(static_age)。通过分析这些不同场景下的 block 实现,我们可以更全面地理解 block 的变量捕获机制。下面让我们通过转换后的 C++ 代码来深入分析其底层实现。
// block 的结构体。 |
通过分析转换后的代码,我们可以清晰地看到 block 捕获变量的本质:block 会将捕获的变量作为成员变量存储在其结构体中。根据变量的作用域和类型,block 的捕获机制可以分为以下几种情况:
局部变量:block 会将局部变量作为初始化参数传入,并作为成员变量存储在结构体中。这种捕获方式对基本类型和对象类型都适用,另外,如果捕获了对象类型,还会保留其内存修饰符(如 __strong、__weak)。
全局变量:由于全局变量在整个程序生命周期内都存在,block 不会对其进行捕获,而是在内部直接访问。
静态变量:block 会捕获局部 static 变量的指针而不是变量的值,并将其存储在结构体中。这样设计使得 block 可以访问和修改静态变量的值。
关于 block 的内存管理机制,主要涉及以下三个方面:
对象变量的捕获管理:在 ARC 环境下,当 block 捕获了对象类型的变量时,系统会调用
__main_block_copy_XXX
函数进行内存管理,确保对象在 block 执行期间不会被释放。对象变量的释放管理:当 block 被释放时,系统会调用
__main_block_dispose_XXX
函数,对捕获的对象变量进行适当的释放操作,防止内存泄漏。block 在捕获对象类型变量时,会同时捕获其内存修饰符(如 __strong、__weak)。从代码中可以看到,per 和 weakPer 在结构体中被分别声明为 __strong 和 __weak 类型。这种设计使得 block 能够正确处理对象的内存管理,这也是为什么在 block 内部使用 weak 引用可以有效避免循环引用的根本原因。
__block 修饰符详解
在 block 中,默认情况下捕获的变量是只读的,无法在 block 内部修改。这是因为 block 在捕获变量时,实际上是将变量的值复制到自己的结构体中。如果我们需要在 block 内部修改捕获的变量,并在 block 执行后保持这些修改,就需要使用 __block 修饰符。
int main(int argc, const char * argv[]) { |
通过 clang 编译器将上述代码转换为 C++ 代码后,我们可以看到 __block 修饰符的底层实现:
// __block 修饰符的结构体。 |
通过分析 clang 转换后的 C++ 代码,我们可以深入理解 __block 修饰符的底层实现机制:
__block 修饰符的本质是将变量包装成一个结构体对象(__block_byref_xxx),这个结构体包含了变量的值、__forwarding 指针等元数据。
当 block 捕获 __block 变量时,实际上捕获的是这个结构体对象的指针。这样设计的好处是:
- 通过指针访问,block 可以修改原始变量的值
- __forwarding 指针的存在,使得变量在 block 被拷贝到堆上时仍能正确访问
- 实现了变量的可修改性和数据同步
这种实现虽然巧妙,但也带来了一些性能开销:
- 每次访问变量都需要通过指针间接访问
- 结构体对象本身会占用额外的内存空间
- 在频繁访问的场景下可能会影响性能
因此,在使用 __block 修饰符时,需要权衡其带来的便利性和性能开销。对于简单的场景,可以考虑使用指针或其他替代方案。
下面这段代码展示了如何使用指针来替代 __block 修饰符,实现 block 内部修改外部变量的功能:
int main(int argc, const char * argv[]) { |
block 的三种类型
从 libclosure 库的源码中,我们可以看到 block 在底层实现中定义了 6 种类型:_NSConcreteStackblock、_NSConcreteMallocblock、_NSConcreteAutoblock、_NSConcreteFinalizingblock、_NSConcreteGlobalblock 和 _NSConcreteWeakblockVariable。
在实际开发中,我们最常遇到的是以下三种类型:
_NSConcreteGlobalblock(全局 block):这种类型的 block 存储在数据区(.data 段),特点是未捕获任何外部变量。由于不需要保存上下文,它的生命周期与程序相同,是最轻量级的 block 类型。
_NSConcreteStackblock(栈 block):这种类型的 block 存储在栈区,在 ARC 环境下我们很少直接遇到这种类型。这是因为 ARC 会自动将被强引用的 block 从栈上拷贝到堆上。
_NSConcreteMallocblock(堆 block):这种类型的 block 存储在堆区,是我们在 ARC 环境下最常见到的 block 类型。当 block 捕获了对象变量时,系统会自动将其拷贝到堆上,并调用
__main_block_copy_XXX
和__main_block_dispose_XXX
函数来管理捕获的对象变量的内存生命周期。
注意事项
在使用 block 时,我们需要注意以下几个关键点:
内存管理
在 ARC 环境下,block 会自动管理内存,但对于捕获的对象类型,block 会强引用该对象,此时需要特别注意循环引用问题。可惜使用 weak 引用来避免循环引用。
变量捕获机制
block 默认采用值捕获方式,会复制外部变量的值到 block 内部。如果你需要修改外部的变量值,可以使用 __block 修饰符。但使用 __block 修饰符会带来额外的内存开销,在性能敏感场景下,可以考虑使用指针替代 __block 修饰符。
线程安全
block 本身是线程安全的,但需要注意在多个线程的 block 中同时访问共享资源时可能存在线程安全问题。建议使用适当的同步机制(如 dispatch_sync、锁等)。
使用建议
- 优先使用普通变量而不是 __block,除非确实需要修改变量值
- 对于简单的回调场景,可以考虑使用 delegate 模式代替 block
- 在异步操作中,注意处理 block 的调用时机和内存管理
通过合理使用这些特性,我们可以充分发挥 block 的优势,同时避免常见的内存和性能问题。
总结
通过分析 clang 转换后的 C++ 代码和 libclosure 源码,我们可以清晰地看到 block 的本质:它是一个封装了函数执行逻辑和上下文环境的 ObjC 对象。
在编译阶段,编译器会进行以下转换:
- 将 block 的代码块编译成一个全局函数。
- 创建一个 block 对象,该对象主要包含:
- 指向全局函数的函数指针
- block 的描述信息(如类型、大小等)
- 捕获的外部变量
当 block 被调用时,系统会:
- 通过 block 对象获取内部函数指针
- 将 block 对象本身作为参数传递给该函数
- 函数内部可以通过 block 对象访问和修改捕获的变量
这种设计非常巧妙,使得 block 既能像普通对象一样进行内存管理,又能像函数一样被调用,同时还能捕获外部变量。这种多面性使得 block 成为了 iOS 开发中最强大的编程特性之一。