探秘block原理

news/2025/1/11 6:43:11/

01

概述

iOS开发中,block大家用的都很熟悉了,是iOS开发中闭包的一种实现方式,可以对一段代码逻辑进行封装,使其可以像数据一样被传递、存储、调用,并且可以保存相关的上下文状态。

很多block原理性的文章都比较老,里面讲的一些知识已经过时,这里用新版的iOS SDK再梳理一遍block原理,也是和大家一起对已有知识做一次复习。

02

内存布局

block本质上可以理解为结构体,对于结构体的内存布局,先用一张图来表示一下,图中字段顺序按照布局的先后顺序:

  • isa:block也有isa,从内存结构上也属于对象,isa指向的是block的类对象,类对象例如__NSMallocBlock__,后续文章会讲到;

  • flags:用于存储一些标志位信息,例如是否捕获外部变量;

  • reserved:系统保留字段,后续可能会用于一些编译优化标志位,或者存储一些临时变量的处理;

  • invoke:函数指针,指向了block要执行的函数地址,也就是block代码块对应的函数地址;

  • descriptor(现在叫desc):指向block_desc_0,包含block大小、捕获的外部变量布局信息、增加引用计数和销毁的相关函数指针;

  • variables:block捕获的外部变量。

e18475710e99f3b78225d8e2d40448b3.jpeg

03

类型

由于block也是对象,可以通过class方法获取到其类型,也就是类对象。block有下面三种类型:

  • __NSGlobalBlock__,没有访问auto变量的block,访问static变量是没问题的。这种类型的变量并没有什么意义,如果不需要用到auto变量,写成方法就可以满足需求;

  • __NSStackBlock__,在MRC环境下,访问了auto变量,会默认被放在栈区。需要手动copy到堆区,ARC环境下会在访问auto变量后,会自动拷贝到堆区;

  • __NSMallocBlock__,由开发者自己管理内存,不会由系统来释放。

block的分配主要是在三个区域,堆区、栈区、全局区,全局区的数据存储在数据段。

block在不同的场景会存在不同的内存区域中,在MRC中创建一个block首先是在__NSStackBlock__内存中的,然后我们使用copy方法将block拷贝到__NSMallocBlock__内存中进行内存管理。后来在ARC中系统已经帮我们做好了copy的操作,创建的block会自动copy__NSMallocBlock__内存中,堆区的block也有引用计数的概念。如果这个block中没有用到任何外部参数,系统会将这个block存放在__NSGlobalBlock__内存中。

c913a0afef72bdbd98f71395736359ac.jpeg

并且block也有继承关系,以下面TestBlock的实例来说,其父类是__NSGlobalBlock__,所有block的父类是NSBlock,并且NSBlock继承自NSObject类。在更早一些的iOS系统中,__NSGlobalBlock__NSBlock之间,还会有一层__NSGlobalBlock的关系(后面没有下划线)。

e9e860f99ca7d209648e62b2a95f8137.jpeg

04

转换C++

下面,我们通过clang命令将block转为结构体,来分析下其具体实现。虽然这并不是最终运行在iOS系统上的代码,其等于一种中间表现形式,后续编译链接优化才会形成运行在手机上的ipa包,但对于我们了解block的实现原理有很大帮助。

4.1转换命令

xcrunXcode用于查找和执行相关命令行的工具集,可以更好的执行clang命令,减少报错。

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc [源文件路径] -o [目标文件路径]

clang命令有下面这些关键参数:

  • -fobjc-arc:如果项目是ARC或者ARCMRC混编的环境,需要通过此参数修饰,表示按ARC的方式进行转换,如果不需要ARC环境可以忽略;

  • -x objective-c++:此参数上面没用,如果包含Objective++源文件的时候,需要用到此参数,以确保clang可以区分OCC++代码;

  • -rewrite-objc:告诉clangC++的方式重写出来,包含的上层代码,clang会以底层代码的方式进行展现;

  • [目标文件路径]:非必传参数,不传的话默认在当前目录生成一个同名的cpp文件,例如main.m对应main.cpp

4.2转换示例

下面在main.m中实现了一个很简单的block,并且没有捕获任何外部变量,通过clang命令查看C++代码,观察block的具体实现原理。

00f1462166473024588d9fd02489c95a.jpeg

转换后将C++源文件拉到最下面,可以看到main函数以及TestBlock的实现,main函数中有很多转义代码,删掉后梳理逻辑会更清晰。

f7c52e76e5d0f1a69d472a96e9dd30f2.jpeg

05

结构体

5.1基础结构

转换后的代码看着比较复杂,但我们只看关键信息,__main_block_impl_0构造函数也可以去掉,整理后就是下面三个结构体。在不包含外部变量和__block的前提下,block结构体各个字段就这么简单,关键就是isaBlock_sizeFuncPtr这三个。

5184de03fe7c376000daf74131afb8a9.jpeg

我们也可以打印block结构体相关字段,但由于block的结构体并没有声明在某个.h文件中,所以需要我们讲clang转换后的结构体粘到对应的文件中,做显示声明。随后用__bridge的方式,将block对象桥接为自己声明的结构体,即可打印对应字段。

44e51b22f299d5b26fbae4462f3b9c28.jpeg

结构体中impl.FuncPtr存储的就是回调函数地址,从地址可以看出是一个虚拟地址,block结构体都存储在堆区。

f3cbcb3a80da27952f3524550dcac601.jpeg

5.2调用部分

看完block结构体的定义,我们来到main函数中,看block的实现和调用转换后是什么样的。将main函数中block相关的转换都去掉,结果如红圈部分。本质上就是两步,第一步是调用__main_block_impl_0的结构体构造函数,第二步是调用结构体的函数指针。

ae8765f95f711b319acf44797c17b6d0.jpeg

第一行main函数中调用的构造方法,是__main_block_impl_0结构体声明的C++构造函数,因为我们创建的是一个最简单block,可以看到block的存储区域是在stack栈区的。即main函数调用完,block生命周期就会结束。

81052833ccb9918733af2aabb3a32994.jpeg

__main_block_impl_0构造函数有两个参数,第一个红圈部分就是传入函数指针地址,函数对应的就是block内部的实现代码。第二个参数是__main_block_desc_0_DATA结构体,其定义为__main_block_desc_0,并且默认实现第一个参数传0,第二个参数是block结构体的大小,结构体为__main_block_impl_0 block自身的结构体大小。第三个参数有默认值,可以不传。

0fb6d3895960933a3d6590f8fb0a690b.jpeg

__main_block_desc_0结构体是一种紧凑型的写法,在声明__main_block_desc_0结构体后,紧接着声明了一个名为__main_block_desc_0_DATA的变量,变量类型为静态变量,并且实现了初始化相关代码。

e436962389273ae83f4dd6418f28067c.jpeg

在执行block的代码位置,可以看到并不是block->impl.FuncPtr的方式调用,而是直接block->FuncPtr的方式调用,中间少了一步。

严谨些来说应该加上impl,但不加也不会出问题。这是因为,如果看未删除转换代码的原始clang代码,可以看到block是被转换为__block_impl的,也就是说被当做__block_impl看待的。如果再结合__main_block_impl_0的结构体定义来看,__block_impl在成员变量的第一位,所以访问FuncPtr是没有问题的,只要不访问Desc就是可以的。

06

外部变量

6.1值类型

如果在block的调用中加一个外部变量,那结构体将会是怎样的?

f5260e86bcd1db4a6691b3d1485b8f04.jpeg

通过clang命令可以可以看到,转换后的__main_block_impl_0中增加了一个同名字段,这很简单没必要过多解释。在__main_block_impl_0构造函数中传入,通过冒号后的初始化列表对value参数进行初始化。

71a50a73be623393a2c6501c93cd7075.jpeg

后面传参和使用,就都是结构体赋值和取值逻辑,很简单。

94f5bfe29b82fe787deee58064b04390.jpeg

6.2值传递

下面这种写法,在block的使用中很容易踩坑。在block中使用value参数,并且打印value参数,发现结果为1,而不是2

b56fcbf676dbea7dbddd0000e1324668.jpeg

通过C++源码我们可以看到,这是因为如果block引用的外部变量是值类型,会采取直接复制值的方式,而不是指针引用。

a2d50b3f0432de4a7f16455ff65cea20.jpeg

想解决这个问题也很简单,通过__block修饰一下值类型,即可实现blockvalue的值和外部value参数统一。

5442504f43156c89f510a36b4c548e95.jpeg

6.3静态变量

我们看一下,如果捕获的是一个static修饰的静态变量,其结构体会是什么实现。

0db25bc636677e044134ce146939cd4f.jpeg

转换为C++代码后,可以看到原来的值传递变成了地址传递,__main_block_impl_0value的引用是指针引用,在main函数中将value的地址传入。如果被static修饰的本身就是一个对象,对象是通过指针引用的,在block的结构体中就是两个星号引用。也就是NSObject **obj

4dbc42fa4f8fc4699a4b7083cc57a9dc.jpeg

正是由于静态变量地址传递的实现,在block内可以对静态变量直接进行更改,而无需用__block进行修饰。

737894d22f7b7ad64acd752ea7d789d9.jpeg

6.4全局变量

如果把value改为全局变量,结构体会有什么变化呢?

65c56008740b61e373f44ded28eeb87a.jpeg

因为全局变量的作用域很大,所以并不需要block进行单独持有即可访问,结构体并不会新增字段。

51f04513f76cc4e9ca9233f123b55202.jpeg

6.5对象类型变量

如果block中引用的是对象,而不是基础数据类型,结构体会是什么定义呢?

c4927fcbb5e2603ec6bfcf64770b4008.jpeg

执行clang命令,执行完成后结构体是下图的,下面代码去掉了转换,以及整理过代码。可以看到多了两个函数指针,__main_block_copy_0__main_block_dispose_0

copy的实现__main_block_copy_0为例,执行后会调用Block_object_assign的实现,在实现中系统会根据person的引用方式,__strong__weak__unsafe_unretained,是强引用还是弱引用,调用对应的内存管理方法。

__main_block_dispose_0函数在block从堆区移除的时候被调用,调用dispose时会调用实现Block_object_dispose函数,函数中会根据person的引用方式,进行对应的减少引用计数或释放操作。

copydispose两个函数都有一个3的参数,这个参数是一个标志位,表示外部变量类型。这里是BLOCK_FIELD_IS_OBJECT表示一个对象类型,也有BLOCK_FIELD_IS_WEAK表示weak引用的变量,BLOCK_FIELD_IS_BLOCK表示block类型的变量等。

25327a0ccd7b9c3b385e0207026a020d.jpeg

07

结尾

感谢大家能把文章读完,这篇文章并不会包含__block__weak相关知识,为了更系统的了解这两部分,后面会新出一篇文章整体来讲一下,敬请期待~


http://www.ppmy.cn/news/1562176.html

相关文章

Ruby语言的并发编程

Ruby语言的并发编程 在现代软件开发中,随着多核处理器的普及和应用需求的多样化,并发编程逐渐成为开发者不可或缺的一部分。Ruby语言作为一种高层次的编程语言,在简洁性和可读性方面有其独特的优势,但在并发编程方面却常常被认为…

配置管理工具和k8s功能重叠部分的优势比较

通过自动化配置管理工具(如 Ansible、Puppet、Chef)和应用内管理机制,也可以实现自动部署、扩缩容、负载均衡和故障恢复等功能。Kubernetes(K8s)在这些方面具有哪些独特的优势呢,尤其是在云原生环境和大规模…

超完整Docker学习记录,Docker常用命令详解

前言 关于国内拉取不到docker镜像的问题,可以利用Github Action将需要的镜像转存到阿里云私有仓库,然后再通过阿里云私有仓库去拉取就可以了。 参考项目地址:使用Github Action将国外的Docker镜像转存到阿里云私有仓库 一、Docker简介 Do…

玩转多线程--入门

目录 什么是多线程? 概念: 优点: 线程和进程区别:(面试常考题) Java线程和操作系统线程的关系: 多线程创建 方法1继承Thread类 方法2实现Runnable接口 star()和run()的区别&#xff1a…

常用字符串处理函数

常用字符串处理函数 strcspn函数原型参数说明返回值使用示例注意事项 strpbrk函数原型参数说明返回值使用示例 strcasecmp函数原型参数说明返回值使用示例注意事项 strcspn strcspn 是一个 C 和 C 标准库函数,用于计算一个字符串中不包含任何指定字符的最长前缀的长…

rk3568 , buildroot , qt ,使用sqlite, 动态库, 静态库

问题说明: 客户反馈 ,buildroot 系统 ,使用qt 使用sqlite ,有报错,无法使用sqlite. 测试情况说明: 我自己测试,发现, buildroot 自己默认就是 使能了 sqlite 的。 是否解决说明&…

Java 和 C++ 的性能对比分析

💖 欢迎来到我的博客! 非常高兴能在这里与您相遇。在这里,您不仅能获得有趣的技术分享,还能感受到轻松愉快的氛围。无论您是编程新手,还是资深开发者,都能在这里找到属于您的知识宝藏,学习和成长…

webrtc自适应分辨率的设置

DegradationPreference 是一个枚举类,用于在视频编码或实时通信(如 WebRTC)中指定系统资源不足时如何处理质量下降的策略。以下是该枚举类的中文解释: enum class DegradationPreference {// 禁用:不根据资源过载信号…