【iOS底层分析】OC闭包 & Swift闭包

2,010 阅读9分钟

基础

  • Block是⼀个自包含的(捕获了上下⽂的常量或者是变量的)函数代码块,可以在代码中被传递和使用。
  • 全局和嵌套函数实际上也是特殊的闭包,闭包采用如下三种形式之一:
    • 全局函数是一个有名字但不会捕获任何值的闭包
    • 嵌套函数是一个有名字并可以捕获其封闭函数域内值的闭包
    • 闭包表达式是一个利用轻量级语法所写的可以捕获其上下文中变量或常量值的匿名闭包

OC-Block

分类

  1. NSGlobalBlock
    • 位于全局区
    • 在Block内部不使用外部变量,或者只使用静态变量和全局变量
  2. NSMallocBlock
    • 位于堆区
    • 被强持有
    • 在Block内部使用局部变量或OC属性,可以赋值给强引用/copy修饰的变量
  3. NSStackBlock
    • 位于栈区
    • 没有被强持有
    • 在Block内部使用局部变量或OC属性,不能赋值给强引用/copy修饰的变量

如下简单demo code所示

int a = 10; // 局部变量
    
void(^Global)(void) = ^{
    NSLog(@"Global");
};
    
void(^Malloc)(void) = ^{
    NSLog(@"Malloc,%d",a);
};
    
void(^__weak Stack)(void) = ^{
    NSLog(@"Stack,%d",a);
};

NSLog(@"%@",Global); // <__NSGlobalBlock__: 0x101aa80b0>
NSLog(@"%@",Malloc); // <__NSMallocBlock__: 0x600003187900>
NSLog(@"%@",Stack); // <__NSStackBlock__: 0x7ff7b12c22f0>

下面重点介绍堆Block。

NSMallocBlock

Block拷贝到堆Block的时机:

  1. 手动copy
  2. Block作为返回值
  3. 被强引用/copy修饰
  4. 系统API包含using Block

所以总结一下堆Block判断依据:

  1. Block内部有没有使用外部变量
  2. 使用的变量类型?局部变量/OC属性/全局变量/静态变量
  3. 有没有被强引用/copy修饰

源码探究

我们创建一个捕获了局部变量的block

#import <Foundation/Foundation.h>

void test() {
    int a = 10;

    void(^Malloc)(void) = ^{
        NSLog(@"%d",a);
    };
}

执行clang -rewrite-objc main.m -o main.cpp命令,查看main.cpp文件可以看到Malloc闭包的结构如下。

struct __test_block_impl_0 {
  struct __block_impl impl;
  struct __test_block_desc_0* Desc;
	
	// 内部存储了变量a
  int a;

	/// 初始化函数。包含三个参数
	// - Parameters:
  ///   - fp: 函数指针
  ///   - desc: 描述
  ///   - _a: flag
	
  __test_block_impl_0(void *fp, struct __test_block_desc_0 *desc, int _a, int flags=0) : a(_a) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

// 创建Malloc闭包,传入参数如下
// fp: (void *)__test_block_func_0
// desc: &__test_block_desc_0_DATA
// _a: 变量a的值(值拷贝)
void(*Malloc)(void) = ((void (*)())&__test_block_impl_0((void *)__test_block_func_0, &__test_block_desc_0_DATA, a));

// __test_block_func_0实现如下
static void __test_block_func_0(struct __test_block_impl_0 *__cself) {
  int a = __cself->a; // bound by copy
			NSLog(···);
    }

Untitled

打开llvm可以看到,该block原本是在栈上,调用了objc_retainBlock方法,而在该方法中实际调用了_Block_copy方法。

在Block.h的源码中可以找到_Block_copy方法,其官方注释是“创建一个基于堆的Block副本,或者简单地添加一个对现有Block的引用。”,从而将这个栈block拷贝到了堆上,下面我们根据该方法的源码来探究一下堆Block的原理。(只截取重点代码)

void *_Block_copy(const void *arg) {
    return _Block_copy_internal(arg, true);
}

static void *_Block_copy_internal(const void *arg, const bool wantsOne) {
    struct Block_layout *aBlock;

		···
    
		// 类型强转为Block_layout
    aBlock = (struct Block_layout *)arg;

		···
    // Its a stack block.  Make a copy.
		// 分配内存
		struct Block_layout *result = malloc(aBlock->descriptor->size);
		if (!result) return NULL;
		memmove(result, aBlock, aBlock->descriptor->size); // bitcopy first
		// reset refcount
		result->flags &= ~(BLOCK_REFCOUNT_MASK|BLOCK_DEALLOCATING);    // XXX not needed
		result->flags |= BLOCK_NEEDS_FREE | 2;  // logical refcount 1
		// isa重新标记为Malloc Block
		result->isa = _NSConcreteMallocBlock;
		_Block_call_copy_helper(result, aBlock);
		return result;
}

Block底层结构为Block_layout

struct Block_layout {
    void *isa;  // isa指针
    volatile int32_t flags; // contains ref count
    int32_t reserved; // 保留位
    void (*invoke)(void *, ...); // call out funtion
    struct Block_descriptor_1 *descriptor;
};

总结:

Block在运行时才会被copy,在堆上开辟内存空间。

循环引用

解决方案

  1. __weak + __strong

    思路: 在block里短暂持有self的生命周期。(weak 自动置空)

    self.name = @"YK";
    
    __weak typeof(self) weakSelf = self;
    self.block = ^{
        __strong typeof(self) strongSelf = weakSelf;
        strongSelf.callFunc();
    };
    
  2. __block

思路: 值拷贝。(手动置空)

我们有如下代码,生成cpp文件看一下

#import <Foundation/Foundation.h>

void test() {
    __block int a = 10;

    void(^Malloc)(void) = ^{
        a++;
        NSLog(@"%d",a);
    };
    
    Malloc();
}
// 可以看到传入的第三个参数,是__Block_byref_a_0结构体类型的a变量地址,而不是上面讲过的直接存储int类型
void(*Malloc)(void) =
((void (*)())&__test_block_impl_0((void *)__test_block_func_0,
                                  &__test_block_desc_0_DATA,
                                  (__Block_byref_a_0 *)&a,
                                  570425344));

// __test_block_impl_0结构体中存储的变量也是__Block_byref_a_0类型
struct __test_block_impl_0 {
  struct __block_impl impl;
  struct __test_block_desc_0* Desc;
  __Block_byref_a_0 *a; // by ref
  __test_block_impl_0(void *fp, struct __test_block_desc_0 *desc, __Block_byref_a_0 *_a, int flags=0) : a(_a->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

// 初始化__Block_byref_a_0如下
__attribute__((__blocks__(byref))) __Block_byref_a_0 a =
{(void*)0,
        (__Block_byref_a_0 *)&a,
        0,
        sizeof(__Block_byref_a_0),
        10};

// __Block_byref_a_0结构体
struct __Block_byref_a_0 {
  void *__isa;
__Block_byref_a_0 *__forwarding; // 指针指向原始值
 int __flags;
 int __size;
 int a; // 值拷贝存储
};

总结 __block 原理

  1. 创建__Block_byref_a_0 结构体
  2. 传给block指针地址
  3. block內修改的是与原始值同一片的内存空间

注意点

根据上述分析我们可以得出结论,如果在OC的block中捕获了没有加__block 的外部变量,在编译时就会将变量值传入(值拷贝),如果捕获了加__block 的外部变量,则会获取到变量指针对应的内存空间的地址。代码验证如下

int a = 1;
__block int b = 2;

void(^Malloc)(void) = ^{
    NSLog(@"a,%d",a);
    NSLog(@"b,%d",b);
};

a = 3;
b = 4;
Malloc();

// 输出结果如下
// a,1
// b,4

Swift-Closure

Swift官方文档的定义:『闭包』是独立的代码块, 可以在你代码中随意传递和使用 。Swift 中的闭包与 Objective-C/C 中的 Block、其他编程语言中的匿名函数相似。闭包可以从定义它们的代码的上下文中捕获存储任何变量。这也被称为这些变量和常量被暂时关闭使用。并且 Swift 负责处理你所捕获的内存进行管理。

  • Swift 的闭包表达式拥有简洁的风格,并鼓励在常见场景中进行语法优化,主要优化如下:

    • 利用上下文推断参数类型和返回值类型
    • 隐式返回单表达式闭包(单表达式闭包可以省略 return 关键字)
    • 参数名称缩写,可以用0,0,1表示
    • 尾随闭包语法:如果函数的最后一个参数是闭包,则闭包可以写在形参小括号的外面。为了增强函数的可读性。
  • Swift 的闭包是一个引用类型,验证如下。我们知道Swift的引用类型在创建时都会调用swift_allocObject方法

// 未调用swift_allocObject
let closure1 = { () -> () in
    print("closure1")
}

// 调用swift_allocObject
let a = 10
let closure2 = { () -> () in
    print("closure2 \(a)")
}

捕获值

捕获方式

  1. 通过[variable1, variabla2]的形式捕获外部变量,捕获到的变量为let不可变类型,且捕获的是外部变量的值拷贝编译时期即确定值,若原始变量为指针类型,那么拷贝的只是指针)
  2. 直接捕获外部变量,获取的是指针,也就是说在闭包内修改变量值的话,原始变量也会被改变。
  3. 如果捕获的是指针类型(Class),无论是否用[variable1],在闭包内对该变量进行修改,都会影响到原始变量

简单验证如下:

// 方式1
var variable = 10
let closure = { [variable] () -> () in
//    variable += 1 // 编译报错”可变运算符的左侧不可变”
    print("closure \(variable)") // 打印:10
}
variable += 1
print(variable) // 打印:11
closure()

可以看到,通过[variable]捕获的变量,即使值已经变化了,闭包内部依然是原始值。

如果用方式2直接捕获呢?

var variable = 10
let closure = { () -> () in
  print("closure origin \(variable)") // 打印:closure origin 11
  variable += 1
  print("closure new \(variable)") // 打印:closure new 12
}
variable += 1
print(variable) // 打印:11
closure()
print(variable) // 打印:12

可见直接获取变量的话,会修改到原始值。无论是闭包内部还是外部,修改的都是同一片内存地址里的变量。

上面说的都是值类型数据,下面我们验证指针类型的捕获:

class YKClass {
    var name = "old"
}

let demoClass = YKClass()

let closure1 = { [demoClass] () -> () in
    demoClass.name = "new1"
    print("closure1 \(demoClass.name)") // 打印:closure1 new1
}

closure1() 
print(demoClass.name) // 打印:new1

let closure2 = { () -> () in
    demoClass.name = "new2"
    print("closure2 \(demoClass.name)") // 打印:closure2 new2
}

closure2() 
print(demoClass.name) // 打印:new2

结果证实了方式3中的结论。无论是哪种形式捕获指针类型,都会影响到原始变量。

一点猜测

根据上面的三种demo,我们可以猜测,对于方式1,闭包对原始变量进行了值拷贝,方式2获取了原始变量的指针(由于值类型的指针即存储了值,所以会修改到原始值),方式3无论是通过[variable]拷贝指针,还是通过直接捕获拿到原始变量的指针,指向的内存地址空间都是没变的。

[variable]闭包捕获列表

对于方式1(closure capture list)我们要探究的是两点

  1. 捕获到的是什么
  2. 修改的是什么

那么一步一步来,我们将代码简化成下面这个样子

var variable = 10

let closure = { [variable] () -> () in
    print("closure \(variable)")
}

通过swiftc -emit-sil main.swift > main.sil指令生成sil文件看看(以下有删减,只截取关键信息)

// 以下为main函数
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s4main8variableSivp       
	// 为全局变量variable分配地址并赋值,存储到%3中
  %3 = global_addr @$s4main8variableSivp : $*Int  
  %4 = integer_literal $Builtin.Int64, 10         
  %5 = struct $Int (%4 : $Builtin.Int64)          
  store %5 to %3 : $*Int                          
  
  ···
  // 开始捕获了!
  %9 = begin_access [read] [dynamic] %3 : $*Int   
	// 将捕获到的指针存储到%10中
  %10 = load %9 : $*Int                           
  end_access %9 : $*Int                           

  // closure在%12
  %12 = function_ref @$s4mainyycfU_ : $@convention(thin) (Int) -> ()
  // 将%10传给closure!!
  %13 = partial_apply [callee_guaranteed] %12(%10) : $@convention(thin) (Int) -> ()

	···
}

// 以下为closure
sil private @$s4mainyycfU_ : $@convention(thin) (Int) -> () {
// 可以看到一个let关键字,证明这是一个不可变类型的变量
  debug_value %0 : $Int, let, name "variable", argno 1 // id: %1
  ···
}

通过对sil的分析,一切都解释得通了!

直接捕获

原始代码简化如下

var variable = 10

let closure = { () -> () in
  variable += 1
}

生成sil文件,先看下main函数的解析结果

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  // 为变量variable分配地址并存储值,到%3
  alloc_global @$s4main8variableSivp              
  %3 = global_addr @$s4main8variableSivp : $*Int  
  %4 = integer_literal $Builtin.Int64, 10         
  %5 = struct $Int (%4 : $Builtin.Int64)          
  store %5 to %3 : $*Int                          

  // 以下只是为closure分配地址并赋值,好像看不出来什么
  alloc_global @$s4main7closureyycvp             
  %8 = global_addr @$s4main7closureyycvp : $*@callee_guaranteed () -> ()
  %9 = function_ref @$s4mainyycfU_ : $@convention(thin) () -> () 
  %10 = thin_to_thick_function %9 : $@convention(thin) () -> () to $@callee_guaranteed () -> ()
  store %10 to %8 : $*@callee_guaranteed () -> ()                    
}

emm好像什么也没看出来,那么closure呢?

sil private @$s4mainyycfU_ : $@convention(thin) () -> () {
bb0:
	···
  
	// 看到没有!在closure内部才会对variable变量的地址进行访问,在%2!%2!
  %2 = begin_access [modify] [dynamic] %0 : $*Int 
  // +1操作
  %3 = struct_element_addr %2 : $*Int, #Int._value 
  
  ····

  // 越界判断
  cond_fail %8 : $Builtin.Int1, "arithmetic overflow" 
  %10 = struct $Int (%7 : $Builtin.Int64)         
  // 将最后计算的结果又存回了%2!
  store %10 to %2 : $*Int
  // 结束对%2地址空间的访问                         
  end_access %2 : $*Int   

	···                                                     
}

神奇,神奇,拍手称奇。我们的猜测得到了证实。

指针类型捕获

通过上面两种方式的验证,其实我们也很好得出关于方式3:指针类型捕获的相关结论了,毕竟捕获的/拷贝的都是指针嘛,管它几个指针只要指向的都是同一片内存空间,那修改的当然就是同一个对象啦。