Block 一些疑问

注意:本文章不是讲解 Block 基础的文章,需要读者有一定的 Block 编程基础。

Block 在我们日常开发中经常会用到,但是它的本质是什么?

__block 是什么?有什么作用?有什么使用注意点?

Block 的属性应该使用什么修饰词?strong 还是 copy?为什么

Block 的代码块内部修改 NSMutableArray,需不需要添加 __block?


底层数据结构

Block 的本质​​是一个封装了函数指针和上下文数据的 ​​Objective-C 对象​。它的底层实现是结构体,这和 Objective-C 对象的底层实现一样​。这个函数指针指向的就是 Block 的主体内的代码所封装出来的函数。而上下文数据就是块主体内用到的其定义范围内的局部变量,可以是自动变量 auto(就是栈上分配的局部变量),也可以是静态变量 static。如果代码块内部没有用到外部的变量就不会被捕获上下文数据到 Block 中去。

我们创建一个 macOS 上的 Command Line Tool 程序来学习 Block 的底层实现。在 main.m 文件中的 main 函数中实现一个最简单的 Block 。如下:

1
2
3
4
5
6
7
8
9
#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
void (^aBlock)(void);
aBlock = ^{
printf("i'm block");
};
return 0;
}

这是一个没有返回值,没有参数,也没有调用的 Block。

Clang 是 LLVM 编译器的编译器前端,用来编译 C,C++,Objective-C 等语言的源码,Xcode 也是使用 Clang 实现的编译能力。它提供了一个 -rewrite-objc 的参数用来将 Objective-C 代码重写为等价的 C++ 代码,我们可以通过这种形式看到 Objective-C 源码的底层实现。打开终端并进入到 main.m 所在的目录,我们使用 Clang 将 main.m 文件重写为等价的 C++ 代码。如下图:

可以看到虽然给出了一些警告,但是并不影响最终成功生成 main.cpp 文件的结果。打开 main.cpp 文件之后,我们拉倒最底部可以看到我们 main 函数内部经过 Clang 重写之后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Block 的通用基础结构(简化)
struct __block_impl {
void *isa; // 指向 Block 的类信息(如 NSStackBlock、NSMallocBlock)
int Flags; // 状态标记(是否被拷贝、是否有析构函数等)
int Reserved; // 保留字段
void *FuncPtr; // 函数指针,指向 Block 实际执行的代码
};

// 具体 Block 的完整结构(包含捕获的变量)
struct __main_block_impl_0 {
struct __block_impl impl; // 基础结构
struct __main_block_desc_0* Desc; // 描述信息(大小、copy/dispose 函数)
// 结构体的构造函数,函数名跟结构体一样
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

// Block 的函数体封装成的函数
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
printf("i'm block");
}

// Block 的描述信息
static struct __main_block_desc_0 {
size_t reserved;
size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

int main(int argc, const char * argv[]) {
void (*aBlock)(void);
aBlock = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA));
return 0;
}

对比 main 函数的代码之后,我们会发现原来的最简单的一个 Block 经过编译器重写之后多出了三个结构体和一个函数。

  • static void __main_block_func_0() 这个函数的内部就是 Block 的 {} 内的代码。
  • struct __block_impl 这个结构体是所有 Block 的基础结构,第一个成员是 isa 指针,跟 NSObject 对象的第一个成员一模一样。有点所有具体 Block 类的抽象父类的意思。
  • struct __main_block_impl_0 这个结构体就是我们 main 函数中最简单的 Block 的底层实现。第一个成员就是上面的 Block 的基础结构体,第二个成员是 struct __main_block_desc_0* Desc 结构体指针。
  • static struct __main_block_desc_0 这个 Block 的描述信息

注意:需要明确的是这里我们通过 clang 的 -rewrite-objc 操作将 Objective-C 代码重写为 C++ 代码,但并不就意味着 Objective-C 的底层是由 C++ 实现的,Objective-C 的底层依旧是 C 实现的。Objective-C 的对外接口和 ABI 规范​​是纯 C 的,但其实现可能混合 C++ 和汇编优化。

struct __block_impl 的第一个成员变量是 isa,这就是 Objective-C 对象的底层表示。可能还是有很多人表示怀疑怎么就是 Objective-C 对象了,那么我可以贴上苹果文档中的原话。。。Although blocks are available to pure C and C++, a block is also always an Objective-C object.

在这个简单的 Block 的底层结构体 struct __main_block_impl_0 中,我们只看到它有一个函数指针 void *FuncPtr,指向了它的代码块内部封装成的函数。没有看到它封装的上下文数据,这是因为我们这个简单的 Block 体内并没有访问任何外部的变量,所以并不会将任何上下文数据捕获到 Block 内。

我们尝试将前面极其简单的 Block 进行一下升级,将在 Block 的内部访问外部一个基本数据类型 int a。代码如下:

1
2
3
4
5
6
7
8
9
10
#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
int a = 10;
void (^aBlock)(void);
aBlock = ^{
NSLog(@"a = %d", a);
};
return 0;
}

再次在终端输入 clang -rewrite-objc main.m 之后,会重新生成 main.cpp 文件并覆盖原来的 main.cpp 文件。下拉到最底部可以看到以下生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct __block_impl {
void *isa;
int Flags;
int Reserved;
void *FuncPtr;
};

struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int a; // 将局部变量 int a 捕获到了结构体内,
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _a, int flags=0) : a(_a) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
int a = __cself->a; // bound by copy
NSLog((NSString *)&__NSConstantStringImpl__var_folders_rt_zkm8hst55kv45x396jh95v3h0000gn_T_main_6474c8_mi_0, a);
}

static struct __main_block_desc_0 {
size_t reserved;
size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

int main(int argc, const char *argv[]) {
int a = 10;
void (*aBlock)(void);
aBlock = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, a));
return 0;
}

这一次我们明显的看到 struct __main_block_impl_0 结构体中多了一个成员 int a,这就是 Block 对外部变量的捕获。

Block 的几种类型

我们已经知道 Block 就是一个 Objective-C 对象,那么可以通过 class 方法,或者 isa 指针查看 Block 的类型。

1
2
3
4
5
6
7
8
9
10
int main(int argc, const char *argv[]) {
void (^aBlock)(void) = ^{
NSLog(@"hello world");
};
NSLog(@"%@", [aBlock class]);
NSLog(@"%@", [[aBlock class] superclass]);
NSLog(@"%@", [[[aBlock class] superclass] superclass]);
NSLog(@"%@", [[[[aBlock class] superclass] superclass] superclass]);
return 0;
}

控制台的输出如下:

1
2
3
4
5
__NSGlobalBlock__
NSBlock
NSObject
(null)
Program ended with exit code: 0

从打印的结果可以验证,Block 的祖先是 NSObject,那它当然是一个 Objective-C 对象,当然存在 isa 指针了。

其实在 Objective-C 中,Block 的类型主要根据其所在的内存分为三种,具体如下:

NSGlobalBlock(全局Block)

  • 定义:没有捕获 auto 变量(也就是栈上的局部变量)的 Block。
  • 内存位置:存储在程序的数据区(类似于全局变量),生命周期与程序一致。
  • 示例:
    1
    void (^globalBlock)(void) = ^{ NSLog(@"This is a global block."); };

NSStackBlock(栈Block)

  • 定义:捕获了 auto 变量的 Block,且未被复制到堆中。
  • 内存位置:存储在栈上,生命周期与当前函数栈帧一致,函数返回后可能被销毁。
  • 注意:在 ARC 环境下,编译器会自动将栈 Block 复制到堆,因此通常不会直接看到栈 Block。
  • 示例1(在 MRC 环境下):
    1
    2
    3
    int a = 10;
    void (^stackBlock)(void) = ^{ NSLog(@"Captured variable: %d", a); };
    // 未调用copy方法时,stackBlock为NSStackBlock。
  • 示例2(在 ARC 环境下)
    1
    2
    3
    4
    int a = 10;
    NSLog(@"%@", [^{
    NSLog(@"%d", a);
    } class]);

ARC 环境还是 MRC 环境可以在 Xcode 的 Build Settings 下的 Objective-C Automatic Reference Counting 设置。

NSMallocBlock(堆Block)​

  • 定义:栈 Block 被显式(MRC)或自动(ARC)复制到堆中后的形态。
  • 内存位置​​:存储在堆中,由引用计数管理生命周期,可被多次使用或跨线程传递。
  • 示例:
    1
    2
    3
    int a = 10;
    void (^heapBlock)(void) = [^{ NSLog(@"Captured variable: %d", a); } copy];
    // 在MRC中需手动调用copy,ARC会自动处理。

关于 Block 类型数量的争议

在 Objective-C 的 ​​语言层面​​,Block 主要分为 NSGlobalBlockNSStackBlockNSMallocBlock 三种类型。如果深入研究 LLVM 编译器有关 Block 的部分,或者 Block 的运行时库 libclosure。就会看到还存在一些特殊的类型 _NSConcreteFinalizingBlock _NSConcreteAutoBlock _NSConcreteWeakBlockVariable 但是这些类型通常对开发者不可见,属于内部实现细节。

变量捕获机制

在 Block 的代码块内部使用或者访问了全局变量,不论是普通的全局变量,还是静态的全局变量,所有全局变量均不会被 Block 捕获到结构体中​​,Block 直接通过地址访问它们。所以这种情况最简单也无需讨论了。

如果在 Block 的代码块内部访问或者使用了外部的局部变量,那么这些局部变量就会被捕获到 Block 的底层结构体中,这个就是 Block 的变量捕获机制。Block 的底层结构会根据捕获的变量类型和修饰符(如 __block)动态生成不同的结构体。其实更准确的说,Block 内部是一个函数栈,外部的局部变量也是一个函数栈,两个栈上的局部变量如果不进行特殊处理是无法互相访问和操作的,如果你的 Block 内部使用了外部的局部变量此时编译器会对 Block 内使用的外部变量进行特殊的处理。

那为啥 Block 的内部只捕获局部变量而不捕获全局变量呢?站在内存的角度来思考就很容易理解。局部变量的作用域结束后,其栈内存会被回收。Block 必须捕获其值或地址,以确保后续执行时仍能安全访问。

自动变量(auto 变量)

基本类型(如 int、float)

默认捕获变量的瞬时值,Block 内部不能修改。后续外部变量的修改不影响 Block 内的副本。

在 C 语言中,默认的局部变量就是 auto 变量,存储在栈上,由操作系统管理其内存。以下是 Block 捕获基本数据类型的 auto 变量的例子:

1
2
3
4
5
6
7
8
9
int main(int argc, const char *argv[]) {
int age = 10;
void (^aBlock)(void) = ^{
NSLog(@"age is %d", age); // 捕获 age 的值为 10
};
age = 20;
aBlock(); // 输出 10 而非 20
return 0;
}

使用 clang -rewrite-objc main.m 重新生成 main.cpp 文件之后查看生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int age;

__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int flags=0) : age(_age) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
int age = __cself->age; // bound by copy
NSLog((NSString *)&__NSConstantStringImpl__var_folders_rt_zkm8hst55kv45x396jh95v3h0000gn_T_main_6fec5e_mi_0, age);
}

static struct __main_block_desc_0 {
size_t reserved;
size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

int main(int argc, const char *argv[]) {
int age = 10;
// __main_block_impl_0 结构体中的 age 是通过值传递的方式捕获的
void (*aBlock)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age));
age = 20;
((void (*)(__block_impl *))((__block_impl *)aBlock)->FuncPtr)((__block_impl *)aBlock);
return 0;
}

如上,在源码中 Block 的内部使用到了外部定义的局部变量 age,那么 Block 的底层结构中就会多出一个成员变量 int age。观察生成的 C++ 代码之后会发现,Block 的主体部分封装成的函数 __main_block_func_0 中的确使用到了 age。但是这个变量 age 是存放在 struct __main_block_impl_0 结构体中的,并在创建结构体实例的时候使用外部的变量 age 进行了初始化,这样虽然类型和名字和外部的变量一模一样,但是这显然不是同一个变量。所以在外部无论如何修改 age 的值,是完全不会改变 Block 对象内部的 age 变量值的。

对象类型(如 NSObject)

捕获指针的副本,并强引用对象。Block 内部访问的是原始对象,但无法修改指针的指向。

1
2
3
4
5
6
NSString *str = @"Hello";
void (^block)(void) = ^{
NSLog(@"%@", str);
};
str = @"World";
block(); // 输出 "Hello"

想知道为什么会这样,同样可以查看 Clang 重写为 C++ 之后的底层实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct __block_impl {
void *isa;
int Flags;
int Reserved;
void *FuncPtr;
};

struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
NSString *str;
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, NSString *_str, int flags=0) : str(_str) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
NSString *str = __cself->str; // bound by copy
NSLog((NSString *)&__NSConstantStringImpl__var_folders_rt_zkm8hst55kv45x396jh95v3h0000gn_T_main_a30fa6_mi_1, str);
}

static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->str, (void*)src->str, 3/*BLOCK_FIELD_IS_OBJECT*/);}

static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->str, 3/*BLOCK_FIELD_IS_OBJECT*/);}

static struct __main_block_desc_0 {
size_t reserved;
size_t Block_size;
void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*);
void (*dispose)(struct __main_block_impl_0*);
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0};

int main(int argc, const char *argv[]) {
NSString *str = (NSString *)&__NSConstantStringImpl__var_folders_rt_zkm8hst55kv45x396jh95v3h0000gn_T_main_a30fa6_mi_0;
void (*block)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, str, 570425344));
str = (NSString *)&__NSConstantStringImpl__var_folders_rt_zkm8hst55kv45x396jh95v3h0000gn_T_main_a30fa6_mi_2;
((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
}

void (*block)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, str, 570425344)); 这句代码中是 Block 进行创建的时候,传入的参数是 str,而不是 &str。所以这跟 static 修饰的变量并不完全一样。此时 Block 底层结构体内的成员 NSString *str 和外部的 str 指向了同一个对象。但是对外部 str 的重新赋值并不会影响内部 str 的指向。

__block 修饰的变量

允许 Block 捕获变量的地址,支持在 Block 内外同步修改。变量会被移动到堆区以延长生命周期。至于 __block 的底层原理会在后面详细分析。

1
2
3
4
5
6
7
__block int a = 10;
void (^block)(void) = ^{
a = 30;
};
a = 20;
block();
NSLog(@"%d", a); // 输出 30

static 修饰的变量

对于全局的 static 变量来说直接访问内存地址,不会捕获到 Block 中去。但是对于局部的 static 变量来说同样是访问内存地址,但是会以指针形式被捕获到 Block 中去。Block 内外修改实时同步。以下演示的是局部 static 变量被捕获的底层实现。

修改源码如下:

1
2
3
4
5
6
7
8
9
10
11
int main(int argc, const char *argv[]) {
int age = 10;
static int height = 50;
void (^aBlock)(void) = ^{
NSLog(@"age is %d, height is %d", age, height);
};
age = 20;
height = 100;
aBlock();
return 0;
}

使用 clang -rewrite-objc main.m 重新生成 main.cpp 文件之后查看生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct __block_impl {
void *isa;
int Flags;
int Reserved;
void *FuncPtr;
};

struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int age;
int *height;
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int *_height, int flags=0) : age(_age), height(_height) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
int age = __cself->age; // bound by copy
int *height = __cself->height; // bound by copy
NSLog((NSString *)&__NSConstantStringImpl__var_folders_rt_zkm8hst55kv45x396jh95v3h0000gn_T_main_25f577_mi_0, age, (*height));
}

static struct __main_block_desc_0 {
size_t reserved;
size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

int main(int argc, const char *argv[]) {
int age = 10;
static int height = 50;
// 将 height 的地址传给了 Block
void (*aBlock)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age, &height));
age = 20;
height = 100;
((void (*)(__block_impl *))((__block_impl *)aBlock)->FuncPtr)((__block_impl *)aBlock);
return 0;
}

观察生成的 C++ 代码可知,static 变量也捕获到 Block 的内部去了。但是跟 auto 变量不同,Block 捕获了 static 变量的指针。在创建 Block 的时候将 height 的地址传入参数。这样不论在 Block 的内部还是外部,就都能实现读写这个变量。

实例变量

通过隐式捕获 self 间接访问。Block 会强引用 self,可能导致循环引用(若 self 持有 Block)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface MyClass : NSObject
@property int value;
@end

@implementation MyClass
- (void)test {
self.value = 10;
void (^block)(void) = ^{
NSLog(@"%d", self.value); // 输出 20
};
self.value = 20;
block();
}
@end

在 Objective-C 中,所有的方法,不论是对象方法还是类方法,在经过编译器处理之后都以 C 语言函数的形式存在底层,其中 C 函数的第一个参数就是 id self,第二个参数时 SEL _cmd 是方法名。如果 Objective-C 方法有参数,那么 C 函数的第三个参数就是方法的参数。在 C 语言中,参数也是局部变量,所以 Block 会捕获 self 到底层结构中。可以通过 Clang 进行验证。首先是源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@interface MyClass : NSObject
@property int value;
@end

@implementation MyClass
- (void)test {
self.value = 10;
void (^block)(void) = ^{
NSLog(@"%d", self.value); // 输出 20
};
self.value = 20;
block();
}
@end

int main(int argc, const char *argv[]) {
MyClass *obj = [MyClass alloc];
[obj test];
}

使用 Clang 重写为 C++ 代码之后,查看 Block 的底层结构体:

1
2
3
4
5
6
7
8
9
10
11
struct __MyClass__test_block_impl_0 {
struct __block_impl impl;
struct __MyClass__test_block_desc_0* Desc;
MyClass *self;
__MyClass__test_block_impl_0(void *fp, struct __MyClass__test_block_desc_0 *desc, MyClass *_self, int flags=0) : self(_self) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

可以看到 MyClass *self 被捕获到 Block 的底层结构中了。

Block 的 copy

在讲 Block 的 copy 之前,先简单介绍一下 MRC 和 ARC

MRC 是手动引用计数的意思,对象的释放与否完全和引用计数关联,当对象的引用计数为 0 时,对象会被释放,内存被系统回收。通过调用 retain 方法会让对象的引用计数加 1,通过调用 release 方法会让对象的引用计数减 1。

而 ARC 是自动引用计数,这里自动的意思其实是编译器帮程序员完成了内存管理,本质依旧是基于 MRC 的引用计数的,编译器分析源码之后在合适的位置插入了 retain 和 release,从而让程序员不需要显示调用 retain 和 release 管理对象内存了。

所以说在 Objective-C 中,如果是使用了 ARC 的情况下,程序员是不需要管理对象内存的,只需要注意对象间可能循环引用导致无法释放的问题。

在 ARC 环境下,编译器会根据情况自动将栈上的 Block 拷贝到堆上,比如以下情况:

  • Block 作为函数的返回值时
  • 将 Block 赋值给 strong 指针时
  • Block 作为 Cocoa API 中方法名含有 usingBlock 的方法参数时
  • Block 作为 GCD API 的方法参数时

在 MRC 环境中,Block 属性应该使用 copy 特性:
@property (nonatomic, copy) void (^block)(void);

在 ARC 环境中,Block 属性可以使用 strong 也可以使用 copy,即使使用 strong 在 ARC 下编译器依然会 copy。

对象类型的 auto 变量释放的问题

场景 1

我们看一下这个场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@interface Person : NSObject
@property int age;
@end

@implementation Person
- (void)dealloc {
NSLog(@"%s", __func__);
[super dealloc];
}
@end

int main(int argc, const char *argv[]) {
void (^block)(void);
{
Person *person = [Person alloc];
person.age = 10;
block = ^(){
NSLog(@"%d", person.age);
};
[person release];
}
block();
return 0;
}

这段代码是 MRC 环境下的,在执行 block() 的时候会发生崩溃:Thread 1: EXC_BAD_ACCESS (code=1, address=0x796a6bda0068)

为什么会发生崩溃呢?其实 Person 的实例 person 在 {} 内创建,同时也在 {} 内释放。再执行到 block() 的时候,person 实例早已经被系统回收了,再去访问当然报糟糕的访问错误。那怎么解决这个问题呢?对 block 调用一次 copy 就可以了。block 调用 copy 时,它会同时对它持有的对象进行内存管理。这样就可以解决这个访问错误问题了。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, const char *argv[]) {
void (^block)(void);
{
Person *person = [Person alloc];
person.age = 10;
block = [^(){
NSLog(@"%d", person.age);
} copy];
[person release];
}
block();
[block release];
return 0;
}

这也从侧面说明了,栈上的 Block 并不会对它持有的对象进行内存管理,所以在栈上的 Block 访问外部的对象类型局部变量的时候,是没有意义的,是一定会报错糟糕的访问的。所以到了 ARC 中,Block 内访问对象类型的 auto 变量的情况下编译器都会自动将 Block 拷贝到堆上。

场景 2

以下代码是在 ARC 环境下的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@interface Person : NSObject
@property int age;
@end

@implementation Person
- (void)dealloc {
NSLog(@"%s", __func__);
}
@end

int main(int argc, const char *argv[]) {
void (^block)(void);
{
Person *person = [Person alloc];
person.age = 10;
block = ^(){
NSLog(@"%d", person.age);
};
}
block();
return 0;
}

这段代码是没有问题的,能够按照预期执行的。Person 实例的释放是在 block 释放时发生的。如果我们在 block 内部访问 __weak 修饰的对象 auto 变量,会发生什么变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(int argc, const char *argv[]) {
void (^block)(void);
{
Person *person = [Person alloc];
person.age = 10;
__weak Person *weakperson = person;
block = ^(){
NSLog(@"%d", weakperson.age);
};
}
block();
return 0;
}

此时,观察程序的运行会发现,在 block() 调用之前,Person 的实例就早已经释放了,并且最终打印的结果也不是 10,而是 0。应该说这也是预期的结果吧,__weak 修饰的变量,即使 Block 被拷贝到堆中的时候,也不会对它持有的 Person *person 进行 retain 导致引用计数加 1。

可以使用以下命令重写为底层的 C++ 代码,可以查看到底层的 Block 结构体内的 person 实例也带上了 __weak 修饰符。

1
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc -fobjc-runtime=ios-5.0.0 main.m
1
2
3
4
5
6
7
8
9
10
11
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
Person *__weak weakperson;
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, Person *__weak _weakperson, int flags=0) : weakperson(_weakperson) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

测验1

以下代码中,person 实例是什么时候释放?touchesBegan: 方法执行完成就释放,还是 2 秒之后释放?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#import "ViewController.h"

@interface Person : NSObject
@end
@implementation Person
- (void)dealloc {
NSLog(@"%s", __func__);
}
@end

@implementation ViewController

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s", __func__);
Person *person = [Person alloc];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"%@", person);
});
}

@end

GCD 的 Block 编译器在编译时会自动调用 copy 方法将它拷贝到内存堆区中,那么 Block 就会对它持有的 Person *person 进行内存管理,使 person 实例的引用计数加 1。这样即使是 touchesBegan: 方法执行完了,person 实例也不会被立即释放,因为还有 Block 对它进行强引用,只有等 2 秒之后,Block 执行完成后被释放时,它会对持有的 person 实例进行一个 release 操作,如果此时 person 的引用计数也是 0 了,那么它也就被系统回收了。

测验2

以下代码中,person 实例是什么时候释放?touchesBegan: 方法执行完成就释放,还是 2 秒之后释放?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#import "ViewController.h"

@interface Person : NSObject
@end
@implementation Person
- (void)dealloc {
NSLog(@"%s", __func__);
}
@end

@implementation ViewController

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s", __func__);
Person *person = [Person alloc];
__weak Person *weakperson = person;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"%@", weakperson);
});
}

@end

根据前面所讲的内容,这也很容易知道答案了,person 在 touchesBegan: 方法执行完之后立即被释放。2 秒后的打印是 null。

测验3

person 实例是什么时候释放?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#import "ViewController.h"

@interface Person : NSObject
@end
@implementation Person
- (void)dealloc {
NSLog(@"%s", __func__);
}
@end

@implementation ViewController

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s", __func__);
Person *person = [Person alloc];
__weak Person *weakperson = person;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"1-------%@", person);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"2------%@", weakperson);
});
});
}

@end

1 秒后打印结果并释放,3 秒后打印 null。对于这种比较复杂的,有多层 Block 嵌套的情况,建议使用 Clang 重写为 C++ 代码之后,查看到底有几个 Block,每个 Block 究竟捕获了什么变量就能轻松的分析出打印的结果了。

测验4

person 实例是什么时候释放?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#import "ViewController.h"

@interface Person : NSObject
@end
@implementation Person
- (void)dealloc {
NSLog(@"%s", __func__);
}
@end

@implementation ViewController

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s", __func__);
Person *person = [Person alloc];
__weak Person *weakperson = person;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"1-------%@", weakperson);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"2------%@", person);
});
});
}

@end

3 秒之后释放。

__block 修饰符

在 Objective-C 中,__block 修饰符用于解决在 Block 内部修改外部变量的问题,并涉及到底层的内存管理机制。以下是详细解析:

一、__block 的作用

1. 允许 Block 修改外部变量

默认情况下,Block 捕获的外部变量是只读的(按值捕获)。使用 __block 后,变量变为可在 Block 内修改,例如:

1
2
3
4
__block int a = 10;
void (^block)(void) = ^{
a = 20; // 允许修改
};

2.延长变量的生命周期

__block 变量会随 Block 的拷贝(从栈到堆)而延长生命周期,确保在 Block 执行时变量依然有效。

3.避免循环引用时的注意事项

在 ARC 下,__block 变量会被 Block 强引用,可能导致循环引用。需配合 __weak 或手动置空解决:

1
2
3
4
5
6
7
__block __weak MyObject *weakObj = obj;
// 或
__block MyObject *strongObj = obj;
^{
[strongObj doSomething];
strongObj = nil; // 打破循环引用
};

二、底层实现原理

编译器会将 __block 修饰的变量包装在一个结构体中,处理内存管理和访问逻辑:

  1. 结构体封装
    变量被包裹在类似以下的结构体中:
    1
    2
    3
    4
    5
    6
    7
    struct __Block_byref_a_0 {
    void *__isa; // 类型指针
    __Block_byref_a_0 *__forwarding; // 指向当前实例的指针
    int __flags;
    int __size;
    int a; // 原始变量
    };
    Block 内部通过指针持有该结构体
  2. 内存管理
    • 栈到堆的拷贝:当 Block 被复制到堆时,__block 结构体也会被复制到堆,栈结构体的 __forwarding 指向堆上的新地址。
    • 统一的访问逻辑:无论结构体在栈还是堆,通过 __forwarding->a 访问变量,确保修改的是最终位置的值。
  3. ARC 与 MRC 的差异
    • MRC 下:__block 变量不会被 Block retain,需手动管理内存。这点需要特别注意!如在前面的场景 1 中,如果给 Person *person 加上了 __block 即使 Block 被拷贝到堆,person 的引用计数并没有增加。执行 Block 时就会发生崩溃。
    • ARC 下:编译器自动处理结构体的内存,对象类型变量会被强引用,可能需用 __weak 避免循环引用。

三、总结

  • 用途:允许 Block 内部修改外部变量,并管理其生命周期。
  • 原理:通过结构体封装变量,利用 __forwarding 指针处理内存迁移,确保跨栈/堆访问的一致性。
  • 注意事项:在 ARC 中需警惕循环引用,合理使用 __weak 或手动断开强引用。

通过 __block,Objective-C 实现了 Block 对外部变量的灵活操作,同时依赖编译器的底层支持确保内存安全。


Block 循环引用问题及解决方案

什么是循环引用?

  • 循环引用 发生在两个或多个对象之间互相强引用,导致对象无法正常释放,引发内存泄漏。
  • 在 Block 中,如果 Block 捕获了持有它的对象(如 self),而该对象又 直接或间接强引用了 Block,就会形成循环引用。

典型场景

1
2
3
4
5
6
7
8
9
10
11
12
// 示例:ClassA 强持有 myBlock,myBlock 内部又隐式强持有了 self(ClassA)
@interface ClassA : NSObject
@property (nonatomic, copy) void (^myBlock)(void);
@end

@implementation ClassA
- (void)setupBlock {
self.myBlock = ^{
[self doSomething]; // 隐式捕获 self(强引用)
};
}
@end

首先 ClassA 明显强持有 myBlock 这点从代码中可以清晰的看到,而 myBlock 的代码块中使用了 self 这个隐式参数,它是个局部变量,会被 Block 捕获到底层的结构体中,保持它原来的强弱性质。这里 self 作为隐式参数,默认就是强引用,所以就构成了循环引用。

解决方案

1. 使用 __weak 弱引用

  • 通过弱引用打破循环链
  • 代码示例:
1
2
3
4
5
6
7
- (void)setupBlock {
__weak typeof(self) weakSelf = self;
self.myBlock = ^{
__strong typeof(weakSelf) strongSelf = weakSelf; // 避免 weakSelf 提前释放
[strongSelf doSomething];
};
}

在 Block 内使用强指针并不是必须得。仅在某些场景下必须使用 strongSelf; 如 Block 内部使用了 GCD 的延迟函数,并在延迟函数的 Block 中需要用到 self。

如以下这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@interface ViewController ()
@property (nonatomic, copy) void (^block)(void);
@property (nonatomic, strong) NSString *name;
@end

@implementation ViewController

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s", __func__);
self.name = @"masterking";
__weak typeof(self) weakself = self;
self.block = ^{
__strong typeof(weakself) strongself = weakself;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"%@", strongself.name);
});
};
self.block();
}

如果不使用 strongself,在点击 ViewController 的视图时,会执行 Block,Block 内延迟 3 秒打印 name。如果在这 3 秒内退出了当前视图控制器,控制器会立即释放,GCD 会在 3 秒之后打印一个 null。使用 strongself 之后,即使在延迟的 3 秒内退出当前视图控制器,视图控制器也不会立即释放,而是等待 3 秒后的 GCD Block 执行完代码,释放 Block 的时候,对 ViewController 进行一次 release 才会被释放。

其实这个问题使用 clang 重写为底层的 C++ 代码之后,查看到底生成了几个 Block,每个 Block 捕获的成员是怎样的就很好理解了。控制器强引用着它的 Block,但是 Block 内使用的 weakself,所以它并没有对控制器强引用,这里不构成循环引用。而 GCD 的 Block 里面使用了 strongself,对控制器形成强引用,在 GCD 的 Block 拷贝到堆区时,会对它捕获的控制器进行一次 retain 使其引用计数加 1,所以即使时视图控制器从当前视图层次结构中移除,即正常的页面退出流程,它也不会被释放,因为 GCD 的 Block 还强引用着这个控制器,只有等 GCD 的 Block 执行完成,GCD 释放 Block 的时候,Block 释放它的成员的时候,控制器的引用计数才变为 0,内存被释放。

2.使用 __block 修饰符

  • 使用 __block 修饰符,在 Block 执行后主动断开引用,需要保证 Block 一定被执行,且最后手动置 nil 中断引用。
  • 代码示例:
1
2
3
4
5
6
7
8
- (void)setupBlock {
__block id blockSelf = self;
self.myBlock = ^{
[blockSelf doSomething];
blockSelf = nil; // 中断强引用
};
self.myBlock();
}

3.避免直接捕获 self

  • 若 Block 无需访问对象属性、方法,改用局部变量。或作为 Block 的参数传递
  • 代码示例1,改用局部变量:
1
2
3
4
5
6
- (void)setupBlock {
NSInteger value = self.age;
self.myBlock = ^{
NSLog(@"%ld", (long)value); // 不涉及 self
};
}
  • 代码示例2,将 self 作为 Block 参数传递:
1
2
3
4
5
6
- (void)setupBlock {
self.myBlock = ^(UIViewController *vc) {
NSLog(@"%@", vc);
};
self.myBlock(self);
}

4.使用 __unsafe_unretained 解决

__unsafe_unretained__weak 的关系

1.推出时间

__unsafe_unretained__weak 是在同一时期推出的,两者都是伴随 ARC(自动引用计数) 的引入而出现的(2011 年 WWDC 发布)。它们的设计初衷都是为了解决循环引用问题,但实现方式和安全性有所不同。

2.为什么需要 __unsafe_unretained

尽管 __weak 更安全,但 __unsafe_unretained 仍然存在的意义如下:

  1. 兼容性
    • 旧系统支持:__weak 要求最低部署目标为 iOS 5 或 macOS 10.7,而 __unsafe_unretained 可以在更早的系统中使用。
    • 非 Objective-C 对象:__weak 仅适用于 Objective-C 对象,而 __unsafe_unretained 可用于 Core Foundation 等非 Objective-C 对象。
  2. 性能优化
    • 零开销:__weak 在对象释放时会将指针自动置为 nil,这一操作需要运行时支持,可能带来轻微的性能损耗。而 __unsafe_unretained 无额外操作,性能更高。
    • 特定场景适用:在明确对象生命周期且能确保安全访问时(如短期局部变量),__unsafe_unretained 可避免 __weak 的开销
  3. 避免强引用副作用
    • 避免 nil 化:某些场景需要指针在对象释放后仍保留原地址(例如调试内存问题),__weak 的自动 nil 化会掩盖问题,而 __unsafe_unretained 能暴露野指针。

总结

  • 优先使用 __weak:绝大多数场景应选择 __weak,避免野指针崩溃。
  • 谨慎使用 __unsafe_unretained:仅在需要兼容旧系统、明确生命周期管理或性能敏感时使用,并确保不会访问已释放对象。