Matrixzk’s Blog

keep moving

ARC 下向 NSArray 添加 Block 元素的一个小坑

May 18th, 2015

一直以来我都认为在 ARC 下,给 Cocoa 框架的集合类,如 NSArray,添加 Block 类型的元素时,Block 是会被编译器自动执行 copy 操作的。而且一直以来的实践也验证了这一事实。但今天在测试如下一段代码时出现了问题。

问题描述

先看下出问题的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
id getBlockArray() {
    int val = 12;
    NSArray *arr = [[NSArray alloc] initWithObjects:^{NSLog(@"block1 val = %d", val);},
                                                    ^{NSLog(@"block2 val = %d", val);},
                                                    nil];
    return arr;
}

typedef void (^MyBlock)(void);

int main(int argc, const char * argv[]) {

    id blkArray = [obj getBlockArray];
    MyBlock block1 = blkArray[0];
    MyBlock block2 = blkArray[1]; // EXC_BAD_ACCESS, crash !!!

    blcok1();
    blcok2();

    return 0;
}

如上所示,在获取数组中第二个 Block 元素时,crash 了,原因是 EXC_BAD_ACCESS,即访问了已被释放的无效内存。很奇怪,调试打印 arr,输出如下:

1
2
3
4
<__NSArrayM 0x100300500>(
    <__NSMallocBlock__: 0x100300410>,
    <__NSStackBlock__: 0x7fff5fbff750>
)

居然一个在堆上,一个在栈上,这。。。有些挑战三观了。

测试验证

确实很奇怪,那不妨来试试给数组填充元素的其他方式吧:

1
2
3
4
5
6
7
8
9
- (id)getBlockArray {
    int val = 10;

    NSMutableArray *arr = [[NSMutableArray alloc] init];
    [arr addObject:^{NSLog(@"block1 val = %d", val);}];
    [arr addObject:^{NSLog(@"block2 val = %d", val);}];

    return arr;
}

以及:

1
2
int val = 10;
NSArray *arr = @[^{NSLog(@"block1 val = %d", val);}, ^{NSLog(@"block2 val = %d", val);}];

这两种情况下调试打印 arr,输出如下:

1
2
3
4
<__NSArrayM 0x100100250>(
    <__NSMallocBlock__: 0x100200550>,
    <__NSMallocBlock__: 0x1003007e0>
)

可以看到都没问题,作为 addObject: 参数添加进来的两个 Block 元素,都被编译器自动执行了 copy 操作,这样 Block 的类型就变成了 __NSMallocBlock,被拷贝到了堆上。好险,三观稍微又正了点儿。但文章开头的问题究竟是什么原因呢?

探寻原因

比较上边的测试代码和出问题的代码,同样都是 ARC 的测试环境,为什么问题代码中数组的两个 Block 元素,第一个在堆上,第二个在栈上呢?联想到像测试代码中这样,将 Block 拷贝到堆上的操作是编译器在编译时完成的,那问题会不会出在初始化方法上呢?然后点开出问题的那个 API:

1
- (instancetype)initWithObjects:(id)firstObj, ... NS_REQUIRES_NIL_TERMINATION;

果然!这里的参数是可变个数的,而且只有第一个参数显式的声明为 id 类型。这下就能解释问题代码中,为什么第一个 Block 元素在堆上而第二个却在栈上:因为只有第一个参数显式的声明为 id 类型,所以编译器在编译阶段只能意识到需要对第一个作为参数传进来的 Block 进行 copy 处理。为了验证这一猜测,下面显式得把后边的 Block 传参强制转换为 id 类型,让编译器看到它:

1
2
3
NSArray *arr = [[NSArray alloc] initWithObjects:^{NSLog(@"block1 val = %d", val);},
                                                (id)^{NSLog(@"block2 val = %d", val);},
                                                nil];

代码顺利运行通过,没有 crash,猜测得到了验证。这真算是一个坑点儿。在 stackoverflow 上看到了一个对类似问题的讨论,可以参考下:Storing Blocks in an Array

扩展

另外需要注意的一点是,在 MRC 下,向方法或函数的参数中传递 Block 时,除了以下两种情况,都需要手动 copy 一下:

  • Cocoa 框架中的方法名含有 usingBlock 的方法。例如 NSArray 的 enumerateObjectsUsingBlock 实例方法。
  • GCD 的 API。例如,dispatch_async 函数。

在 ARC 下,除了上述两种情况外,在如下两种情况,编译器也帮我们自动做了 copy 操作:

  • Block 作为函数或方法的返回值返回时。(此场景和 ARC 下普通的对象作为函数或方法返回值返回时的场景一致)
  • 将 Block 赋值给附有 __strong 修饰符的变量时。(ARC 下的局部变量和成员变量默认都是 __strong 的,只是作用域不同)

这里有一个有趣的小测试Objective-C Blocks Quiz,可以测下自己对 Block 的理解。

返回顶部