0%

背景知识

进程

进程是操作系统中最重要的抽象,它是程序(指令和数据)运行的真正实例。它给每个应用提供了两个非常关键的抽象:一是逻辑控制流,二是私有地址空间。逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。私有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。这样的抽象使得具体的进程不需要操心处理器和内存的相关适宜,也保证了在不同情况下运行同样的程序能得到相同的结果。

XNU(X is Not Unix)

Core OS

iOS 系统架构分大致为四层,每个层级提供不同的服务。这里我们重点关注处于 Core OS 层的 Darwin 操作系统,它是 macOS 和 iOS 操作环境的操作系统部分,是类 Unix 操作系统。Darwin 的内核是 XNU,XNU 采用的是微内核 Mach 和 宏内核 BSD 的混合内核,具备微内核和宏内核的优点。
XNU

Mach 是为解决早期 Unix 中“一切皆文件”的抽象机制的不足(不方便进行进程间通信(IPC))而出现的替代。它负责操作系统最基本的工作,包括进程和线程抽象、处理器调度、进程间通信、消息机制、虚拟内存管理、内存保护等。但是因为内核态/用户态的上下文切换会额外消耗时间,同时内核与服务进程之间的消息传递也会降低运行效率,苹果深度定制了 BSD 宏内核,使其和 Mach 混合使用。BSD 是对 Mach 封装,提供了 POSIX 应用程序接口(Portable Operating System Interface of UNIX)及进程管理、安全、网络、驱动、内存、文件系统(HFS+)、网络文件系统(NFS)、虚拟文件系统(VFS)等功能。

进程对应到 Mach 是 Mach Task,Mach Task 可以看做是线程执行环境的抽象,包含虚拟地址空间、IPC 空间、处理器资源、调度控制、线程容器。进程在 BSD 里是由 BSD Process 处理,BSD Process 扩展了 Mach Task,增加了进程 ID、信号信息等。

线程对应到 Mach 是 Mach Thread,是 Mach 里的最小执行单位。Mach Thread 有自己的状态,包括机器状态、线程栈、调度优先级(有 128 个,数字越大表示优先级越高)、调度策略、内核 Port、异常 Port。线程对应到 BSD 中为POSIX 兼容的线程模型 pthread。

Mach 的模块包括进程和线程都是对象,对象之间不能直接调用,只能通过 Mach Msg 进行通信,也就是 mach_msg() 函数。在用户态可以通过 mach_msg_trap() 函数触发陷阱来处理异常消息切换到内核态,BSD 在异常消息机制的基础上建立了信号处理机制,用户态产生的信号会先被 Mach 转换成异常,BSD 将异常再转换成信号。

Mach-O

Mach-O is a bunch of file types.iOS 的可执行文件及动态库、静态库等都是 Mach-O 格式的。
Mach-O File type
Mach-O 文件的内部被划分成用大写字母表示的 Segment,每个 Segment 的空间大小为页的整数倍(为了方便虚拟内存的实现)。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。Segment 内部又被划分成小写字母表示的 Section,Section 的空间大小并不一定为页的整数倍,多个 Section 之间不会重叠。
Mach-O segment
在 __TEXT segment 中包含了 Mach header,header 中存储了文件类型、适用的 CPU 架构及加载指令数量等基本信息:

1
2
3
4
5
6
7
8
9
10
struct mach_header_64 {
uint32_t magic; // 64位还是32位
cpu_type_t cputype; // CPU 类型,比如 arm 或 X86
cpu_subtype_t cpusubtype; // CPU 子类型,比如 armv7
uint32_t filetype; // 文件类型
uint32_t ncmds; // load commands 的数量
uint32_t sizeofcmds; // load commands 大小
uint32_t flags; // 标签
uint32_t reserved; // 保留字段
};

紧跟在 header 之后的是多条 Load Command,其中提供了内核与 dyld 加载 Mach-O 文件时所需要的辅助信息。
在 __LINKEDIT segment 中存储了诸如函数名称和全局变量的名称及地址等信息的元数据,还保存了每个页的 hash 用于载入时确保文件不被篡改。

XNU 加载主程序(内核态)

在传统的 Unix 系统中,fork() 是唯一用来创建新进程的方法,该方法将复刻一个当前进程的完整结构,包括二进制代码。所以负责启动其他 App 的进程为了能跑其他人的程序,还需要配合 exec() 方法,把 fork 出来的进程的 image 覆盖成新 App 的。
XNU 加载主程序时,会fork()进程,并对进程进行一些基本设置,比如为进程分配虚拟内存、为进程创建主线程、将 Mach-O 可执行文件映射(map 而不是 read)进内存等。相关的代码我们可以看苹果的开源文件:kern_exec.

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
int __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval)
{
// 为简化描述,省略部分代码。
...
struct uthread *uthread; // 线程
task_t new_task = NULL; // Mach Task
...
context.vc_thread = current_thread();
context.vc_ucred = kauth_cred_proc_ref(p);

// 分配大块内存,不用堆栈是因为 Mach-O 结构很大。
MALLOC(bufp, char *, (sizeof(*imgp) + sizeof(*vap) + sizeof(*origvap)), M_TEMP, M_WAITOK | M_ZERO);
imgp = (struct image_params *) bufp;

// 初始化开辟的 imgp 结构里的公共数据
...

uthread = get_bsdthread_info(current_thread());
if (uthread->uu_flag & UT_VFORK) {
imgp->ip_flags |= IMGPF_VFORK_EXEC;
in_vfexec = TRUE;
} else {// 程序如果是启动态,就需要创建新的 mach task 和 thread。这些指针将会在 image active 之后指向新的进程。
imgp->ip_flags |= IMGPF_EXEC;
// fork 进程
imgp->ip_new_thread = fork_create_child(current_task(),
NULL, p, FALSE, p->p_flag & P_LP64, TRUE);
// 异常处理
...

new_task = get_threadtask(imgp->ip_new_thread);
context.vc_thread = imgp->ip_new_thread;
}

// 这个函数主要是分配内存,检查权限,然后调用 load_machfile(),在从0x000000起始位置开始留出至少4G(64位)不可读写的空间用于捕捉NULL指针引用,根据 ASLR 随机一个地址开始加载 Mach-O 文件,读取 Mach-O header,根据解析后得到的 load command 信息,通过映射方式加载到内存中。还会使用 activate_exec_state() 函数处理解析加载 Mach-O 后的结构信息,设置执行 App 的入口点。
error = exec_activate_image(imgp);

if (imgp->ip_new_thread != NULL) {
new_task = get_threadtask(imgp->ip_new_thread);
}
...

kauth_cred_unref(&context.vc_ucred);

if (!error) {
task_bank_init(get_threadtask(imgp->ip_new_thread));
proc_transend(p, 0);

thread_affinity_exec(current_thread());

// 继承进程处理
if (!in_vfexec) {
proc_inherit_task_role(get_threadtask(imgp->ip_new_thread), current_task());
}

// 设置进程的主线程
thread_t main_thread = imgp->ip_new_thread;
task_set_main_thread_qos(new_task, main_thread);
}
...
}

设置完入口点后会通过load_dylinker()函数来解析加载 dyld,然后将入口点地址改成 dyld 的入口地址。这一步完后,内核部分就完成了 Mach-O 文件的加载。剩下的就是用户态层 dyld 加载 App 了。
load mach-o

Dyld 加载动态库(用户态)

Dyld 也是开源的,在dyldStartup.s中 Dyld 的入口函数__dyld_start中,设置好一些寄存器参数后,调用dyldbootstrap::start()方法,其中调用了dyld::_main(),该方法会加载相关的动态库,完成后返回 APPmain()函数地址,然后调用main()函数就进入我们熟悉的程序入口了。

Load dylibs

dyldbootstrap::start()方法会根 ASLR(Address Space Layout Randomization)计算主执行文件偏移后的地址与 Mach-O header 指针,将其作为参数传递给dyld::_main()函数,接下来:

  1. setContext(),使用 Mach-O header 中的信息检查并运行环境,创建链接上下文。
  2. 验证主执行文件与系统架构的兼容性成功后,实例化主执行文件的 ImageLoader。解析主执行文件的 header,获取需要加载的依赖库的列表。
  3. 找到每个 dylib 对其实例化一个 ImageLoader,打开文件读取开头部分,确保它是 Mach-O 文件。然后找到代码签名让内核校验签名。成功后对文件的每个 segment 调用mmap()。由于 dylib 可能会依赖其他 dylib,所以这个加载过程是递归的。一般应用会加载 100-400 个 dylib,但是大多数是系统库,这些库大部分已经被缓存在内存中了,所以加载速度很快。

load dylibs

Fix-ups

加载完所有的 dylibs 后,还处于各自独立的状态,需要通过 Fix-ups 将它们绑定在一起。由于代码签名的存在,我们不能直接修改指令,那么如何在一个 dylib 中调用另一个 dylib 的方法呢?这里采用了叫做 dynamic PIC(Position independent Code)的技术,它会在 __DATA segment 中创建一个指针,指向要调用的方法或变量。所以 Fix-ups 都是在修正 __DATA segment 中的指针和数据,将符号绑定在地址上。留意保存在 __DATA segment 中的 __la_symbol_ptr section 中的符号并不是在启动时绑定的,而是在程序运行时首次访问时才绑定的。

Rebasing and Binding

Fix-ups 分为两种类型:

  • Rebasing:在 dylib 内部调整指针,由于 ASLR 的使用,dylib 会被整体偏移到某个地址,所以需要遍历所有的指针并添加一个偏移值。这里可能会导致 page fault 和 COW,从而会导致I/O问题,但是好在是序列化的,内核会预先读取,减少I/O 消耗。
    rebasing

  • Binding:设置指向该 dylib 之外的指针,本质上是通过符号名称绑定的,这些符号信息保存在 __LINKEDIT segment 中,表示这个数据指针要指向某个符号。dyld 需要去符号表中找到该符号对应的实现,然后将指针值存储到 __DATA segment 中的指针中。
    binding

Notify Objc Runtime

这部分内容我在之前的博客:Runtime 源码笔记:对象与类 中类的加载过程一节中有描述到,在 Runtime 的入口函数_objc_init中有注册 dyld 相关事件的回调函数:

1
_dyld_objc_notify_register(&map_images, load_images, unmap_image);

OC 基于 Runtime 机制可以用类的名字来实例化一个类的对象,Runtime 维护了一张映射类名与类的全局表,当 ImageLoader 加载完 dylib 后,dyld 会调用回调函数map_images对其进行解析和处理,将可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都按格式 fix-up 后成功加载到内存中。
notify objc runtime

Initializer

在完成所有的数据 fix-ups 后,就可以对需要初始化的数据进行初始化操作了。OC 的+load以及 C++ 的constructor方法都会在这个阶段执行。
initializers

完成上面这些工作后,dyld 会调用main()函数。其中调用UIApplicationMain(),我们的 App 就这样启动了。

注:本文中的 dyld 加载动态库的过程是基于 dyld2 描述的,iOS13 系统中已经升级为 dyld3,加载过程略有不同。

参考

排序比较

冒泡排序

冒泡排序
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n-1 次,就完成了 n 个数据的排序工作。

swift实现冒泡排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func bubbleSort(_ nums: inout [Int]) {
for i in 0..<nums.count-1 {
var flag = true
for j in 0..<nums.count-1-i {
if nums[j] > nums[j+1] {
let temp = nums[j+1]
nums[j+1] = nums[j]
nums[j] = temp
flag = false
}
}
if flag { break }
}
}

稳定性

在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。

空间复杂度

冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。

时间复杂度

最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)。平均情况时间复杂度也为 O(n2)。

插入排序

插入排序

首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

冒泡排序和插入排序的时间复杂度都是 O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?
从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。

swift实现插入排序

1
2
3
4
5
6
7
8
9
10
11
func insertionSort(_ nums: inout [Int]) {
for i in 1..<nums.count {
let temp = nums[i]
var j = i - 1
while j >= 0 && nums[j] > temp {
nums[j+1] = nums[j]
j -= 1
}
nums[j+1] = temp
}
}

稳定性

对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。

空间复杂度

插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。

时间复杂度

如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。
平均情况时间复杂度为O(n2)。

选择排序

选择排序

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

swift实现选择排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//选择排序的交换过程中破坏了稳定性,其不是稳定的排序算法
func selectionSort(_ nums: inout [Int]) {
for i in 0..<nums.count {
var j = i
var min = nums[i]
var minIndex = j
while j<nums.count {
if nums[j]<min {
min = nums[j]
minIndex = j
}
j += 1
}
let temp = nums[i]
nums[i] = min
nums[minIndex] = temp
}
}

稳定性

选择排序是一种不稳定的排序算法,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

空间复杂度

选择排序空间复杂度为 O(1),是一种原地排序算法。

时间复杂度

选择排序需要 2 次循环遍历,所以最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2)。

归并排序

归并排序
递归的归并排序

归并排序使用分治思想,先把待排序序列拆分成一个个子序列,直到子序列只有一个元素,停止拆分,然后对每个子序列进行边排序边合并。归并排序的一个缺点是它需要一个临时的“工作”数组,其大小与被排序的数组相同。 它不是原地排序
分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

swift实现归并排序

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
func mergeSort(_ array: [Int]) -> [Int] {
guard array.count > 1 else { return array }
let middleIndex = array.count / 2
let leftArray = mergeSort(Array(array[0..<middleIndex]))
let rightArray = mergeSort(Array(array[middleIndex..<array.count]))
return merge(leftArray,rightArray)
}

func merge(_ leftPile: [Int],_ rightPile: [Int]) -> [Int] {
var leftIndex = 0
var rightIndex = 0
var orderedPile = [Int]()
while leftIndex < leftPile.count && rightIndex < rightPile.count {
if leftPile[leftIndex] < rightPile[rightIndex] {
orderedPile.append(leftPile[leftIndex])
leftIndex += 1
} else if leftPile[leftIndex] > rightPile[rightIndex] {
orderedPile.append(rightPile[rightIndex])
rightIndex += 1
} else {
orderedPile.append(leftPile[leftIndex])
leftIndex += 1
orderedPile.append(rightPile[rightIndex])
rightIndex += 1
}
}
while leftIndex < leftPile.count {
orderedPile.append(leftPile[leftIndex])
leftIndex += 1
}
while rightIndex < rightPile.count {
orderedPile.append(rightPile[rightIndex])
rightIndex += 1
}
return orderedPile
}

稳定性

在元素拆分的时候,虽然相同元素可能被分到不同的组中,但是合并的时候相同元素相对位置不会发生变化,故稳定。

空间复杂度

需要用到一个数组保存排序结果,也就是合并的时候,需要开辟空间来存储排序结果,在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。

时间复杂度

最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。

快速排序

要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
快排分区函数图解

swift实现快速排序

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
func partition(_ array:inout [Int],_ low:Int,_ high:Int) -> Int {
let pivot = array[high]//直接选择最后一个数字为分区点
var i = low
for j in low..<high {
if array[j] < pivot {
let temp = array[j]
array[j] = array[i]
array[i] = temp
i += 1
}
}
array[high] = array[i]
array[i] = pivot
return i
}

func quickSortC(_ array:inout [Int],_ low:Int,_ high:Int) -> Void {
if low >= high { return }

let index = partition(&array, low, high)
quickSortC(&array,low,index-1)
quickSortC(&array,index+1,high)
}
func quickSort(_ array:inout [Int]){
quickSortC(&array, 0, array.count-1)
}

稳定性

在元素分组的时候,相同元素相对位置可能会发生变化,故不稳定。

空间复杂度

不同实现空间复杂度不太一样,可以实现原地排序,空间复杂度为O(1)。

时间复杂度

与选取的 pivot 值有关系,如果 pivot 值为最大或最小,导致只有一边进行快速排序,时间复杂度为 O(n2) , 如果选择中间的值为 O(nlogn),平均时间复杂度为O(nlogn);

归并排序和快速排序的区别

归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
归并快排区别

计数排序

计数排序

计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

计数排序的思想是,对每一个输入元素,计算小于它的元素个数,构造辅助数组C,C的长度为k。第一次遍历A后,得到[0,k)区间上每个数出现的次数,将这些次数写入C,然后把C中每个元素变成前面所有元素的累加和,接下来,再次从后向前遍历数组A,根据取出的元素查找C中对应下标的值,再把这个值作为下标找到R中的位置,即是该元素排序后的位置。

swift实现计数排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func countingSort(_ array: [Int]) -> [Int] {
guard array.count > 1 else { return array }

let max = array.max() ?? 0
var countArray = [Int](repeating: 0, count: Int(max + 1)) //其中存储小于或者等于元素值的个数
for i in array {
countArray[i] += 1//计算每个元素出现的次数
}

for i in 1 ..< countArray.count {
let sum = countArray[i] + countArray[i - 1]
countArray[i] = sum//算出小于等于此元素值的个数
}

var sortedArray = [Int](repeating: 0, count: array.count)
for i in array {
countArray[i] -= 1
sortedArray[countArray[i]] = i
}
return sortedArray
}

稳定性

空间复杂度

该算法使用长度为n + 1和n的数组,因此所需的总空间为O(2n)

时间复杂度

第一次遍历A并计算C所花时间是O(n),C累加所花时间是O(k),再次遍历A并给R赋值所花时间是O(n),因此,总时间为O(k + n)。在实际中,当k=O(n)时,我们一般会采用计数排序,这时的运行时间为O(n)。

注:该系列是我在学习《数据结构与算法之美》课程时,为加深理解和日后翻看所做的笔记及个人思考的记录。

复杂度分析

事后统计法

将代码跑一遍,测试得到算法的执行时间和占用内存的空间大小这种事后统计法虽然是正确的,但是有很大的局限性:

  1. 测试结果非常依赖测试环境
  2. 测试结果受数据规模影响很大

大O复杂度表示法

时间复杂度

1
T(n) = O(f(n))

T(n) 表示代码的执行时间,n 表示数据规模的大小,f(n) 表示每行代码执行的次数总和,上面的公式就表示所有代码的执行时间与每行代码的执行次数成正比

大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是代表代码执行时间随数据规模增长的变化趋势,所以也叫渐进时间复杂度。 当 n 很大时,f(n)中的低阶,常量,系数并不左右增长趋势,所以可以忽略,只记录一个最大量级就可以了。例如:T(n)=O(n²)T(n)=O(logn)这样。常见的时间复杂度有如下几种:

复杂度

  • 最好/最坏情况时间复杂度:是指在最理想/最糟糕的情况下,执行这段代码的时间复杂度。
  • 平均时间复杂度:最好/最坏时间复杂度对应的都是极端情况下的复杂度,平均复杂度将各种情况发生的概率考虑进去,是所有情况下代码执行次数的加权平均值。
  • 均摊时间复杂度:均摊时间复杂度其实是一种特殊的平均时间复杂度,一般是指对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度较高,而且这些操作之间存在前后连贯的时序关系,此时,我们将这一组操作放在一起分析,看能否将较高时间复杂度的那次操作的耗时均摊到其他时间复杂度比较低的操作上。

空间复杂度

表示算法的存储空间与数据规模增长的变化趋势,也叫渐进空间复杂度。

数组

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

  • 线性表是一种数据排成一条线一样的结构,每个线性表上的数据最多只有前和后两个方向。数组、链表、队列、栈都是线性表结构。

线性表

在非线性表中,数据之间并不是简单的前后关系。
非线性表

  • 连续的内存空间和相同类型的数据使得数组支持根据下标随机访问的特性。

数组a

1
2
//按下标随机访问数组中某个元素时,通过如下寻址公式,计算出该元素的内存地址
a[i]_address = base_address + i * data_type_size
  • 插入操作:将一个数据插入到长度为 n 的数组的第 k 个位置,需要先将 k~n 的这部分元素顺序向后挪一位,再插入新元素。其最好、最坏及平均时间复杂度为 O(1)、O(n)、O(n)。但是当数组中的数据没有任何规律,只是被当做存储数据的集合时,可以直接将第 k 位数据搬到数组的最后,将新元素直接放到第 k 位,将时间复杂度减为 O(1)。
  • 删除操作:与插入操作类似,为保证内存连续性,也需要搬移数据。最好、最坏及平均时间复杂度也为 O(1)、O(n)、O(n)。但是在某些特殊情况下,我们不追求数据的连续性时,可以采取标记元素已经被删除,并不真正搬移数据,只有在数组没有更多的存储空间时才执行一次真正的删除操作,这样大大减少了操作导致的数据搬移。

数组越界问题

在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界是时编译器应该如何处理。因为访问数组的本质就是访问一段内存地址,只要数组通过偏移计算得到的内存地址是可用的,那么程序就不会报错。

链表

与数组不同,链表并不需要一块连续的内存,它通过“指针”将一组零散的内存块串联起来使用。
数组链表内存分布

单链表

单链表
链表的插入删除操作只需要考虑相邻结点的指针改变,因此时间复杂度为 O(1),但是想要随机访问第 K 个元素就需要顺着指针依次遍历了,时间复杂度为 O(n)。

循环链表

循环链表

双向链表

双向链表

如何基于链表实现 LRU 缓存淘汰算法

我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表:

  1. 如果该数据已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
  2. 如果此数据没有缓存到链表中,若此时缓存未满则将此结点直接插入到链表的头部,若此时链表已满则删除链表尾结点,将新的数据结点插入到链表头部。

写好链表代码的技巧

  • 将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
  • 对于非自动内存管理的语言来说,删除链表结点时,也一定要记得手动释放内存。
  • 为简化编程逻辑,可以引入哨兵结点,这样不管链表是不是空,head 指针都会一直指向这个哨兵结点,这种有哨兵结点的链表叫带头链表。

带头链表

  • 重点留意边界情况,如链表为空时,链表只有一个结点时,链表头结点跟尾结点的操作等等。

栈是一直“操作受限”的线性表,只允许在一端插入和删除数据,并且满足后进先出,先进后出的特性。栈只支持两个基本操作:入栈 push()和出栈 pop()。

栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈。

栈在函数调用中的应用

操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

栈在表达式求值中的应用

编译器是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

栈用在表达式求值

栈在括号匹配中的应用

假设表达式中只包含三种括号,圆括号 ()、方括号 [] 和花括号{},并且它们可以任意嵌套。比如,{[{}]}或 [{()}([])] 等都为合法格式,而{[}()] 或 [({)] 为不合法的格式。我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。

队列

队列跟栈一样,也是一种操作受限的线性表数据结构。它满足先进先出的特性,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。

跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。

队列与栈

循环队列

循环队列是为了解决在顺序队列中入队当 tail=n 时的数据搬移的问题。

循环队列

使用循环队列最关键的问题是确定好队空和队满的判定条件。在用数组实现的非循环队列中,队空的判断条件是 head == tail,队满的判断条件是 tail == n。在循环队列中队空的判断条件仍然是 head == tail,判断队满的条件为 (tail+1)%n=head。

递归

递归需要满足的三个条件

  1. 一个问题的解可以分解为几个子问题的解
  2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
  3. 存在递归终止条件

写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

前言

本文是对Mastering Grand Central Dispatch WWDC2011 - Session 210以及 GCD 其他内容的学习笔记。

GCD 按照我的理解就是一个系统级的并发模型,通过这个模型你不再需要直接接触底层的“线程”和“锁”去编写大量的对线程安全要求很高的代码,而是在模型中将“执行任务的 Block”添加进合适的“调度队列(dispatch queue)”中就可以轻松实现多线程编程。当然它还附赠了例如dispatch_once在线程安全的环境下实现单例等很多好用的功能!

要编写多线程代码,首先要理解下面这些基本的概念:

基本概念

进程(Process)与线程(Thread)

进程是程序(指令与数据)的真正运行实例。在早期面向进程设计的操作系统中,进程是程序的基本执行实体;而现代面向线程设计的操作系统中,进程本身不是基本运行单位而是线程的容器。计算机可以同时运行多个进程,有前台进程也有后台进程。线程是进程中的实际运作单位,一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

并行(Parallelism)与并发(Concurrency)

Grand Central Dispatch Tutorial for Swift 3: Part 1/2中的这张图很清晰的解释了两者的区别:

Concurrency_vs_Parallelism
在单核 CPU 中通过“上下文切换”(即保存 CPU 的寄存器等信息到各自路径专用的内存块中,从切换目标路径专用的内存块中复原寄存器等信息,继续执行目标路径的命令列的过程)在某个线程与其他线程间反复执行,并发的看上去像是同时执行多个线程一样。而在多核 CPU 中则可以通过并行真正的同时执行多个线程。

GCD 是建立在线程的基础之上的,在下层系统维护着一个共享的线程池,你只需要将执行任务的 block 或者函数添加进 dispatch queues 中,然后由 GCD 来决定在哪个线程上面执行它。GCD 根据系统状态及可用资源来决定进行多大程度的并行。留意并行要求必须是并发的,但是并发并不能保证一定并行!

分发队列(Dispatch Queue)

Dispatch Queue 是管理你添加的要执行任务的队列,它按照先进先出(FIFO)的顺序执行处理。Dispatch Queue 是线程安全的,也就意味着你可以同时在多个线程访问它。Dispatch Queue 分为 Serial Queue和 Concurrent Queue 两种(注:下面部分图片来自 Ray 家的教程):

串行队列(Serial Queue)

Serial-Queue-Swift
Serial Queue 保证在任何时间同时只能执行一个任务,它会等待当前正在进行的任务结束之后再处理其他任务。由 GCD 来控制任务的执行时机,两个任务之间会有多久的间隔也是不确定的。一旦生成 Serial Queue 并添加了任务进去,系统对于一个 Serial Queue 就只生成并使用一个线程。但是多个 Serial Queue 对应各自不同的线程,因此他们之间是可以并行执行的。Serial Queue 与线程的关系可以看下面的gif图(注:图片截取自 WWDC 视频):

2018-07-09 23_21_23

并发队列(Concurrent Queue)

Concurrent-Queue-Swift

Concurrent Queue 允许同时执行多个任务,其中的任务会按照被添加的顺序出队开始执行,但是它不会等待正在执行的任务结束就可以开始下一个任务。任务结束的顺序及同一时刻正在执行的任务数量是不确定的。一个 Concurrent Queue 可以使用多个线程同时执行多个处理。什么时候开始执行一个任务,在多核环境下是使用“上下文切换”还是在另一个核心上运行这些都是由 GCD 决定的。Concurrent Queue 与线程的对应关系可以看下图:

2018-07-10 00_02_32

常用 API

dispatch_get_main_queue/dispatch_get_global_queue

有两种途径得到Dispatch Queue:

  1. 获取系统标准提供的 Main Queue/Global Queues
  2. 通过dispatch_queue_create函数生成的 Custom Queues

Main Queue 中的任务是在主线程 Runloop 中执行的,因为主线程只有一个,Main Queue 自然也就是一个 Serial Queue。由于在主线程中执行,因此要将用户界面的更新等必须在主线程中执行的任务添加到 Main Queue 中。可以通过dispatch_get_main_queue()函数获取。

Global Queue 是整个系统共享的 Concurrent Queue,它包含四个不同优先级的队列:high, default, low, 和 background。在 iOS 8.0 之前,只需要将任务提交到对应执行优先级的 Global Queue 中即可保证任务执行的优先级顺序。例如获取一个高优先级队列的函数如下:

1
2
//第二个参数 flag 是为未来保留的,现在传入 0 即可。
dispatch_queue_t highGlobalQue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

现在你不需要直接指定 Queue 的优先级了,而是指定一个 Quality of Service (QoS) 级别来暗示任务的重要性,然后 GCD 通过这个值来决定使用哪个优先级的队列。Qos 级别有以下几种:

  • QOS_CLASS_USER_INTERACTIVE: 这个级别代表任务需要尽快执行以便提供良好的用户体验,主要用于 UI 刷新,处理事件等需要低延迟的任务。这个级别的任务应该在主线程执行。
  • QOS_CLASS_USER_INITIATED: 这个级别代表任务是从 UI 线程初始化的,可以异步执行,主要用于尽快的得到执行结果,这个级别被映射到了高优先级队列。
  • QOS_CLASS_DEFAULT: 默认的任务级别。映射到默认优先级队列。
  • QOS_CLASS_UTILITY: 这个级别的任务通常是耗时的,主要用于计算,磁盘IO,网路通信等,它被设置为尽量节省能源的。这个级别被映射到了低优先级队列。
  • QOS_CLASS_BACKGROUND:这个级别的任务大多是用户感知不到的,会被映射到后台优先级队列
  • QOS_CLASS_UNSPECIFIED:这个级别表示服务质量缺失。

获取一个QOS_CLASS_USER_INITIATED级别的队列方法如下:

1
dispatch_queue_t userInitiatedQue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);

dispatch_queue_create

1
2
3
4
//第一个参数"com.zane.serialQueue"用来标记这个队列,在 Instruments 中调试时作为队列的名字。第二个参数使用 NULL/DISPATCH_QUEUE_SERIAL 表示生成串行队列,使用 DISPATCH_QUEUE_CONCURRENT 表示生成并发队列
dispatch_queue_t customeSerialQue =dispatch_queue_create("com.zane.serialQueue", NULL);

dispatch_queue_t customeConcurrentQue = dispatch_queue_create("com.zane.concurrentQueue", DISPATCH_QUEUE_CONCURRENT);

上面代码创建的 Serial Queue,在往这个队列中提交了任务之后,系统就会为它生成一个线程。使用这个方法创建多少个 Serial Queue,就会对应生成多少个相互间并行的线程,因此如果创建大量的 Serial Queue 就会消耗大量内存,引起大量上下文切换,使得程序性能降低因此只应该在多个线程更新共享资源会导致数据竞争时,使用一个 Serial Queue.

上面创建的 Concurrent Queue,不管创建多少个,系统都只会为其使用有效管理的几个线程。

早期通过这个方法生成的 Queue 还需要使用dispatch_release()函数释放它,iOS 6 之后,系统通过ARC 来管理生成的 Queue,提交进 Queue 的 block 会持有一个该 Queue 的引用,所以只有在 Queue 中所有的 block 执行完之后才会释放。

dispatch_sync/dispatch_async

可以通过下面两个常用的函数向 Queue 中添加任务:

1
2
void dispatch_async(dispatch_queue_t queue, dispatch_block_t block);
void dispatch_sync(dispatch_queue_t queue, dispatch_block_t block);

dispatch_async函数是非同步(asynchronous)的,他将 block 添加进 queue 中,不做任何等待立即返回,这样不会阻碍当前执行的线程,可以继续进行下一行函数,常用于处理后台任务。dispatch_async函数内部会先将 block 拷贝到堆中,避免 block 执行前就被销毁。

dispatch_sync函数是同步(synchronous)的,它将 block “同步的”添加进 queue 中,在添加进的这个 block 任务执行完成之前,dispatch_sync函数会一直等待,该函数会处于调用状态而不返回。也就是说当前线程是停止的。当 block 中的任务执行结束之后,函数返回,当前线程继续进行下一行函数。

dispatch_async不同,dispatch_sync不会对该 queue 执行 retain 操作,它从调用者那里“借来”一个对 queue 的引用,而且也不会对 block 做Block_copy操作。

dispatch_sync函数的特性导致其很容易引起死锁的情况,如在执行一个 Serial Queue 的任务的线程中,将一个任务 block 同步添加到这个 Serial Queue 中时,就会发生死锁。因为该线程中正在执行这个函数,它已经处于调用状态而不返回,也就无法执行 block 中的任务,两者互相等待,形成死锁。实例代码如下:

1
2
3
4
5
6
dispatch_queue_t customeSerialQue =dispatch_queue_create("com.zane.serialQueue", NULL);
dispatch_async(customeSerialQue, ^{
dispatch_sync(customeSerialQue, ^{
NSLog(@"work");
});
});

其实还有相对应的xxx_f的直接提交函数任务的 API,上面的两个函数实现中其实也是封装调用了下面的函数。

1
2
3
//当提交的 work 函数调用时,context 作为第一个参数传递给 work 函数,表示上下文数据。work 函数不能为空。
void dispatch_async_f(dispatch_queue_t queue, void *context, dispatch_function_t work);
void dispatch_sync_f(dispatch_queue_t queue, void *context, dispatch_function_t work);

dispatch_barrier_sync/dispatch_barrier_async

这个函数通常用于处理“读者写者”的问题。当多个线程同时对一份数据进行操作时,这时就很容易出现“线程不安全”的问题,在 GCD 出现之前我们通常使用@synchronized()锁或者NSLock锁来提供同步机制。如下所示:

1
2
3
4
5
6
7
8
9
10
- (NSString *)someString{
@synchronized(self){
return _someString;
}
}
-(void)setSomeString:(NSString *)someString {
@synchronized(self){
_someString = someString;
}
}

这种写法会给对象self自动创建一个锁,等块中的代码执行完毕后就释放这个锁。它的缺点在于如果你代码中有大量的@synchronized(self)时,他们都共用同一个锁,程序可能会等待另一段于此无关的代码执行完毕。

有 GCD 之后,我们可以使用 Serial Queue 来提供替代方案,将读取与写入操作都写入同一个 Serial Queue 中,即可保证数据同步。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_customeSerialQue =dispatch_queue_create("com.zane.serialQueue", NULL);
//...
- (NSString *)someString{
__block NSString *tempString;
dispatch_sync(_customeSerialQue, ^{
tempString = _someString;
});
return tempString;
}

-(void)setSomeString:(NSString *)someString {
//注:其实设置方法也可以使用 dispatch_async,因为这里并不需要等待返回值。
dispatch_sync(_customeSerialQue, ^{
_someString = someString;
});
}

更进一步,我们可以发现这个问题中,多个读取方法是可以并发执行的,但是读取方法与写入方法之间不能并行执行。这时就可以请出dispatch_barrier_sync/dispatch_barrier_async方法了。他们在 Concurrent Queue 上工作时提供了一个串行式的瓶颈,在队列中,通过他们提交的任务必须单独执行,这就意味着在 barrier 之前提交的任务必须先全部完成,然后再单独执行 barrier 提交的任务,执行完成后,队列又恢复正常的并发状态。这些特性意味着该方法只对 Concurrent Queue 有意义,因为 Serial Queue 本身就是一次执行一个任务的。

Dispatch-Barrier-Swift
再看一个动图,加深理解:
2018-07-12 10_40_14

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_customeConcurrentQue =dispatch_queue_create("com.zane.concurrentQueue", DISPATCH_QUEUE_CONCURRENT);
//...
- (NSString *)someString{
__block NSString *tempString;
dispatch_sync(_customeConcurrentQue, ^{
tempString = _someString;
});
return tempString;
}
-(void)setSomeString:(NSString *)someString {
dispatch_barrier_async(_customeConcurrentQue, ^{
_someString = someString;
});
}

“读者写者问题”的实例代码如上,还有一点需要留意的是在上述代码中我们使用了dispatch_queue_create生成的自定义的并发队列,而没有使用dispatch_get_global_queue获取系统的全局并发队列,这是因为全局并发队列是系统资源,他不喜欢你来操纵它,所以dispatch_barrier_async函数用在它上面是没有效果的,效果跟使用dispatch_async函数一样。同理,可以暂时挂起队列和恢复队列执行的dispatch_suspend/dispatch_resume对于全局并发队列也是没有效果的。

dispatch_after

dispatch_after用于一个任务的延后执行。它并不是在指定的时间执行,而是在指定的时间异步的将任务添加进 queue 中。它的一个简单的示例如下:

1
2
3
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"work");
});

这段代码与 3 秒后使用dispatch_async函数将 block 添加进 Main Queue 中的效果是一样的。它的第一个参数是dispatch_time_t类型的值,一般通过dispatch_time函数或者dispatch_walltime函数获取,前者通常用于计算相对时间,后者用于计算绝对时间。这个参数传入DISPATCH_TIME_NOW 的话,倒不如直接使用dispatch_async函数。传入DISPATCH_TIME_FOREVER结果是未定义的。

dispatch_once

dispatch_once是用来保证在应用程序中只执行一次处理的 API。它是线程安全的操作,因此几乎成了 Objctive-C 中实现单例的标准方法了:

1
2
3
4
5
6
7
8
+ (instancetype)sharedInstance {
static SomeClass *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}

dispatch_once的第一个参数是一个指向dispatch_once_t的指针,用来测试 block 中的任务是否已经完成。对于只需执行一次的任务来说,每次调用传入的该标记值应该完全相同。所以该指针指向的变量应该为静态变量或者全局变量。

调度组(Dispatch Group)

Dispatch Group 涉及到集合的同步化,你将多个任务添加到一个分组之后,就可以等待所有的任务执行完毕,或者提供一个回调函数后继续往下执行,当所有任务执行完毕后收到通知,执行回调函数。更厉害的是你提交的任务可以属于不同的 Queue。这种特性常用于必须在指定的任务都完成的情况下才能继续的情况。

先看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_UTILITY, 0);
//1生成一个 group
dispatch_group_t group = dispatch_group_create();
//2异步添加任务
dispatch_group_async(group, queue, ^{
NSLog(@"work1");
});
dispatch_group_async(group, queue, ^{
NSLog(@"work2");
});
dispatch_group_async(group, dispatch_get_main_queue(), ^{
NSLog(@"work3");
});
//3挂起线程,等待任务完成或超时
long result = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, (int64_t)2*NSEC_PER_SEC));
if(result == 0) {
NSLog(@"work done");
}else {
NSLog(@"working");
}

我们先通过dispatch_group_create方法生成一个 group, 然后通过dispatch_group_async函数将 block 添加进 queue 中并与 group 联系起来之后,group 就会保持一个其中未完成任务的数量值,连接一个任务时增加该值,任务完成后减少该值。后面的dispatch_group_waitdispatch_group_notify函数就是使用这个数量值来判断与这个 group 连接起来的所有任务是否完成。

dispatch_group_async函数与dispatch_async相似,只是多了将任务 block 与 group 联系起来的作用。

在这里我们使用了dispatch_group_wait函数,它会一直处于调用状态而不返回,从而阻塞了当前线程,直到 group 中的所有任务执行完成或者到达指定的时间。返回时若所有任务都执行完成,这个函数的返回值为 0,否则不为 0。你也可以指定时间参数为DISPATCH_TIME_FOREVER让它一直等待直到全部任务完成。

除了上面这种同步等待的方式,还可以使用dispatch_group_notify来异步的获取通知,它的调用不会阻塞当前线程,直接返回。在检测到队列中的所有任务完成时,dispatch_group_notify会将执行结束处理的 block 添加到 它指定的 queue 中。上例中的注释 3 后的部分可以替换成下面这样:

1
2
3
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
NSLog(@"work done!");
});

还有一个特殊的需求,试想:如果我们打算在任务 block 中添加一个想要异步执行的任务,比如下载一张图片等等,这个时候若使用下面这种方式添加任务:

1
2
3
4
5
6
7
8
dispatch_group_async(group, queue, ^{
NSURLSession *session = [NSURLSession sharedSession];
NSURL *url = [NSURL URLWithString:@"/images/thehitchhiker2.png"];
NSURLSessionDownloadTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSLog(@"download done!");
}];
[task resume];
});

你可能不会达到期望的效果,程序可能会在打印了work done!之后再打印download done!,这是因为连接在 group 上的任务是异步的,它的 block 程序已经执行完了,但是它的实际下载并没有完成,而如果我们想观察的是下载任务的结束的话就需要请出dispatch_group_enterdispatch_group_leave的组合了。实例如下:

1
2
3
4
5
6
7
8
dispatch_group_enter(group);
NSURLSession *session = [NSURLSession sharedSession];
NSURL *url = [NSURL URLWithString:@"/images/thehitchhiker2.png"];
NSURLSessionDownloadTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSLog(@"work done");
dispatch_group_leave(group);
}];
[task resume];

这样就能达到我们的目的了。dispatch_group_enter函数表示一个任务进入这个 group 了,会增加队列中未完成任务的数量值,dispatch_group_leave表示一个任务已经完成,会减少队列中未完成任务的数量值。这两个函数的组合使得我们可以更合理的控制 group 中未完成任务的个数,从而达到更精确的控制。dispatch_group_enterdispatch_group_leave应该彼此对应,如果 enter 了而没有 leave,那么这一组任务将永远不会完成。

dispatch_apply

1
void dispatch_apply(size_t iterations, dispatch_queue_t queue, void (^block)(size_t));

这个 API 会将带参数的 block 提交到 queue 中,用于多次调用。调用次数由第一个参数 iterations 决定,并且他会阻塞当前线程等待任务的迭代次数完成后才返回。若 queue 参数是 Concurrent queue 的话,那么这些迭代任务可以并发执行。若是 Serial queue 的话,且该 queue 对应的线程是当前正在运行的线程,则会与disparch_sync一样发生死锁的情况。注意若迭代次数过多或者执行任务很简单的话,该函数所带来的并发收益不能抵消创建线程等其他开销,这种情况下最好还是使用for循环。

dispatch_set_target_queue

1
void dispatch_set_target_queue(dispatch_object_t object, dispatch_queue_t queue);

这个 API 可以为一个 object 设置 target queue,这个 object 可以是 dispatch queue、dispatch source或者 dispatch io 等等,target queue 最终决定了 object 所包含的任务会在哪个 queue 被调用。

queue 之间会形成一套层级体系,除了 Global queue 之外其他的 queue 都有其 target queue, queue 中的任务最终会在其 target queue 中执行。Global queue 在这个层级的最上层,沿着这个层级体系,所有的 queue 最终其 target queue 都会指向 Global queue和 Main queue。由 Global queue 的优先级(上文中讲到 Global queue 有如下优先级:high, default, low, 和 background)决定最终的执行优先级。使用dispatch_queue_create生成的 queue, 不管是 Serial queue 还是 Concurrent queue 其 target queue 默认都是 default 优先级的 Global queue。

dispatch_set_target_queue 的过程是类似dispatch_barrier_async,所以它不会影响已经添加在 queue 中的任务的执行过程,只会影响设置完 target queue 之后添加的任务。要留意设置 target queue 时不要形成循环。

修改一个 object 的 target queue 会影响它原本的行为:

  • Dispatch queues:将一个 queue 的 target queue 设置为 Serial queue 会同步化这个 queue。例如下图中的层级体系中(图片来自Effective Objective-C 2.0),排在 queue B与 queue C 中的 block 会在 queue A 中依次执行。于是排列在 queue A、B、C中的 block 总会错开串行执行(他们之间没有固定排序)。但是 queue A 与 queue D 中的 block 则可以并行执行。target queue hierarchy当然,如果你将一个 concurrent queue 的 target queue 指向 serial queue,那么其中的任务会串行的执行。
  • Dispatch sources: dispatch source 的 target queue 决定了它的事件处理的 block 将会被被提交到哪个 queue 中。
  • Dispatch I/O channels:如果一个 Dispatch I/O 的 target queue 被设置为 background 优先级的 Global queue 时,dispatch_io_readdispatch_io_write这些操作将会被节流。

若你有向一个 Serial queue 的前端添加任务的需求的话,使用dispatch_set_target_queue也可以满足你的需求:
jump the queue

dispatch_queue_set_specific/dispatch_get_specific

1
2
void dispatch_queue_set_specific(dispatch_queue_t queue, const void *key, void *context, dispatch_function_t destructor);
void * dispatch_get_specific(const void *key);

dispatch_queue_set_specific适用于将任意数据以键值对的形式关联到指定的 queue 中。留意与NSDictionary中的对象比较不同的是这个函数中的 key 比较的是指针值,而不是指针所指向的对象。所以可以使用一个静态变量的指针来传入这个参数。不建议直接传入一个字符串常量。context 参数是与 key 关联的上下文参数,可以为 NULL。在 destructor 参数中可以释放前面的 context 参数,当队列释放时,或者有新的值与该 key 关联时,原有的值就会被移除,这个析构函数就会被调用。

dispatch_get_specific这个函数需要在一个执行于 queue 中的 block 中调用,用以获取这个 queue 关联的数据,若不是在 queue 中运行的代码中调用则会返回 NULL。需要注意的是这个函数如果在指定的 queue 中查找不到 key 对应的数据,则会沿着 queue 的层级体系一直向上查找,直到找到数据或者达到根队列位置。要是传入的 queue 是 Global queue 也会返回 NULL。

dispatch_get_specific的特性可以用于解决一个 queue 层级间的同步化导致死锁的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
dispatch_queue_t serialQueueA = dispatch_queue_create("com.zane.serialQueueA", NULL);
dispatch_queue_t serialQueueB = dispatch_queue_create("com.zane.serialQueueB", NULL);

dispatch_set_target_queue(serialQueueB, serialQueueA);

static int kQueueSpecific;
CFStringRef stringValue = CFSTR("queueA");
//析构函数只能带有一个指针参数且返回值为空,这里可以使用 CFRelease 作为析构函数
dispatch_queue_set_specific(serialQueueA, &kQueueSpecific, (void *)stringValue, (dispatch_function_t)CFRelease);

//通过 queue 特有关联数据,判断若在 queueA 中则直接执行 block,若不在则同步到 queueA 中执行,以此避免同步队列的死锁。
dispatch_sync(serialQueueB, ^{
dispatch_block_t block = ^{NSLog(@"work");};
//dispatch_get_specific 在 queueB 中找不到,再去它的 target queue 中寻找。
CFStringRef specificValue = dispatch_get_specific(&kQueueSpecific);
if (specificValue) {
block();
}else {
dispatch_sync(serialQueueA, block);
}
});

dispatch_queue_get_label()

在 iOS 中,如果我们要判断代码是否运行在主线程,可以直接使用 NSThread.isMainThread()方法。但如果要判断是否运行在主队列(main queue)呢?

需要注意的是,每个应用都只有一个主线程,但主线程中可能有多个队列,则不仅仅只有主队列,所以 NSThread.isMainThread() 方法并没有办法判断是否是在主队列运行。而GCD也没有提供相应的方法。那该如何处理呢?

在AFNetworking中有如下处理办法:

1
2
3
4
5
6
7
8
#ifndef dispatch_main_async_safe
#define dispatch_main_async_safe(block)
if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())) {
block();
} else {
dispatch_async(dispatch_get_main_queue(), block);
}
#endif

信号量(Dispatch Semaphore)

信号量是一个同步对象,用于保持在 0 至指定最大值之间的一个计数值。当线程完成一次对该 semaphore 对象的等待(wait)时,该计数值减一;当线程完成一次对 semaphore 对象的释放(release)时,计数值加一。当计数值为0,则线程等待该 semaphore 对象不再能成功直至该 semaphore 对象变成 signaled 状态。semaphore 对象的计数值大于 0,为 signaled 状态;计数值等于0,为 nonsignaled 状态。

我们通过下面的例子看看 GCD 中的信号量怎么用:

1
2
3
4
5
6
7
8
9
10
11
12
13
dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0);
//创建一个信号量,初始值为1
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);
NSMutableArray *mutableArray = [NSMutableArray array];
for (int i = 0; i < 10000; i++) {
dispatch_async(queue, ^{
//当信号量大于等于1时,dispatch_semaphore_wait 会将信号量减 1 ,并返回。若信号量小于1 ,则会一直等待,直到信号量大于1或者超时。该函数的返回值与 dispatch_group_wait 函数一样。
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
[mutableArray addObject:@(i)];
//在执行完需要同步的任务后,将信号量的值加 1,使得其他线程中最先等待该信号量的代码继续执行。
dispatch_semaphore_signal(semaphore);
});
}

需要留意的是当信号量变量还在使用时,对该变量进行重新赋值或者置空会释放之前的信号量,从而引起崩溃!

Dispatch Source

Dispatch Source 是 BSD 系统内核惯有功能 kqueue 的包装,kqueue是在 XNU 内核中发生各种系统事件(例如 Unix 信号、文件描述符、Mach 端口事件、定时器等等)时,在应用程序编程方执行处理的技术。其内存占用小,尽量不占用资源,可以说是应用程序处理 XNU 内核中发生的各种时间的方法中最优秀的一种。

Dispatch Source 可以在这些事件发生时,在指定的 queue 中执行事件的处理。下面我们通过一个使用 Dispatch Source 设置定时器的例子看看他的简单用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//创建一个 dispatch source 来监控底层系统事件,当事件发生时,将相应的 handler block 提交到指定 queue 中。第一个参数为监控的时间类型,第二个参数是与第一个参数有关的信号编号,地灿哥参数也是与第一个参数有关的特定时间标志,第四个参数为提交 handler block 的 queue。这个创建过程是异步执行的。
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
//设置定时器,第三个参数为间隔时间,这里设置为 DISPATCH_TIME_FOREVER 表示不需要重复。第四个参数为允许系统延迟的时间。注意即使该值为0,系统也可能会延迟这个定时器的执行。
dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), DISPATCH_TIME_FOREVER, 1 * NSEC_PER_SEC);
//定时器指定时间所需要提交到 queue 中执行的任务 block
dispatch_source_set_event_handler(timer, ^{
NSLog(@"timer fired!");
//可以使用 dispatch_source_cancel 来取消一个 dispatch source
dispatch_source_cancel(timer);
});
//设置取消 dispatch source 时的处理,这个函数是异步执行的,取消之后就不会再有更多的 event 事件被传递,但是已经加入 queue 中的处理可以继续执行。
dispatch_source_set_cancel_handler(timer, ^{
NSLog(@"timer canceled");
});
//dispatch source 创建后是处于 suspend 状态的,在设置完 event handler 等属性之后需要调用 dispatch_resume 开启
dispatch_resume(timer);

Dispatch source 是不可重入的,任何在 dispatch source 处于 suspend 状态或者 event handler 正在执行的时候接收的事件都会被合并,并在 dispatch source resume 之后或者 handler 返回后被提交。

Dispatch Source 与 Dispatch Queue 不同的是它是可以取消的,而且可以设置取消时的 handler。

需要留意的一点是 dispatch source 在 suspend 状态下,如果直接设置 source = nil 或者重新创建 source 都会造成崩溃。正确的方式是在 resume 状态下调用dispatch_source_cancel后再重新创建。

其他

本文主要讨论了一些常用的 API,着重描述了使用时的一些注意点,容易导致问题的地方。阅读完会对iOS中使用 GCD 进行多线程编程有一个大概的认识。还有在一些其他情况下,诸如读取大文件等等也可使用 Dispatch I/O及 Dispatch Data 等,可以用时再去研究。

参考:

我们所看见的和听见的都是通过眼睛和耳朵的构造将这些信息转化为大脑能够解析的电信号,通过模拟信号传递给我们的。模拟信号利用对象的一些物理属性来表达、传递信息,例如声音、光、温度、位移、压强,这些物理量可以使用传感器测量。模拟信号中,不同的时间点位置的信号值是连续变化的。而数字世界中的信号是离散的,由 1 和 0 两个状态表示,要将模拟信号转换成能够在计算机中存储并传输的数字信号,需要经过模拟-数字转换过程,这个过程称为采样

采样

通常采样与量化联合进行,例如对于音频信号来说,模拟信号先由采样器按照一定时间间隔采样获得时间上离散的信号,再经模数转换器在数值上也进行离散化,从而得到数值和时间上都离散的数字信号。大多情况下所说的“采样”就是指这种采样与量化结合的过程。

音频采样

我们一般使用麦克风来记录声音,它可以将机械能量转换成电能量。当我们发出声音时,会带动周围的空气产生特定频率和振幅的震动,这样麦克风中那层薄薄的膜片也会根据其感受到的声波进行震动,带动线圈震动,由此产生与输入信号相同频率和振幅的电流信号,这个信号中的频率与振幅分别代表了声音的音调与音量(对于复杂波形来说,音调对应的是波的基频)。
声音信号

麦克风所产生的电压信号类似于上图中的波形,这种模拟信号可以在磁带上记录成磁场强度的变化或在黑胶唱片上记录成沟槽大小的变化。但是当存储介质为计算机时,一般会通过线性脉冲编码调试(即Linear PCM)的过程将其数字化。这个过程会间隔固定的时间对信号进行测量,然后用独特的数字记号(通常为二进制)来量化。其中涉及两个重要的参数:

  • 采样率:采样率表示音频信号每秒的取样数,它决定了音频文件的频率范围。从下图可以看出采样率越高,数字波形的形状越接近原始模拟波形。根据采样定理当采样率达到奈奎斯特频率,即频率达到所需要采样对象的最高频率的两倍时可以产生足够好的数字化效果。人类的听觉可以接收的音频范围为 20HZ~20kHZ,所以使用CD录制的音频采样率通常为 44.1kHZ,其所能捕捉的最大频率为20.05kHZ,对于人耳来说已经足够好了。
    采样率

  • 位元深度:位元深度是用于保存样本值的字节数,它定义了在线性维度上可行的离散度,决定了我们所能捕捉的音频样本的精度。为每个样本的整理量化分配过少的位结果信息会导致数字音频信号产生噪声和扭曲。使用位元深度为 8 的方法可以提供 256 个离散级别的数据,对于一般音频也可使用。CD音质的位元深度为 16,更专业级别的音频录制环境的位元深度可能达到24或更高。

图像采样

对于图像,我们一般使用扫描或者拍照技术对其信息进行采集。在数字化过程中,与音频基于时间的采样不同,图像是基于空间采样的(对于视频这种既有空间属性也有时间属性的信号,这两种方式都会使用)。空间采样包含对一副图片在一定分辨率下捕捉其亮度和色度,进而创建由该图片的像素点数据所构成的数字化结果。

在采样时,将二维空间中连续的图片在水平和垂直方向上等距的分割为矩形网状结构,形成一个个微小的方格称为像素。像素没有固定的尺寸大小,它只是对一个最小完整采样的抽象。这样一副图片就被采样成有限个像素点构成的集合。然后通过量化将各像素的灰度值从模拟量转化为离散量。其中有如下主要参数:

  • 采样点数:对一幅图像采样时,若每行像素为M个,每列像素为N个,则图像尺寸为M * N个像素。像素数量对图片质量有显著影响。一般来说图像采样点数越多,图像质量越好,但数据量越大。例如下图为采样点数从 256 * 256 逐步减少到到 8 * 8 对图像质量的影响。
    像素点数

  • 量化级数:对于灰度图像我们需要将各像素的灰度值量化,一般使用 8 位 256 个量化级数来表示 0~255 的灰度值。量化级数越多,图像质量越好。下图表示采样点数一定时,量化级数从 256 递减至 2 时对图像质量的影响。
    灰度

对于彩色图像,基于RGB色彩模型按照颜色成分——红(R)、绿(G)、蓝(B)分别采样和量化的。若各种颜色成分均按 8 位量化,即每种颜色量级别是 256,可以处理256×256×256=16777216 种颜色。

扩展阅读:相机工作原理

编码压缩

对于音视频数字信号,如果不加压缩的存储和传输虽然能够保留原始的呈现效果,但是会占用大量的存储空间与带宽。例如对于一个双声道,44.1kHz,16位LPCM音频文件的码率为:2 * 44.1kHz * 16bit=1.346Mbit/s,也就是每分钟要占用 10MB的空间。对于一个帧率为 30FPS,1280 * 720分辨率的每个像素采用 24 位的RGB色彩空间的视频文件其码率为:30 * 1280 * 720 * 24bit = 634Mbit/s,也就是每分钟要占用 4.6GB的空间。这样的码率显然在当前(虽然现在都8102年了)的设备存储及网络传输中显得难以接受,因此我们需要对这些音视频文件进行编码压缩。

扩展阅读:频压缩编码和音频压缩编码的基本原理。 注:本文中音视频编码原理部分内容摘录自该博客。

音频编解码

音频编码主要是将音频采样数据(如PCM数据)中声音信号的冗余成分去除压缩为音频码流,从而降低音频的数据量的过程。

音频编码基本原理

所谓冗余成分是指音频中不能被人耳感知到的信号,它们对确定声音的音调等信息没有任何的帮助。冗余信号包含人耳听觉范围外的音频信号(即频率在20Hz~20KHz之外的部分)以及被掩蔽掉的音频信号。根据人耳听觉的生理和心理声学现象,当一个强音信号与一个弱音信号同时存在时,弱音信号将被强音信号所掩蔽而听不见,这样弱音信号就可以视为冗余信号而不用传送。这就是人耳听觉的掩蔽效应。主要表现在频谱掩蔽效应和时域掩蔽效应:

频谱掩蔽效应

一个频率的声音能量小于某个阈值之后,人耳就会听不到,这个阈值称为最小可闻阈。当有另外能量较大的声音出现的时候,该声音频率附近的阈值会提高很多,即所谓的掩蔽效应。如图所示

频域掩蔽效应
我们可以看出人耳对2KHz~5KHz的声音最敏感,而对频率太低或太高的声音信号都很迟钝。当有一个频率为0.2KHz、强度为60dB的声音出现时,其附近的阈值提高了很多。由图中我们可以看出在0.1KHz以下、1KHz以上的部分,由于离0.2KHz强信号较远,不受0.2KHz强信号影响,阈值不受影响;而在0.1KHz~1KHz范围,由于0.2KHz强音的出现,阈值有较大的提升,人耳在此范围所能感觉到的最小声音强度大幅提升。如果0.1KHz~1KHz范围内的声音信号的强度在被提升的阈值曲线之下,由于它被0.2KHz强音信号所掩蔽,那么此时我们人耳只能听到0.2KHz的强音信号而根本听不见其它弱信号,这些与0.2KHz强音信号同时存在的弱音信号就可视为冗余信号而不必传送。

时域掩蔽效应

当强音信号和弱音信号同时出现时,还存在时域掩蔽效应。即两者发生时间很接近的时候,也会发生掩蔽效应。时域掩蔽过程曲线如图所示,分为前掩蔽、同时掩蔽和后掩蔽三部分。

时域掩蔽效应

前掩蔽是指人耳在听到强信号之前的短暂时间内,已经存在的弱信号会被掩蔽而听不到。同时掩蔽是指当强信号与弱信号同时存在时,弱信号会被强信号所掩蔽而听不到。后掩蔽是指当强信号消失后,需经过较长的一段时间才能重新听见弱信号,称为后掩蔽。这些被掩蔽的弱信号即可视为冗余信号。

主要音频编解码方案

  • WAV:WAV 编码的一种实现是在 PCM 数据格式的前面加上 44 字节,分别用来描述 PCM 的采样率,声道数及数据格式等信息,WAV 文件与无损文件是不同的,它只不过是不去对原始文件做压缩。音质很好,大量软件及平台都支持。
  • WMA:微软推出的数字音频编码方案,一般情况下会比同样音质的MP3文件体积小。与MP3一样,需要购买版权支持。
  • MP3:当前较为流行的一种有损压缩的数字音频编码方案之一。iOS的AVFoundation框架只提供对MP3数据的解码,并不支持对其编码。
  • AAC:与H.264标准对应的音频编码方案,相比MP3格式有显著的提升,且没有专利问题,也是一种有损压缩方案。AAC是iPhone等多种设备的默认音频编码格式。它的扩展名主要有:.aac,.mp4,m4a。

当然还有许多其他音频编解码方案,但是对于iOS开发学习过程来说,主要针对AAC编码方案即可。

视频编解码

视频编码主要是将视频像素数据(如YUV数据)压缩为视频码流,降低视频数据量的过程。等等,YUV是什么鬼?我们像素数据采集的时候不是RGB数据么?其实在我们平时拍照或者拍视频的时候会经历一个色彩二次抽样的过程,将数据转换为YVU颜色模式的数据。在下面的相机拍照的工作流程图中也可以看到这个步骤:

IMG_8077

色彩二次抽样

由于人类的视觉系统对颜色的位置及移动不及对亮度敏感,所以可以使用较多的亮度细节、较少的色度细节作优化,而不至于图片的质量受损严重。分别用一个亮度分量(Y’)及两个不同颜色分量(色度UV,分别用Cb和Cr来表示蓝色和红色的浓度偏移量)来替换原来的RGB数据从而减少颜色数据的过程就叫色彩二次抽样。

Barn-yuv

这张维基百科上的彩色图片可以让你对一张图片的 Y’及U、V组成部分有一个直观的认识。为节省带宽,大多数YUV格式平均使用的每像素位数都少于24位。其成分按照 J:a:b 的格式来取值:

  • J:水平抽样参照(概念上区域的宽度)。通常为4。
  • a:在J个像素第一行中的色度抽样数目(Cr, Cb)。
  • b:在J个像素第二行中的额外色度抽样数目(Cr, Cb)

其中主要的格式有4:2:0,4:2:2,4:4:4。在所有的格式下,每个像素的全部亮度信息都被保存下来了,在4:4:4时全部的色彩信息也被保存下来了,表示完全取样,数据量相比于RGB模型没有变化。4:2:2表示2:1的水平取样,垂直完全采样,数据量相比于RGB模式减少三分之一,4:2:0表示2:1的水平取样,垂直2:1采样。数据量相比于RGB模式减少二分之一。注意当前iPhone上的摄像头就是采用4:2:0的方式进行拍摄的。下图可以帮助理解这个二次抽样比例的模式:

Jab

视频编码基本原理

数据压缩是通过去除数据中的冗余信息而达成。就视频数据而言,数据中的冗余信息有如下几种:

  • 数据冗余:例如如空间冗余、时间冗余、结构冗余、信息熵冗余等,即图像的各像素之间存在着很强的相关性。消除这些冗余并不会导致信息损失,属于无损压缩。
  • 视觉冗余:人眼的一些特性比如亮度辨别阈值,视觉阈值,对亮度和色度的敏感度不同,使得在编码的时候引入适量的误差,也不会被察觉出来。可以利用人眼的视觉特性,以一定的客观失真换取数据压缩。这种压缩属于有损压缩。

一般的数字视频压缩编码方法都是混合编码,即将变换编码,运动估计和运动补偿,以及熵编码三种方式相结合来进行压缩编码。通常使用变换编码来消去除图像的帧内冗余,用运动估计和运动补偿来去除图像的帧间冗余,用熵编码来进一步提高压缩的效率。

变换编码

变换编码的作用是将空间域描述的图像信号变换到频率域,然后对变换后的系数进行编码处理。一般来说,图像在空间上具有较强的相关性,变换到频率域可以实现去相关和能量集中。常用的正交变换有离散傅里叶变换,离散余弦变换等等。数字视频压缩过程中应用广泛的是离散余弦变换,简称DCT变换。

变换编码一般用于帧内压缩,去除空间冗余信息和部分视觉冗余信息,所以一般是有损压缩算法,通常也用于JEPG静态图像压缩算法,但是通常是对原始图片的部分处理以生成极高质量的照片,通过这一步骤创建的帧称为 I-frames。

运动估计和运动补偿

这一步骤主要通过以组为单位的视频帧消除时间序列上的相关性来压缩冗余数据,是帧间压缩。例如对于像新闻联播这种背景静止,画面主体运动较小的数字视频,每一幅画面之间的区别很小,画面之间的相关性很大。对于这种情况我们没有必要对每一帧图像单独进行编码,而是可以只对相邻视频帧中变化的部分进行编码,从而进一步减小数据量。

运动估计一般将当前的输入图像分割成若干彼此不相重叠的小图像子块,例如一帧图像的大小为1280 * 720,首先将其以网格状的形式分成40 * 45个尺寸为16 * 16的彼此没有重叠的图像块,然后在前一图像或者后一个图像某个搜索窗口的范围内为每一个图像块寻找一个与之最为相似的图像块。这个搜寻的过程叫做运动估计。通过计算最相似的图像块与该图像块之间的位置信息,可以得到一个运动矢量。这样在编码过程中就可以将当前图像中的块与参考图像运动矢量所指向的最相似的图像块相减,得到一个残差图像块,由于残差图像块中的每个像素值很小,所以在压缩编码中可以获得更高的压缩比。这个相减过程叫运动补偿。

GOP

由于编码过程中需要使用参考图像来进行运动估计和运动补偿,因此参考图像的选择显得很重要,很多帧组合在一起作为一组图片(简称GOP),如下图所示。其中有三种不同类型的帧:

  • I-frames:又称关键帧,只使用本帧内的数据进行编码,在编码过程中它不需要进行运动估计和运动补偿。包含创建完整图片需要的所有数据,因此其尺寸是最大的,压缩比并不高,解压时间短。每个GOP正好有一个I-frames。
  • P-frames:又称预测帧,编码时使用一个前面的I-frames或P-frames作为参考图像进行运动补偿,实际上是对当前图像与参考图像的差值进行编码。
  • B-frames:又称双向帧,编码过程中它要使用一个前面的I-frames或P-frames和一个后面的I-frames或P-frames进行预测。几乎不需要存储空间,但解压时间最长。

熵编码

熵编码是一种独立于介质的具体特征的进行无损数据压缩的方案。其基本原理是对信源中出现概率大的符号赋予短码,对于出现概率小的符号赋予长码,从而在统计上获得较短的平均码长。可变字长编码通常有霍夫曼编码、算术编码、游程编码等。通过这种变长编码算法来进一步提高编码效率。

主要视频编码方案

视频编码有各种各样的方案标准,其实整体看起来还是比较混乱的,这里我们只举例几种稍作了解:

  • MPEG-2:是MPEG工作组于1994年发布的视频和音频压缩国际标准,通常用来为广播信号提供视频和音频编码,包括卫星电视、有线电视等。是采用以香农信息论为基础的预测编码、变换编码、熵编码及运动补偿等第一代数据压缩编码技术。
  • MPEG-4:一般特指MPEG-4第2部分,是基于第二代压缩编码技术制定的国际标准,它以视听媒体对象为基本单元,实现了从基于像素的传统编码向基于对象和内容的现代编码的转变,提高了压缩率。
  • H.264又称为MPEG-4第10部分,是目前最主流的方案,充分利用了现有MPEG-4标准中的各个环节,即保留了以往压缩技术的优点和精华又具有其他压缩技术无法比拟的许多优点。是一种面向块,基于运动补偿的视频编码标准。可以提供更高的编码效率,更高清的视频画面质量,更好的网络适应能力。

封装格式

我们平时在电脑中存储的或者网上下载的各种.mov、.avi、.mp4等等后缀名的文件,平时我们将这些类型视为文件格式,其实更准确的理解是这些类型是文件的封装格式。封装格式也是元文件格式,封装格式就是按照一定的规范结构将视频数据,音频数据等放在一起。所谓的结构不仅包含了媒体信息,如视频、音频、字幕、编码和时间信息等,还包含了描述性的元数据,比如电影标题、歌曲作者等。这些元数据可以通过工具进行呈现。

下面我们了解一下一些常用的封装格式:

  • QuickTime:苹果公司开发的封装格式,它可存储的内容相当丰富,除了视频、音频以外还可支持图片、文字(文本字幕)等。文件后缀名为.mov、.qt。
  • AVI:微软公司开发的对抗苹果QuickTime格式的技术,AVI本身只提供了一个框架,内部的图像数据及声音数据可以支持任意的编码形式。AVI与其他格式最大的不同是它将索引放在文件尾部,所以不能支持流媒体播放。文件后缀名为.avi。
  • MP4:是MPEG-4第 14 部分规范定义的容器格式,是从QuickTime直接派生出来的行业标准格式,视频主要采用H.264编码格式,音频主要采用AAC编码格式,同时也支持老版本的MPEG编码标准。文件后缀名为.mp4,在苹果生态系统中,也常用.m4a的音频文件及.m4v的视频文件等格式区分基于MP4格式的特定媒体类型。
  • TS:TS封装支持几乎所有编码的高清视频和音轨文件。视频编码有MPEG2、MPEG4 AVC、VC1,音频则种类DD、TrueHD、DTS、DTSHD等。TS的全名是Transport Stream,在打包视频和音频时,能提供时间戳,在整个打包视频的任何时段开始播放,都能顺利解码并保持音画同步。文件后缀为.ts。
  • FLV:FLV流媒体格式是一种全新的视频格式,全称为Flash Video。由于它形成的文件极小、加载速度极快,使得网络观看视频文件成为可能。它的出现有效地解决了视频文件导入Flash后,使导出的SWF文件体积庞大,不能在网络上很好地应用等缺点,因此FLV格式成为了当今主流视频格式。目前几乎所有全球热门的在线视频网站都采用了FLV视频格式。文件后缀为.flv。
  • MKV:MKV格式是民间流行的一种视频格式,以它兼容众多视频编码见长,可以是DivX、XviD、RealVideo、H264、MPEG2、VC1等等。但是由于是民间格式,没有版权限制,又易于播放,所以官方发布的视频影片都不采用mkv,网上制作下载常见。文件后缀为.mkv。

OC

Runtime

重识 Objective-C Runtime - Smalltalk 与 C 的融合
Runtime 源码笔记:对象与类
Objective-C类成员变量深度剖析
深入理解Objective-C:Category
深入理解 Objective-C:方法缓存
刨根问底Objective-C Runtime

【翻译】Objective-C Runtime Programming Guide
Objective-C 消息发送与转发机制原理

Block

Block 笔记
一道Block面试题的深入挖掘

一种查看Block中引用的所有外部对象的实现方法
追踪 Objective-C Block 代码定义的位置
追踪 Objective-C 方法中的 Block 参数对象

ARC

《Objective-C 高级编程》干货三部曲(一):引用计数篇
Objective-C 引用计数原理
iOS底层学习——weak实现原理和销毁过程
黑幕背后的Autorelease
深入理解Autorelease Pool

自动释放池的前世今生 —- 深入解析 autoreleasepool
DEAD in iOS Memory
iOS Memory Deep Dive
iOS Memory Deep Dive

多线程 GCD 锁

【WWDC】Mastering Grand Central Dispatch
iOS多线程:『NSOperation、NSOperationQueue』详尽总结

Power, Performance, and Diagnostics: What’s new in GCD and XPC
Advanced NSOperations–WWDC 2015 - Session 226
Mastering Grand Central Dispatch–WWDC 2011 - Session 210
GCD 深入理解:第一部分
GCD 深入理解:第二部分
iOS多线程到底不安全在哪里?
17 | 远超你想象的多线程的那些坑
如何用Xcode8解决多线程问题

Threading Programming Guide(1)
《Objective-C 高级编程》干货三部曲(三):GCD篇
深入浅出GCD之基础篇

iOS中常见的八种锁
正确使用多线程同步锁@synchronized()
Threading Programming Guide(3)

KVO

KVC/KVO 笔记
一种基于KVO的页面加载,渲染耗时监控方法
巧妙利用 KVO 实现精准的 VC 耗时检测
如何优雅地使用 KVO

Foundation

iOS 中集合遍历方法的比较和技巧
NSFast​Enumeration / NSEnumerator
一些NSArray,NSDictionary,NSSet相关的算法知识
NSHash​Table & NSMap​Table
分类(Category) VS 扩展(Extension)
关联对象 AssociatedObject 完全解析

iOS NSNotification使用及原理实现

Swift

A站 的 Swift 实践 —— 下篇
Swift5.0的Runtime机制浅析
Swift 中的 struct 和 class
为什么Swift说自己是安全的?

面向协议编程与 Cocoa 的邂逅 (上)

Runloop

深入理解RunLoop
Threading Programming Guide(2)
解密 Runloop
iOS应用UI线程卡顿监控
卡顿监控
13 | 如何利用 RunLoop 原理去监控卡顿?
Runloop-实际开发你想用的应用场景
iOS 事件处理机制与图像渲染过程

触摸事件

iOS触摸事件全家桶
关于TapGesture、UIResponder 链和 target-action 事件的相互影响的机理和应用

UIControl

UIKit: UIControl

Timer

iOS倒计时的探究与选择
NSTimer循环引用原因及解决方案
小心 NSTimer 中的内存泄漏

iOS关于时间的处理

图像渲染流程

42 | iOS原生、大前端和Flutter分别是怎么渲染的?
iOS 渲染原理解析
iOS圆角的离屏渲染,你真的弄明白了吗

iOS 保持界面流畅的技巧

动画

iOS动画(Core Animation)总结
iOS 视图控制器转场详解
WWDC 2013 Session笔记 - iOS7中的ViewController切换
View Controller 转场

Image

移动端图片格式调研
iOS 处理图片的一些小 Tip
WWDC2018-Image and Graphics Best Practices
iOS性能优化——图片加载和处理
iOS图片加载速度极限优化—FastImageCache解析
谈谈 iOS 中图片的解压缩
iOS中的imageIO与image解码

图片之旅
Large Image Downsizing

SDWebImage

源码浅析 SDWebImage 5.5.2

YYWebImage

YYWebImage 源码剖析:线程处理与缓存策略

Text

YYText 源码剖析:CoreText 与异步绘制
28 | 怎么应对各种富文本表现需求?

Quartz 2D

Quartz 2D编程指南之一:概览

启动

点击 Run 之后发生了什么?
iOS App启动过程
优化 App 的启动时间

02 | App 启动速度怎么做优化与监控?
iOS App从点击到启动
iOS 启动时间与Dyld3
云音乐 iOS 启动性能优化「开荒篇」
如何对 iOS 启动阶段耗时进行分析

性能优化

Getting Started with Instruments
深入剖析 iOS 性能优化
16 | 性能监控:衡量 App 质量的那把尺
Perfect smooth scrolling in UITableViews
iOS拾遗—— Assets Catalogs 与 I/O 优化
性能深度分析之System Trace

正经分析iOS包大小优化

监控系统

iOS 性能监控 SDK —— Wedjat(华狄特)开发过程的调研和整理
带你打造一套 APM 监控系统
iOS云音乐APM性能监控实践

iOS微信内存监控
14 | 临近 OOM,如何获取详细内存分配信息,分析内存问题?
你真的了解OOM吗?——京东iOS APP内存优化实录
iOS 内存泄漏场景与解决方案

内存

柒 虚拟内存与动态内存分配
36 | iOS 是怎么管理内存的?

Cache

YYCache 源码解析
YYCache 源码剖析:一览亮点
iOS当中的Cache设计

Debug

Understanding Crashes and Crash Logs
WWDC 2018:理解崩溃以及崩溃日志

Crash 符号化
iOS 符号化:基础与进阶
漫谈 iOS Crash 收集框架
iOS 启动连续闪退保护方案
iOS KVO crash 自修复技术实现与原理解析

与调试器共舞 - LLDB 的华尔兹
小笨狼与LLDB的故事

架构

iOS 架构模式 - 简述 MVC, MVP, MVVM 和 VIPER (译)
iOS架构之View层的架构方案
浅谈 MVC、MVP 和 MVVM 架构模式

ReactiveCocoa 和 MVVM 入门®

模块化

iOS 组件化方案探索
iOS组件化方案
iOS 组件化 —— 路由设计思路分析
移动端组件化架构(上)
移动端组件化架构(下)

美团外卖iOS多端复用的推动、支撑与思考

网络

深度优化iOS网络模块
iOS 端定位「网络问题」
iOS Authentication Challenge

AFNetworking

AFNetworking
AFNetworking 源码分析
探秘AFNetworking
NSURLSession最全攻略
Network.framework 入门

NSCoding

NSCoding / NSKeyed​Archiver
领悟到 NSCoding 是一个坑,Apple 花了10年时间

WebView

iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够(上)
JavaScriptCore全面解析 (下篇)
深入理解JSCore
从零收拾一个hybrid框架(一)– 从选择JS通信方案开始#几种通信方式的优缺点对比
WebView性能、体验分析与优化
iOS 端 h5 页面秒开优化实践

Flutter

Flutter动态化热更新的思考与实践
Flutter GetX使用—简洁的魅力!

埋点

iOS自动化埋点探索
无痕埋点的设计与实现
09 | 无侵入的埋点方案如何实现?
打造一个通用、可配置、多句柄的数据上报 SDK

加密/Code Signing

iOS App 签名的原理
代码签名探析
iOS 的 Code Signing 体系
深度长文:细说iOS代码签名
将代码签名配置迁移到 Xcode 8

逆向

移动App入侵与逆向破解技术-iOS篇
小蚁摄像机App加密探究
iOS应用逆向工程资料

音视频

iOS 视频编辑核心架构
(强烈推荐)移动端音视频从零到上手
FFMPEG视音频编解码零基础学习方法
视频压缩编码和音频压缩编码的基本原理
即时通讯音视频开发
VideoLab - 高性能且灵活的 iOS 视频剪辑与特效框架
FFmpeg原理

Dark Mode

微软是如何适配 Dark Mode 的?

实用小技巧

iOS知识小集 —— 微博话题
knowledge-kit
10 个 iOS 开发实用小技巧

iOS系统中导航栏的转场解决方案与最佳实践

I

2018 6月底面试经历简单回忆
2017年5月iOS招人心得(附面试题)
阿里、字节 一套高效的iOS面试题解答(完结)
Interview-Question & Answer
《招聘一个靠谱的iOS》面试题参考答案(上)

集合
CS-Notes
ios-interviews

引言

同事突然指着Runtime(objc4-723)的源码objc-private.h中下面这段,问我:“objc_object结构体中只有一个isa变量,那对象中的实例变量去哪了?”

1
2
3
struct objc_object {
isa_t isa;
}

一下子有点儿懵逼,印象中的对象的内存布局总是如下图所示:

对象中实例变量的布局

声明一个RoundedRectangle类的实例对象变量,该变量指向对象中继承自NSObject类的isa实例变量,isa后紧跟着继承自其父类Shape中的fillColorbounds实例变量,之后是它自己的radius实例变量。

那么,这到底是怎么回事呢?

对象

我们在最初学习 Objective-C 时知道,所有的对象都包含一个叫isa的变量,该变量是一个指向对象所属类的指针,其定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct objc_class *Class;
typedef struct objc_object *id;

struct objc_object {
Class isa;
};

struct objc_class {
Class isa; //指向该类的 metaClass
#if !__OBJC2__ //以下在OBJC2(06年发布)版本后就废弃了
Class super_class; //指向该类的父类,若该类为根类时为NULL
const char *name; //类名
long version; //类的版本信息
long info; //类信息,运行时使用个位标识,如普通类、元类
long instance_size; //类的实例变量大小(包含继承自父类的实例变量)
struct objc_ivar_list *ivars; //类的成员变量地址列表
struct objc_method_list **methodLists; //类的方法地址列表
struct objc_cache *cache; //缓存的方法列表
struct objc_protocol_list *protocols; //遵循的协议列表
#endif
};

id是一种特殊的类型,它能指代任意的 Objective-C 对象类型。对象是由objc_object结构体来定义的。其中包含一个Class类型的变量isaClass是一个指向objc_class结构体的指针,在这个结构体中存放着类的“元数据”。

Tagged Pointer

然而在处理器从32位迁移到64位后,对象指针扩大为64位的整数,为了使地址内存对齐,一些位将永远是零。出于节省内存和提高运行效率的目的,苹果爸爸在WWDC 2013 Session 404中提出了 Tagged Pointer 的概念,因此也修改了objc_object的定义。

Tagged Pointer

Tagged Pointer2

当对象指针的最低有效位(LSB)为 1 时,则该指针为 Tagged Pointer。其实这个指针的值本质上已经不是指向对象的地址了,而是保存着对象数据的值类型的变量。所以它的内存并不存储在堆中,也不需要 malloc/free。

我们再看一下objc_object的定义的变化:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
struct objc_object {
private:
isa_t isa;
public:
Class ISA(); //非 tagged pointer 对象获取其 Class 的函数
Class getIsa(); //tagged pointer 对象获取其 Class 的函数
void initIsa(Class cls /*nonpointer=false*/);
void initClassIsa(Class cls /*nonpointer=maybe*/);
void initProtocolIsa(Class cls /*nonpointer=maybe*/);
void initInstanceIsa(Class cls, bool hasCxxDtor);
Class changeIsa(Class newCls);
//...
}

union isa_t
{
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }

Class cls;
uintptr_t bits;
//这里只提取了__x86_64__架构的结构体定义,其他如__arm64__等架构类似。
# define ISA_MASK 0x00007ffffffffff8ULL
# define ISA_MAGIC_MASK 0x001f800000000001ULL
# define ISA_MAGIC_VALUE 0x001d800000000001ULL
struct {
uintptr_t nonpointer : 1; //标志位:0表示原生指针,1表示使用 taggedPointer 优化内存
uintptr_t has_assoc : 1; //是否绑定过关联对象,若没有可快速释放
uintptr_t has_cxx_dtor : 1; //是否有析构函数,若没有可快速释放
uintptr_t shiftcls : 44;//所属类指针的非零位
uintptr_t magic : 6; //固定值0xd2,调试器用来分辨对象的
uintptr_t weakly_referenced : 1; //是否被 __weak 变量引用过,若没有可快速释放
uintptr_t deallocating : 1; //是否正在释放
uintptr_t has_sidetable_rc : 1; //引用计数值是否过大
uintptr_t extra_rc : 8; //对象的引用计数值-1(若值为5,则对象引用计数为6)
};
}

inline void
objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor)
{
assert(!isTaggedPointer());

if (!nonpointer) {
isa.cls = cls;
} else {
assert(!DisableNonpointerIsa);
assert(!cls->instancesRequireRawIsa());

isa_t newisa(0);
//这里截取SUPPORT_INDEXED_ISA = 0 时的代码,查知只有在watchABI的环境下SUPPORT_INDEXED_ISA值才为1
newisa.bits = ISA_MAGIC_VALUE;
// isa.magic is part of ISA_MAGIC_VALUE
// isa.nonpointer is part of ISA_MAGIC_VALUE
newisa.has_cxx_dtor = hasCxxDtor;
newisa.shiftcls = (uintptr_t)cls >> 3;

isa = newisa;
}
}

isa变量的类型由Class类型变为了isa_t的类型,这个类型是一个联合(union),在联合中,几个字段共用同一块内存,其长度为联合中最大字段的长度。在initIsa方法中,当不启用 Tagged Pointer 时,就直接使用类的指针为isa.cls赋值,它还是表示对象所属类的指针。当启用时,使用宏ISA_MAGIC_VALUE来初始化isa.bits,这样已经为结构体内的magicnonpointer字段赋值了,然后根据函数参数设置has_cxx_dtor,将类的指针右移 3 位以消除用于内存对齐所补的 0,使用非 0 位来为shiftcls字段赋值。

类与元类

无论是否启用 Tagged Pointer,isa变量中都保存了对象所属类的信息。接下来我们看一下定义类的结构体objc_class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache; // 缓存指针和 vtable,加速方法调用
class_data_bits_t bits; // class_rw_t类型的结构体指针加 retain/release/alloc/dealloc 路径优化的标记位

class_rw_t *data() {
return bits.data();
}
void setData(class_rw_t *newData) {
bits.setData(newData);
}
//...
}

objc_class是继承自objc_object的,因此类本质上也是对象,称为类对象。类对象中包含继承来的isa变量,与实例对象中isa变量“指向”对象的所属类类似(这里的指向指的是变量中保存了对象所属类的信息,可以通过此信息找到类,下文中均如此表述),类对象的isa指向该类的元类(metaclass)。这一点可以从创建类与元类的函数objc_allocateClassPair中看出:

动态创建类与元类

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
Class objc_allocateClassPair(Class superclass, const char *name, 
size_t extraBytes)
{
Class cls, meta;
//加锁
rwlock_writer_t lock(runtimeLock);
//检查类名是否已经存在,验证父类是否合格(已实现的构建完成的类或者创建根类时可为空)
if (getClass(name) || !verifySuperclass(superclass, true/*rootOK*/)) {
return nil;
}
//为类与元类开辟空间
cls = alloc_class_for_subclass(superclass, extraBytes);
meta = alloc_class_for_subclass(superclass, extraBytes);
//初始化类与元类
objc_initializeClassPair_internal(superclass, name, cls, meta);
return cls;
}
//本来不想贴这长串代码,但是细读对理解类的创建过程的有所理解
static void objc_initializeClassPair_internal(Class superclass, const char *name, Class cls, Class meta)
{
runtimeLock.assertWriting();

class_ro_t *cls_ro_w, *meta_ro_w;
//分配class_rw_t和class_ro_t结构体空间
cls->setData((class_rw_t *)calloc(sizeof(class_rw_t), 1));
meta->setData((class_rw_t *)calloc(sizeof(class_rw_t), 1));
cls_ro_w = (class_ro_t *)calloc(sizeof(class_ro_t), 1);
meta_ro_w = (class_ro_t *)calloc(sizeof(class_ro_t), 1);
cls->data()->ro = cls_ro_w;
meta->data()->ro = meta_ro_w;

// 设置class_rw_t结构体内基本信息
//类已分配但未注册|class_rw_t->ro是class_ro_t在堆中的拷贝|class_t->data是class_rw_t不是class_ro_t|类已开始实现但未完成
cls->data()->flags = RW_CONSTRUCTING | RW_COPIED_RO | RW_REALIZED | RW_REALIZING;
meta->data()->flags = RW_CONSTRUCTING | RW_COPIED_RO | RW_REALIZED | RW_REALIZING;
cls->data()->version = 0;
meta->data()->version = 7;

//设置cls_ro_t结构体内基本信息
cls_ro_w->flags = 0;
meta_ro_w->flags = RO_META;//元类
if (!superclass) {
cls_ro_w->flags |= RO_ROOT;//根类
meta_ro_w->flags |= RO_ROOT;
}
if (superclass) {
//设置cls_ro_t->instanceStart和cls_ro_t->instanceSize为父类的instanceSize
cls_ro_w->instanceStart = superclass->unalignedInstanceSize();
meta_ro_w->instanceStart = superclass->ISA()->unalignedInstanceSize();
cls->setInstanceSize(cls_ro_w->instanceStart);
meta->setInstanceSize(meta_ro_w->instanceStart);
} else {
cls_ro_w->instanceStart = 0;
meta_ro_w->instanceStart = (uint32_t)sizeof(objc_class);
cls->setInstanceSize((uint32_t)sizeof(id)); // just an isa
meta->setInstanceSize(meta_ro_w->instanceStart);
}
//设置类名与元类名
cls_ro_w->name = strdupIfMutable(name);
meta_ro_w->name = strdupIfMutable(name);
//初始化储存属性内存管理特性的值
cls_ro_w->ivarLayout = &UnsetLayout;//记录__strong的实例变量
cls_ro_w->weakIvarLayout = &UnsetLayout;//记录__weak的实例变量
//设置元类与类的索引
meta->chooseClassArrayIndex();//此处函数中逻辑只在 SUPPORT_INDEXED_ISA = 1 时编译
cls->chooseClassArrayIndex();

// 分别为类与元类添加它们与父类和元类的联系
cls->initClassIsa(meta);
if (superclass) {
meta->initClassIsa(superclass->ISA()->ISA());
cls->superclass = superclass;
meta->superclass = superclass->ISA();
addSubclass(superclass, cls);
addSubclass(superclass->ISA(), meta);
} else {//没有父类,则该类为根类
meta->initClassIsa(meta);//根类的元类指向元类本身
cls->superclass = Nil;//根类的父类为 Nil
meta->superclass = cls;//根类的元类的父类指向根类
addRootClass(cls);
addSubclass(cls, meta);
}
//初始化方法缓存列表为空
cls->cache.initializeToEmpty();
meta->cache.initializeToEmpty();
}

cls->initClassIsa(meta);可以看出类对象cls确实使用元类对象meta初始化了它的isa变量。接下来的代码在该类有没有父类的情况下分别建立起了类与元类与他们各自父类与元类的联系。superclass指针确立了继承关系,isa描述了实例所属的类,通过这些联系建立起来了”类的继承体系”,如下图所示,通过这张布局关系图,我们可以查出对象能否响应某个选择子,是否遵从某项协议等,并且能够通过isMemberOfClassisKindOfClass等类型信息查询方法来检视类的继承体系。

objc-isa-class-diagram

需要注意以下两点:

  1. 所有的 metaclass 的isa都指向根类的元类,包括根元类!这样就形成了一个闭环。当向对象(类对象/元类对象)发送消息时,runtime 会在对象所属的类的方法列表里面查找消息对应的方法,这样的闭环会保证这一步执行正确。
  2. 元类的父类指向类的父类的元类(绕口令啊(ノ ゚Д゚)ノ ┻━━┻),例外的是根类,根类的 superclass为 nil,根类的元类的superclass指向根类。当在对象所属类的方法列表中没有找到对应的方法时,runtime 会去类的父类中查找,如果找到了就跳转到方法的实现代码中,如果一路向上找到根类也没有找到时,runtime 的”消息传递机制”就结束了。接下来会启动“消息转发机制”,详情可查看这里:【翻译】Objective-C Runtime Programming Guide

我们说类对象结构体objc_class中存放着类的“元数据”,例如类的实例实现了哪些方法,具备多少个实例变量及其布局等信息,而元类中保存了类对象本身所具备的元数据,“类方法”就定义在这里,因为这些方法可以理解成类对象的实例方法。objc_class结构体中的isasuperclass变量构建起了类的继承体系,接下来我们看看剩下的部分。

cache_t

objc_class结构体中的cache字段主要用于缓存调用过的方法。当对象接收到消息时,runtime 根据isa去对象所属类的方法列表中查找,如果每次都经过这样的流程,方法调用的效率会比较差,因此在第一次调用过一个方法后,这个方法就会被缓存在cache中,下次接收到消息时会先在这里查找,没有才去方法列表中查找。

1
2
3
4
5
6
7
8
9
10
11
12
struct cache_t {
struct bucket_t *_buckets; //一个用于存储缓存方法的散列表
mask_t _mask; //_mask+1 = 目前分配的用来缓存方法的 bucket 的总数
mask_t _occupied; //目前实际占用的 bucket 数量
//...
}

struct bucket_t {
private:
cache_key_t _key; //方法选择子对应的 key
IMP _imp; //方法实现的函数指针
};

class_data_bits_t

objc_class结构体中的bits字段的注释为:”class_rw_t * plus custom rr/alloc flags”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define FAST_IS_SWIFT           (1UL<<0)
// class or superclass has default retain/release/autorelease/retainCount/
// _tryRetain/_isDeallocating/retainWeakReference/allowsWeakReference
#define FAST_HAS_DEFAULT_RR (1UL<<1)
#define FAST_REQUIRES_RAW_ISA (1UL<<2)
#define FAST_DATA_MASK 0x00007ffffffffff8UL

struct class_data_bits_t {
uintptr_t bits;
private:
bool getBit(uintptr_t bit)
{
return bits & bit;
}
//...
class_rw_t* data() {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
bool hasCxxCtor() {
return getBit(FAST_HAS_CXX_CTOR);
}
//...
}

可以看出这个结构体中只有一个 64 位的值bits,该数据中保存了一个指向class_rw_t结构体的指针和是否为 swift 类、是否有默认的retain/release等方法及是否要求 raw isa 三个标志位。结构体中还提供了使用掩码来访问这些数据的方法(甚至还提供一些对class_rw_t结构体中flags字段的访问函数)。引用深入解析 ObjC 中方法的结构文章中的配图可以看得更明白一些:
class_data_bits_t

class_rw_t

class_data_bits_t结构体中的data()方法返回了指向class_rw_t结构体的指针,这个结构体中保存了类的方法、属性和协议等信息。先看一下它的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct class_rw_t {
// Be warned that Symbolication knows the layout of this structure.
uint32_t flags; //标记信息
uint32_t version; //类的版本信息

const class_ro_t *ro; //编译期就确定的类信息

method_array_t methods; //方法列表
property_array_t properties; //属性列表
protocol_array_t protocols; //协议列表

Class firstSubclass; //首个子类
Class nextSiblingClass; //下一个兄弟类

char *demangledName; //取消名字修饰后的类名
//省略 SUPPORT_INDEXED_ISA = 1 时的 index 字段
}

class_rw_t中还包含一个与它类似的结构体class_ro_t的常量指针。从命名我们可以猜出class_ro_t中存储的是只读的信息,而class_rw_t中存储了可读写的信息。实际上也是如此,在class_ro_t中存储了当前类在编译期就已经确定的属性、方法以及遵循的协议等信息,class_rw_t存储的内容是可以动态修改的,所以运行时对类的扩展大都存储在这里。先看下class_ro_t的定义:

class_ro_t

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct class_ro_t {
uint32_t flags; //标记信息
uint32_t instanceStart; //类自己定义的实例变量的起始偏移量
uint32_t instanceSize; //类的总的实例变量大小
#ifdef __LP64__
uint32_t reserved; //保留的数据
#endif

const uint8_t * ivarLayout; //__strong修饰的实例变量

const char * name; //类名
method_list_t * baseMethodList; //方法列表
protocol_list_t * baseProtocols; //协议列表
const ivar_list_t * ivars; //实例变量列表

const uint8_t * weakIvarLayout; //__weak修饰的实例变量
property_list_t *baseProperties; //属性列表

method_list_t *baseMethods() const {
return baseMethodList;
}
};

在上面的“创建新的类与元类”部分,我们看到在初始化类与元类的函数objc_initializeClassPair_internal中,开辟了class_rw_tclass_ro_t的存储空间,并将返回的指针保存在相应的数据中。接着对class_rw_t中的flags,version,ro字段进行了初始化,在addSubclass(superclass, cls)方法中又设置了自身的nextSiblingClass以及父类的firstSubclass。同时初始化了class_ro_t中的flags,instanceStart,instanceSize,ivarLayout,weakIvarLayout,name等字段(注意当存在父类时,本类的instanceStartinstanceSize都使用父类的instanceSize进行了初始化)。

我们上面不是说过class_ro_t结构体中的值是只读的嘛?为什么在这里对其中的变量进行了设置?而且如果你阅读过Objective-C Runtime函数列表,就会发现class_addIvar这个函数也是在运行时通过修改类的class_ro_t中的字段,向类中添加了实例变量。

这是因为这些操作都是发生在动态构建类的过程中!class_addIvar的文档中有这么一段话:

This function may only be called after objc_allocateClassPair and before objc_registerClassPair. Adding an instance variable to an existing class is not supported.

也就是说一旦完成类的构建过程,就不能再修改class_ro_t中的内容了,当然也就不能向其中添加实例变量了。objc_registerClassPair中做的事情就是将类的class_rw_t中的flags标记的RW_CONSTRUCTING | RW_REALIZING标志位清除,同时设置RW_CONSTRUCTED这个表示类已经构建完成的标志位。

类的加载过程

在上面一部分,我们通过阅读创建类与元类的函数objc_allocateClassPair对动态创建类的过程有了一定的理解,并且认识了表示类的结构体及其初始化的过程。但是对于我们在源代码中编写的类,它的加载过程是怎样的呢?

我们编写的源代码在编译结束后链接的过程中就静态链接进程序的二进制文件中了,而程序中引入的系统库如”Foundation.framework”和包含 Runtime 的”libobjc.A.dylib”等则是通过苹果的动态链接器-dyld(the dynamic link editor)在程序启动时动态加载的。我们看一下 Runtime 的初始化入口方法_objc_init:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;

//各种初始化
environ_init();
tls_init();
static_init();
lock_init();
exception_init();
//注册dyld事件的回调函数
_dyld_objc_notify_register(&map_images, load_images, unmap_image);
}

在这个函数的最后注册了 dyld 相关事件的回调函数,当 ImageLoader 读取了 images(可执行文件或动态库等)并将其加载进内存后,dyld 会调用回调函数map_images对其进行解析和处理。接下来当需要对该 image 进行初始化时,dyld 就会调用回调函数load_images对其初始化。

map_images

map_images中提取了 images 中 ObjC 相关的元数据(Class、Selector、Protocol等符号)进行了初始化,主要过程发生在_read_images函数中:

  1. 针对包含有 swift 旧版本代码和对 sdk 版本在 OS X 10.11之前的进行disableTaggedPointers操作。

  2. 初始化一个全局的映射表gdb_objc_realized_classes用来存储没有在 dyld 共享缓存中优化过的 Classes,注意这个名字中的 realized 属于误用,其中存放的类也可以是未实现的状态。

  3. readClass : 将类与元类照编译器编译好的方式从二进制中读出来,然后将不重复的非元类插入到gdb_objc_realized_classes中,该函数的返回值有3中情况:

    • cls:即类的指针,注意如果该类之前被 alloc 为未来实现的类则需要拷贝一个新类并将rw的类型class_rw_t强制转换为class_ro_trw->ro 赋值,设置新的rw并将其添加进 remappedClasses,注意这里的newCls就已经是realized的状态了。代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class_rw_t *rw = newCls->data();
    const class_ro_t *old_ro = rw->ro;
    memcpy(newCls, cls, sizeof(objc_class));
    rw->ro = (class_ro_t *)newCls->data();
    newCls->setData(rw);
    freeIfMutable((char *)old_ro->name);
    free((void *)old_ro);

    addRemappedClass(cls, newCls);

    replacing = cls;
    cls = newCls;
    • nil:当类没有父类或者当前父类是弱连接的,将这个类添加进需要重新映射的表后返回 nil.
    • something else: 指向保留的用于为未来实现的类开辟的空间.
  4. 修正上一步中添加进remappedClasses的类,用已经实现的新类替换旧类的引用。

  5. 修正 selector 的引用。

  6. 读取 protocals 以及修正其引用。

  7. 对于实现了+load方法或者有静态实例的 non-lazy classes 执行realizeClass来实现它(注意对于 lazy classes 则是在其首次接收到消息时才实现它),返回类真实的数据结构使其处于可用的状态,关键代码如下所示:

    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
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    //将 ro 替换为 rw,标记类为 已实现|实现中
    ro = (const class_ro_t *)cls->data();
    if (ro->flags & RO_FUTURE) {
    // This was a future class. rw data is already allocated.
    rw = cls->data();
    ro = cls->data()->ro;
    cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE);
    } else {
    // Normal class. Allocate writeable class data.
    rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1);
    rw->ro = ro;
    rw->flags = RW_REALIZED|RW_REALIZING;
    cls->setData(rw);
    }
    //查询是否为元类
    isMeta = ro->flags & RO_META;
    //设置version
    rw->version = isMeta ? 7 : 0; // old runtime went up to 6

    // Choose an index for this class.
    // Sets cls->instancesRequireRawIsa if indexes no more indexes are available
    cls->chooseClassArrayIndex(); //仅在watchABI下有效

    // Realize superclass and metaclass, if they aren't already.
    // This needs to be done after RW_REALIZED is set above, for root classes.
    // This needs to be done after class index is chosen, for root metaclasses.
    supercls = realizeClass(remapClass(cls->superclass));
    metacls = realizeClass(remapClass(cls->ISA()));

    //...省略SUPPORT_NONPOINTER_ISA时的代码

    // Update superclass and metaclass in case of remapping
    cls->superclass = supercls;
    cls->initClassIsa(metacls);

    // Reconcile instance variable offsets / layout.
    // This may reallocate class_ro_t, updating our ro variable.
    if (supercls && !isMeta) reconcileInstanceVariables(cls, supercls, ro);

    // Set fastInstanceSize if it wasn't set already.
    cls->setInstanceSize(ro->instanceSize);

    // Copy some flags from ro to rw
    if (ro->flags & RO_HAS_CXX_STRUCTORS) {
    cls->setHasCxxDtor();
    if (! (ro->flags & RO_HAS_CXX_DTOR_ONLY)) {
    cls->setHasCxxCtor();
    }
    }

    // Connect this class to its superclass's subclass lists
    if (supercls) {
    addSubclass(supercls, cls);
    } else {
    addRootClass(cls);
    }

    // Attach categories
    methodizeClass(cls);
    return cls;

    在上面的reconcileInstanceVariables过程中,在ro->instanceStart < super_ro->instanceSize情况下即父类扩展了它的实例变量使得实例变量大小增大时,校准了ro->instanceSizero->instanceStartivars容器中每个ivar->offset指向的值以实现non-fragile instance variables的功能!

    methodizeClass过程中将ro->baseMethods(),ro->baseProperties,ro->baseProtocols分别添加进rw->methods,rw->propertiesrw->protocols容器列表中,然后将通过unattachedCategoriesForClass获取的未附加的分类附加在类上。

  8. 读取 Category ,然后通过addUnattachedCategoryForClass将分类注册到它的所属类上,之后调用remethodizeClass将实例方法、协议和属性添加到类上,将类方法添加到元类上。需要注意的是:1,Category 的方法没有“完全替换掉”原来类已经有的方法,也就是说如果 Category 和原来类都有methodA,那么 Category 附加完成之后,类的方法列表里会有两个methodA;2,Category的方法被放到了新方法列表的前面,而原来类的方法被放到了新方法列表的后面,这也就是我们平常所说的category的方法会“覆盖”掉原来类的同名方法,这是因为运行时在查找方法的时候是顺着方法列表的顺序查找的,它只要一找到对应名字的方法,就会返回。

load_images

load_images方法就是调用+load方法,看一下prepare_load_methods方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
classref_t *classlist = 
_getObjc2NonlazyClassList(mhdr, &count);
for (i = 0; i < count; i++) {
schedule_class_load(remapClass(classlist[i]));
}

category_t **categorylist = _getObjc2NonlazyCategoryList(mhdr, &count);
for (i = 0; i < count; i++) {
category_t *cat = categorylist[i];
Class cls = remapClass(cat->cls);
if (!cls) continue; // category for ignored weak-linked class
realizeClass(cls);
assert(cls->ISA()->isRealized());
add_category_to_loadable_list(cat);
}

这个函数基本就是准备好实现了+load方法的类和分类,将其分别添加到 loadable_list 中。需要留意的是在schedule_class_load方法中会递归调用schedule_class_load(cls->superclass),来保证先将父类添加进 loadable_list 中。

准备好后就调用了call_load_methods,关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
do {
// 1. Repeatedly call class +loads until there aren't any more
while (loadable_classes_used > 0) {
call_class_loads();
}

// 2. Call category +loads ONCE
more_categories = call_category_loads();

// 3. Run more +loads if there are classes OR more untried categories
} while (loadable_classes_used > 0 || more_categories);

需要注意的是:

  • 类的+load方法是先于分类调用的!但是在类的+load方法中可以调用该类的分类中声明的方法,因为在此之前分类就已经 attach 到这个类上面了。
  • call_class_loads从 loadable_list 中依次取出 class 然后调用+load,这里保证了父类优先调用的顺序!
  • +load方法是直接使用函数内存地址的方式调用的:(*load_method)(cls, SEL_load),它对于实现了+load方法的添加进 loadable_list 中的每个类与分类都会调用。因此这里成了极佳的Method Swizzling的时机。但是分类之间的+load执行顺序是按照编译顺序决定的,因此不同的编译顺序会导致分类间的+load顺序不固定。类的载入顺序不同也导致类之间+load方法执行顺序不固定。
  • 如果代码还依赖了其他程序库,那么其他程序库里面的相关类的+load方法会先执行。
  • 留意与+load类似的+initialize方法,可以执行类的初始化操作,不同的是调用这个方法时,运行时系统已经处于正常状态了,在这里可以调用任意类的任意方法。+initialize方法是惰性调用的,在程序首次使用类的时候才会调用一次它,这个方法是线程安全的,执行时会堵塞其他线程。还有与+load不同,+initialize遵循普通的继承与覆写规则,当类没有实现这个方法时,会调用其父类的实现。

对象的创建

接下来简单看下对象的创建过程,我们通过调用[[XXXClass alloc] init]来生成一个实例对象。

alloc

在代码[[NSObject alloc] init]中添加一个断点,查看一下调用栈:
alloc

初始化对象的关键代码都在_class_createInstanceFromZone方法中,抽取出一个主要流程的代码如下:

1
2
3
4
5
6
7
8
9
    //获取实例对象内存对齐后的大小
size_t size = cls->instanceSize(extraBytes);
//分配存储空间
obj = (id)calloc(1, size);
if (!obj) return nil;
//初始化isa,这部分代码我们再文章上面可以看到
obj->initInstanceIsa(cls, hasCxxDtor);
return obj;
}

init

1
2
3
4
5
6
7
8
9
10
11
- (id)init {
return _objc_rootInit(self);
}

id
_objc_rootInit(id obj)
{
// In practice, it will be hard to rely on this function.
// Many classes do not properly chain -init calls.
return obj;
}

可以看到init操作只是简单的返回了对象背身。

最后

看完了这些我们也能回答引言中提出的问题了:Objective-C对象不能简单对应于一个C struct,访问成员变量不等于访问C struct成员,当生成对象时,开辟出来对象的instanceSize大小的内存区域并返回指向该空间的指针,在这个内存空间中不仅包含了objc_object中定义的isa还有其他所有的实例变量(包括继承自父类的及其自己的),我们通常所说的类的实例对象也就指的是这块内存空间。

参考

原文地址:Objective-C Runtime Programming Guide

简介

Objective-C 语言将尽可能多的决定从编译期和链接期推迟到运行时。它尽可能的动态化。这就意味着这门语言不仅需要编译器还需要运行时系统来执行它编译好的代码。运行时系统对于 Objective-C 语言来说就像操作系统一样来使它运行起来。

这篇文档着眼于NSObject类和 Objective-C 程序如何与运行时系统交互。重点探讨了在运行时动态加载新类的范例,将消息转发给其他对象。还有对在程序运行时如何查找对象的有关信息提供了相关资料。

你可以通过阅读此文档来了解 Objective-C 运行时系统是如何工作的以及如何利用它。同时,你几乎不需要了解这篇文档就可以写 cocoa 应用了。

不同平台与版本的运行时系统

不同的平台上使用着不同版本的运行时系统。

早期版本与现代版本

现在同时存在着两个版本的运行时系统:“现代版本”和“早期版本”,现代版本是与 Objective-C 2.0以及很多其他特性一起引入的。早期版本的编程接口可以在 Objective-C 1 Runtime Reference 中查看。现代版本的编程接口可以在 Objective-C Runtime这里查看。

现代版本的运行时系统最值得注意的地方在于实例变量是“稳固的”:

  • 在早期版本的运行时系统中,如果你改变了一个类的实例变量的布局,你就得重新编译所有继承于此类的类。
  • 在现在版本中则不需要。

除此之外,现代版本的运行时系统支持已声明属性的实例变量的自动合成。

不同平台的版本

iphone 应用程序和 OS X 10.5 之后的 64 位程序都使用现代版本的运行时系统。

其他程序(OS X 上的 32 位应用)使用了早期版本的运行时系统。

与运行时系统交互

Objective-C 程序可以在三个不同的层次上与运行时系统进行交互:通过 Objective-C 源代码;通过 Foundation 框架中NSObject类中定义的方法;通过直接调用运行时函数。

Objective-C 源代码

大多数情况下,运行时系统都是在幕后自动工作的,你仅仅需要编写和编译 Objective-C 源代码。

当你编译包含 Objective-C 类和方法的代码时,编译器会创建实现该语言动态特征的数据结构和函数调用。数据结构中会包含类和分类的定义以及协议声明中的信息。其中有《The Objective-C Programming Language》中Defining a Class and Protocols章节中讨论过的类和协议对象,以及方法选择器,实例变量模板及其他从源代码中提取出的信息。最基本的运行时方法是在Messaging中描述的发送消息的方法。它通过源代码中的消息表达式调用。

NSObject 方法

Cocoa 中的大多数对象都是NSObject类的子类,因此他们都继承了NSObject类定义的方法。(一个例外是NSProxy类)这些方法使得每一个实例对象和类对象都有与生俱来的一些能力。然而极少数情况下,NSObject 类只提供了相关方法的模板,并不提供所有的实现代码。

举例来说,NSObject类定义了一个返回类的相关内容的字符串的description实例方法,主要用于调试-GDB的print-object命令就打印这个方法返回的字符串。由于并不知道类中包含什么,所以NSObjectdescription方法的实现只是返回包含对象的名字以及内存地址的字符串。NSObject的子类可以实现这个方法来返回更多信息。比如基础类NSArray返回了它包含对象的列表。

有些NSObject类的方法只是向运行时系统查询相关信息。这些方法允许对象自省。例如,class方法是对象用来确定自己的类的方法;isKindOfClass:isMemberOfClass:是用来检查对象在继承等级中的位置的;respondsToSelector:方法是用来指明一个对象是否可以接受特定消息的;conformsToProtocol:方法用于指明一个对象是否声明要实现相应协议中定义的方法;methodForSelector:方法提供了方法实现的内存地址。这些方法为对象提供了自省的相关能力。

运行时函数

运行时系统是一个有公用接口的动态共享库,接口包含了存储在/usr/include/objc目录下的头文件中的一系列函数和数据结构。它们中的大多数函数允许你在写 Objective-C 代码时使用直白的 C 代码来复制编译器的行为。其他的组成了NSObject类中方法相关功能的基础。这些函数使开发其他运行时系统接口成为可能并生产了提高开发环境能力的工具。这些函数并不是编写 Objective-C 程序是必须的,但是有些函数偶尔会非常有用。这些函数都在Objective-C Runtime Reference中列出了。

发送消息

本章主要描述消息表达式如何转换成对objc_msgSend的函数调用,以及如何通过名字查找方法。然后解释了如何使用objc_msgSend函数,如果需要的话怎么绕过消息的动态绑定。

objc_msgSend 函数

在 Objective-C 中,直到运行时消息才与方法实现绑定在一起。编译器将如下消息表达式:

1
[receiver message]

转换为objc_msgSend消息函数的调用。这个函数将接收者及消息中提及的方法名字也就是方法选择器作为它的两个基本参数:

1
objc_msgSend(receiver, selector)

任何在消息中传递的参数也传给objc_msgSend:

1
objc_msgSend(receiver, selector, arg1, arg2, ...)

这个发送消息函数做了动态绑定的所有工作:

  • 它首先查找到方法选择器所指向的程序(方法实现)。由于不同的类可以对相同的方法提供不同的实现,所以这个准确的方法实现就取决于接收者所属的类。
  • 然后调用这个程序,并传递消息接收者对象(指向其数据的指针)和其他指定的参数。
  • 最后,将程序的返回值作为自己的返回值返回。

注意:编译器会自动调用objc_msgSend这个发送消息函数,你不应该在自己的代码中直接调用它。

发送消息的关键在于编译器为每个类和对象所创建的结构体,每个类的结构体包含了如下两个重要元素:

  • 一个指向其父类的指针。
  • 一个类的分发表。这个表记录了方法选择器和它对应的特属于该类的方法实现的指针之间的关联。如方法选择器setOrigin::setOrgin::方法的实现程序的地址间的相互关联,方法选择器displaydisplay方法的实现程序的地址之间相互关联,等等…

当一个新的对象被创建成功后,内存空间已经开辟好了,它的实例变量也已经初始化完成了,对象的变量中的第一个就是指向其类结构体的命名为isa的指针。通过这个指针,对象可以访问其所属类,通过所属类可以访问它的所有父类。

严格来说isa指针并不是 Objective-C 语言的一部分,但是它确实是运行时系统所必需的。一个对象必须与定义在objc/objc.h中的struct objc_object结构体所定义的等同。任何继承自NSObjectNSProxy的对象都自动包含了这个isa变量,你几乎不需要创建自己的根对象。

类与对象的结构体的元素如下图所示:

Figuire 3-1

当一个消息发送给对象时,objc_msgSend函数顺着对象的isa指针找到对象所属类的结构体,从而在类的分发表中查找方法选择器。如果找不到就顺着指向父类的指针找到父类的结构体并在其中的分发表中继续查找。连续的失败会使objc_msgSend函数顺着类的继承层级一路找到NSObject类。一旦定位到方法选择器之后,这个函数就会调用与之关联的方法实现,并将消息接受对象的数据结构作为参数传递过去。

这就是在运行时决定最终的方法调用的过程,或者用行话来说就是面向对象编程,即方法是动态绑定到消息上的。

为加速发送消息的过程,运行时系统会随着方法的调用建立起方法选择器与方法的实现地址之间的缓存。每一个类都会都单独的缓存,其中不仅包含了类自己定义的方法还有继承的方法。在objc_msgSend函数查找分发表之前,它会例行先查找一下接收者对象所属类的缓存(有一种理论是一个方法在使用之后很快又会被再次使用)。如果在缓存中找到这个方法选择器,那么发送消息过程只会比一般的函数调用慢一丢丢。因此一旦程序运行时间足够长致使缓存系统“热身”后,几乎所有的消息都可以在缓存中找到对应的方法。程序运行时,缓存会自动扩充来容纳新的消息。

使用隐藏参数

objc_msgSend函数查找到实现方法的程序时,它就会调用这个程序并将消息中的参数传递过去。此外还有两个隐藏的参数被传递过去:

  • 消息的接收对象
  • 方法的选择器

这些参数给方法表达式调用起的方法实现提供了足够准确的信息。说它们是“隐藏的”是因为在定义方法的源代码中并没有声明它们,它们是在编译时被插入实现代码中的。

尽管这些参数并没有被显示的声明,在源代码中依然可以引用它们(跟引用其他接收者对象的实例变量一样),方法中使用self来引用接收者对象,使用_cmd来引用方法选择器。在下面的例子中,_cmd引用strange方法的选择器,self用来引用接收strange消息的对象。

1
2
3
4
5
6
7
8
9
- strange
{
id target = getTheReceiver();
SEL method = getTheMethod();

if ( target == self || method == _cmd )
return nil;
return [target performSelector:method];
}

这两个参数中self更有用一些,通过它方法中才可以访问接收者对象的实例变量。

获取一个方法的地址

绕过动态绑定的唯一办法是获取到方法的地址,然后像函数一样直接调用它。当你需要连续多次执行某一方法又想要避免每一次都需要发送消息的过程时,绕过动态绑定才可能会稍微合理。

你可以通过定义在NSObject类中的方法:methodForSelector:来获取指向方法实现的程序的指针,然后通过该指针来调用程序。methodForSelector:返回的指针必须被小心的转换成合适的函数类型,参数类型与返回值类型都必须被转换。

下面的例子中展示了setFill:方法的实现程序是如何被调用的:

1
2
3
4
5
6
void (*setter)(id, SEL, BOOL);
int i;

setter = (void (*)(id, SEL, BOOL))[target methodForSelector:@selector(setFilled:)];
for ( i = 0 ; i < 1000 ; i++ )
setter(targetList[i], @selector(setFilled:), YES);

传入程序的前两个参数是接收者对象(self)和方法选择器(_cmd),这两个参数在方法被当做函数调用时必须显示的传入。

使用methodForSelector:来绕过动态绑定能节省大多数发送消息所需的时间,但是这种节省只会在特定消息被连续多次重复,像上面的for循环中那样才会比较明显。

注意methodForSelector:方法是由 Cocoa 运行时系统提供的,它并不是语言的一部分。

动态方法解析

本章描述了如何动态的提供方法的实现。

动态方法解析

有时候你可能需要动态提供方法的实现,举个例子,当 Objective-C 中的已声明的属性使用了@dynamic指令时:

1
@dynamic propertyName;

这个指令告诉编译器与属性相关方法都会被动态的在运行时提供。

你可以通过实现resolveInstanceMethod:为实例方法动态的提供实现程序,实现resolveClassMethod:来为类方法动态的提供实现程序。

一个 Objective-C 方法就是最少有两个参数-self_cmd的简单 C 函数。你可以使用class_addMethod函数来给一个类添加一个函数作为方法。例如对于如下的函数:

1
2
3
void dynamicMethodIMP(id self, SEL _cmd) {
// implementation ....
}

你可以像下面这样使用resolveInstanceMethod:将它作为一个方法(resolveThisMethodDynamically方法)动态的添加进类中。

1
2
3
4
5
6
7
8
9
10
@implementation MyClass
+ (BOOL)resolveInstanceMethod:(SEL)aSEL
{
if (aSEL == @selector(resolveThisMethodDynamically)) {
class_addMethod([self class], aSEL, (IMP) dynamicMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:aSEL];
}
@end

消息转发与动态方法解析是互补的,动态方法解析是在消息转发机制开始前进行的。当respondsToSelector:instancesResponsToSelector:方法被调用时,动态方法解析机制首先会给你提供方法实现IMP的机会。如果你实现了resolveInstanceMethod方法,但是还想要特定的选择器通过消息转发机制被转发,只需要对这些选择器返回NO

动态加载

Objective-C 程序可以在运行时加载和链接新的类和分类,这些代码被合并入程序中并且和启动时加载进的类和分类被同等对待。

动态加载可以做很多事情,举个例子,系统偏好设置里面的各种模块都是被动态加载的。

在 Cocoa 环境中,动态加载普遍被用于应用程序的自定义化。运行时加载其他人编写的模块-诸如 Interface Builer 加载自定义的选项板, OS X 系统偏好设置加载自定义的偏好模块。这些可加载模块可以通过一种你允许的但却不能参与或者定义它的方式来扩展你应用的能力。你只需要提供框架,由其他人来实现代码。(注:本段原文中似乎是以系统为第一人称来表述动态加载的功能,不清楚时可翻看原文)。

除此之外还有运行时函数(定义在objc/objc-load.h中的objc_loadModules)用来动态加载 Mach-O 文件类型的 Objective-C 模块,Cocoa 的 NSBundle 类也提供了更方便的面向对象的集成了相关服务的动态加载方法。查看NSBundle类来获取更多信息。

消息转发

给一个对象发送它不能处理的消息是一个错误。然而在提示错误之前,运行时系统给接收者对象第二次机会来处理这条消息。

转发

给一个对象发送它不能处理的消息时,在提示错误之前,运行时系统会给对象发送带有一个NSInvocation对象(其中封装了原始消息和传入的参数)作为唯一参数的消息forwardInvocation:

你可以通过实现forwardInvocation:方法给消息提供一个默认的响应或者其他操作来避免错误。就像forwardInvocation:这个方法名所暗示的它主要用于将消息转发给其他对象。

为了解转发的意图,想象这样一个场景:假设你在设计一个可以响应negotiate消息的对象,你想在这个响应中包含其他不同对象的响应,你可以简单的通过在negotiate消息的实现代码中把negotiate消息传递给其他对象。

更进一步,假设你想让对象对negotiate消息的响应完全是其他某个类所实现的响应,一种实现办法是使你的类继承于那个类。然而这种方法或许不可能实现,因为你的类与实现negotiate消息的类很大可能属于不同分支的继承链中。

即使你的类不能继承negotiate方法,你仍然可以通过传递消息给其他类的实例来简单的借用它的实现:

1
2
3
4
5
6
- (id)negotiate
{
if ( [someOtherObject respondsTo:@selector(negotiate)] )
return [someOtherObject negotiate];
return self;
}

这种方式比较麻烦,尤其是当你想把一大堆消息传递给其他对象时。你必须为每一个想要从其他类借用的方法实现提供一个方法实现。更甚者,当你编写代码时,你可能并不知道所有想要转发的消息和目标对象,这种情况就难以处理了。这些消息或许是运行时触发的,或许变成未来实现的新的方法和类。

forwardInvocation:消息提供了一种更具有普适性的动态而非静态的方式来为处理这种问题。它是这样工作的:当一个对象因为没有与消息的选择器相匹配的方法而不能响应这条消息时,运行时系统会通过发送一条forwardInvocation:消息来通知该对象。每个对象都会从NSObject类中继承这个forwardInvocation:方法,然而NSObject对这个方法的实现只是简单的调用了doesNotRecognizeSelector:。通过重写这个方法来实现自己的逻辑,你就可以利用forwardInvocation:提供的机会将消息转发给其他对象。

为了转发消息,forwardInvocation:只需要:

  • 决定消息转发给谁。
  • 然后将带有原始参数的消息发送给它。

发送消息可以使用invokeWithTarget:方法:

1
2
3
4
5
6
7
8
- (void)forwardInvocation:(NSInvocation *)anInvocation
{
if ([someOtherObject respondsToSelector:
[anInvocation selector]])
[anInvocation invokeWithTarget:someOtherObject];
else
[super forwardInvocation:anInvocation];
}

被转发的消息的返回值被返回到原来发送消息的对象。任何类型的返回值都会被返还到原发送者,包括id类型、结构体、双精度浮点数等等。

forwardInvocation:消息可以用作不能识别的消息的分发中心,将消息打包后发送给不同的接收者。或者也可以被用作转发站,将所有的消息发送到相同的目的地,或者简单的”吞噬”掉某些消息使其没有响应也不会发出错误提示。forwardInvocation:方法还可以将好几条消息合并到同一个响应中去。forwardInvocation:的行为取决于实现它的人,但是它所提供的将对象添加进一条转发链中的机会为程序设计提供了更多的可能性。

注:只有在接收者并不会被调用起一个已经存在的方法的时候forwardInvocation:才能处理消息。例如:加入想要接收者对象把negotiate消息转发给其他对象,那么接收者就不能有negotiate的方法实现。如果有的话,消息就永远不会被发送到forwardInvocation:

可以查询NSInvocation文档来获取更多消息转发与调用的信息。

转发与多重继承

转发过程可以模拟继承,可以被借用于在 Objective-C 程序中实现一些多重继承的效果。如图 Figure 5-1 所示,对象通过消息转发来实现借用或”继承”其他类中的实现方法来响应消息。

上图中,Warrior类的实例对象将negotiate消息转发给Diplomat类的实例对象。这个勇士就会像外交官一样来谈判,勇士对象似乎可以响应negotiate消息,而且实际上也确实响应了,即使大部分工作是由外交官对象来完成的。

对象通过转发消息来实现从两个不同的继承链分支(自己的继承链和响应消息的对象的继承链)继承,在上面的例子中,Warrior类似乎同时继承了它的父类和Diplomat类。

消息转发提供了你想从多重继承获取的大部分特性,但是两者还有很大区别:多重继承将不同的能力集成与同一对象上,它倾向于实现庞大的多功能的对象。而消息转发会把不同的任务分派给不同的对象,会将问题分拆给小的对象,但是又将这些对象连接起来,使得从消息发送者的角度来看这一切都是透明的。

代理对象

消息转发不仅模仿多重继承,还使得开发轻量级的对象来代表或者“隐藏”其他背后的对象,代理对象作为其他对象的替身将消息过滤给他们。

在《The Objective-C Programming Language》中“Remote Messaging”中讨论的代理就是这样一种代理方式。代理负责将消息转发给一个远程对象(译注:位于不同的地址空间)的各种事务,保证通过这种连接,参数可以被拷贝和取回,它不会做过多的事情,它不会复制远程对象的功能,而是仅仅为它提供一个本地地址,使得存在于另一个应用中的远程对象可以接收到消息。(译注:这一部分不熟悉的话可以去The Objective-C 2.0 Programming Language看一下)

也可以实现其他类型的代理对象,举个例子,假设你有一个要操控很多数据的对象-或许它创建了一个复杂的图片或者读取了磁盘中的文件,创建这个对象会很耗时,所以你倾向于惰性的设置它-即当你真正需要时或者系统资源空闲时。同时你需要一个占位的对象来使得应用中的其他对象正常运行。

在这种情况下,你可以在最初创建一个轻量级的代理对象而不是成熟的对象。这个对象可以完成一些简单的功能,如回答关于数据的问题等,但是主要还是用于为大对象占位置,当时机来临时将消息转发给它。当代理对象的forwardInvocation:消息收到发给其他对象的消息时,它要保证那个对象存在,如果不存在的话就创建一个。大对象的所有消息都通过代理对象。所以对于程序的其他部分来说,代理对象和大对象是相同的。

转发和继承

尽管转发可以模仿继承,但是NSObject类并不会混淆他们。像respondToSelector:isKindOfClass:这样的消息就只会在继承链中查找,绝不会去转发链中查找。举个例子:如果勇士对象被问到是否会响应negotiate消息时:

1
2
if ( [aWarrior respondsToSelector:@selector(negotiate)] )
...

返回值永远是 NO,即使在某种意义上它可以通过转发给外交官对象来无错误的接收并响应negotiate消息。

大多数情况下 NO都是正确的答案,但不总是。如果你使用消息转发来创建一个代理对象或者扩展你类的能力。转发机制应该像继承一样是透明的。如果想要你的对象真的像继承了要转发到的对象的行为时,你需要重写一下respondToSelector:isKindOfClass:方法将你的转发机制考虑在内。

1
2
3
4
5
6
7
8
9
10
11
- (BOOL)respondsToSelector:(SEL)aSelector
{
if ( [super respondsToSelector:aSelector] )
return YES;
else {
/* Here, test whether the aSelector message can *
* be forwarded to another object and whether that *
* object can respond to it. Return YES if it can. */
}
return NO;
}

除了respondToSelector:isKindOfClass:之外,instancesRespondToSelector:方法也需要反映转发机制。如果使用了协议,conformToProtocol:方法也需要添加进需要重写方法的列表中。相似的,如果对象转发了它收到的远程消息,它应该实现能返回最终响应转发来的消息的方法的确切描述的methodSignatureForSelector:方法。举个例子:如果对象能转发消息给它的代理对象,你要像下面这样实现methodSignatureForSelector:方法:

1
2
3
4
5
6
7
8
- (NSMethodSignature*)methodSignatureForSelector:(SEL)selector
{
NSMethodSignature* signature = [super methodSignatureForSelector:selector];
if (!signature) {
signature = [surrogate methodSignatureForSelector:selector];
}
return signature;
}

你可以将这些有转发机制有关的方法都整理在一起,当有转发消息的时候就调用它。

注:这是个用于其他方法都不能解决的问题的高级技巧,它并不打算替代继承,如果要使用这个技巧,确保你完全了解转发消息的类和接收转发消息的类的行为。

这一部分提到的方法在NSObject文档中可以查阅,可以查询NSInvocation文档来获取invokeWithTarget:方法的相关信息。

类型编码

为协助运行时系统,编译器会将每个方法中的返回值和参数的类型编码成字符串并且和方法选择器联系起来。这种编码方案在其他上下文中也很有用所以通过@encode()编译器指令将其公开化了。给一个特定类型,@encode()会返回一个编码了这个类型的字符串。类型可以是int,指针,有标签的结构体或联合这样的基础类型,或者一个类名,或者任何类型。这个指令可以像 C 中的sizeof()操作符一样用。

1
2
3
char *buf1 = @encode(int **);
char *buf2 = @encode(struct key);
char *buf3 = @encode(Rectangle);

下表列出了各种类型编码,注意它们中很多会与你编码对象来归档或者分发时的编码重复,但是还有一些编码是你在写一个编码器时不能使用的,还有一些当你为不使用@encode()指令编写一个编码器想使用的编码。(查看NSCoder类获取更多关于编码对象来归档和分发的信息。)

Table 6-1 Objective-C 类型编码

编码 语义
c A char
i An int
s A short
l A long ,l is treated as a 32-bit quantity on 64-bit programs.
q A long long
C An unsigned char
I An unsigned int
S An unsigned short
L An unsigned long
Q An unsigned long long
f A float
d A double
B A C++ bool or a C99 _Bool
v A void
* A character string (char *)
@ An object (whether statically typed or typed id)
# A class object (Class)
: A method selector (SEL)
[array type] An array
{name=type…} A structure
(name=type…) A union
bnum A bit field of num bits
^type A pointer to type
? An unknown type (among other things, this code is used for function pointers)

重要:Objective-C 不支持long double类型,@encode(long double)返回d,跟double使用同样的编码。

数组的类型编码是包裹在方括号中的;左方括号后紧跟数组中的元素个数,然后是数组类型。例如一个有12个指向floats的指针的数组会被编码成:

1
[12^f]

结构体的类型编码被包裹在花括号中,联合被包裹在括号中,结构体编码中先写出标签,然后按顺序列出结构体中的成员的类型,例如:

1
2
3
4
5
typedef struct example {
id anObject;
char *aString;
int anInt;
} Example;

会被编码成:

1
{example=@*i}

不论传入@encode()的是结构体定义的类型名(Example)还是结构体的标签名(example),编码都会返回同样的结果。对于指向结构体的指针来说编码也会携带同样的信息:

1
^{example=@*i}

然而多加一层间接寻址(译注:指针的指针)就会去掉类型的内部信息:

1
^^{example}

对象也会被当做结构体看待,例如将NSObject类名传给@encode()会产生如下编码:

1
{NSObject=#}

NSObject类只声明了一个实例变量,一个 Class类型的isa变量。

(译注:尝试如下所示)

1
2
3
4
5
6
7
NSLog(@"%s",@encode(NSObject));
NSLog(@"%s",@encode(id));
NSLog(@"%s",@encode(NSObject *));
//打印结果为:
//{NSObject=#}
//@
//@

注意当在协议中声明方法中有类型限定符时,@encode()指令并不会返回他们,但是运行时系统会使用下表中的额外的编码。

Table 6-1 Objective-C 方法编码

编码 语义
r const
n in
N inout
o out
O bycopy
R byref
V oneway

已声明的属性

当编译器碰到属性声明时(在 The Objective-C Programming Language 的 Declared Properties 章节描述),会生成与封装它的类、分类或者协议相关的描述性的元数据。你可以使用能够通过名字在类或协议中查询属性的函数,获取属性的类型作为一个@encode字符串的函数或者可以拷贝属性特征列表为一个 C 字符串数组的函数来访问这些元数据。每一个类和协议都有一串的已声明的属性可以使用。

属性类型和函数

Property结构体为 property 描述符提供了一个不透明的处理。

你可以使用函数class_copyPropertyListprotocol_copyPropertyList来分别获取类(包含以及加载的分类)和协议相关联的属性的数组。

1
2
objc_property_t *class_copyPropertyList(Class cls, unsigned int *outCount)
objc_property_t *protocol_copyPropertyList(Protocol *proto, unsigned int *outCount)

例如给定如下的类的声明:

1
2
3
4
5
@interface Lender : NSObject {
float alone;
}
@property float alone;
@end

你可以像这样获取属性列表:

1
2
3
id LenderClass = objc_getClass("Lender");
unsigned int outCount;
objc_property_t *properties = class_copyPropertyList(LenderClass, &outCount);

你可以使用property_getName函数来找到属性的名字。

1
const char *property_getName(objc_property_t property)

你可以使用class_getPropertyprotocol_getProperty函数用属性的名字分别从类和协议中获取相应的属性的引用。

1
2
objc_property_t class_getProperty(Class cls, const char *name)
objc_property_t protocol_getProperty(Protocol *proto, const char *name, BOOL isRequiredProperty, BOOL isInstanceProperty)

你可以使用property_getAttributes函数来查找一个属性的名字和@encode编码类型的字符串。查看类型编码一章和下面的内容来获取更多类型编码字符串相关的信息。

1
const char *property_getAttributes(objc_property_t property)

将这些代码组合在一起,你就可以打印和这个类相关的属性的列表了:

1
2
3
4
5
6
7
8
9
id LenderClass = objc_getClass("Lender");
unsigned int outCount, i;
objc_property_t *properties = class_copyPropertyList(LenderClass, &outCount);
for (i = 0; i < outCount; i++) {
objc_property_t property = properties[i];
fprintf(stdout, "%s %s\n", property_getName(property), property_getAttributes(property));
}
//译注:打印结果:
//alone Tf,V_alone

属性类型字符串

你可以使用property_getAttributes函数来查找一个属性的名字和@encode编码类型的字符串以及其他特性。

这个字符串以T开头,紧跟着@encode编码类型的字符串和一个逗号,以V后面跟着属性背后的实例变量的名字结束,中间是以逗号隔开的各种特性的描述符,如下表所示:

Table 7-1 已声明的属性类型编码

编码 语义
R The property is read-only (readonly).
C The property is a copy of the value last assigned (copy).
& The property is a reference to the value last assigned (retain).
N The property is non-atomic (nonatomic).
G The property defines a custom getter selector name. The name follows the G (for example, GcustomGetter,).
S The property defines a custom setter selector name. The name follows the S (for example, ScustomSetter:,).
D The property is dynamic (@dynamic).
W The property is a weak reference (__weak).
P The property is eligible for garbage collection.
t Specifies the type using old-style encoding.

属性特性描述示例

给定下面的定义:

1
2
3
4
enum FooManChu { FOO, MAN, CHU };
struct YorkshireTeaStruct { int pot; char lady; };
typedef struct YorkshireTeaStruct YorkshireTeaStructType;
union MoneyUnion { float alone; double down; };

下表展示了属性声明及相应的由property_getAttributes:函数返回的字符串:

属性声明 属性描述
@property char charDefault; Tc,VcharDefault
@property double doubleDefault; Td,VdoubleDefault
@property enum FooManChu enumDefault; Ti,VenumDefault
@property float floatDefault; Tf,VfloatDefault
@property int intDefault; Ti,VintDefault
@property long longDefault; Tl,VlongDefault
@property short shortDefault; Ts,VshortDefault
@property signed signedDefault; Ti,VsignedDefault
@property struct YorkshireTeaStruct structDefault; T{YorkshireTeaStruct=”pot”i”lady”c},VstructDefault
@property YorkshireTeaStructType typedefDefault; T{YorkshireTeaStruct=”pot”i”lady”c},VtypedefDefault
@property union MoneyUnion unionDefault; T(MoneyUnion=”alone”f”down”d),VunionDefault
@property unsigned unsignedDefault; TI,VunsignedDefault
@property int (*functionPointerDefault)(char *); T^?,VfunctionPointerDefault
@property id idDefault;Note: the compiler warns: “no ‘assign’, ‘retain’, or ‘copy’ attribute is specified - ‘assign’ is assumed” T@,VidDefault
@property int *intPointer; T^i,VintPointer
@property void *voidPointerDefault; T^v,VvoidPointerDefault
@property int intSynthEquals; In the implementation block:
@synthesize intSynthEquals=_intSynthEquals; Ti,V_intSynthEquals
@property(getter=intGetFoo, setter=intSetFoo:) int intSetterGetter; Ti,GintGetFoo,SintSetFoo:,VintSetterGetter
@property(readonly) int intReadonly; Ti,R,VintReadonly
@property(getter=isIntReadOnlyGetter, readonly) int intReadonlyGetter; Ti,R,GisIntReadOnlyGetter
@property(readwrite) int intReadwrite; Ti,VintReadwrite
@property(assign) int intAssign; Ti,VintAssign
@property(retain) id idRetain; T@,&,VidRetain
@property(copy) id idCopy; T@,C,VidCopy
@property(nonatomic) int intNonatomic; Ti,VintNonatomic
@property(nonatomic, readonly, copy) id idReadonlyCopyNonatomic; T@,R,C,VidReadonlyCopyNonatomic
@property(nonatomic, readonly, retain) id idReadonlyRetainNonatomic; T@,R,&,VidReadonlyRetainNonatomic

修正历史

下表列出了Objective-C Runtime Programming Guide 的修改历史。

时间 记录
2009-10-19 Made minor editorial changes.
2009-07-14 Completed list of types described by property_getAttributes.
2009-02-04 Corrected typographical errors.
2008-11-19 New document that describes the Objective-C 2.0 runtime support library.

Blocks

Blocks 是 C 语言的扩展功能,是带有自动变量(局部变量)匿名函数。这一概念在其他程序语言中也称为闭包(Closure)、Lambda等。
C 语言标准不允许不带有名称的函数,想要不通过函数名来调用函数可以如下所示:

1
2
3
4
5
6
int func(int count) {
return count + 1;
}

int (*funcptr)(int) = &func;
int result = (*funcptr)(10);

但是其实在赋值函数指针时,还是用到了函数的名称。

C语言的函数中使用的变量有一下几种:

  • 函数的参数 - 存储在栈中
  • 自动变量(局部变量)- 存储在栈中
  • 静态变量(静态局部变量)- 作用域与局部变量相同,但是改变了存储方式,从而改变了生存周期
  • 全局变量
  • 静态全局变量 - 与全局变量一样是静态存储方式,但是将其作用域限制在了定义该变量的源文件内

进程在虚拟内存中的结构

能够在函数的多次调用中传递值的变量有:静态局部变量、静态全局变量和全局变量,这几种变量虽然作用域不同,但是在整个程序中,一个变量总是保持在一个内存区域中,函数对其多次访问也是访问同一个值。

Blocks 提供了类似 C++ 和 Objective-C 类生成实例或对象来保持变量值的方法,它保持局部变量的值。同时避免了声明类的大量代码,也没有使用静态变量、静态全局变量或全局变量时,访问的总是同一个值的问题。

Blocks的语法

我们看一个简单的Block的定义:

1
2
3
^int (int count){ 
return count + 1;
};

其实它与一般的C语言函数只有两点不同:1、返回值类型前面带有”^”符合,2、返回值类型后面没有函数名。

1
2
3
int testFunc (int count){ 
return count + 1;
};

我们在通常使用中会省略Block的返回值类型,所以上面的表达式通常写为:

1
2
3
^(int count){ 
return count + 1;
};

在省略返会在类型时,如果表达式中没有return语句,就使用void类型,有return语句就使用该返回值的类型,若有多个return语句,那么所有的语句返回值类型应该相同。
如果不使用参数的话,参数列表也可以省略。

Block类型变量

在 Block 语法下,可将 Block 语法赋值给声明为 Block 类型的变量中。即源代码中一旦使用 Block 语法就相当于生成了可赋值给 Block 类型变量的“值”。Block 类型变量完全可以与一般的 C 语言变量一样使用。
我们将上文中的 Block 赋值给 Block 类型的变量:

1
int (^blk)(int) = ^(int count){return count + 1;};

但是在函数参数或返回值中使用 Block 类型变量时,记述起来很复杂,因此常用 typedef 来解决:

1
2
typedef int (^blk_t)(int);
blk_t blk = ^(int count){return count + 1;};

Blocks的实现

Block的实质

Block的本质就是 C 语言结构体的实例,Block 就是 Objective-C 对象。
Block 的语法看上去很特别,但实际上是作为普通的 C 语言源代码来处理的。我们可以用 clang 编译器的“clang -rewrite-objc 源代码文件名”指令将源代码转化为我们可读的源代码。

下面我们看如下这段代码转化为 C++ 源代码:

1
2
3
4
5
6
7
int main()
{
void (^blk)(void) = ^{
printf("Block\n");
};
return 0;
}

转化后的 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
struct __block_impl {
void *isa;
int Flags;
int Reserved;
void *FuncPtr;//函数指针
};

//Block结构体
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;//用于描述这个Block的附加信息

//Block构造函数
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) {
impl.isa = &_NSConcreteStackBlock;//isa指针指向这个Block所属的类 _NSConcreteStackBlock
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

//通过Block使用的匿名函数其实被转化为简单的C的函数代码
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
printf("Block\n");
}

static struct __main_block_desc_0 {
size_t reserved;//为今后版本升级保留的区域
size_t Block_size;//Block的大小

} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

//main 函数
int main()
{
void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA));
return 0;
}

Block截获自动变量值

Block 是带有自动变量(局部变量)匿名函数,”带有自动变量值”在 Block 中表现为截获 Block 中使用到的自动变量值。
我们看一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
{
int dmy = 256;
int val = 10;
const char *fmt = "var = %d\n";
void (^blk)(void) = ^{
printf(fmt,val);
};

val = 2;
fmt = "These values were changed. var = %d\n";

blk();//打印出var = 10,因为Block保存了该自动变量的瞬间值,在执行Block后即使改变该自动变量值也不会影响Block执行时的自动变量值。
return 0;
}

转化后的 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
42
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;

const char *fmt; //Block中访问的自动变量被作为成员变量追加到了Block结构体实例中
int val; //

__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, const char *_fmt, int _val, int flags=0) : fmt(_fmt), val(_val) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
const char *fmt = __cself->fmt; // Block实际执行的函数中访问的自动变量来自Block结构体实例
int val = __cself->val; //

printf(fmt,val);
}

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 dmy = 256;//没有在Block中访问,所以没有被截获
int val = 10;

const char *fmt = "var = %d\n";

void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, fmt, val));

val = 2;
fmt = "These values were changed. var = %d\n";

((void (*)(__block_impl *))((__block_impl *)blk)->FuncPtr)((__block_impl *)blk);

return 0;
}

__block说明符

实际上,自动变量值截获只能保存执行 Block 语法瞬间的值,保存后就不能在 Block 中改写该值。试图在 Block 中修改自动变量会导致编译错误。
如果我们要改变 Block 中截获的自动变量值,有以下两个方法:

  • 使用 static 修饰自动变量值将其变为静态变量,修改其存储域。
  • 使用 __block 修饰符

我们看一下下面的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int global_val = 1;//全局变量
static int static_global_val = 2;//全局静态变量

int main()
{
static int static_val = 3;//静态变量

void (^blk)(void) = ^{
global_val *=1;
static_global_val *=2;
static_val *=3;
};
return 0;
}

转化后的 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
int global_val = 1;//全局变量与全局静态变量并没有被捕获到Block中
static int static_global_val = 2;

struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int *static_val;//截获了静态变量的指针
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int *_static_val, int flags=0) : static_val(_static_val) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {

int *static_val = __cself->static_val; // 从结构体中获取该指针

global_val *=1;//对于全局变量和静态全局变量的访问与转换前完全相同
static_global_val *=2;//

(*static_val) *=3;//使用静态变量的指针对其进行访问
}

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()
{
static int static_val = 3;
void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, &static_val));
return 0;
}

可以看到对于静态变量,Block 会捕获其指针对其进行访问。这种方法似乎对于自动变量也可以,为什么不截获自动变量的指针呢?因为自动变量再其作用域结束时已经被废弃了,Block调用时再去通过指针访问会出错。

我们在来看看使用“__block存储域类说明符”的例子:

1
2
3
4
5
6
7
int main(){
__block int val = 10;
void (^blk)(void) = ^{
val = 1;
};
return 0;
}

转化后的 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
42
struct __Block_byref_val_0 {//被捕获的__block变量生成的结构体并不在Block用__main_block_impl_0结构体中,这样做是为了能再多个Block中使用同一个__block变量
void *__isa;
__Block_byref_val_0 *__forwarding;
int __flags;
int __size;
int val;//最初的自动变量变为了结构体中的实例变量
};

struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
__Block_byref_val_0 *val; // 结构体指针。__block将自动变量变成了栈上生成的结构体实例

__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, __Block_byref_val_0 *_val, int flags=0) : val(_val->__forwarding) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
__Block_byref_val_0 *val = __cself->val; // 获取结构体指针

(val->__forwarding->val) = 1;//通过__forwarding指针访问变量值
}
static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->val, (void*)src->val, 8/*BLOCK_FIELD_IS_BYREF*/);}

static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->val, 8/*BLOCK_FIELD_IS_BYREF*/);}

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()
{
__attribute__((__blocks__(byref))) __Block_byref_val_0 val = {(void*)0,(__Block_byref_val_0 *)&val, 0, sizeof(__Block_byref_val_0), 10};

void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_val_0 *)&val, 0x22000000));
return 0;
}

__block 说明符将自动变量变成了栈上生成的结构体实例 __Block_byref_val_0,且这个结构体的指针被 Block 结构体所捕获。被捕获的 __block变量生成的结构体并不在Block用__main_block_impl_0结构体中,这样做是为了能在多个Block中使用同一个 __block变量。
__Block_byref_val_0 结构体实例的成员变量 __forwarding 持有该结构体实例自身的指针,通过这个 __forwarding 指针访问成员变量 val。 如下图所示

访问__block变量

为什么要使用指向结构体自身的 __forwarding 指针来访问其val值呢?为什么 Block 作为返回值时可以超出其变量作用域而存在呢?
其实,Block 所属类有以下几种:

  • __NSConcreteStackBlock –该类的对象分配在栈上
  • __NSConcreteGlobalBlock –该类的对象分配在.data区
  • __NSConcreteMallocBlock –该类的对象分配在堆中

在记述全局变量的地方使用Block变量时,实际的Block对象类为 __NSConcreteGlobalBlock。还有一种情况当 Block 语法的表达式中不使用应截获的自动变量时,Block也是 __NSConcreteGlobalBlock 类对象。

Blocks提供了将 Block 和 __block 变量从栈上复制到堆上的方法来使 Block 可以超出其作用域而存在,这时候在堆上分配的 Block 就是 __NSConcreteMallocBlock 类对象。栈上的 Block 会在以下几种情况时复制到堆:

  • 调用Block的 copy 方法时(除以下3种情况时均推荐自己调用copy方法,防止Block被废弃)
  • Block作为函数返回值返回时
  • 将Block赋值给附有 __strong修饰符 id 类型的类或Block类型成员变量时
  • 在方法名中含有 usingBlock的Cocoa框架方法或者GCD的API中传递Block时

复制__block变量

将 Block 复制到堆之后,栈上的Block 的 __forwarding 指针指向了堆上的Block,从而保证了任何时候都可以正确的访问到val值。

Block截获对象

如果Block捕获的自动变量是 Objective-C 对象,会是什么情况呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
blk_t blk;
{
id array = [NSMutableArray new];
blk = [^(id object){
[array addObject:object];
NSLog(@"array count = %ld",[array count]);
//array = [NSMutableArray array];//不能给array重新赋值,因为捕获的是指向该对象的指针,而不是对象本身
} copy];//调用copy方法将block复制到堆上,才会对截获的array调用retain方法,否则array会被释放
}

blk([NSObject new]);//打印array count = 1
blk([NSObject new]);//打印array count = 2
blk([NSObject new]);//打印array count = 3

可以看到在作用域结束后,array并没有没废弃,这个对象被Block截获,并且在Block复制到堆时对其进行了retain操作,从而持有了这个对象。
我们看看上面代码的 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
42
43
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
id __strong array;//截获的对象

__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, id _array, int flags=0) : array(_array) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself, id obj) {
id __strong array = __cself->array;
[array addObject:obj]

NSLog(@"array count = %ld",[array count]);
}
//当block复制到堆时调用
static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {
_Block_object_assign((void*)&dst->array, (void*)src->array, 3/*BLOCK_FIELD_IS_OBJECT*/);//相当于调用retain方法
}
//当堆上的block被废弃的时候调用
static void __main_block_dispose_0(struct __main_block_impl_0*src) {
_Block_object_dispose((void*)src->array, 3/*BLOCK_FIELD_IS_OBJECT*/);//相当于调用release方法
}

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};

blk_t blk;
{
id __strong array = [[NSMutalbleArray alloc] init];
blk = &__main_block_impl_0(__main_block_func_0, &__main_block_desc_0_DATA, array, 0x22000000));
blk = [blk copy];
}
(*blk->impl.FuncPtr)(blk,[[NSObject alloc] init]);
(*blk->impl.FuncPtr)(blk,[[NSObject alloc] init]);
(*blk->impl.FuncPtr)(blk,[[NSObject alloc] init]);

在OC中,C语言结构体不能含有__strong修饰符的变量,因为编译器不知道合适进行C语言结构体的初始化喝废弃操作,不能很好的管理内存,但是运行时库能准确把我 Block 从栈复制到堆以及堆上的Block被废弃的时机,因此Block结构体中可以含有附有 __strong或__weak修饰的变量。

__block修饰对象

当我们想要在block中修改被捕获的对象时,可以用__block修饰,其代码如下:

1
__block id obj = [[NSObject alloc] init];//obj默认是__strong

该代码经过clang转换后的__block变量结构体部分如下:

1
2
3
4
5
6
7
8
9
struct __Block_byref_obj_0 {
void *__isa;
__Block_byref_obj_0 *__forwarding;
int __flags;
int __size;
void (*__Block_byref_id_object_copy)(void*, void*);//如果__block对象变量从栈复制到堆时,使用_Block_object_assign函数
void (*__Block_byref_id_object_dispose)(void*);//堆上的__block对象变量被废弃时,使用_Block_object_dispose函数
__strong id obj;
};

在Block中使用__strong修饰符的对象类型自动变量的情况下,当Block从栈复制到堆时,使用__Block_object_assign函数,持有Block截获的对象。
需要特别注意的是如果对象是__weak修饰的,则不论是否有__block修饰,Block复制到堆时,均不持有该对象。

Block循环引用

在Block内部使用__strong修饰符的对象类型的自动变量时,当Block从栈复制到堆的时候,该对象就会被Block所持有。如果这个对象还同时持有Block的话,就容易发生循环引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef void(^blk_t)(void);
@interface Person : NSObject
{
blk_t blk;
}

@implementation Person

- (instancetype)init
{
self = [super init];
blk = ^{
NSLog(@"self = %@",self);
};
return self;
}

@end

Person 类对象持有Block类型成员变量blk,init实例方法中Block语法使用了附有__strong修饰符的对象self,并且由于Block语法赋值在了成员变量blk中,因此该栈上生成的Block会复制到堆,从而持有self,导致了循环引用。
可以使用__weak修饰符打破循环:

1
2
3
4
5
6
7
8
9
- (instancetype)init
{
self = [super init];
id __weak temp = self;
blk = ^{
NSLog(@"self = %@",temp);
};
return self;
}

有时候也可以使用__block修饰符来打破循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (instancetype)init
{
self = [super init];
__block id temp = self;
blk_= ^{
NSLog(@"self = %@",temp);
temp = nil;//需要再blk中将temp置空。
};
return self;
}
- (void)execBlock {
blk();//保证blk被执行。
}

使用__block来打破循环的话,需要再block中将对象置为nil,并且保证block执行来打破循环。

KVC/KVO 并不属于 Objective-C 语言的特性,而是 Cocoa 提供的一种特性。KVC/KVO 是通过采用NSKeyValueCoding / NSKeyValueObserving 非正式协议的方式所实现的一种机制。从协议的角度讲,它是给我们定义了一套去遵循和实现的方法。不过NSObject 提供了相关方法的默认实现,不需要我们显示的实现了。

KVC

除了通过调用访问方法和直接设置实例变量来更改对象状态外,还可以通过键-值编码(key-value coding)的间接方式。这种间接访问能让开发者在运行时而非编译期决定访问哪个属性。即使在编译期还不知道属性的键是什么也无所谓。这种动态访问对于 nib 文件的加载和 iOS 中的 Core Data 尤其重要,在 MacOS 中,KVC 则是 AppleScript 接口的基础部分。

基本使用

键-值编码中的基本调用是-valueForKey:-setValue:forKey:方法,向对象发送消息,并传递想要访问的属性名称字符串(键)作为参数。KVC 的默认实现可以参考苹果的官方文档《Key-Value Coding Programming Guide》中的这一章:Accessor Search Patterns。下面简单介绍一下,当调用-valueForKey:方法时,会按照如下步骤进行:

  1. 首先查找名为get<Key><key>is<Key>或者_<key>的 getter 方法,若找到则跳到第5步,否则进行下一步。

  2. 查找匹配如下模式的实例方法,若匹配则 KVO 系统会创建一个可以响应所有 NSArray 方法的 NSKeyValueArray 数组代理对象并返回,它是NSArray的子类,这个代理数组对象与 KVC 结合起来使用中行为与真正的数组对象一样。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    -countOf<Key>
    //以下二选一
    -objectIn<Key>AtIndex:
    -<key>AtIndexes:
    //<优化可选>
    -get<Key>:range:

    //若是可变容器类的属性还需实现
    -insertObject:in<Key>AtIndex:
    -removeObjectFrom<Key>AtIndex:
    //<优化可选>
    -replaceObjectIn<Key>AtIndex:withObject:
  3. 查找如下的匹配模式,若匹配则创建与上述数组代理对象类似的行为与 NSSet 一致的集合代理对象并返回。

    1
    2
    3
    -countOf<Key>
    -enumeratorOf<Key>
    -memberOf<Key>:
  4. 若接收者的类方法accessInstanceVariablesDirectly返回YES,则查找名为_<key>, _is<Key>, <key>, 或者is<Key>的实例变量,找到则跳到5,否则跳到6

  5. 若获取到的属性是对象指针则直接返回。若获取到的属性是标量类型的值且可以封装为 NSNumber 对象则封装后返回。若获取到的属性是标量类型的值但不能封装为 NSNumber 对象则封装为 NSValue 对象后返回。

  6. 若上面的所有步骤都失效,则调用valueForUndefinedKey:方法,该方法默认会抛出异常,但是 NSObject 子类可以重写并对特殊的 key 提供相应的行为。

-valueForKey:在 Objective-C 运行时中使用元数据打开对象并进入其中查找需要的信息。在 C 或 C++ 中不能执行这种操作。通过使用 KVC,没有相关 getter 方法也能获取对象值。
对于上面步骤5中的自动装箱和开箱标量值。可以看下如下的例子:

1
2
3
4
5
6
@interface Car : NSObject
@property (nonatomic, assign) float mileage;
@end

[car setValue:@(200.0) forKey:@"mileage"];
NSNumber *mileage = [car valueForKey:@"mileage"];

setValue:forKey:会将 @(200.0) 开箱取出值,再调用setMileage:或者更改 mileage 变量。

键路径

可以使用 valueForKeyPath:setValue:forKeyPath:来通过键路径访问属性。键路径可以包含嵌套关系,如下所示:

1
[car setValue:@(200) forKeyPath:@"engine.horsepower"];

KVC 实现高阶消息传递

如果键路径中有 NSArray 或 NSSet 这样的容器类属性,则该键路径的其余部分将被发送给容器类的每个对象。而不是对容器本身进行操作。结果会被添加进返回的容器中。这样可以方便的用一个容器对象创建另一个容器对象。示例如下:

1
2
NSArray *array = @[@"foo",@"bar",@"ted"];
NSArray *capitals = [array valueForKey:@"capitalizedString"];

把消息(capitalizedString)作为参数传递称为高阶消息传递。

容器操作符

键路径不仅能引用对象值,还可以引用一些运算符来进行一些运算。如:

1
2
NSArray *array = @[@"foo",@"bar",@"ted"];
NSInteger totalLength = [[array valueForKeyPath:@"@sum.length"] intValue];

@sum是一个操作符,对指定的属性(length)求和。key path 中的 @ 符号代表了一个特定的集合方法。具体的使用也可以参考 NSHipster 的这篇文章:KVC Collection Operators。 KVC 能非常轻松的处理集合类,但是它需要解析字符串来计算你想要的答案,所以通常速度比较慢。

nil

对于标量值的属性,如果使用 nil 作为参数调用setValue:forKey:,那么键会被传递给setNilValueForKey:方法,如果该对象支持标量值属性这是为 nil,则需要实现这个方法。默认的行为是抛出异常。
对 NSDictionary 对象的setObject:forKey:方法传入 nil 值,会发出警告。但是若使用setValue:forKey:传入 nil 值,则会把对应键的值从字典中删除。

KVO

Cocoa 有若干观察者机制,包括委托和 NSNotification,但是 KVO 的开销更小,被观察的对象允许其他对象去监听它的某个属性的改变,并且不需要有任何额外的代码来通知观察者,而如果没有观察者,KVO 就没有运行时的消耗,只有对象被真正观察时,KVO 系统才添加通知代码。
关于 KVO 的基本使用可以看看以下几篇博客:

注册观察者

要让一个对象监听另一个对象属性的变化,首先被观察对象要添加观察者对象为其相关属性的观察者。调用方法如下(**注意这个方法调用时,两个对象都不会被retain**):

1
2
3
4
- (void)addObserver:(NSObject *)observer 
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void *)context;

注册 KVO 时,要给 context 传递进去一个随机数据作为上下文数据。因为一个类只能有一个 KVO 回调,所以可能收到父类注册的属性变化事件。如果是这样,需要把回调传递给 super,所以要用唯一的 context 来识别观察的事件,这样子类和父类都能安全的观察同样的键值而不会冲突。推荐一个比较好的 context 值的声明方法:

1
static void * xxContext = &xxContext;

将其声明在 .m 文件的顶端,xxContext 静态变量中存储着自己的指针,可以用来确定唯一的 context。
传入的 keyPath 最好也不要用字符串字面值,因为不能被编译器检查拼写错误。推荐使用 NSStringFromSelector(@selector(length))这种写法。

options 为 NSKeyValueObservingOptions 的选项组合。它指定了观察通知中包含了什么数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
//通知的 change 字典中应该包含新值
NSKeyValueObservingOptionNew = 0x01,
//通知的 change 字典中应该包含旧值
NSKeyValueObservingOptionOld = 0x02,
/*立即发送一个通知给观察者,甚至在观察者注册方法返回之前。如果同时组合了 NSKeyValueObservingOptionNew 选项的话,
通知的 change 字典中总会包含新值。但是永远不会包含旧值。(虽然最初的那一个通知中值可能是旧值,但是对于观察者来说也是新值。)
可以将这个选项用来避免初始化时需要显示的调用观察者的 -observeValueForKeyPath:ofObject:change:context: 回调方法中的代码。
当这个选项用于 addObserver:toObjectAtIndexes:forKeyPath:options:context: 方法时。通知将会发送给观察者被添加的每一个索引对象。
*/
NSKeyValueObservingOptionInitial = 0x04,
/*不仅在属性改变后发送通知,还在改变前发送一条通知。改变前发送的通知的 change 字典中会包含NSKeyValueChangeNotificationIsPriorKey 入口,
但是永远不会包含 NSKeyValueChangeNewKey 入口。通常用于当观察者自身的 KVO 需要为自己的某个属性调用 -willChange... 方法,而这个属性的值又依赖于被观察对象的属性时。
改变后发送的通知的 change 字典中与不指定此选项时是相同的。(按顺序排列的 to-many 关系的 NSOrderedSets属性例外)。
*/
NSKeyValueObservingOptionPrior = 0x08
};

观察者回调

观察者必须实现-observerValueForKeyPath:ofObject:change:context:方法,来对被观察对象属性修改的通知做相应的处理。对观察者对象来说,它所有观察的改变都被聚集到这个方法回调中来,所以需要通过 context 上下文信息来准确的判断是哪个被观察对象的改变通知。
change 字典是NSDictionary<NSKeyValueChangeKey,id>类型的, NSKeyValueChangeKey 定义如下:

1
2
3
4
5
6
typedef NSString * NSKeyValueChangeKey;
NSKeyValueChangeKey const NSKeyValueChangeKindKey;
NSKeyValueChangeKey const NSKeyValueChangeNewKey;
NSKeyValueChangeKey const NSKeyValueChangeOldKey;
NSKeyValueChangeKey const NSKeyValueChangeIndexesKey;
NSKeyValueChangeKey const NSKeyValueChangeNotificationIsPriorKey;

举个例子,当注册时的 options 包含NSKeyValueObservingOptionInitial时:

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
@interface Engine: NSObject
@property (nonatomic, assign) NSInteger horsepower;
@end

@implementation Engine
@end

@interface Car : NSObject
@end

static void * carContext = &carContext;
@implementation Car
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == carContext) {
//仅打印 change 字典
NSLog(@"change = %@",change);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

- (void)setupCar {
Engine *engine = [[Engine alloc] init];
engine.horsepower = 300;

[engine addObserver:self forKeyPath:NSStringFromSelector(@selector(horsepower)) options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:carContext];
engine.horsepower = 100;
}
@end

打印结果如下如下所示,注册时会回调一次,change 字典中包含了 new 值:300。改变属性为 100 时,又回调一次, change 字典中包含 old 值:300,new 值:100。

1
2
3
4
5
6
7
8
9
change = {
kind = 1;
new = 300;
}
change = {
kind = 1;
new = 100;
old = 300;
}

将上述注册方法中的NSKeyValueObservingOptionInitial选项换成NSKeyValueObservingOptionPrior时,打印结果如下:

1
2
3
4
5
6
7
8
9
10
change = {
kind = 1;
notificationIsPrior = 1;
old = 300;
}
change = {
kind = 1;
new = 100;
old = 300;
}

在属性改变为 100 前回调一次,change 字典中包含了notificationIsPrior键,且它的值总为@(YES)。属性改变后回调中的 change 字典跟其他选项时一样。
上面的例子中,kind 的值总是 1,其实 NSKeyValueChangeKindKey 键的值的定义如下所示:

1
2
3
4
5
6
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};

当观察的对象关系是一对多时,出现插入、剔除或替换时就会出现其他值了,change 字典中其他键值对也会相应变化。

移除观察者

当观察者完成了对监听对象的观察之后,被观察对象需要调用-removeObserver:forKeyPath:-removeObserver:forKeyPath:context:方法移除观察者。如果忘记调用该方法移除观察者的话,程序会崩溃,因为在这些对象被释放之后,KVO 依然保留着它们的注册信息,KVO对这种情况的处理就是让程序直接崩溃。
若调用该方法时,传入的观察者对象并没有被注册为该键路径的观察者,程序也会崩溃。因此要追踪自己观察的属性。一个妥协的方法是可以使用 @try/@catch 来使它不崩溃,但这个方法并不推荐。如下所示:

1
2
3
4
@try {
[string removeObserver:self forKeyPath:NSStringFromSelector(@selector(length))];
}
@catch (NSException *exception) {}//@catch中没有做处理

计算属性

计算属性是指该属性依赖于其他一个或多个属性的变化而变化。因此在 KVO 中,所依赖的其他属性变化时,我们也想要监听到计算属性的相应通知。NSKeyValueObserving协议提供了下面的两个方法:

1
2
3
+(NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key
//或针对某一属性的:
+(NSSet<NSString *> *)keyPathsForValuesAffecting<key>

举个例子,一般的 color 属性都是依赖于RGB三个部分的变化:

1
2
3
4
5
6
7
8
9
10
11
12
+(NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
//调用父类的此方法,获取父类中可能对 key 指定属性产生影响的属性集合
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if([key isEqualToString:@"color"]){
keyPaths = [keyPaths setByAddingObjectsFromArray:@[@"red",@"green",@"blue"]];
}
return keyPaths;
}
//或者如下方法,确定父类没有对此属性的依赖属性时:
+(NSSet<NSString *> *)keyPathsForValuesAffectingColor {
return [NSSet setWithObjects:@"red",@"green",@"blue",nil]
}

重写了这个方法之后,无论是color属性自己发生了变化还是redgreenblue属性发生变化,我们都会收到color属性的通知回调。

容器类

观察容器类和观察其中的对象是不同的。KVO 旨在观察关系而不是集合。对于集合属性,我们将其当成一个整体来监听它的变化,而无法监听集合中的某个元素的变化。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@interface Car : NSObject
@property (nonatomic, strong) NSMutableArray *wheels;
@end

@interface Garage : NSObject
- (void)repair;
@end

@implementation Car
@end

static void * garContext = &garContext;
@implementation Garage
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"change = %@",change);
}
- (void)repair {
Car *car = [[Car alloc] init];
[car addObserver:self forKeyPath:NSStringFromSelector(@selector(wheels)) options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:garContext];
car.wheels = [NSMutableArray array];
[car.wheels addObject:@"frontWheel"];
}
@end

上述例子在调用了 garage 对象的 repair方法后的打印结果如下:

1
2
3
4
5
6
change = {
kind = 1;
new = (
);
old = "<null>";
}

可见当我们观察 car 对象的 wheels 数组属性时,获取的是真正的可变数组,只有在 wheels 属性被整体修改的时候,才会触发 KVO,在后面添加元素进去的时候并没有触发 KVO!

代理集合对象

在上面的 KVC 部分我们说过,只要实现了指定的方法,我们就可以使用**代理集合对象(collection proxy object)**来通过 KVC 处理集合相关的操作。此外我们可以通过调用mutableArrayValueForKey:mutableSetValueForKey:来获取可变数组属性或可变集合属性的代理对象。集合代理对象与 KVO 结合起来也十分强大,KVO 机制在这些代理集合对象改变的时候可以把详细变化放进 change 字典中。我们可以将上个例子中的最后两行代码换成下面的试试:

1
2
3
car.wheels = [NSMutableArray array];
NSMutableArray *wheels = [car mutableArrayValueForKey:@"wheels"];
[wheels addObject:@"backWheel"];

打印结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
change = {
kind = 1;
new = (
);
old = "<null>";
}
change = {
indexes = "<_NSCachedIndexSet: 0x100703a70>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
kind = 2;
new = (
backWheel
);
}

可以看到往 wheels 数组中添加对象时,也触发了KVO。并且 change 字典中有详细的变化信息。

KVO的实现

KVO 是通过 Objective-C 的 runtime 来实现的,第一次对一个对象调用addObserver:forKeyPath:options:context:时,框架会创建这个类的新的 KVO 子类,并将被观察对象转换为新子类的对象(将新对象的 isa 指针指向新子类)。在这个特殊的子类中重写所有被观察属性的 setter 方法,并在其中添加通知变化的代码。这种继承和方法注入是在运行时而不是编译时实现的。
在 KVO 的默认实现中,在被观察属性发生改变之前,会自动调用willChangeValueForKey:方法,这就会记录旧的值。而当改变发生后,didChangeValueForKey:会被调用,继而观察者的observeValueForKey:ofObject:change:context:也会被调用。
我们也可以手动来发送这些通知,从而达到更精确的管理。实现方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//被观察类必须重写如下方法:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
if([key isEqualToString:@"horsepower"]) {
return NO;//关闭了 -willChangeValueForKey: 和 -didChangeValueForKey: 的自动调用,
}else {
return [super automaticallyNotifiesObserversForKey:key];
}
}
//手动发送通知:
-(void)setHorsepower:(NSInteger)horsepower {
if(_horsepower != horsepower) {
[self willChangeValueForKey:@"horsepower"];
_horsepower = horsepower;
[self didChangeValueForKey:@"horsepower"];
}
}
//注:对于集合代理对象,发送通知的方法更为详细。

KVO 和线程

KVO 的行为是同步的,并且与所观察值的变化发生在同样的线程上。没有队列或者 run-loop 的处理。因此当我们试图从其他线程改变属性值的时候要十分小心,除非能确定所有的观察者都是用线程安全的方法处理 KVO 通知,我们不应该将 KVO 和多线程混用起来。
KVO 的同步特性十分强大,只要我们在单一线程运行,则对于:

1
self.horsepower = 200;

KVO 能保证所有 horsepower 的观察者在 setter 方法返回前收到通知。

调试

我们可以在 lldb 中查看一个被观察对象的所有观察信息:

1
2
(lldb)po [observedObject observationInfo]

observationInfo 这个方法的默认实现是以对象的指针作为键从一个全局字典中获取信息。得到的是一个 NSKeyValueObservationInfo 对象,它包含了指定对象上的所有的监听信息。
由此可见 KVO 的信息是存储在一个全局字典中,而不是存储在对象本身。

其他

注意:并不是所有的类的所有属性都兼容 KVO。如果类的所有者不保证类的某个属性兼容 KVO,我们就不能保证 KVO 正常工作。详情可以查看官方文档。