从 Core Foundation 看更大世界
Core Foundation 是被 iOSer 忽略的一个重要框架。说重要,因为 Core Foundation 提供了丰富的组件库,这些组件库可以很好的用于开发工作。
但之所以被忽略,因为很多开发工作,可以用更友好的 Foundation 框架替代。
Core Foundation 有 Foundation 没有的功能,比如 CFDictionary 的 Key 元素无需实现 NSCoping 协议、CFArray 可以不进行对象引用计数等。反过来,Foundation 也有 Core Foundation 无法胜任的工作,最大的来说就是自动引用计数功能。
在 iOS 项目开发过程中,我们可以使用基于 C 语言的 Core Foundation 框架写一些业务功能逻辑,甚至有时候非用 Core Foundation 不可,因为它有 Foundation 没有的功能。
Foundation 是用 Objective-C 语言写的,Core Foundation 使用 C 和 C++ 语言写的。我们都知道 Objective-C 是 C 的超集,所以认为 Objective-C 和 C、C++ 混编是正常的。
那么,什么是超集?Objective-C 是动态的面向对象的,C 是静态的面向过程的,如何实现这个超集?
既然 Objective-C 可以和 C、C++ 一起使用,那么 Golang 呢?我们可不可以用 Go 来做混合开发?
通过 Core Foundation,可以有更大的认知空间。
比如各类高级语言在计算机中是如何运行的?Dart (flutter) 可以做混合开发,原理是什么?Lua 做热更新,它不是 C 语言也不是 Objective-C 语言,是怎么被计算机调用执行的?用 Node.js 写 iOS 代码,到底行不行?
各种风马牛不相及的高级编程语言,是否有各自的边界?
iOS 开发框架有哪些
框架是库的更高一层描述,这里的库一般指的是动态库,但说静态库,也完全没有问题。
比如,我们要写一个语音识别功能,我们写了 1-n 个动态库 (a.framework、b.framework…) 来完成这个功能。在项目最后,客户说只需要一个动态库,我们就把这 1-n 个动态库组合成 1 个动态库并命名 GJAudioKit.framework,就可以叫 GJAudioKit 为语音识别框架了。
所以框架这个专有名字该怎么解释说明?
一来是众多库组合起来的意思。为了一个功能,需要写 1-n 个库。最后将 1-n 个库组合成 1 个库,这个库就叫一个框架。
二来框架是一个抽象,是比库更高层级的抽象。不管静态库还是动态库,都是目标文件 (.o) 的合集。而框架比库的抽象层级更高,表示一个功能、一个业务或者一个模块,比如语音识别框架。
iOS 系统提供了很多框架给我们使用,详见下图:
我们经常使用的框架都正在图中的分层结构中找到对应的影子。
也可以从 Xcode 的资源文件中查看,如下图:
刚才说到框架有 1-n 个库组成,上图中的 SwiftUI.framework 框架里面只有一个 SwiftUI.h 头文件,而 Foundation.framework 框架里面有近 130 个头文件。我们可以理解为一个头文件即可单独生成一个动态库。
框架的理解就到这,值得一提的是,上面列出的框架,都是系统提供的,也都是 C、C++、Objective-C 写的。我们自己当然也可以开发需要的库或者框架,那么,我们必须要用 C 族语言开发吗?
如果用 Go 来写 iOS 框架会怎么样
手机和 PC 有很多不同,比如 PC 的硬件都是可以拆卸的,而手机一般都不会拆卸,所有硬件都是集成到一个主板焊死的,我们不能随便更换存储和内存。CPU 也不一样,PC 有很多种类的 CPU 支持,可以根据用户是美术生或者喜欢玩游戏而选择不同的产品型号,比如撕裂者等。而手机为了省电,都用的 ARM 架构 CPU。
但不管手机和 PC 如何不同,有一个共同点是计算机发展几十年来不曾改变的,乃至手机和计算器都是一样的原理,那就是他们都依托发展于冯诺伊曼机。
程序需要执行,不能执行没有任何意义的程序,所以输入输出是必须的。而冯诺伊曼机的程序执行就是执行二进制。
所以从这点上看,高级语言再怎么变化,最终也跑不了 CPU 指令集二进制执行这个宿命。
PC 上我们可以跑上千种语言,因为这些语言最终都是二进制。只要语言被编译汇编成能够被执行的指令集,那么这些语言就有被编写和执行的意义,不管是在 PC 上执行,或者手机或者树莓派上执行。
所以,Go 当然可以在 iOS 手机上运行,不仅 Go,Java、Ruby、Lua、Node.js 都可以。那,怎么才能将 Go 写到 iOS 程序里面呢?
这就要分析 C 是如何被 iOS 系统执行的。因为他们都属于高级语言,如果 C 能够依靠一个逻辑被执行,那么 Go 按照这个逻辑也就可以执行。
C 语言是如何被操作系统执行的
系统调用和运行时
很久很久之前,是用纸带打洞进行编程。那时候 CPU 直接执行二进制。
现在就不行了,因为有操作系统存在了。操作系统是一个大管家,管理着所有的应用程序,通过合理的管理应用程序的内存,进行系统级别的控制。
操作系统该如何操作,才能管理应用程序呢?显然,控制了代码,就控制了所有。如果实际运行的代码,都在操作系统的管辖范围内,那么操作系统就想怎么控制就怎么控制了。
这个时候,我们就无法绕过操作系统直接让 CPU 执行我们的二进制了,而是需要让操作系统在中间做一个中间者,我们调用操作系统的接口,操作系统进而让 CPU 执行进入内核态 (内核塌陷),这个时候我们的代码才算被执行。这个操作系统的接口,就是系统调用。
操作系统提供了完善的服务,人们都装了主流的几大操作系统。因为我们的代码需要被用户拿去执行才有意义和价值,所以,我们的代码全都需要接受操作系统的控制。
所以现在,我们的 C 语言代码从开发阶段到被执行,是下面这样的:
系统调用,都是汇编实现的,并实现了 C 的接口供用户调用。这里需要说明一点,几大操作系统都是用 C 语言提供的系统调用接口。不管用户态是什么类型的高级语言,系统调用提供的仅仅是 C 接口。
下面是部分系统调用接口,
系统调用接口并不是很多,都是操作系统提供给外界的刚需接口,大约 350 个左右(不同系统的接口数量不同)。这个时候,C 开发人员开发的时候都是调用 open 用来打开文件,调用 brk 来申请内存。显然和现实不太一样,我们开发的时候,都用的 fopen 打开文件,用 malloc 申请内存。这是为什么呢?
原因就是直接使用系统调用,非常困难。具体分两点来说:
- 系统调用提供的接口都是基础接口,比较生硬且基础。程序员需要的一个很基础功能,可能需要调用好多个系统调用接口才能完成。
- 系统调用是操作系统提供的。如果用户用 Linux 系统的系统调用接口开发了程序 A,那么如果想让程序 A 在 Windows 系统上运行,那是不可能的,因为两个系统的系统调用接口完全不一样。
显然,C 语言开发者直接进行系统调用,遇到了困难。而中间件可以解决所有困难,如果解决不了,那就再加一个中间件。
下面是添加了 C Runtime Library(运行时)后的调用流程:
运行时是一个中间层,用户写的代码,最终调用的都是运行时接口。这个接口可以专门为 C 语言提供非常丰富的接口调用,有下面四种情况:
- 1 个系统调用接口可以为 n 个运行时接口提供服务,比如 malloc 和 free 都使用了 brk。
- 1 个运行时接口可以调用 n 个系统调用接口,比如 w 接口,需要 x、y、z 接口同时提供服务。
- 1 个运行时接口可以仅调用 1 个系统调用接口,这个时候是 1-1 关系。
- 1 个运行时接口可以不调用系统调用接口,如 strcpy,专门的 C 语言字符串处理函数。
而且,不同系统提供的系统调用是不同的,但只要改变运行时,不需要修改用户代码,即可适配多平台。而每个系统都需要维护一套语言级别的运行时,这是必要且可行的。
这样,C 运行时作为中间层,极大的提高了开发人员生产力。
跨过运行时直接系统调用
这里有一点需要说明,C 运行时虽然作为高级语言和系统调用的中间层,但也不一定非要过这个中间层不可。因为操作系统只管理系统调用,而上层如何调用系统调用,是不受约束的。如果有一门语言,完全没有运行时概念,用户代码直接对接系统调用完全没有问题,就是上面说的系统调用生硬且基础和不能跨平台两个缺点。所以 C 语言真实调用逻辑如下:
开发人员在编写代码的时候,可以调用 C 运行时库接口,也可以直接进行系统调用。但还是那句话,这样用的人肯定不多,项目里面可能个别代码会如此实现,但一定是有足够把握才会这么做。
Windows API 的存在
还有一点需要说明,Windows 和 Linux 还有些不一样。Linux 的 CRT 直接进行系统调用,而 Windows 又加了一层中间层,名叫 Window API。这个中间层夹在系统调用和 MCRT 之间,如下图:
Window API 对微软意义重大,作为最出名对商业软件,Window API 更好的保障了用户升级带来对兼容性,所以中间层真的很好用。
C 运行时和 C 标准库的关系
下面额外补充一点,C 语言规范中,出了标准,没有出实现。所以 C 语言的编译器和相关库版本非常多。
因为不同操作系统之间,有相同特性也有不同特性,所以不同操作系统的运行时接口有相同的也有不同的。
比如,内存分配,提供的 api 都是 malloc。而 windows 有图形界面的绘图 api,对应的 linux 就没有体现。
于是就把默认提供的 api 如 malloc 或者 printf 等叫做 C 标准库。其他各自独有的也并入 C 运行时库。详见下图:
C 的运行时比较特别,主要因为 C 出了标准,却没有给实现,于是各家为政。所以 C 的运行时库里面包含了 C 标准库,还有其他接口如启动函数等。而对于其他高级语言,一般就没有运行时库包含标准库的概念,因为标准库和运行时库也是独立的。比如,Objective-C 里面,系统提供了非常多的标准库即框架,这些框架都是动态库形式,但他们不是运行时,有单独的运行时库负责系统调用 (OC 比较特别,实际为对接 C 运行时而非系统调用,下面会详细说明)。
C 语言运行总结
到这里,不知道大家有没有发现秘密,C 语言能够被执行,有两个要点:
- 系统调用。对外界提供统一的内核塌陷。
- C 运行时。提供 C 程序员开发的接口。
所以做为高级语言的 C 语言,能够在计算机和手机甚至嵌入式系统执行,核心就在于系统调用。我们编写的代码,在编译汇编链接后,都变成了对运行时的调用,而运行时对系统调用 Api 进行调用。系统调用由操作系统控制,所以操作系统才能严丝合缝的对我们编写的代码进行控制和管理。当然最终执行还是 CPU 执行指令,只是优先级、内存分配、线程调度等,都是操作系统控制了。
所以,只要一门高级语言,最终能够通过 L Runtime Library (语言运行时) 进行系统调用,那么,该语言就可以在操作系统的控制下,完成指令集的运行。
Objective-C 是如何被操作系统执行的
上面我们了解了高级语言 C 语言运行的原理,那么我们紧接着看下 Objective-C 是如何被运行的。
我们都知道,Objective-C 是 C 语言的超集,这个超集该如何理解呢?
C 是面向过程的静态的,C 不是动态化语言(不是完全动态化),因为函数调用在编译时候已经确定。
但是基于 C 语言的 Objective-C 语言,却是完完全全的动态化语言。Objective-C 是面向对象的编译型语言,所有函数的执行都是在运行过程中确定,超集是如何做到这些功能的呢?
C 的代码执行我们已经知道,我们写的代码,在编译后都变成 C 运行时函数调用的二进制。
这个 C 运行时作为中间层,干了一件大事,就是完成对系统调用的隔离和封装。
超集从字面可以理解,是在 C 的基础上做一些事情。
所以,我们可以想一下,如果在一个中间层上面再加一个中间层,在 C 运行时上面再加一个 OC 运行时,那么 OC 运行时是不是就可以做更多的事情?
比如,在 Objective-C 中,代码调用 A 类的 a 对象的 method_1 () 函数,那么在运行时,我们希望调用 method_2 () 函数。那么这么一个函数调用的变化,肯定不能依靠系统调用来做,它管内核状态,不管应用层事情。也不能 C 运行时来做,因为 C 运行时是 C 语言特有的功能,不会单独为高级语言 Objective-C 来做这个事,本身它也做不了,因为它是静态语言,自身都没有动态性。so,肯定有一层单独为 Objective-C 做了这个事情,这一层,就是 OC 运行时。
我们通过 OC 运行时源码分析一下对象创建的过程。
如果我们有一个 OC_Person 类,如下:
1 | @interface OC_Person : NSObject |
现在,我们要创建一个对象 person,即:
1 | OC_Person *person = [[OC_Person alloc] init]; |
详细调用流程图如下:
从上面的函数调用链,我们发现有两个关键点:
- Objective-C 进行对象内存初始化的时候,通过 Objective-C 的函数调用,最终调用到 C 语言的 calloc () 函数调用。
- 内存分配的大小,是通过”->” 结构体从一个 struct 拿到的。即调用一个结构体的 instanceSize 函数。(C++)
所以,我们在 Objective-C 里面创建一个对象并进行内存分配,开始的时候调用的是 OC 运行时,最终是调用了 C 运行时。我们创建的 Objective-C 对象本身,在运行时阶段,都是通过 struct 结构体获取值,所以对象在项目 Build 后,都转化成 C/C++ 结构代码了。
我们在看下下面代码执行流程:
1 | [person setName:@"x"]; |
详细调用过程如下:[person setName:@"x"]
->objc_msgSend(person,SEL(setName),@"x")
-> 汇编代码在运行时阶段查找 struct OC_Person{...}
结构体中的 setName 函数的地址 p_setName->call Oxab435c2(p_setName)
。
上面函数调用过程分析如下:
- Objective-C 的函数调用,是通过汇编语言编写的 objc_msgSend 进行的中转。其实 objc_msgSend 本身就是一个中间层,是动态转发的入口,将函数调用中转到运行时阶段。
- OC 运行时阶段进行函数地址的查找,在找到对应的函数地址后,进行地址调用 (函数执行)。
从上面对 OC 运行时的分析,我们可以看出,说 Objective-C 是 C 的超集,其实应该这样理解:
Objective-C 是高级语言,在代码编译后,会调用 OC 运行时接口,进行相关操作如对象创建,方法查找等。而 OC 运行时接口的具体实现,则是依托 C 运行时实现的。
比如,我们创建的 Objective-C 对象,在编译后,都会转换成 struct 结构体的形式进行 OC 运行时调用。再比如,我们创建对象调用 OC 运行时的 alloc 接口,在内部,却是调用的 C 运行时的 calloc 接口 (显然,calloc 调用的是系统调用的 brk 接口)。
面向对象是面向开发人员的,OC 运行时负责面向对象的 Objective-C 代码和 C 运行时之间的沟通。
Objective-C 的标准库和 C 就不一样了。C 的标准库上面说到,是 C 运行时的一部分。对于其他高级语言来说,标准库就是单纯为应用层封装的动态库,不属于运行时的一部分了。
下面是具体的流程图:
我在之前文章中,也有一个 Objective-C 和运行时库的说明:Objective-C 和 Runtime。
Go 该如何被 iOS 系统执行
我们已经分析了 C 和 Objective-C 在 iOS 操作系统上运行的原理。
我们可以确定,我们写的代码,只要最终能够对接系统调用并编译成二进制交由操作系统运行,那么我们的代码就能运行。
我们写的代码都是高级语言代码,比如我们写的高级语言的函数调用:[OC_Person alloc]
,这个函数在编译后我们假设为 call Oxa1b2c3
,其中 Oxa1b2c3
是 OC 运行时的_objc_rootAlloc
函数的虚拟地址。
在_objc_rootAlloc
内部会调用 calloc()
进行 C 运行时函数调用,我们假设为 call Oxd4e5f6
。到此为止,我们自己写的 [OC_person alloc] 代码,我们知道在代码区里面,那么 Oxa1b2c3
和 Oxd4e5f6
这两个运行时的函数在哪里呢?
运行时说到底,也是代码。运行时有两种存在形式,一个是动态库,一个是静态库。
我们的操作系统都默认有 C 的动态库运行时。所以我们在 Linux A 电脑编译出来的 ELF 执行文件,在 Linux B 电脑上是可以直接运行的,就是因为 C 运行时库是用动态库的形式在执行文件启动后进行链接的。不仅仅操作系统,只要是计算机,大差不差都有 C 的动态库运行时,所以 C 语言才如此通用。因为只要写了 C 语言,不出意外到哪里都可以跑起来,除非用了特定系统的 api。
那如果有一个操作系统,真的没有 C 的动态库运行时,是不是就不能支持可执行文件了呢?
运行时存在两种形式,一种动态库,还有一种静态库。我们在编译可执行文件的时候,可以把运行时打到可执行文件中,这样刚才说到的 Oxa1b2c3
和 Oxd4e5f6
运行时函数就打到可执行文件中了,即代码区。这样,及时操作系统没有安装 C 的动态库运行时,可执行文件一样可以跑起来。只不过,这样的化,可执行文件就会变大,因为包含了静态库运行时的大小。
所以 Objective-C 能够在 iOS 和 Mac 上运行,就是因为这两个系统里面,有动态库 OC 运行时。
那 Objective-C 能不能在安卓手机或者树莓派上面运行呢?因为 Objective-C 不支持运行时的静态库链接,而安卓和树莓派上没有动态库 OC 运行时,所以就运行不了 Objective-C App 了,因为找不到对应的函数调用,即上面说到的 Oxa1b2c3
和 Oxd4e5f6
。
Go 静态库运行时的必要性
所以,Golang 该如何在 iOS 系统上执行?Golang 本身是高级语言,肯定有运行时库,分别有动态库和静态库两个版本。因为 iOS 操作系统本身没有 Golang 运行时,那么在编写 Golang 的代码后,在编译链接的时候,把 Golang 的静态库链接到最终的执行文件中 (静态库或者动态库,或者叫框架),那么这串 Golang 编写的代码,就能够在 iOS 系统上完美的运行起来。
这个时候,Golang 运行时需要做那些事情呢?
- Golang 需要做一个静态库运行时,链接到执行文件中。因为 iOS 系统本身只有 Objective-C 运行时、C 运行时、C++ 运行时,没有 Golang 运行时。
- Golang 原本肯定没有考虑运行在 iOS 上,所以 Golang 的运行时对接了 Windows 和 Linux 的系统调用。那么现在,Golang 的静态库运行时就需要对接 iOS 的系统调用。
当上面两个步骤完成,我们就可以通过 Golang 编写代码并导出 framework 库,在 iOS 系统上被执行。
我们写出 Golang 库,肯定还是希望被 Objective-C 调用,因为 Objective-C 和 C 支持混编,而 Go 有一个库 CGo
,可以让 Go 和 C 连通,所以 Objective-C 这个时候就可以放心的调用 Golang 开发的库 / 框架了。
下面是 Golang 在 iOS 系统上的运行流程图:
幸运的是,Go 开发 iOS 所需要的库 / 框架,目前已经有发行版了,即 Go Mobile。我测试一下,简单一行代码,在编译后的嵌入动态库中,也有 1.5M,原因就是这个动态库中,有 Go 的静态运行时和 CGO 静态库。
其他高级语言如何编写 iOS 需要的动态库
其实不止 Go,Node.js 也一样可以用来开发 iOS 的动态库,有这个 Node.js for Mobile Apps。
Go 和 Node.js 能够写 iOS 动态库,那么按照同样的逻辑,其他高级语言也一样可以做这件事,比如,游戏开发中,很多就用 Lua 来做热更新,Lua 代码要被执行,也需要一个 Lua 的静态运行时嵌入到库中。
上面说到的,还都是业务功能库,不是 UI。那 Flutter 就是完全依靠 Dart 语言来做跨平台的混合开发方案。Flutter 的库会让 iOS App 的包体积增加 15-25M,就是因为里面有一系列的运行时和相关 UI 组件库存在。
我们通过上面 Go 的大小可以发现,运行时库本身没有多大,Flutter 库比较大的原因,是 Flutter 通过 Dart 完全重新实现了自己一套 UI 框架,所以代码量肯定是巨大的,框架体积自然就增大了。
限制高级语言的枷锁是什么
所以我们可以发现,至少在 iOS 上面,是没有高级语言限制的,只要高级语言有这个运行在 iOS 系统上的需求,都能实现。
而 Go 如果后期希望像 Flutter 一样实现 UI 框架,也一样没有问题。限制它们的,仅仅是业务需求罢了(实际上,对于 Go 和 Node.js 的 iOS 动态库开发,需求不大,所以都是试探性发展,因为 C 和 C++ 已经足够优秀,用 Objective-C 来开发本身也够用了)。举例而言,C++ 是编写稳定后台服务的热门语言,而基于 C++ 的 Qt,就可以用来做跨平台的 GUI。而 Swift 初期被用来开发 iOS/Mac App,现在也一样可以用作服务器开发。甚至 Javascript 只是浏览器端的脚步语言,引入 V8 引擎后,JS 已经花开两朵,前端和 Node.js 后台发展的都非常棒。
我们也可以认知到,高级语言的存在,只是特定场景的需求。如果当年苹果不开发 Objective-C,用 Java 来开发 iOS App,也完全可以的,只是苹果需要一套自己的能够被私有控制的开发体系。
语言是用来完成特定场景的工作任务,如果用 Objective-C 来写服务器的 I/O 多并发,显然没有 Go 和 Node.js 的事件驱动来的吞吐量大。而 Objective-C 后期能不能实现协程、多进程等特性?当然可以,就是需要不需要而已。
限制语言功能及发展的,仅仅是它的业务场景,而不在于语言本身或者操作系统。
Core Foundation 和 Foundation 的区别
我们在上面已经研究了语言和框架。框架和库的关系,在文章开头也已经说明。
这里就来研究一下 Core Foundation 和 Foundation 两个框架的区别和联系。
Core Foundation 是基于 C 开发的,Foundation 是基于 Objective-C 开发的。但是有一点,Foundation 是基于 Core Foundation 封装并实现了 Core Foundation 所没有的部分。
我们可以用下图来表示 Core Foundation 和 Foundation 的关系:
Foundation 用 Objective-C 封装了 Core Foundation 的 C 组件,并实现了额外了组件供开发人员使用。而 Core Foundation 也有一些 Foundation 没能彻底封装的功能,这些功能是 Core Foundation 特有的。
下面可以看一下 Foundation 和 Core Foundation 的组件库都有哪些:
从图中,我们可以看到,Foundation 的组件是多于 Core Foundation 的,比如 NSBundle 在 Core Foundation 就没有体现。而 NSArray 就和 Core Foundation 的 CFArray 是对应的。反过来,Core Foundation 的 CFTree 和 CFBitVector 在 Foundation 里也没有体现,或许是在其他组件中使用到了这两个算法库。
因为 Core Foundation 是 C 实现的,虽然 Objective-C 能够兼容并调用 C,但是和 C 相互通信并转换,就不那么容易了。
其实 Objective-C 和 C 直接通信就像 Go 和 C 直接通信一样,是高级语言之间的通信。Go 有 CGO 库完成了这个中间层,Objective-C 虽然基于 C,有得天独厚的优势,但是如果没有官方实现,那还是会出现高级语言之间的代沟。
举例来说,现在有下面两个 C 和 Objective-C 代码:
1 | C: |
我们现在创建一个 C 语言的 p 变量和 Objective-C 的 pp 对象,尝试将他们互通,代码如下:
1 | C: |
C 是面向过程的,Objective-C 是面向对象的。上面的 p 和 pp 的格式转化,目前来看的确是没有办法完成的。也就是说,缺少 <Conver>
这个环节。
Objective-C 本身是可以直接使用 C 代码的,虽然转化比较困难,但可以在不转化的前提下,直接调用 C 结构体变量进行使用。但是 C 却没有办法直接调用 Objective-C 对象了,所以这个时候可以写一个转换层,来完成这个工作:
1 | Objective-C to C: |
我们可以看到,通过这样中转的方式,我们可以将 C 和 Objective-C 相互转换并通信。
显然,大家也发现有些费事。虽然这些转换如果真的要写 C 代码,那么就必不可少。但是如果使用 Core Foundation,那会方便很多。
我们刚才说过,Foundation 是封装的 Core Foundation,苹果开发了一个强大的功能,即桥接(Bridge)。通过桥接,可以非常方便的实现 C 和 Objective-C 的数据转换,比如下面:
1 | CFMutableDictionaryRef cf_mu_dic = CFDictionaryCreateMutable(kCFAllocatorDefault, 10, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); |
对于 Core Foundation 使用过程中产生的变量,都可以通过桥接的方式,变成 Foundation 对象。桥接帮我们做了格式转换的同时,也帮我们做了 ARC。
刚才,我们在执行 p = conver(pp)
的时候,大家注意,后面使用了 C 语言的内存释放,即 free(p)
。Core Foundation 本身也有引用计数,但是没有自动计数即 ARC。所以 Core Foundation 的对象释放的时候,需要调用 CFRelease,那么在桥接到 Foundation 后,就可以使用 Objective-C 的 ARC 了,非常方便。
桥接中的__bridge/__bridge_transfer/__bridge_retain
可以很方便的帮我们做对象管理转移操作,我们就不需要手动去释放内存了。
这里还是要补充一点,基于 C 语言的 Core Foundation 之所以能作为 Objective-C 开发框架,就是上面提到的,只要是高级语言,只要有相关运行时,就可以用来开发组件 / 库 / 框架。
Core Foundation 和 C 与 Objective-C 的转换
桥接(Bridge)
我们从上面 Core Foundation 和 Foundation 之间了解到,通过桥接,可以很好的转换 Core Foundation 和 Foundation 对象。
桥接做了两件事,一个是自动引用计数,一个是格式转换。
我们先说一下格式转换,因为桥接的转换局限性很大。
我们上面把 C 的 struct person
结构变量转换成 Objective-C 的 Class Person
,需要自己写类似于 C_Person * conver(Person *p)
的转换函数。说明 C 和 Objective-C 之间转换本身是不能直接进行的。
但是 Core Foundation 的 CFArray、CFString 等和 Foundation 的 NSArray 和 NSString 等转换,通过桥接就可以直接转换。这是因为 Core Foundation 比较特别。Core Foundation 是苹果自己写的 C 代码,所以在桥接的时候,苹果拥有 Core Foundation 的数据结构和 Foundation 的对象细节,所以桥接可以自动完成转换工作。
而我们自己写的 C 结构体变量,和 Objective-C 对象之间,就不能很好转换了,如果我们写了 C 代码需要和 Objective-C 进行转换,就必须自己写一个中间层了。
这就是桥接对于数据格式转换的局限性,准确来说,桥接对数据格式转换,的确只在 Core Foundation 里面才有体现,毕竟如上所说,苹果自己知道 Core Foundation 和 Foundation 之间的所有细节。
这对于我们来说,其实已经完全够用了,因为我们真实业务开发场景,如果需要避免 Objective-C 的运行时带来的消耗,的确可以通过 Core Foundation 来编写代码。
下面再说桥接的另一个大杀器,那就是自动引用计数。
Core Foundation 在和 Foundation 进行转换的时候,可以通过__bridge/__bridge_transfer/__bridge_retain
进行自动引用计数控制,这个不在细说。
这里介绍 C 和 Objective-C 之间通过桥接进行引用计数控制。引用计数是针对 Objective-C 对象来说的,我们看一下 Objective-C 对象和 C 之间的转换:
1 | Person *oc_p1 = Person.new; |
上面我们使用 **__bridge** 进行 Objective-C 和 C 的强制指针转换,表示不对计数进行任何改变。
在代码执行到第 3 步的时候,就会奔溃。
因为第 1 步前,堆对象的计数为 1,第 1 步没有改变计数,堆对象计数还是 1。
经过第 2 步,堆对象不在有引用计数了,所以堆对象就被释放了。
在第 3 步,想要使用 C 的 c_p 指针的时候,这个指针所存储的 0x0000600003977500
堆地址,已经变成野指针了,使用的时候直接会崩溃。
下面看下 **__bridge_retained 和__bridge_transfer** 的作用:
1 | Person *oc_p1 = Person.new; |
这里代码运行情况分析如下:
第 1 步之前,堆对象计数为 1。经过第 1 步后,**__bridge_retained 会使得计数 + 1,堆对象计数变成 2。
经过第 2 步,堆对象计数变成了 1。
第 3 步__bridge_transfer** 会使得计数 - 1,堆对象计数变成 0,堆对象被释放。
第 4 步会打印 null,因为 oc_p2 本身为 null。
第 5 步,程序崩溃,因为 c_p 指针存储的堆对象已经释放,指针此时为野指针。
从上面两个例子,我们可以看到,在和 C 进行赋值的过程中,桥接帮我们做了引用计数的工作。和 Core Foundation 的转换过程中的计数规则是一样的。
我们在赋值过程中,使用 **__bridge_retained 和__bridge_transfer 可以有效的降低崩溃风险,因为这两种 bridge 方式,帮我们做了引用计数的加和减。
单独进行__bridge 赋值的时候,引用计数没有改变,相当于同一时间,有多个指针指向堆对象,但是对象的计数却和指向指针的个数不一致。如果对象被释放,很可能还有指针在指向,这个时候使用就会发生野指针。
通过 retained 和 transfer**,在赋值过程中,加 1 和减 1 是同步的,这样可以有效降低对象计数和指向指针个数不一致的野指针风险。
通过桥接给 C 和 Objective-C 赋值的风险
通过上面两个例子,或者写更多其他 Objective-C 和 C 指针赋值的代码后,就会发现这样写代码的风险非常大。最大的风险就是野指针和内存不释放。
如果完全写 Objective-C 的代码,OC 运行时已经帮我们处理了引用计数和对象释放后指针自动变 nil 问题,所以我们大概率不会出现野指针和内存不释放情况(OC 运行时的 Weak 表帮我们处理了对象释放后指针自动变 nil。而 Objective-C 的引用计数的内存管理方式,也容易因为循环引用导致内存不释放,这是引用计数管理内存的天然缺陷)。
但是在 C 赋值嵌入进来后,即使通过桥接进行计数管理,也依旧摆脱不了随时崩溃的风险。原因就是因为对象被释放导致野指针随时可能会发生,或者对象无法释放导致内存泄漏。
对于经常写 C 代码的程序员来说,应该不会担心这些问题,因为他们已经习惯内存需要手动管理。被拥有自动内存释放机制娇生惯养的程序员们,就需要注意这个风险了。
比如下面代码,就很发生内存泄漏:
1 | void func { |
上面代码中,前面用 C 语言申请了 10 个字节的堆空间,然后开始赋值被转成 Objective-C 的 NSString 对象。
oc_str 是 ARC 控制的,出了 func 函数作用域,内存就会被释放。可是如果忘记写 free(c_chars);
这行代码,就会导致 10 字节的内存泄漏。像这样的内存细节,防不胜防的同时又会慢慢耗尽内存空间。
所以,如果需要避免 Objective-C 的运行时带来的消耗而想采用 C 写业务,最好使用 Core Foundation,它和 Foundation 之间的桥接非常完美,一般不会出问题。而自己写 C 进行混用,野指针和内存不释放是挥之不去的地雷。
Core Foundation 的使用
Core Foundation 只是一个非常优秀的框架,但是苹果用 C 写的 Core Foundation 框架和 Objective-C 写的 Foundation 框架,不是 iOS 框架的全部。框架是库的抽象,用 Golang 等其他高级语言,一样可以写出优秀的框架。Dart 就是举足轻重的例子。
上文的截图中,给出了 Core Foundation 框架里面都有哪些好用的组件,比如 CFString、CFDate 等。下面的一些示例,是用 Foundation 不好实现的。
CFRunloop 介绍
iOS 的 Runloop 水还是很深的。我也写了 Runloop 的一篇文章,一直在草稿中未能发布。因为牵涉面太广,如事件驱动、线程休眠、自动释放池、UI 刷新等。通过 Runloop 能够更加清楚明白的理解 App 运行的原理,也可以做非常多有用的东西,如主线程卡顿监控、线程保活等。
Foundation 提供了 NSRunloop 供我们开发人员使用,但是 NSRunloop 有一个大坑,对于不了解 Runloop 的开发人员来说,很容易陷进去。
网上有很多 Runloop 的介绍,在介绍让线程执行一段时间的时候,会使用 [[NSRunLoop currentRunLoop]run]
。我揣摩本意,发现他们并不是想要让线程永久长活,但是却使用了 run
函数。这样会使得当前线程永远无法释放,是永远。因为 NSRunloop 里面 run 函数是对 CFRunLoopRun()
函数的 true 循环封装,当结束一次循环后,NSRunloop 会立刻再次调用 CFRunLoopRun()
函数,没有任何办法可以销毁当前线程的 Runloop。这样,项目里面就永远的多出来一条可能已经不再需要的线程。主线程就使用的这个逻辑。
在 CFRunnloop 里面,仅有两种方式安全启动线程的 runloop,分别为 CFRunLoopRun()
和 CFRunLoopRunInMode
,其中 CFRunloopRun
还是语法糖。这两种启动方式,都是一次循环,客户端可以自行控制啥时取消 Runloop,有效的降低 Runloop 未知风险。相关源码如下:
1 | CFRunloop.c |
iOS 的 runloop,就是通过调用 CFRunLoopRunSpecific()
->__CFRunLoopRun()
实现的。其中,NSRunloop 的 run 函数,相当于下面代码:
1 | - (void)run { |
所以,线程永远也无法销毁。因为 CFRunloopRun () 函数会在 Mode 切换或者手动调用 CFRunLoopStop()
等情况下执行完毕,但是外部的 do-while true 循环,永远结束不掉。
这里,如果需要写 Runloop 相关的代码,我强烈建议使用 CFRunloop,而不要使用 NSRunloop。相比来说,CFRunloop 提供了比 NSRunloop 更加细致化的 Api,相比之下,NSRunloop 就寥寥无几了。
下面是我写的一些 CFRunloop 测试代码,因为 Core Foundation 是 C 语言写的,所以里面的组件都是面向过程的调用方式,和面向对象有些不同:
1 | self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(createThread) object:nil]; |
1 | 主线程下调用"addObserver",可以实时查看主线程的Runloop状态 |
CFDictionary 介绍
Foundation 里面有 NSDictionary 与之对应,如果我们希望用我们自定义的对象为 key,存储与 NS 字典中,直接存储是不行的:
1 | // 定义Person类 |
上面代码中,如果我们把自定义 Person 类的对象 p 作为 key 存储到 NSMutableDictionary 中,运行时是会崩溃的。
因为 Foundation 规定,字典的 key 必须要实现 NSCoping
协议,字典在添加属性的时候,是调用 [key copy] 作为字典 key 的。
改写如下:
1 | @interface Person : NSObject<NSCopying> |
如果我们使用 Core Foundation,就可以避开这个限制,即 Person 类不需要实现 NSCoping
协议,如下:
1 | Person *p = Person.new; |
CFDictionary 默认会对 Key 和 Value 做 retain,所以我们使用 **__bridge 即可。当 p 被当作 key 加入 cf_mu_dic 后,p 的引用计数已经变成 2 了。
如果我们使用__bridge_retained**,如下:
1 | Person *p = Person.new; |
这里因为”__bridge_retained” 缘故,p 置空和移除 CFDictionary 所有元素后,对象的引用计数还是 1,所以内存泄漏。
CFDictionary 的 key 不需要实现 NSCoping
协议这一特性,YYModel 就有使用,这也是 YYModel 使用 CFDictionary 的最终原因:
1 | YYMemoryCache.m |
YYModel 使用 CFDictionary,就是因为缓存对象是各式各样的,极大可能都是没有实现 NSCoping
协议的。
因为 YYModel 通过一个__unsafe_unretained
类型的双向链表来保存对象,所以 YYModel 需要一个容器来持有缓存对象防止被提前释放。
为了加快查询对象的速度,使用查找复杂度为 1 的 hash map 结构即字典 (CFDictionary),而非数组 (CFArray)。
CFDictionary 还有一个巨大特性,是可以吊打 NSDictionary 的,那就是可以自行控制引用计数。下图表示 CFDictionary 的创建函数及相关函数调用:
我们举例一下,
1 | _dic = CFDictionaryCreate(CFAllocatorGetDefault(), keys, values, n, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); |
这里创建一个_dic 变量,这里每个 key 都会经过 kCFTypeDictionaryKeyCallBacks
结构体获取到 retain/release/copyDescription/equal/hash
进行函数调用。
比如,如果两个 key 一样,那么 equal 就会比对出 true,第二个 key 元素就会被过滤。注意,这里和 NSDictionary 不一样,NSDictionary 是完全 hash map table,两个元素如果一样,就会通过拉链法或者开放寻址法进行存储。但是在 CFDictionary 里面,如果两个元素 equal 为 true,则会过滤另一个。
然后,一个 key 被存储的时候,会调用 retain 函数进行引用计数 + 1。这里调用的是系统默认的,如图中所示,如果我们用自己的 retain 函数代替系统的,就可以实现引用计数的多变性:
1 | void * Custom_CFDictionaryRetainCallBack(CFAllocatorRef allocator, const void *value) { |
我们通过改写一个 key retain 函数,就可以改变 CFDictionary 的 key 在 retain 时候的计数是否 + 1。
如果执行 CFDictionaryRemoveAllValues(cf_mu_dic);
,则字典中所有元素都会被移除,这个时候每个 key 都会被调用 release 函数执行引用计数 - 1 操作,我们也可以重写:
1 | void Custom_CFDictionaryReleaseCallBack(CFAllocatorRef allocator, const void *value) { |
我们改写 key release 函数,就可以使得 key 被移除释放的时候,引用计数不在 - 1。
这里,我们的操作性非常强,我们可以提供自己的函数地址,就可以实现多样化的 CFDictionary 引用计数逻辑,详细代码如下:
1 | const void * custom_dictionary_key_retain(CFAllocatorRef allocator, const void *value) { |
这里,我们描述了很多 CFDictionary 相比于 NSDictionary 的不同,有如下:
- CFDictionary 的 key 不需要实现 NSCoping 协议,NSDictionary 的 key 如果没有实现 NSCoping 协议,则会运行时崩溃。YYModel 等开源库主要就是使用了这个特性。
- CFDictionary 的 key 如果相等,在元素不会被插入。NSDictionary 则会通过拉链法和开放寻址法进行数据存储。
- CFDictionary 的 key 和 value 的引用计数,都可以自行控制。NSDictionary 的 key 用的 [key copy],value 用的 retain。
所以,CFDictionary 相比 NSDictionary 来说,扩展性也更强。
最近一直在喝 Luckin Coffee,最近因为收入造假,快要被纳斯达克下市了。