窥探block:iOS闭包底层原理完全解析

由 布多(budo) 发布于 2025-05-10

前言

block 是 iOS 开发中一个非常常用的特性,它允许我们将执行逻辑和数据封装成一个代码块,并将其作为函数参数、返回值进行传递。这种设计使得 block 在实现回调、异步操作、链式调用等场景时变得异常优雅和高效。block 最引人注目的特性在于它能够捕获并持有外部变量,这使得它在处理异步任务、事件响应等场景中表现出色,同时也为开发者提供了极大的灵活性。

本文将和大家一起深入剖析 block 的底层实现原理,从内部数据结构、内存管理机制到使用注意事项等多个维度,帮助读者全面理解这一技术的精髓,从而在实际开发中更好地运用 block 特性。

关于 block 的数据结构是开源的,在 libclosure 库中,感兴趣的可以去看一下。

block 的本质

int main(int argc, const char * argv[]) {
void (^block)(void) = ^{
printf("----block-----\n");
};
block();
return 0;
}

上面这段代码展示了 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 的结构体。
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;

__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) {
impl.isa = &_NSConcreteStackblock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

// block 的执行体。
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
printf("----block-----\n");
}

// block 的描述信息。
static struct __main_block_desc_0 {
size_t reserved;
size_t block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

struct __block_impl {
void *isa;
int Flags;
int Reserved;
// 指向 block 执行体的函数指针。
void *FuncPtr;
};

int main(int argc, const char * argv[]) {
void (*block)(void) = &__main_block_impl_0(__main_block_func_0, &__main_block_desc_0_DATA);
block->FuncPtr(block);
return 0;
}

通过分析转换后的代码,我们可以得出以下结论:

  1. block 本质上是一个 ObjC 对象。因为在 ObjC 中,任何以 isa 指针作为首成员的结构体都可以被视为一个对象。从代码中可以看到,__block_impl 结构体的第一个成员就是 void *isa

  2. block 的创建过程实际上是在初始化一个 __main_block_impl_XXX 结构体实例。这个结构体主要包含了:

    • __block_impl 结构体(主要包含 isa 指针和函数指针);
    • __main_block_desc_0 结构体指针(主要包含 block 相关的描述信息)。
  3. block 的执行过程:

    • 系统会将 block 的代码块编译成一个全局函数(如 __main_block_func_0);
    • 这个全局函数的地址会被保存在 __block_implFuncPtr 成员中;
    • 当调用 block 时,实际上是通过 FuncPtr 找到这个全局函数调用,并把 block 本身作为参数传递给这个函数。

变量捕获机制

当 block 捕获外部变量时,编译器会根据捕获变量的类型和修饰符,生成不同的 block 结构体。这种设计使得 block 能够正确地管理不同类型变量的内存和生命周期。主要分为以下几种捕获情况:

  1. 捕获全局变量;
  2. 捕获局部变量(分为基本类型和对象类型);
  3. 捕获静态变量。

另外还有关于捕获 __block 修饰的变量,这个我们会在后面的 __block 修饰符详解 章节详细讨论。

int global_age = 30;

int main(int argc, const char * argv[]) {
int age = 10;
static int static_age = 20;
Person *per = [[Person alloc] init];
__weak typeof(per) weakPer = per;

void (^block)(void) = ^{
printf("block: %p, %p, %d, %d, %d\n", per, weakPer, age, static_age, global_age);
};
block();
return 0;
}

这段代码展示了 block 捕获外部变量的几种典型场景:全局变量(global_age)、局部基本类型变量(age)、局部对象类型变量(per 和 weakPer)以及静态变量(static_age)。通过分析这些不同场景下的 block 实现,我们可以更全面地理解 block 的变量捕获机制。下面让我们通过转换后的 C++ 代码来深入分析其底层实现。

// block 的结构体。
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
Person *__strong per;
Person *__weak weakPer;
int age;
int *static_age;
};

// block 的执行体。
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
Person *__strong per = __cself->per;
Person *__weak weakPer = __cself->weakPer;
int age = __cself->age;
int *static_age = __cself->static_age;

printf("block: %p, %p, %d, %d, %d\n", per, weakPer, age, *static_age, global_age);
}

// 当 block 被拷贝到堆上时,会调用这个函数对捕获的对象变量进行内存管理。
static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {
_block_object_assign((void*)&dst->per, (void*)src->per, 3);
}

// 当 block 释放时,会调用这个函数对捕获的对象变量进行释放。
static void __main_block_dispose_0(struct __main_block_impl_0*src) {
_block_object_dispose((void*)src->per, 3);
}

// block 的描述信息。
static struct __main_block_desc_0 {
size_t reserved;
size_t block_size;
void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*);
void (*dispose)(struct __main_block_impl_0*);
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0};

int main(int argc, const char * argv[]) {
// 此处省略了 age、per、weakPer、static_age 的初始化代码。

void (*block)(void) = &__main_block_impl_0(__main_block_func_0,
&__main_block_desc_0_DATA,
per, weakPer, age,
&static_age, 570425344);
block->FuncPtr(block);
return 0;
}

通过分析转换后的代码,我们可以清晰地看到 block 捕获变量的本质:block 会将捕获的变量作为成员变量存储在其结构体中。根据变量的作用域和类型,block 的捕获机制可以分为以下几种情况:

  1. 局部变量:block 会将局部变量作为初始化参数传入,并作为成员变量存储在结构体中。这种捕获方式对基本类型和对象类型都适用,另外,如果捕获了对象类型,还会保留其内存修饰符(如 __strong、__weak)。

  2. 全局变量:由于全局变量在整个程序生命周期内都存在,block 不会对其进行捕获,而是在内部直接访问。

  3. 静态变量:block 会捕获局部 static 变量的指针而不是变量的值,并将其存储在结构体中。这样设计使得 block 可以访问和修改静态变量的值。

关于 block 的内存管理机制,主要涉及以下三个方面:

  1. 对象变量的捕获管理:在 ARC 环境下,当 block 捕获了对象类型的变量时,系统会调用 __main_block_copy_XXX 函数进行内存管理,确保对象在 block 执行期间不会被释放。

  2. 对象变量的释放管理:当 block 被释放时,系统会调用 __main_block_dispose_XXX 函数,对捕获的对象变量进行适当的释放操作,防止内存泄漏。

  3. block 在捕获对象类型变量时,会同时捕获其内存修饰符(如 __strong、__weak)。从代码中可以看到,per 和 weakPer 在结构体中被分别声明为 __strong 和 __weak 类型。这种设计使得 block 能够正确处理对象的内存管理,这也是为什么在 block 内部使用 weak 引用可以有效避免循环引用的根本原因。

__block 修饰符详解

在 block 中,默认情况下捕获的变量是只读的,无法在 block 内部修改。这是因为 block 在捕获变量时,实际上是将变量的值复制到自己的结构体中。如果我们需要在 block 内部修改捕获的变量,并在 block 执行后保持这些修改,就需要使用 __block 修饰符。

int main(int argc, const char * argv[]) {
__block int age = 10;

void (^block)(void) = ^{
age = 20;
};

block();
printf("age: %d", age);
}

通过 clang 编译器将上述代码转换为 C++ 代码后,我们可以看到 __block 修饰符的底层实现:

// __block 修饰符的结构体。
struct __block_byref_age_0 {
void *__isa;
__block_byref_age_0 *__forwarding;
int __flags;
int __size;
int age;
};

// block 的执行体。
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
__block_byref_age_0 *age = __cself->age;
age->__forwarding->age = 20;
}

int main(int argc, const char * argv[]) {
// 等价于 __block int age = 10;
__block_byref_age_0 age = {
0,
&age,
0,
sizeof(__block_byref_age_0),
20
};

void (*block)(void) = &__main_block_impl_0(__main_block_func_0,
&__main_block_desc_0_DATA,
&age,
570425344);

block->FuncPtr(block);
printf("age: %d", age.__forwarding->age);
}

通过分析 clang 转换后的 C++ 代码,我们可以深入理解 __block 修饰符的底层实现机制:

  1. __block 修饰符的本质是将变量包装成一个结构体对象(__block_byref_xxx),这个结构体包含了变量的值、__forwarding 指针等元数据。

  2. 当 block 捕获 __block 变量时,实际上捕获的是这个结构体对象的指针。这样设计的好处是:

    • 通过指针访问,block 可以修改原始变量的值
    • __forwarding 指针的存在,使得变量在 block 被拷贝到堆上时仍能正确访问
    • 实现了变量的可修改性和数据同步
  3. 这种实现虽然巧妙,但也带来了一些性能开销:

    • 每次访问变量都需要通过指针间接访问
    • 结构体对象本身会占用额外的内存空间
    • 在频繁访问的场景下可能会影响性能

因此,在使用 __block 修饰符时,需要权衡其带来的便利性和性能开销。对于简单的场景,可以考虑使用指针或其他替代方案。

下面这段代码展示了如何使用指针来替代 __block 修饰符,实现 block 内部修改外部变量的功能:

int main(int argc, const char * argv[]) {
int age = 10;
int *pointer_age = &age;
void (^block)(void) = ^{
*pointer_age = 20;
};
block();
printf("age: %d\n", age);
}

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 时,我们需要注意以下几个关键点:

  1. 内存管理

    在 ARC 环境下,block 会自动管理内存,但对于捕获的对象类型,block 会强引用该对象,此时需要特别注意循环引用问题。可惜使用 weak 引用来避免循环引用。

  2. 变量捕获机制

    block 默认采用值捕获方式,会复制外部变量的值到 block 内部。如果你需要修改外部的变量值,可以使用 __block 修饰符。但使用 __block 修饰符会带来额外的内存开销,在性能敏感场景下,可以考虑使用指针替代 __block 修饰符。

  3. 线程安全

    block 本身是线程安全的,但需要注意在多个线程的 block 中同时访问共享资源时可能存在线程安全问题。建议使用适当的同步机制(如 dispatch_sync、锁等)。

  4. 使用建议

    • 优先使用普通变量而不是 __block,除非确实需要修改变量值
    • 对于简单的回调场景,可以考虑使用 delegate 模式代替 block
    • 在异步操作中,注意处理 block 的调用时机和内存管理

通过合理使用这些特性,我们可以充分发挥 block 的优势,同时避免常见的内存和性能问题。

总结

通过分析 clang 转换后的 C++ 代码和 libclosure 源码,我们可以清晰地看到 block 的本质:它是一个封装了函数执行逻辑和上下文环境的 ObjC 对象。

在编译阶段,编译器会进行以下转换:

  1. 将 block 的代码块编译成一个全局函数。
  2. 创建一个 block 对象,该对象主要包含:
    • 指向全局函数的函数指针
    • block 的描述信息(如类型、大小等)
    • 捕获的外部变量

当 block 被调用时,系统会:

  1. 通过 block 对象获取内部函数指针
  2. 将 block 对象本身作为参数传递给该函数
  3. 函数内部可以通过 block 对象访问和修改捕获的变量

这种设计非常巧妙,使得 block 既能像普通对象一样进行内存管理,又能像函数一样被调用,同时还能捕获外部变量。这种多面性使得 block 成为了 iOS 开发中最强大的编程特性之一。

文章作者: 布多
文章链接: https://budo.top/2025/05/10/iOS/窥探block:iOS闭包底层原理完全解析/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 布多的博客