Objective-C Block 从入门到面试

0 阅读25分钟

前言

Block 是 Objective-C 开发中非常常见的一种语法。网络请求回调、按钮点击、动画完成通知、GCD 异步任务和数组排序,都可能使用 Block。

很多人第一次接触 Block 时会觉得它只是“可以保存起来、以后执行的一段代码”。这个理解可以帮助入门,但面试还会继续追问:Block 如何保存外部变量?变量什么时候复制?为什么 Block 属性通常用 copy?__block 和 __weak 有什么区别?如何解决循环引用?

本文从最基础的使用方式开始,逐步解释 Block 的本质、变量捕获、三种类型、内存管理和循环引用,并把常见面试题串联起来。

一、Block 是什么

1. 先看一个普通函数

普通函数可以把一段逻辑封装起来,调用时执行:

void sayHello(void) {
    NSLog(@"Hello");
}
​
sayHello();

这个函数有一个特点:函数代码和调用环境是分开的。函数本身不会自动保存某个局部变量的值。

2. Block 的基本写法

Block 可以把一段代码保存成一个值,之后再调用:

void (^sayHello)(void) = ^{
    NSLog(@"Hello");
};
​
sayHello();

拆开看:

void (^sayHello)(void)
  • 第一个 void 表示 Block 的返回值类型。
  • ^ 表示这是一个 Block 类型。
  • sayHello 是变量名。
  • 最后的 (void) 表示 Block 不接收参数。

带参数和返回值的例子:

int (^sum)(int, int) = ^int(int a, int b) {
    return a + b;
};
​
int result = sum(2, 3);

如果编译器可以推断类型,也可以省略返回值类型:

int (^sum)(int, int) = ^(int a, int b) {
    return a + b;
};

3. Block 的本质

面试中可以先给出一句准确但容易理解的话:

Block 本质上是一个 Objective-C 对象,它封装了函数调用以及调用时需要的环境。

说它是对象,是因为 Block 内部也有类似 isa 的信息,可以使用 class 方法查看它的运行时类型。说它封装了环境,是因为 Block 会把需要在内部使用的外部变量保存下来。

因此,Block 不只是一个函数指针。函数指针只保存代码地址,而 Block 还可能保存:

  • 函数实现地址
  • 被捕获的外部变量
  • 描述 Block 大小和复制、销毁行为的信息
  • 运行时管理 Block 所需的其他标记

二、Block 是怎样被调用的

下面的代码包含三步:创建 Block、保存 Block、调用 Block。

void (^printName)(NSString *) = ^(NSString *name) {
    NSLog(@"%@", name);
};
​
printName(@"Jiayi");

从概念上看,编译器会把它转换成类似下面的结构。真实结构由编译器和运行时决定,以下代码只用于帮助理解:

struct BlockImpl {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    void *descriptor;
    // 这里还可能有捕获的变量
};
  • isa 让 Block 具有对象特征。
  • invoke 指向真正执行 Block 内容的函数。
  • descriptor 指向描述信息,例如 Block 大小和复制、销毁函数。
  • 如果 Block 捕获了外部变量,这些变量会成为 Block 结构的一部分,或者由 Block 结构间接指向。

面试回答

问:Block 的原理是什么?本质是什么?

可以回答:

Block 本质上是一个封装了函数调用和调用环境的 Objective-C 对象。编译器会把 Block 转换成结构体,结构体中包含 isa、调用函数指针、描述信息,以及捕获的外部变量。调用 Block 时,本质上是执行它保存的函数实现。

三、为什么 Block 可以访问外部变量

Block 有一个变量捕获机制。它会分析 Block 内部使用了哪些外部变量,并以不同方式保存它们。

int age = 20;
​
void (^block)(void) = ^{
    NSLog(@"age = %d", age);
};
​
block();

这里的 age 是 Block 外部定义的局部变量。Block 能在稍后执行时仍然访问它,是因为编译器捕获了 age 的值。

1. auto 局部变量:值传递

普通局部变量默认是 auto 变量。Block 捕获它时,通常会把变量当前的值复制一份到 Block 内部:

int age = 20;

void (^block)(void) = ^{
    NSLog(@"%d", age);
};

age = 30;
block(); // 通常输出 20

这里要注意两件事:

  • Block 捕获的是变量当时的值,不是外部变量本身。
  • 如果 Block 捕获的是对象类型的局部变量,复制的是对象指针,但 ARC 下会按照捕获方式管理对象的引用关系。

2. static 局部变量:指针传递

局部 static 变量的生命周期贯穿整个程序运行过程。Block 捕获它时,通常通过指针访问同一个变量:

static int age = 20;

void (^block)(void) = ^{
    NSLog(@"%d", age);
};

age = 30;
block(); // 输出 30

3. 全局变量:直接访问

全局变量的生命周期也贯穿程序运行过程。Block 不需要把它复制到内部,可以直接访问:

int globalAge = 20;

void (^block)(void) = ^{
    NSLog(@"%d", globalAge);
};

4. 捕获规则速记

变量类型Block 是否捕获典型访问方式
普通局部变量 auto是捕获值
局部静态变量 static是保存地址,通过指针访问
全局变量否直接访问

四、__block 到底有什么作用

1. 解决什么问题

普通局部变量被 Block 捕获后,Block 内部拿到的是它的一份值,默认不能直接修改外部变量:

int count = 0;

void (^block)(void) = ^{
    // count = 1; // 编译错误
};

使用 __block 后,编译器会把变量包装成一个可共享的 byref 结构,Block 和外部代码都可以访问这个共享变量:

__block int count = 0;

void (^block)(void) = ^{
    count = 1;
};

block();
NSLog(@"%d", count); // 1

所以,__block 的核心作用是:

允许 Block 内部修改局部变量,并让 Block 与外部代码共享这个变量。

2. __block 不能修饰什么

__block 主要用于局部变量,不能用于全局变量和已经是 static 的变量:

__block int globalValue;       // 不能这样使用全局变量
static __block int staticValue; // 不能这样组合使用

3. __block 的底层结构

为了让变量可以在 Block 和原作用域之间共享,编译器会生成类似这样的 byref 结构:

struct BlockByrefCount {
    void *isa;
    struct BlockByrefCount *forwarding;
    int flags;
    int size;
    int count;
};

关键是 forwarding 指针。变量最开始可能位于栈上,Block 被复制到堆上后,byref 变量也可能被复制到堆上。原来的结构会通过 forwarding 指向堆上的新结构,这样无论从栈上的 Block 还是堆上的 Block 访问,都能找到当前有效的变量。

开发者通常不需要手动操作这些结构。面试时理解它们的意义即可:__block 不是简单加了一个关键字,而是改变了变量的存储方式。

面试回答

问:__block 的作用是什么?

可以回答:

__block 用于解决 Block 内部不能修改普通局部变量的问题。编译器会把 __block 变量包装成 byref 结构,Block 和外部代码通过同一份存储访问它。它也会影响变量的内存管理,但它本身不等于 weak,也不能自动解决循环引用。

五、修改 NSMutableArray 需要 __block 吗

这是一个非常典型的面试题,关键在于区分“修改对象内容”和“修改变量指向”。

1. 修改数组内容,不需要 __block

NSMutableArray *array = [NSMutableArray array];

void (^block)(void) = ^{
    [array addObject:@"A"];
};

block();

这里没有给 array 变量重新赋值,只是向它指向的可变数组发送 addObject: 消息。因此不需要 __block。

2. 修改数组变量本身,需要 __block

__block NSMutableArray *array = [NSMutableArray array];

void (^block)(void) = ^{
    array = [NSMutableArray array];
};

block();

这里改变的是 array 这个变量保存的指针,所以需要 __block。

3. 不可变对象和可变对象的区别

下面两种操作含义不同:

NSString *name = @"A";
name = @"B";              // 修改变量指向,需要 __block

NSMutableString *text = [NSMutableString stringWithString:@"A"];
[text appendString:@"B"]; // 修改对象内容,不需要 __block

面试回答

如果只是通过数组指针修改 NSMutableArray 的内容,不需要 __block。如果要让 Block 内部给数组变量重新赋值,才需要 __block。判断标准是修改对象,还是修改变量本身。

六、Block 的三种类型

Block 按照存储位置通常分为三类。可以通过 class 方法或 isa 查看具体类型,但这些类名属于实现细节,不建议在业务逻辑中依赖它们。

1. Global Block

全局 Block 通常不访问外部的 auto 局部变量:

void (^globalBlock)(void) = ^{
    NSLog(@"global");
};

它可以放在程序的数据区域,不需要保存局部变量环境。全局 Block 调用 copy 后通常仍然是全局 Block,复制不会产生新的堆对象。

运行时名称通常类似:

__NSGlobalBlock__
_NSConcreteGlobalBlock

2. Stack Block

访问了 auto 局部变量的 Block,初始情况下通常位于栈上:

int age = 20;
void (^stackBlock)(void) = ^{
    NSLog(@"%d", age);
};

栈上的 Block 只在当前作用域有效。作用域结束后,不能再依赖这个栈 Block 继续存在。

运行时名称通常类似:

__NSStackBlock__
_NSConcreteStackBlock

3. Malloc Block

栈上的 Block 调用 copy 后会复制到堆上,成为 Malloc Block:

int age = 20;
void (^stackBlock)(void) = ^{
    NSLog(@"%d", age);
};

void (^heapBlock)(void) = [stackBlock copy];

运行时名称通常类似:

__NSMallocBlock__
_NSConcreteMallocBlock

4. 调用 copy 的结果

原来的 Blockcopy 后的结果
Global Block通常仍是 Global Block
Stack Block复制到堆上,变成 Malloc Block
Malloc Block引用计数增加,仍在堆上

Block_copy 和 Block_release 在 MRC 下更常见。ARC 下,编译器和对象属性会自动处理大部分复制和释放工作。

七、为什么 Block 属性通常使用 copy

1. 栈 Block 的生命周期问题

如果把一个访问了局部变量的 Block 直接保存起来,而没有复制到堆上,原作用域结束后,栈上的 Block 可能已经无效:

@interface Downloader : NSObject
@property (nonatomic, copy) void (^completion)(NSData *data);
@end

使用 copy 后,setter 会把栈上的 Block 复制到堆上,保证属性在作用域结束后仍然可以调用。

2. ARC 下为什么仍然建议 copy

ARC 会在部分场景自动复制 Block,例如:

  • Block 作为函数返回值
  • Block 赋值给强引用变量
  • Block 作为某些 Cocoa API 的参数
  • Block 作为 GCD API 的参数

但是,属性声明仍然应该表达清楚语义:这个属性需要拥有一份可以跨作用域保存的 Block。copy 是 Objective-C 中 Block 属性最明确、最稳妥的声明方式。

3. strong 和 copy 的区别

在 ARC 下,对 Block 属性使用 strong,系统通常也会保证 Block 被复制到堆上,因此许多情况下表现相同。但从属性语义和跨 ARC/MRC 兼容角度看,Block 属性更推荐写成:

@property (nonatomic, copy) void (^completion)(NSData *data);

MRC 下几乎必须使用 copy,否则保存栈 Block 会带来悬空指针问题。

4. ARC 下 assign 为什么仍然不安全

下面这种声明不适合作为长期保存 Block 的属性:

@property (nonatomic, assign) void (^completion)(NSData *data);

assign 只保存 Block 的地址,不会复制 Block,也不会建立持有关系。假设有以下代码:

- (void)setup {
    NSObject *object = [NSObject new];

    void (^tempBlock)(void) = ^{
        NSLog(@"%@", object);
    };

    self.completion = tempBlock;
}

ARC 下,tempBlock 作为默认的强引用局部变量,通常会让 Block 在这段代码执行期间具备堆上的生命周期。可是 setup 结束后,局部强引用 tempBlock 也会释放,而 assign 属性没有留下新的持有关系。如果没有其他对象持有这个 Block,Block 可能被释放,属性中留下的地址就会变成悬空指针。

因此,下面两件事要分开理解:

强引用局部变量:可能让 Block 在当前作用域内自动复制到堆上
copy 属性:让属性长期复制并持有 Block

ARC 常见的自动复制场景包括 Block 作为返回值、赋值给强引用变量、作为部分 Cocoa API 参数和作为 GCD API 参数。它只保证对应场景需要的生命周期,不会把 assign 属性自动变成 copy 属性。

5. 悬空指针和野指针

严格来说,两者有区别:

  • 悬空指针:指针原来指向有效对象,但对象已经释放,指针仍保存旧地址。
  • 野指针:指针没有正确初始化,或者指向完全不可用的地址。

assign 保存已经释放的 Block 地址时,准确说是形成了悬空指针。访问这个地址时,可能表现为野指针访问错误,例如 EXC_BAD_ACCESS。日常交流中,人们也常把悬空指针统称为野指针。

面试回答

问:Block 属性修饰词为什么是 copy?

Block 可能最初位于栈上,栈帧结束后就不能继续使用。copy 会把栈 Block 复制到堆上,使它可以跨作用域保存。ARC 会在一些场景自动复制 Block,但属性使用 copy 可以明确表达 Block 的生命周期语义,并兼容 MRC。

八、Block 捕获对象时的内存管理

理解 Block 的内存管理,要区分两种情况:Block 自己在哪里,以及 Block 捕获的变量是什么修饰符。

1. 栈上的 Block

栈上的 Block 生命周期很短。对于捕获的对象变量,不能简单地用“Block 一定强引用对象”来概括。真正稳定的生命周期管理通常发生在 Block 被复制到堆上时。

2. Block 复制到堆上

当 Block 被复制到堆上,系统会执行 Block 内部的 copy 逻辑。对于捕获的对象类型 auto 变量,ARC 会根据它的修饰符处理引用关系:

  • __strong:通常形成强引用。
  • __weak:保持弱引用,不增加对象引用计数。
  • __unsafe_unretained:不持有对象,也不会在对象释放后自动置 nil。

概念上可以理解为:

Block copy
    -> 处理捕获变量
    -> strong 变量 retain
    -> weak 变量注册弱引用
    -> unsafe_unretained 只保存地址

当堆上的 Block 被销毁时,系统会执行 dispose 逻辑,释放它持有的对象或 byref 变量。

PPT 中常见的底层函数名称包括:

_Block_object_assign
_Block_object_dispose

这些是编译器和 Block 运行时使用的内部机制。开发者一般不直接调用它们,但可以通过它们理解:Block 复制时建立引用关系,销毁时解除引用关系。

3. 捕获的是对象指针,不是对象的完整副本

Person *person = [[Person alloc] init];

void (^block)(void) = ^{
    NSLog(@"%@", person.name);
};

Block 捕获的是 person 这个指针变量。ARC 下,当 Block 需要长期保存并被复制到堆上时,默认会强引用该对象,而不是把 Person 对象深拷贝一份。

4. 把状态放到对象属性中

如果需要让多个 Block 共享一个状态,可以把状态放到对象的属性中。修改对象的属性不需要 __block:

@interface FinishState : NSObject
@property (nonatomic, assign) BOOL finished;
@end

@property (nonatomic, copy) void (^checkBlock)(void);

- (void)setup {
    FinishState *state = [FinishState new];

    void (^startBlock)(void) = ^{
        state.finished = YES;
    };

    self.checkBlock = ^{
        NSLog(@"%@", state.finished ? @"YES" : @"NO");
    };

    startBlock();
}

- (void)check {
    self.checkBlock();
}

startBlock 和 checkBlock 访问的是同一个 state 对象。checkBlock 被 copy 到堆上后,会继续持有捕获的 state,因此 setup 方法结束后,稍后调用 checkBlock 仍然可以读到 finished == YES。

这里的区别是:

state.finished = YES; // 修改对象属性,不需要 __block
state = [FinishState new]; // 修改 state 变量指向,需要 __block

九、__block 变量的内存管理

1. 基本过程

__block 变量有自己的 byref 结构。它从栈移动到堆时,也会参与 Block 的复制和销毁流程:

Block copy
    -> 复制 byref 结构
    -> 处理 byref 中保存的对象

Block dispose
    -> 销毁 byref 结构
    -> 释放 byref 中持有的对象

2. ARC 与 MRC 的差异

在 ARC 下,如果 __block 变量保存的是对象,byref 结构在移动到堆上后通常会按照 ARC 规则持有对象。因此,__block 在 ARC 下不再像早期 MRC 时代那样天然具有“不会 retain 对象”的效果。

在 MRC 下,__block 对象变量的行为不同,不能简单套用 ARC 的结论。面试回答时必须先说明内存管理环境。

3. 重要结论

__block 的主要职责是允许修改变量并共享存储位置。它不是专门用来弱引用对象的修饰符。

如果目标是避免 Block 持有对象,应明确使用 __weak,或者在不支持 ARC 的旧代码中使用 __unsafe_unretained,但后者有访问悬空指针的风险。

4. ARC 下仍然会使用 __block 的场景

ARC 并没有取消 __block。它仍然适合以下场景:

修改计数器或结果变量

__block NSInteger count = 0;

[array enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
    count++;
}];

让多个 Block 共享一个变量

__block BOOL finished = NO;

void (^startBlock)(void) = ^{
    finished = YES;
};

void (^checkBlock)(void) = ^{
    NSLog(@"%@", finished ? @"YES" : @"NO");
};

startBlock();
checkBlock();

如果 checkBlock 被 copy 到属性或传给会保存它的异步 API,finished 对应的 byref 存储也会随 Block 一起保留,之后仍能读到最新值。BOOL 本身不是对象,延长的是保存它的 byref 存储生命周期。

但如果只是修改 NSMutableArray 的内容,仍然不需要 __block。如果目的是避免 Block 强引用对象,则应该使用 __weak,不要依赖 __block。

十、Block 和循环引用

1. 循环引用是怎样形成的

典型场景如下:

@interface ViewController ()
@property (nonatomic, copy) void (^action)(void);
@end

@implementation ViewController

- (void)setup {
    self.action = ^{
        NSLog(@"%@", self.title);
    };
}

@end

引用关系是:

ViewController 强引用 Block
Block 强引用 ViewController

两边互相持有,引用计数无法归零,导致对象无法释放。

2. ARC 下用 __weak

推荐写法:

__weak typeof(self) weakSelf = self;

self.action = ^{
    NSLog(@"%@", weakSelf.title);
};

Block 只弱引用 self,因此 self 销毁时不会因为 Block 而继续存活。

3. 回调执行期间使用 strong 临时变量

如果回调执行过程中必须保证对象仍然存在,可以在 Block 内部临时建立强引用:

__weak typeof(self) weakSelf = self;

self.action = ^{
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return;
    }

    [strongSelf updateUI];
};

这段代码的含义是:Block 长期只弱引用 self,执行回调时,如果对象还存在,就在本次调用期间临时强引用它。

现代 Objective-C 也可以写成:

__weak typeof(self) weakSelf = self;
self.action = ^{
    typeof(self) self = weakSelf;
    if (!self) {
        return;
    }
    [self updateUI];
};

4. __unsafe_unretained 的风险

__unsafe_unretained id weakObject = self;

__unsafe_unretained 不会 retain 对象,也不会在对象销毁时自动置为 nil。对象释放后,变量可能仍然保存着原来的地址,再次访问可能造成野指针崩溃。

因此,ARC 下优先使用 __weak。只有在非常明确的性能、兼容性或底层场景中,才考虑 __unsafe_unretained。

5. __block 能不能解决循环引用

有时可以看到这种写法:

__block id blockSelf = self;

self.action = ^{
    NSLog(@"%@", blockSelf);
    blockSelf = nil;
};

它依赖 Block 执行后把 byref 变量置为 nil,从而打破引用关系。但它有前提:Block 必须确实执行,而且必须执行到清空变量的代码。如果 Block 永远不执行,循环引用仍然存在。

在 ARC 下,__block 对象变量在移动到堆上通常可能被强引用,因此不能把它当成通用的弱引用方案。一般优先使用 __weak。

6. MRC 下的旧方案

在 MRC 环境中,__block 变量和 ARC 下的内存管理行为不同,历史代码可能利用 __block 或 __unsafe_unretained 解决循环引用。但这类代码依赖手动内存管理,容易出现释放时机错误和野指针问题。维护旧项目时需要先确认是否开启 ARC,再判断方案。

面试回答

问:使用 Block 有哪些注意事项?

可以回答:

首先要注意 Block 的生命周期,长期保存时使用 copy。其次要注意变量捕获,普通局部变量按值捕获,需要修改时使用 __block。第三要注意 Block 与对象之间的循环引用,ARC 下通常使用 __weak,必要时在执行期间转成临时强引用。__block 主要用于修改变量,不能简单当作弱引用使用。

十一、Block 作为属性、参数和返回值

1. Block 作为属性

typedef void (^RequestCompletion)(NSData *data, NSError *error);

@interface RequestManager : NSObject
@property (nonatomic, copy) RequestCompletion completion;
@end

使用 typedef 可以让复杂的 Block 类型更容易阅读。

2. Block 作为方法参数

- (void)loadDataWithCompletion:(void (^)(NSData *data, NSError *error))completion;

更推荐使用类型别名:

typedef void (^LoadCompletion)(NSData *data, NSError *error);
- (void)loadDataWithCompletion:(LoadCompletion)completion;

3. Block 作为返回值

- (NSInteger (^)(NSInteger))makeCalculator {
    return ^NSInteger(NSInteger value) {
        return value * 2;
    };
}

Block 作为返回值时,ARC 通常会保证它能够离开当前作用域继续使用。MRC 下则需要特别注意返回 Block 的复制处理。

4. 回调执行时机

一个异步 API 接收了 Block,并不代表 Block 一定马上执行。Block 可能:

  • 立即同步执行
  • 在后台线程执行
  • 投递到主线程执行
  • 执行多次
  • 永远不执行,例如请求被取消或流程提前结束

因此设计回调 API 时,应明确线程、执行次数和是否可能不回调。调用者也不应该默认回调一定在主线程。

十二、Block 与 GCD

GCD 大量使用 Block:

dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
    NSData *data = [self loadData];

    dispatch_async(dispatch_get_main_queue(), ^{
        [self updateUIWithData:data];
    });
});

这里有两个需要注意的点:

  1. GCD 会保存异步任务 Block,因此 Block 及其捕获的对象可能比当前方法存活得更久。
  2. 外层 Block 如果强引用 self,而 self 又长期持有执行任务的对象或队列相关回调,就需要检查是否形成环。

常见写法:

__weak typeof(self) weakSelf = self;

dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
    NSData *data = [weakSelf loadData];

    dispatch_async(dispatch_get_main_queue(), ^{
        [weakSelf updateUIWithData:data];
    });
});

但不能机械地对所有 GCD Block 都使用 weak。一次性异步任务通常会自然结束,是否需要弱引用取决于任务生命周期和业务语义。更重要的是明确谁持有谁,以及任务取消时如何处理对象生命周期。

十三、Block、代理和通知的选择

1. Block 回调

适合一次性结果返回或逻辑简单的回调:

typedef void (^LoginCompletion)(BOOL success, NSError *error);

优点是调用位置集中、代码直观。风险是容易捕获 self 形成循环引用,也不适合表达大量不同事件。

2. Delegate

适合一个对象持续向另一个对象报告多个事件:

@protocol PlayerDelegate <NSObject>
- (void)playerDidStart;
- (void)playerDidFinish;
@end

代理通常使用 weak,避免被代理对象和代理方相互持有。

3. Notification

适合多个对象关注同一个广播事件,但关系较松散。使用通知时要注意及时移除观察者,避免事件来源和接收者难以追踪。

十四、Block 常见误区

误区 1:Block 就是函数指针

Block 不只是代码地址,还可能保存捕获变量和内存管理信息。函数指针不能直接完成同样的变量捕获能力。

误区 2:用了 __block 就没有循环引用

__block 解决的是变量修改和共享存储问题。在 ARC 下,__block 对象变量可能形成强引用,仍然可能循环引用。

误区 3:修改 NSMutableArray 必须加 __block

只修改数组内容不需要 __block。给数组变量重新赋值才需要。

误区 4:所有 Block 属性都应该用 strong

ARC 下某些场景 strong 和 copy 结果相近,但 Block 属性使用 copy 更明确,也兼容 MRC,是通用推荐写法。

误区 5:使用 weakSelf 就一定安全

weakSelf 只解决强引用环的一部分问题。它可能在异步回调执行前已经释放,导致回调什么也不做。还要考虑任务是否需要保证对象存活、回调是否在正确线程执行,以及回调是否会被重复调用。

误区 6:__unsafe_unretained 等同于 weak

两者都不会增加引用计数,但 weak 会在对象销毁后自动清零,__unsafe_unretained 不会,后者可能产生野指针。

十五、如何查看 Block 的类型和底层代码

1. 查看运行时类型

NSLog(@"%@", [block class]);
NSLog(@"%p", block);

运行时可能打印出:

__NSGlobalBlock__
__NSStackBlock__
__NSMallocBlock__

这些名称主要用于调试和学习,不建议业务代码依赖。

2. 将 Objective-C 转换成 C++

可以使用 Clang 查看编译器生成的近似 C++ 代码:

xcrun -sdk iphoneos clang -arch arm64 \
    -rewrite-objc \
    -fobjc-arc \
    -fobjc-runtime=ios-8.0.0 \
    main.m \
    -o main.cpp

如果源文件依赖 UIKit 等框架,可以补充:

-framework UIKit

转换后的代码可能看到类似 __block_literal_global、__block_impl、__Block_byref_xxx 等结构。不同 Xcode、Clang、架构和编译参数会生成不同细节,所以转换结果适合辅助理解,不应当当作稳定的公开 ABI。

3. 为什么可能出现 cannot create __weak reference

使用 Clang 转换包含 __weak 的代码时,可能因为没有开启 ARC 或没有指定合适的 Objective-C Runtime 而报错。可以确认:

  • 编译参数是否包含 -fobjc-arc
  • 是否指定了支持 weak 的运行时版本
  • 当前文件和工程是否使用 ARC

例如:

xcrun -sdk iphoneos clang -arch arm64 \
    -rewrite-objc \
    -fobjc-arc \
    -fobjc-runtime=ios-8.0.0 \
    main.m \
    -o main.cpp

十六、面试题集中回答

问:Block 的本质是什么?

答:Block 本质上是一个 Objective-C 对象,封装了函数实现和调用环境。它可以捕获外部变量,并在之后的时间和指定的线程中执行。

问:Block 有哪些类型?

答:通常分为 Global Block、Stack Block 和 Malloc Block。没有捕获 auto 局部变量的通常是 Global Block;捕获了 auto 变量的 Block 初始通常位于栈上;栈 Block 调用 copy 后会复制到堆上,成为 Malloc Block。具体类型属于实现细节。

问:为什么 Block 属性使用 copy?

答:Block 可能位于栈上,作用域结束后就不能继续使用。copy 会把栈 Block 复制到堆上,保证它可以跨作用域保存。ARC 会在强引用局部变量、返回值和部分 API 参数等场景自动处理复制,但 assign 属性不会因此获得长期持有关系。Block 属性使用 copy 可以明确表达生命周期语义,并兼容 MRC。

问:ARC 下 Block 属性使用 assign 会有问题吗?

答:会。assign 只保存 Block 地址,不复制也不持有 Block。即使 Block 赋值给强引用局部变量时被 ARC 自动复制到堆上,局部变量离开作用域后,Block 仍可能因为没有其他持有者而释放,assign 属性就会留下悬空指针。长期保存 Block 应使用 copy。

问:ARC 下强引用局部变量会自动复制 Block 吗?

答:通常会。Block 赋值给默认的强引用局部变量时,ARC 会使用适合 Block 的持有语义,使栈 Block 能在当前强引用生命周期内安全使用。但这只保证强引用变量本身的生命周期,不能替代属性的 copy。

问:__block 的作用是什么?

答:__block 让 Block 可以修改局部变量。编译器会把它包装成 byref 结构,Block 和外部代码共享同一份存储。它不等于 weak,在 ARC 下也不能把它当作通用的循环引用解决方案。

问:Block 修改 NSMutableArray 需要 __block 吗?

答:如果只是调用 addObject: 修改数组内容,不需要。若要给数组变量重新赋值,才需要 __block。判断关键是修改对象内容,还是修改变量本身。

问:Block 如何捕获变量?

答:普通局部 auto 变量通常按值捕获,局部 static 变量通常通过指针访问,全局变量不需要捕获而是直接访问。对象类型变量被捕获后,Block 被复制到堆时会按照 strong、weak 或 unsafe_unretained 等修饰符处理引用关系。

问:ARC 下如何解决 Block 循环引用?

答:如果对象强引用 Block,而 Block 又捕获对象,应使用 __weak typeof(self) weakSelf = self,让 Block 弱引用对象。执行回调时如果需要保证对象在本次调用期间存在,可以把 weakSelf 临时转成强引用。一次性 GCD 任务是否需要 weak,要根据生命周期判断。

问:__block 和 __weak 有什么区别?

答:__block 主要解决变量可修改和共享存储问题,__weak 主要解决对象引用关系和生命周期问题。一个关注变量存储方式,一个关注对象是否被持有,二者用途不同。

问:对象作为 Block 捕获变量时,一般需要 __block 吗?

答:不一定。只读取对象或修改对象内容时不需要,例如 [array addObject:obj]。只有重新给对象变量赋值时才需要 __block。如果需要避免 Block 强引用对象,则使用 __weak;ARC 下 __block 对象变量移动到堆后仍可能被强引用。

问:assign 造成的是悬空指针还是野指针?

答:严格来说是悬空指针,因为属性保存了已经释放对象的旧地址。访问悬空指针时,可能触发通常所说的野指针错误,例如 EXC_BAD_ACCESS。日常语境中两者经常混用。

十七、学习和排查 Block 问题的顺序

遇到 Block 相关问题时,可以按下面顺序分析:

  1. 先看 Block 的签名,确认参数和返回值是否匹配。
  2. 再看 Block 捕获了哪些外部变量。
  3. 判断捕获的是普通值、对象指针、static 变量还是全局变量。
  4. 判断 Block 是否会离开当前作用域,长期保存时确认是否需要 copy。
  5. 检查 Block 和对象之间是否互相强引用。
  6. 确认回调执行线程、执行次数和对象是否可能提前释放。
  7. 如果行为仍然不清楚,再用 class、断点和 Clang 转换结果辅助验证。

十八、总结

理解 Block 可以抓住四个核心问题:

Block 是什么?
封装了函数调用和调用环境的对象。

外部变量怎么进 Block?
auto 通常按值捕获,static 通过地址访问,全局变量直接访问。

为什么要 copy?
让可能位于栈上的 Block 复制到堆上,延长可用生命周期。

如何避免循环引用?
分析对象持有关系,ARC 下通常用 __weak,而不是把 __block 当成 weak。

面试时,先讲清楚这四点,再补充 Block 类型、byref 结构、forwarding 指针、ARC 与 MRC 差异以及 GCD 场景,答案就会从“记概念”变成“能解释实际问题”。