在iOS开发中,Block是Objective-C(以下简称OC)的核心特性之一,也是面试高频考点——从日常的UI回调、网络请求回调,到GCD异步任务,Block无处不在。但很多开发者对Block的认知仅停留在“匿名函数”的表层,不清楚其底层结构、变量捕获的规则、copy的底层逻辑,更难精准定位循环引用的本质,导致开发中频繁出现内存泄漏、崩溃等问题。
本文将从底层原理出发,结合objc4-818.2源码(适配iOS 13+),逐一对Block的底层结构、变量捕获规则、copy逻辑、循环引用本质四大核心点进行拆解,每个知识点都搭配可直接在Xcode中运行的实战示例,全程无冗余、重点突出,既适合新手入门,也适合开发者查漏补缺、深化理解。
前置说明:本文聚焦OC中的Block,Swift中的Block(闭包)有自身的底层实现,暂不展开;所有示例均基于64位架构(32位已淘汰),涉及的源码均做简化处理,保留核心逻辑,便于理解;文中涉及的内存地址、运行结果,可直接复制代码到Xcode中验证。
一、底层结构:Block本质是什么?(源码拆解)
很多人误以为Block是“函数”,但从底层来看,Block的本质是一个OC对象——它继承自NSObject,有自己的isa指针,内部封装了“函数实现地址”和“捕获的变量”,是一个“带状态的函数对象”。
1. Block底层结构体(objc4源码简化版)
在objc4源码中,Block的核心结构体是struct __block_impl和struct __XXX_block_impl_0(XXX为Block所在的函数名,编译器自动生成),简化后如下:
// Block的基础结构体(所有Block都包含该结构体) struct __block_impl { void *isa; // isa指针,标识Block的类型(如__NSGlobalBlock__、__NSStackBlock__、__NSMallocBlock__) int Flags; // 标志位,存储Block的状态(如是否可copy、是否已copy等) int Reserved; // 保留字段,用于后续扩展 void *FuncPtr; // 函数指针,指向Block的具体实现代码 }; // 自定义Block的结构体(编译器自动生成,命名格式为__函数名_block_impl_0) struct __main_block_impl_0 { struct __block_impl impl; // 内嵌基础结构体,包含isa、函数指针等核心信息 struct __main_block_desc_0* Desc; // 描述信息结构体,存储Block的大小、copy/destroy函数等 // 这里存储Block捕获的变量(捕获的变量会作为结构体成员存在) int a; // 示例:捕获的auto变量a __weak id weakSelf; // 示例:捕获的weak指针weakSelf }; // Block描述信息结构体(存储Block的元数据) struct __main_block_desc_0 { size_t reserved; // 保留字段 size_t Block_size; // Block的内存大小 void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*); // copy函数指针 void (*dispose)(struct __main_block_impl_0*); // 释放函数指针 };2. Block的三种类型(关键区分)
根据Block的存储位置和isa指针指向,Block分为3种类型,不同类型的copy逻辑、生命周期完全不同,这也是后续copy逻辑和内存管理的核心基础:
Block类型 | 存储位置 | isa指向 | 核心特点 |
全局Block(__NSGlobalBlock__) | 全局数据区 | _NSGlobalBlock | 不捕获任何变量,生命周期与程序一致,无需copy |
栈Block(__NSStackBlock__) | 栈内存 | _NSStackBlock | 捕获auto变量,生命周期随栈帧销毁而销毁,需copy到堆 |
堆Block(__NSMallocBlock__) | 堆内存 | _NSMallocBlock | 由栈Block copy而来,生命周期由引用计数管理,需手动管理内存 |
3. 实战示例1:区分三种Block类型
通过代码打印Block的类型,直观理解三种Block的区别,可直接复制运行:
#import <UIKit/UIKit.h> #import <objc/runtime.h> int main(int argc, char * argv[]) { NSString * appDelegateClassName; @autoreleasepool { // 1. 全局Block:不捕获任何变量 void (^globalBlock)(void) = ^{ NSLog(@"全局Block:不捕获任何变量"); }; NSLog(@"全局Block类型:%@", object_getClass(globalBlock)); // 2. 栈Block:捕获auto变量(未copy) int a = 10; void (^stackBlock)(void) = ^{ NSLog(@"栈Block:捕获auto变量a = %d", a); }; NSLog(@"栈Block类型:%@", object_getClass(stackBlock)); // 3. 堆Block:将栈Block copy到堆 void (^mallocBlock)(void) = [stackBlock copy]; NSLog(@"堆Block类型:%@", object_getClass(mallocBlock)); } return UIApplicationMain(argc, argv, nil, appDelegateClassName); }运行结果:
全局Block类型:__NSGlobalBlock__ 栈Block类型:__NSStackBlock__ 堆Block类型:__NSMallocBlock__补充说明:ARC环境下,某些场景(如将Block赋值给strong指针)会自动触发copy,将栈Block转为堆Block,后续会详细讲解。
二、变量捕获:Block如何“记住”外部变量?(核心规则)
Block的核心特性之一是“捕获外部变量”,即Block内部可以访问外部的变量,但并非所有变量都会被捕获,捕获规则由变量的存储类型决定(auto、static、全局变量),不同存储类型的变量,捕获方式和生命周期完全不同。
核心原则:Block只捕获“会被销毁的变量”,全局变量、static变量不会被销毁,因此Block不捕获其值,而是直接访问其地址;auto变量会随栈帧销毁,因此Block会捕获其值,形成副本。
1. 变量捕获的3种场景(附示例)
场景1:捕获auto变量(局部变量,默认auto)
auto变量是最常见的局部变量(如int a = 10),生命周期随函数栈帧销毁而销毁。Block捕获auto变量时,会复制变量的值到Block结构体中,Block内部访问的是副本,而非原变量,因此修改原变量不会影响Block内部的副本,反之亦然。
#import <UIKit/UIKit.h> int main(int argc, char * argv[]) { NSString * appDelegateClassName; @autoreleasepool { int a = 10; // auto变量(默认,可省略auto关键字) void (^block)(void) = ^{ // 访问的是捕获的副本,不是原变量a NSLog(@"Block内部a = %d", a); }; a = 20; // 修改原变量a的值 block(); // 执行Block,打印的是捕获时的副本(10) } return UIApplicationMain(argc, argv, nil, appDelegateClassName); }运行结果:Block内部a = 10
原理:Block捕获auto变量a时,会在其结构体(__main_block_impl_0)中添加一个int类型的成员变量a,将原变量a的值(10)复制到该成员变量中,后续Block内部访问的都是这个副本。
场景2:捕获static变量(静态局部变量)
static变量存储在全局数据区,生命周期与程序一致,不会随栈帧销毁。Block捕获static变量时,不复制值,而是捕获变量的地址,因此修改原变量会影响Block内部的访问结果,反之亦然。
#import <UIKit/UIKit.h> int main(int argc, char * argv[]) { NSString * appDelegateClassName; @autoreleasepool { static int a = 10; // static变量 void (^block)(void) = ^{ // 访问的是static变量的地址,不是副本 NSLog(@"Block内部a = %d", a); a = 30; // 通过地址修改原变量的值 }; a = 20; // 修改原变量a的值 block(); // 执行Block,打印20 NSLog(@"Block执行后,外部a = %d", a); // 打印30 } return UIApplicationMain(argc, argv, nil, appDelegateClassName); }运行结果:
Block内部a = 20 Block执行后,外部a = 30场景3:访问全局变量/全局静态变量
全局变量、全局静态变量(定义在函数外部)存储在全局数据区,生命周期与程序一致,Block不捕获这类变量,直接通过地址访问,因此修改全局变量会直接影响Block内部的访问结果。
#import <UIKit/UIKit.h> int globalA = 10; // 全局变量 static int staticGlobalA = 20; // 全局静态变量 int main(int argc, char * argv[]) { NSString * appDelegateClassName; @autoreleasepool { void (^block)(void) = ^{ // 直接访问全局变量地址,不捕获 NSLog(@"全局变量globalA = %d", globalA); NSLog(@"全局静态变量staticGlobalA = %d", staticGlobalA); }; globalA = 100; staticGlobalA = 200; block(); // 打印修改后的值 } return UIApplicationMain(argc, argv, nil, appDelegateClassName); }运行结果:
全局变量globalA = 100 全局静态变量staticGlobalA = 2002. 特殊情况:__block修饰符(修改auto变量)
默认情况下,Block内部不能修改捕获的auto变量(因为访问的是副本,修改副本无意义),若想在Block内部修改auto变量,需给变量添加__block修饰符。
原理:__block修饰的auto变量,会被包装成一个__Block_byref_XXX_0结构体(编译器自动生成),Block捕获的是该结构体的地址,而非变量本身,因此可以通过结构体修改原变量的值。
#import <UIKit/UIKit.h> int main(int argc, char * argv[]) { NSString * appDelegateClassName; @autoreleasepool { __block int a = 10; // __block修饰的auto变量 void (^block)(void) = ^{ a = 20; // 可以修改原变量的值 NSLog(@"Block内部修改后,a = %d", a); }; block(); NSLog(@"Block执行后,外部a = %d", a); // 打印20 } return UIApplicationMain(argc, argv, nil, appDelegateClassName); }运行结果:
Block内部修改后,a = 20 Block执行后,外部a = 20三、copy逻辑:Block为什么要copy?(底层流程)
结合前面的Block类型可知,栈Block的生命周期随栈帧销毁而销毁(比如函数执行完毕,栈帧释放,栈Block也会被销毁),若在栈Block销毁后再执行它,会导致野指针崩溃。因此,当我们需要在栈帧销毁后仍能使用Block时,必须将其copy到堆内存,转为堆Block——这就是Block copy的核心目的。
1. Block copy的底层流程(核心)
不同类型的Block,copy行为不同,底层流程可总结为3句话(重点):
全局Block(__NSGlobalBlock__):copy后还是自身,因为它本身存储在全局数据区,无需复制;
栈Block(__NSStackBlock__):copy后会生成一个新的堆Block(__NSMallocBlock__),将栈Block的内容(函数指针、捕获的变量)复制到堆中,同时修改isa指针指向;
堆Block(__NSMallocBlock__):copy后引用计数+1,本质是retain操作,不会生成新的Block。
2. ARC环境下的自动copy(关键细节)
在ARC环境下,编译器会自动对Block进行copy操作,避免栈Block销毁后崩溃,常见的自动copy场景:
将Block赋值给strong指针(如@property (strong, nonatomic) void (^block)(void););
将Block作为函数返回值返回;
将Block传入GCD函数(如dispatch_async、dispatch_after);
将Block赋值给NSArray、NSDictionary等容器。
3. 实战示例2:验证Block的copy逻辑
通过代码打印Block的地址和类型,验证不同类型Block的copy行为:
#import <UIKit/UIKit.h> #import <objc/runtime.h> int main(int argc, char * argv[]) { NSString * appDelegateClassName; @autoreleasepool { // 1. 全局Block copy void (^globalBlock)(void) = ^{ NSLog(@"全局Block"); }; void (^globalBlockCopy)(void) = [globalBlock copy]; NSLog(@"全局Block原地址:%p,copy后地址:%p", globalBlock, globalBlockCopy); NSLog(@"全局Block原类型:%@,copy后类型:%@", object_getClass(globalBlock), object_getClass(globalBlockCopy)); NSLog(@"------------------------"); // 2. 栈Block copy int a = 10; void (^stackBlock)(void) = ^{ NSLog(@"栈Block:a = %d", a); }; void (^stackBlockCopy)(void) = [stackBlock copy]; NSLog(@"栈Block原地址:%p,copy后地址:%p", stackBlock, stackBlockCopy); NSLog(@"栈Block原类型:%@,copy后类型:%@", object_getClass(stackBlock), object_getClass(stackBlockCopy)); NSLog(@"------------------------"); // 3. 堆Block copy(栈Block copy后得到) void (^mallocBlockCopy)(void) = [stackBlockCopy copy]; NSLog(@"堆Block原地址:%p,copy后地址:%p", stackBlockCopy, mallocBlockCopy); NSLog(@"堆Block原类型:%@,copy后类型:%@", object_getClass(stackBlockCopy), object_getClass(mallocBlockCopy)); } return UIApplicationMain(argc, argv, nil, appDelegateClassName); }运行结果(关键部分):
全局Block原地址:0x1000081c0,copy后地址:0x1000081c0 全局Block原类型:__NSGlobalBlock__,copy后类型:__NSGlobalBlock__ ------------------------ 栈Block原地址:0x7ff7bfeff3a0,copy后地址:0x6000000100008000 栈Block原类型:__NSStackBlock__,copy后类型:__NSMallocBlock__ ------------------------ 堆Block原地址:0x6000000100008000,copy后地址:0x6000000100008000 堆Block原类型:__NSMallocBlock__,copy后类型:__NSMallocBlock__结论:全局Block copy后地址、类型不变;栈Block copy后地址变化,类型转为堆Block;堆Block copy后地址、类型不变,仅引用计数+1。
四、循环引用:本质是什么?(避坑实战)
循环引用是Block开发中最常见的问题,也是面试重点——很多开发者只知道“用weakSelf避免循环引用”,却不清楚循环引用的本质,导致遇到复杂场景时仍会踩坑。
核心结论:Block循环引用的本质是“相互强引用”——对象强引用Block,Block同时强引用该对象,形成闭环,导致两者都无法被系统释放,进而造成内存泄漏。
1. 循环引用的典型场景(示例)
最常见的场景:ViewController中定义一个strong修饰的Block属性,Block内部访问self(强引用self),形成“self → Block → self”的闭环。
#import <UIKit/UIKit.h> @interface ViewController : UIViewController // strong修饰的Block属性(self强引用Block) @property (strong, nonatomic) void (^myBlock)(void); @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; // Block内部访问self(Block强引用self) self.myBlock = ^{ NSLog(@"Block内部访问self:%@", self); }; } // 析构函数,验证是否释放 - (void)dealloc { NSLog(@"ViewController dealloc"); } @end问题:当ViewController被pop或dismiss后,dealloc方法不会被调用——因为self强引用myBlock,myBlock强引用self,两者形成循环引用,无法被释放,造成内存泄漏。
2. 循环引用的本质拆解(结合底层结构)
结合前面的Block底层结构,我们可以拆解循环引用的本质:
self(ViewController对象)有一个strong属性myBlock,因此self会强引用myBlock(Block的引用计数+1);
Block内部访问self,会捕获self(因为self是auto变量,Block会复制self的强引用到其结构体中),因此Block会强引用self(self的引用计数+1);
此时,self和Block相互强引用,引用计数都无法降为0,系统无法释放它们,形成内存泄漏。
3. 避坑方案:3种常用方式(附示例)
方案1:使用__weak修饰self(最常用)
在Block外部定义一个__weak修饰的weakSelf,Block内部访问weakSelf(弱引用),打破“相互强引用”的闭环——weakSelf不会增加self的引用计数,当self被释放时,weakSelf会自动置为nil。
- (void)viewDidLoad { [super viewDidLoad]; // 定义weakSelf,弱引用self __weak typeof(self) weakSelf = self; self.myBlock = ^{ // Block内部访问weakSelf(弱引用,不增加self的引用计数) NSLog(@"Block内部访问weakSelf:%@", weakSelf); }; }补充:若Block内部有异步操作(如网络请求、延迟执行),需在Block内部再用__strong修饰weakSelf,避免self在异步操作执行前被释放(即“weak-strong dance”):
- (void)viewDidLoad { [super viewDidLoad]; __weak typeof(self) weakSelf = self; self.myBlock = ^{ // 异步操作前,强引用weakSelf,避免self被释放 __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; // 防止self已释放 // 执行异步操作 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ NSLog(@"异步操作执行:%@", strongSelf); }); }; }方案2:使用__block修饰self(ARC环境下需手动置nil)
用__block修饰self(此时Block捕获的是__Block_byref结构体的地址),在Block执行完毕后,手动将self置为nil,打破循环引用——适用于需要在Block内部修改self的场景。
- (void)viewDidLoad { [super viewDidLoad]; // __block修饰self(ARC环境下,__block修饰的对象会被强引用) __block typeof(self) blockSelf = self; self.myBlock = ^{ NSLog(@"Block内部访问blockSelf:%@", blockSelf); // Block执行完毕后,手动置nil,打破循环引用 blockSelf = nil; }; // 必须执行Block,否则blockSelf不会置nil,仍会内存泄漏 self.myBlock(); }方案3:使用第三方参数传递self(不推荐,仅作了解)
将self作为Block的参数传递,Block内部通过参数访问self,不捕获self,从而避免循环引用——缺点是破坏Block的简洁性,仅适用于简单场景。
// 定义带参数的Block属性 @property (strong, nonatomic) void (^myBlock)(ViewController *); - (void)viewDidLoad { [super viewDidLoad]; self.myBlock = ^(ViewController *vc) { // 通过参数访问self,不捕获self NSLog(@"Block内部访问self:%@", vc); }; // 调用Block时,传递self self.myBlock(self); }4. 实战示例3:排查循环引用(验证是否释放)
通过dealloc方法的打印,验证循环引用是否被解决:
#import <UIKit/UIKit.h> @interface ViewController : UIViewController @property (strong, nonatomic) void (^myBlock)(void); @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; // 方案1:weakSelf + weak-strong dance __weak typeof(self) weakSelf = self; self.myBlock = ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ NSLog(@"异步操作执行:%@", strongSelf); }); }; // 执行Block self.myBlock(); } - (void)dealloc { NSLog(@"ViewController dealloc"); // 能打印,说明无循环引用,已释放 } @end运行结果:当ViewController被pop后,会打印“ViewController dealloc”,说明循环引用已解决,对象正常释放。
五、总结:Block核心要点(面试必记)
结合前面的底层解析和实战示例,Block的核心要点可总结为4句话,覆盖所有高频考点:
本质:Block是OC对象,底层是包含isa指针、函数指针、捕获变量的结构体,继承自NSObject;
变量捕获:auto变量捕获值(副本),static变量捕获地址,全局变量不捕获;__block修饰auto变量可实现内部修改;
copy逻辑:栈Block copy到堆,全局Block copy不变,堆Block copy仅retain;ARC环境下多种场景自动copy;
循环引用:本质是相互强引用(self→Block→self),常用__weak+weak-strong dance解决,需注意异步场景的安全问题。