在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所在的函数名,编译器自动生成),简化后如下:
c
// 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的区别,可直接复制运行:
objectivec
#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);
}
运行结果:
markdown
全局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内部的副本,反之亦然。
objectivec
#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内部的访问结果,反之亦然。
objectivec
#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);
}
运行结果:
ini
Block内部a = 20
Block执行后,外部a = 30
场景3:访问全局变量/全局静态变量
全局变量、全局静态变量(定义在函数外部)存储在全局数据区,生命周期与程序一致,Block不捕获这类变量,直接通过地址访问,因此修改全局变量会直接影响Block内部的访问结果。
objectivec
#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);
}
运行结果:
ini
全局变量globalA = 100
全局静态变量staticGlobalA = 200
2. 特殊情况:__block修饰符(修改auto变量)
默认情况下,Block内部不能修改捕获的auto变量(因为访问的是副本,修改副本无意义),若想在Block内部修改auto变量,需给变量添加 __block修饰符。
原理:__block修饰的auto变量,会被包装成一个__Block_byref_XXX_0结构体(编译器自动生成),Block捕获的是该结构体的地址,而非变量本身,因此可以通过结构体修改原变量的值。
objectivec
#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);
}
运行结果:
ini
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行为:
scss
#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);
}
运行结果(关键部分):
markdown
全局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"的闭环。
objectivec
#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。
objectivec
- (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"):
scss
- (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的场景。
ini
- (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的简洁性,仅适用于简单场景。
objectivec
// 定义带参数的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方法的打印,验证循环引用是否被解决:
objectivec
#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解决,需注意异步场景的安全问题。