iOS Block 底层深度解析:结构、变量捕获、copy逻辑与循环引用本质

在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 = 200

2. 特殊情况:__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底层结构,我们可以拆解循环引用的本质:

  1. self(ViewController对象)有一个strong属性myBlock,因此self会强引用myBlock(Block的引用计数+1);

  2. Block内部访问self,会捕获self(因为self是auto变量,Block会复制self的强引用到其结构体中),因此Block会强引用self(self的引用计数+1);

  3. 此时,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句话,覆盖所有高频考点:

  1. 本质:Block是OC对象,底层是包含isa指针、函数指针、捕获变量的结构体,继承自NSObject;

  2. 变量捕获:auto变量捕获值(副本),static变量捕获地址,全局变量不捕获;__block修饰auto变量可实现内部修改;

  3. copy逻辑:栈Block copy到堆,全局Block copy不变,堆Block copy仅retain;ARC环境下多种场景自动copy;

  4. 循环引用:本质是相互强引用(self→Block→self),常用__weak+weak-strong dance解决,需注意异步场景的安全问题。

相关推荐
传奇开心果编程18 分钟前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
tink2 小时前
告别 Xcode IDE:用 VS Code + SweetPad + XcodeGen 开发 iOS 应用的完整指南
ios·swiftui·xcode
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程5 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程5 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
黑科技iOS上架5 小时前
iOS隐私合规扫描工具
ios·审核
传奇开心果编程5 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
传奇开心果编程5 小时前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer
可乐鸡翅yeah_5 小时前
hls.js 缓冲区参数 maxBufferLength、maxBufferSize 通俗讲解与业务调参
开发语言·javascript·ios·音视频·safari·m3u8
茶底世界之下1 天前
Metal 冷启动并发下,Pipeline Cache 为什么必须合并在途编译
ios·swift