Block 是 Objective-C 里一个看似简单、实则很考功底的概念。日常写代码时,大家基本都把它当成“一段可以捕获变量的匿名函数”来用,但一旦涉及到 Block 的 copy、内存布局、捕获变量这些底层细节,很多人就开始含糊了。尤其是面试的时候,面试官特别喜欢从“Block 存哪里”“copy 之后发生了什么”这类问题一路追问下去。这篇文章我直接基于底层源码和实际调试经验,把 Block Copy 的内存布局这件事彻底讲透,顺便把我在实际工程里踩过的坑也一并放出来。
这篇文章适合三类人看:正在准备 iOS 面试、想深入理解 Block 底层原理的开发者,以及被 Block 循环引用、数组持有 Block 导致异常崩溃折磨过的同学。内容不会太长篇大论讲理论,而是直接对着结构体和拷贝流程逐步拆解,看完你就能自己在 Xcode 里验证。
1. 内容整体设计与思路拆解
1.1 为什么非要盯着 Block 的内存布局看
先抛一个很现实的问题:Block 在被定义、被赋值、被存储、被异步执行这几个环节里,内存位置其实一直在变化。很多人只知道“ARC 下 Block 会自动 copy”,但不知道为什么 copy、什么时候 copy、copy 前后捕获的变量发生了什么变化。这些问题的答案,全部藏在 Block 的内存布局里。
Block 本质上是一个结构体对象,只是它同时还携带了函数指针和捕获的上下文数据。它的内存布局决定了三件事:Block 是什么类型(全局、栈上还是堆上)、Block 捕获了哪些变量、这些变量是值拷贝还是引用拷贝。理解了布局,就相当于拿到了 Block 的解剖图,后面遇到任何诡异问题,都能对着图去定位。
所以这篇文章的整体思路就是:先把 Block 在底层的结构体定义啃下来,再对照三种 Block 类型的差异,最后重点拆解 copy 操作执行时布局发生了什么变化,以及这些变化在实际工程中的影响。
1.2 用一句大白话理解 Block 到底是什么
如果抛开官方定义,我会这样跟你描述 Block:它是一段代码 + 一份“当时的现场记录”。代码就是你要执行的逻辑,现场记录就是在 Block 定义那一刻,它要捕获的那些外部变量的值或引用。这份现场记录,就是通过结构体里的字段保存下来的。
为什么要用结构体而不是单纯的函数指针?因为函数指针只能指向一个确定的函数,但 Block 的执行结果依赖捕获的变量。比如你在循环里定义一个 Block 收集 i 的值,如果 Block 不把 i 保存下来,等循环结束再执行 Block 时,i 早变了。所以 Block 必须在定义时就把 i 的值或引用“冻结”到自己的结构体里,这就是所谓的“捕获”。
理解了这一点,就能明白为什么 Block 的底层结构体里既有 invoke 函数指针,又有保存捕获变量的空间。它们是 Block 这个“特殊对象”的两个核心组成。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Block 的底层结构:解剖内存布局的核心骨架
2.1 Block 结构体的标准字段解析
Block 在底层的结构体定义,可以从 Apple 开源代码里找到,我挑最核心的部分讲。Block 的实际结构如下:
c复制struct Block_layout {
void *isa; // 指向所属类的指针
int flags; // 标志位,记录 Block 类型、是否有签名等
int reserved; // 保留字段,暂时没用
void (*invoke)(void *, ...); // 函数指针,指向 Block 真正执行的代码
struct Block_descriptor *descriptor; // 描述信息,包括 copy/dispose 函数、签名等
// 捕获的变量从这里开始依次排列
};
这里最值得关注的是 isa 和 descriptor。isa 说明 Block 确实是一个对象,它的类型可能是 _NSConcreteGlobalBlock、_NSConcreteStackBlock 或 _NSConcreteMallocBlock 之一。descriptor 则描述了 Block 的“附加信息”,包括 Block 的大小、可选函数指针 copy/dispose、签名等等。
再往下看,结构体末尾紧跟着的就是捕获的变量。怎么排列?按照捕获顺序依次排下来,每个变量的对齐方式遵循 C 语言的对齐规则。
