iOS (二)

+(void)load 和 +(void)initialize 方法有什么用处?

在应用程序启动过程中,初始化一个依赖于 libobjc 动态库的 image 后,会调用 libobjc 动态库的 image 实例的load_images函数,在该函数实现中会调用本次初始化的 image 中的所有 objc 类和 category 的+load方法。首先调用父类的+load方法,接着调用子类的+load方法,然后调用 category 的+load方法。

运行时系统是直接到类对象的 ro (class_ro_t)的方法列表中去查找+load方法对应的函数指针的,只有类自己实现了+load方法时,才能查找到对应的函数指针,并通过该函数指针去调用这个+load方法。由于没有使用objc_msgSend函数去调用+load方法,category 实现的+load方法不会覆盖宿主类实现的+load方法。

+load方法是线程安全的,可以在+load方法中添加一些需要在应用程序的main函数之前执行的特殊操作,+load方法的一个常见使用场景是我们通常会在该方法中实现方法交换。

+ (void)load {

    Method originalFunc = class_getInstanceMethod([self class], @selector(originalFunc));

    Method swizzledFunc = class_getInstanceMethod([self class], @selector(swizzledFunc));

    method_exchangeImplementations(originalFunc, swizzledFunc);
}

在首次调用类的某个类方法时,在慢速查找类方法的函数指针时,会从该类所在继承链中的基类开始依次判断继承链中的父类是否已初始化。如果没有,则会通过objc_msgSend函数去调用这个父类的+initialize方法,并将这个父类标记为已初始化。接着,会判断该子类是否已初始化。如果没有,则会通过objc_msgSend函数去调用该子类的+initialize方法,并将该子类标记为已初始化。

由于是通过objc_msgSend函数去调用的+initialize方法,如果子类没有实现+initialize方法,那么父类的+initialize方法可能会被多次调用。并且,如果 catergory 实现了+initialize方法,那么宿主类的+initialize方法会被覆盖掉。

+initialize方法也是线程安全的,其主要用来执行一些不方便在应用程序启动过程中执行的操作。

+ (void)initialize {
    if (self == [Parent class])
    {
        // 执行一些不方便在应用程序启动过程中执行的操作
    }
}

沙盒机制

安装应用程序时,系统会为每个应用程序开辟一个与之对应的存储区域,这个存储区域被称为沙盒。所有的非代码文件都保存在沙盒中,例如图片、 音频、属性列表和文本文件等。每个沙盒之间是相互独立的,应用程序只能在与其对应的沙盒中读写文件,不能直接访问其他应用程序的沙盒。要访问其他应用程序的沙盒,必须先请求访问权限(如访问系统应用程序“照片”和“通讯录”时,必须先请求权限)。

应用程序的沙盒中包括 Documents、Library(内有 Caches 和 Preferences 目录)和 tmp 三个目录:

  • Documents:运行应用程序时生成的一些需要长久保存的数据(例如游戏进度存档、应用程序个人设置等)会保存在此目录中。iOS 在进行 iCloud 备份时,会备份此目录下的数据。
  • Library/Caches:从远程下载的文件和图片等数据保存在该目录中,该目录下的数据不会被自动删除,需要我们手动进行清理。iOS 在进行 iCloud 备份时,不会备份该目录下的数据。该目录主要用于保存运行应用程序时生成的需要长期使用的,体积较大且不需要备份的数据。
  • Library/Preferences:保存通过“偏好设置”写入的数据。iOS 在进行 iCloud 备份时,会备份此目录下的数据。该目录由系统自动管理,通常用来存储一些基本的应用程序配置信息,例如是否自动登录。
  • tmp:保存运行应用程序时产生的一些临时数据,应用程序退出、 系统磁盘空间不足或者手机重启时,会自动清除该目录中的数据,无需我们手动清除。iOS 在进行 iCloud 备份时,也不会备份该目录下的数据。

数据持久化的几种方式

将数据存储到本地可以在重启设备或者应用程序时避免重要数据的丢失,Cocoa 框架提供了以下几种数据持久化方式:

  • 直接写入文件:Cocoa 框架为NSStringNSArrayNSDictionaryNSData类提供了writeToFile:atomically:writeToURL:atomically:方法来将数据直接写入文件中。这两个方法只能存储NSStringNSArrayNSDictionaryNSDataNSDateNSNumber类型的数据,不支持存储自定义对象。通常将NSArrayNSDictionary类型的数据存储为 plist (属性列表)文件,NSString类型的数据存储为 txt 文件,图片和音视频的NSData类型的二进制数据被写成相应编码格式的文件。这种方式适用于存储小型并且很少需要更改的数据,例如省市列表和图片之类的数据。
  • 偏好设置:偏好设置是专门用来保存应用程序的配置信息的,例如字体大小、是否自动登录等。偏好设置本质上是以 plist(属性列表)文件的形式存储的,数据被保存在应用程序沙盒 Library/Preferences 目录下的以此应用包名来命名的 plist 文件中。
  • 归档:对模型对象进行归档可以轻松将复杂对象写入文件中,然后再从文件中读取它们。要对模型对象归档,则模型对象类中声明的属性必须是标量数据类型或者是遵循并实现了NSCoding协议的自定义对象类型。
  • SQLite 数据库:SQLite 是基于 C 语言开发的数据库,提供 C 语言 API 来对数据库执行读写操作,使用起来较为繁琐。其适合存储大量且经常需要更改的数据内容。
  • Core Data:Core Data 封装了数据库的操作过程以及数据库中的数据和 Objective-C 对象的转换过程。使用 Core Data 来存储数据时,不需要手动编写任何 SQL 语句。

项目中网络层是如何做安全处理的?

移动端应用程序安全问题一般分为以下三类:

  • 代码安全,包括代码混淆、加密或者应用程序加壳。
  • 数据存储安全,主要指在磁盘做数据持久化的时候所做的加密。
  • 网络传输安全,指对从客户端传输到服务端的数据进行加密,防止网络世界当中其他节点对数据的窃听。

网络安全相关的算法:

  • 对称加密算法,代表算法:AES。
  • 非对称加密算法,代表算法:RSA,ECC。
  • 电子签名,用于确认消息发送方的身份。
  • 消息摘要生成算法,用于检测消息是否被第三方修改过,例如:MD5,SHA。

对于服务端来说,只要满足以下三点就说明收到的请求是安全的:

  • 请求有客户端的电子签名,表明请求确实是来自客户端。
  • 请求没有被篡改过。
  • 请求被某种加密算法加密过,只有客户端和服务端知道如何解密。

对于保证网络传输的安全,有以下几点建议:

  • 应尽量使用 HTTPS 协议,HTTPS 协议可以过滤掉大部分的安全问题。
  • 不要明文传输账号/密码信息。
  • POST 请求和 GET 请求都不安全,使用 HTTP 时,应该对参数进行加密和签名处理。
  • 使用 HTTP 时,不要使用301跳转,301跳转很容易被劫持而重定向到其他地址。
  • 客户端发送的请求都带上 Message Authentication Code -- 消息认证码。这样不但能保证请求没有被篡改,还能保证请求确实来自合法客户端。带上消息认证码之后,服务器就可以过滤掉绝大部分的非法请求。
  • HTTP 请求使用临时密钥。在不具备 HTTPS 条件或对网络性能要求较高且缺乏 HTTPS 优化经验的场景下,HTTP 的流量也应该使用 AES 进行加密。AES 的密钥可以由客户端来临时生成,不过这个临时的 AES key 需要使用服务器的公钥进行加密,确保只有自己的服务器才能解开这个请求的信息,当然服务器的响应也需要使用同样的 AES key 进行加密。由于 HTTP 的应用场景都是由客户端发起,服务器响应,所以这种由客户端单方生成密钥的方式可以一定程度上便捷的保证通信安全。

内存分类和内存分区

内存分类

iOS 的内存分为 RAM 内存和 ROM 内存:

  • RAM:运行内存(内存条),CPU 可以直接访问,读写速度非常快,但是不能掉电存储。其又分为:
    • 静态SRAM,速度快,我们常说的一级缓存,二级缓存就是指它,价格相对要高。
    • 动态DRAM,速度相对较慢,需要定期的刷新(充电),我们常说的内存条就是指它,价格相对要低,手机中运行内存也是指它。
  • ROM:存储性内存,可以掉电存储,例如:SD卡,硬盘。

内存分区

iOS 的内存存储区域分为栈区,堆区,静态/全局区,常量区,代码区。

内存分区.png

栈区:栈是由高地址向低地址扩展的先进后出的数据结构,是一块连续的内存的区域。栈的空间很小,大概1-2M。函数(方法)在执行时,会向系统申请一块栈区内存。函数中的局部变量和参数会存储在栈区,它们由编译器分配和释放。函数执行时分配,执行结束后释放。当栈的剩余空间小于所申请的空间时,会出现异常,并提示栈的溢出。所以大量的局部变量和函数循环调用可能会耗尽栈内存而造成程序崩溃。

堆区:堆是由低地址向高地址扩展的数据结构,是一块不连续的内存的区域(分配方式类似于链表)。堆区用来存储实例对象,一般由程序员自己管理。例如,使用alloc申请内存,使用free释放内存。

静态/全局区:静态变量和全局变量都存储在静态/全局区中,程序结束时由系统释放。

常量区:常量存储在常量区中,程序结束时由系统释放。

代码区:代码区用于存放函数的二进制代码。

堆和栈的区别

  • 管理方式:栈由编译器自动管理,无需程序员自己手动控制。堆由程序员自己手动管理,容易产生内存泄漏。
  • 申请空间大小:栈的空间只有1-2M,如果申请的空间超过了栈的剩余空间,会提示栈溢出。而堆的大小取决于计算机系统中有效的虚拟内存,所以堆获得的空间比较大。
  • 碎片问题:对于堆来说,频繁的 new/delete 会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来说,由于栈是先进后出的队列,所以不会产生碎片。
  • 分配方式:堆都是动态分配的。而栈有两种分配方式:静态分配和动态分配。静态分配是编译器完成的,例如局部变量的分配。动态分配是由alloc函数进行分配的,但是栈的动态分配和堆是不同的,栈的动态分配由编译器进行释放,无需程序员手动释放。
  • 分配效率:栈是由机器系统提供的数据结构,计算机会在底层堆栈提供支持,分配专门的寄存器存放栈的地址,压栈和出栈都有专门的指令执行,这就决定了栈的效率比较高。而堆是 C/C++ 函数库提供的,其机制很复杂。

Block

// 定义2个局部变量
int num = 10;
NSObject *obj = [[NSObject alloc] init];

// 定义一个 successBlock
void (^successBlock) (void) = ^{
    NSLog(@"%d",num);
    NSLog(@"%@",obj);
};

// successBlock 会被编译为 __successBlock_impl_0 结构体
struct __successBlock_impl_0 {
    
    // __block_impl 结构体,包含 isa 指针和函数指针
    struct __block_impl impl;

    // block 的描述信息
    struct __successBlock_desc_0 *desc;

    // successBlock 捕获的变量
    int num;
    NSObject *obj;
    
    // __successBlock_impl_0 结构体构造函数
    __successBlock_impl_0(void *fp, struct __successBlock_desc_0 *desc, int _num, NSObject *_obj, int flags=0) : num(_num), obj(_obj) {
        
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
    }
};

// __block_impl 结构体
struct __block_impl {
    void *isa; // isa指针,指向 block 对象的所属类
    int Flags; // 标识,按位存储 block 对象的引用计数和类型信息
    int Reserved; // 保留变量
    void *FuncPtr; // 函数指针
};

// block 描述信息的数据结构
struct __successBlock_desc_0 {
    uintptr_t reserved;   // 保留字段
    uintptr_t size;          // block 的内存大小
};

// 根据 successBlock 的代码块定义一个函数
static void __successBlock_func_0(struct __successBlock_impl_0 *__cself) {

    // block中访问局部变量时,实际上访问的是 __successBlock_impl_0 结构体中的变量
    int num = __cself->num;
    NSObject *obj = __cself->obj;

    // 执行 NSLog 语句
}


什么是block

block 本质上是一个 Objective-C 对象,block 对象中封装有一个指向其类对象的isa指针,一个指向【根据 block 定义所实现的函数】的函数指针,以及 block 捕获的外部变量。block 调用本质上就是函数调用。

block的内存管理

如果 block 没有访问外部变量,则该 block 是一个存储在全局区的全局 block,其isa指针指向_NSGlobalBlock,对全局 block 执行copy操作是无效的。

如果 block 有访问外部变量,则该 block 是一个存储在栈区的栈 block,其isa指针指向_NSStackBlock。当栈 block 的作用域(栈帧)被释放时,栈 block 也会被释放。

如果想要延迟调用 block,则需要对 block 执行copy操作,以便将栈 block 从栈区复制到堆区,从而延长 block 的生命周期。此时,该 block 是一个存储在堆区的堆 block,其isa指针指向_NSMallocBlock

block捕获变量

访问局部变量时,编译器创建的 block 对象中也会包含一个名称和类型完全相同的变量,并将局部变量的值赋值给该变量。在 block 中访问局部变量,实际上访问的是 block 对象中的对应变量。如果在 block 中对局部变量执行赋值操作,则实际上是在对 block 对象中的对应变量执行赋值操作,所以局部变量的值并不会被改变。因此,不能直接在 block 中直接对局部变量执行赋值操作。如果这样做的话,编译器会报错。

  • 如果局部变量是基本数据类型,在 block 中更改局部变量的值,实际上更改的是 block 对象中与之对应的变量的值,所以不能在 block 中直接修改基本数据类型局部变量的值。
  • 如果局部变量是一个实例对象,由于将实例对象赋值给了 block 对象中与之对应的变量,所以 block 对象会强引用这个实例对象。在 block 中可以修改局部对象指针所指内存区域的内容,也就是可以直接修改局部对象的属性值,因为 block 对象中的对象指针和局部对象指针所指内存地址是相同的。但不能直接修改局部对象指针所指向的内存地址,也就是不能将另一个对象赋值给局部对象指针,因为这只是修改了 block 对象中的对象指针所指的内存地址,并不能修改局部对象指针所指的内存地址。

ARC 模式下,block 在捕获对象类型的局部变量时,会连同对象的所有权修饰符(__weak__strong__unsafe_unretained__autoreleasing)一起捕获。例如,在 block 中访问__weak变量时,block 对象中的对应变量也会是一个__weak变量。

访问静态局部变量时,编译器创建的 block 对象中会包含一个指针变量,并将静态局部变量的指针赋值给该指针变量。由于静态局部变量的指针和 block 对象中的指针变量是同一个指针,修改 block 对象中的指针变量所指向的内存地址,就是修改了静态局部变量的指针所指向的内存地址,所以可以在 block 中直接修改静态局部变量的值。

访问全局变量静态全局变量时,block 不会对它们进行捕获。

访问其他 block 时,在 block 从栈区复制到堆区时,如果有需要,其他 block 也会从栈区复制到堆区。

__block实现原理

// 定义两个 __block 局部变量
__block int num = 1;
__block NSObject *obj = [[NSObject alloc] init];

// 经过 Clang 编译后会转换为
struct __Block_byref_num_0 {
    void *__isa;
    __Block_byref_num_0 *__forwarding;
    int __flags;
    int __size;
    // 局部变量 num
    int num;
}

struct __Block_byref_obj_1 {
    void *__isa;
    __Block_byref_obj_1 *__forwarding;
    int __flags;
    int __size;
    void (*__Block_byref_id_object_copy)(void*, void*);
    void (*__Block_byref_id_object_dispose)(void*);
    // 局部变量 obj
    NSObject *obj;
}

编译器在编译时,会将__block修饰的局部变量转换为一个__Block_byref_xxx_x结构体,__Block_byref_xxx_x结构体中包含一个与局部变量完全相同的变量,一个指向其自身__forwarding指针,以及一个指向其类对象的isa指针。

注意__block只能用来修饰局部变量, 所以__Block_byref_xxx_x结构体刚开始是存储在栈区的。

访问__block修饰的局部变量时,实际上访问的是__Block_byref_xxx_x结构体中的变量。(__Block_byref_xxx_x -> __forwarding -> 变量

在 block 中访问__block修饰的局部变量时,block 会以指针拷贝的方式强引用这个__Block_byref_xxx_x结构体。因此,在 block 中可以直接修改__Block_byref_xxx_x结构体中的变量的值。

当访问__block变量的栈区 block 在从栈区复制到堆区时,栈区__Block_byref_xxx_x结构体也会从栈区复制到堆区。此时,会将栈区__Block_byref_xxx_x结构体的__forwarding指针指向堆区__Block_byref_xxx_x结构体。这样,在栈区__Block_byref_xxx_x还没被释放时,修改栈区__Block_byref_xxx_x结构体的变量的值时,实际上修改的是堆区__Block_byref_xxx_x结构体的变量的值。

注意:当__block修饰的局部变量是对象类型时,在 MRC 模式下,__Block_byref_xxx_x结构体在引用对象时,不会增加对象的引用计数。但是在 ARC 模式下,还是会增加对象的引用计数。

如何 hook 所有的 block 调用?

对 Objective-C 对象执行赋值操作时,会调用其retain方法来增加其引用计数。__NSStackBlock__NSMallocBlock__NSGlobalBlock这三个类都重载了NSObjectretain方法,所以可以使用方法交换来 hook 它们的retain方法。

在自定义retain方法的实现中,我们可以拿到 block 对象,然后将 block 对象的函数指针所指向的原始函数地址保存在 block 对象的描述信息desc中的保留字段reserved中,并将函数指针指向自定义函数的地址。

在自定义函数中,通过保存在 block 对象的描述信息desc中的保留字段reserved中的原始函数地址来调用原始函数,然后再执行其他额外操作。

实现自定义函数的难点在于每个 block 的参数个数和参数类型是不一样的,需要直接用汇编语言去实现这个自定义函数。

block 不仅可以用在 Objective-C 语言中,LLVM 对 C 语言进行的扩展也能使用 block,例如 GCD 中就大量的使用了 block。在 C 语言中如果对一个 block 进行赋值或者拷贝,需要通过 C 函数__Block_copy(定义在 libsystem_blocks.dylib 动态库中)来实现。我们可以使用 fishhook 第三方库来 hook 这个 C 函数。

更多信息,可以参考这篇文章:运行时Hook所有Block方法调用的技术实现

hook 特定类型的block,可以参考这篇文章:Block hook 正确姿势?

线程和进程之间有什么区别与联系?

  • 进程是正在运行的应用程序的实例(可执行代码文件),是系统进行资源分配的基本单位。启动一个 iOS 应用程序,就是启动了一个进程,一个进程中可以包含多个线程。
  • 线程是单独的代码执行路径,是 CPU 独立运行和独立调度的基本单位。
  • 进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其他进程产生影响。
  • 线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉。
  • 多进程的程序要比多线程的程序健壮,但在切换进程时,资源消耗较大且效率差。
  • 对于一些要求同时运行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
  • 进程和线程都是应用程序运行的基本单元。

GCD

GCD的实现原理

GCD 维护有一个线程池,线程池中存放着一些线程,这些线程可以被重用,如果一个线程在一段时间内没有被使用,就会销毁这个线程。线程池中存放的线程数量是由系统来决定的。

当有任务被添加到串行队列中时,GCD 会从线程池中取出一个线程,然后按照先进先出的顺序将串行队列中的任务调度到这个线程上去执行。当所有任务执行完毕后,这个线程会被放回到线程池中。

当有任务被添加到并行队列中时,GCD 会从线程池中取出多个线程,然后按照先进先出的顺序将并行队列中的任务分别调度到不同的线程上去执行。每个线程执行完其接收的任务后,会被放回到线程池中。

GCD 重复使用已经创建好的线程,而不是每次创建一个新线程,提高了程序运行效率。

dispatch_queue_t

添加到调度队列中的任务总是按照先进先出的顺序被调度到合适的线程上运行。

串行调度队列需要等待前一个任务执行完毕后,才会继续调度下一个任务。并行调度队列不会等待前一个任务执行完毕,就继续开始调度下一个任务。

调度队列相对于其他调度队列并行调度其任务,任务的序列化仅限于单个调度队列中的任务。在选择调度哪些任务时,系统会考虑队列的优先级,并且由系统确定在任何时间点调度队列能够调度的任务的总数。

GCD 为每个应用程序都提供了一个主调度队列和四个并行调度队列来供我们直接使用,这些队列对于应用程序来说是全局的。主调度队列是一个串行队列,可以使用该队列将任务调度到应用程序的主线程上运行。四个并行调度队列是通过优先级来区分的,分别为默认、高、低优先级队列和后台运行队列。

可以通过挂起队列来暂时阻止其调度任务,并能在之后某个时间点恢复队列来让其继续调度任务。

死锁

在主线程使用dispatch_sync函数往主队列中添加一个任务A时,会导致死锁。

当主线程开始执行dispatch_sync函数时,其会将任务A添加到主队列中。dispatch_sync函数会阻塞主线程直到任务A完成,而任务A只有在dispatch_sync函数执行完毕后才会从主队列调度到主线程上去执行,这就使得dispatch_sync函数和任务A永远无法完成执行,导致主线程一直处于阻塞状态。

dispatch_barrier_async 和 dispatch_barrier_sync 栅栏函数

使用栅栏函数向队列中添加的任务只能在前面添加的任务被调度执行完毕后,才会从队列中调度到合适的线程上执行。在栅栏函数所添加任务执行完毕之前,后面添加的任何任务都不会执行。(栅栏函数只对并行队列有意义

可以使用dispatch_barrier_aync函数来实现数据的多读单写:

/*
*多读单写需满足以下三个要求:
*1.读写互斥
*2.写写互斥
*3.读读并发
**/


- (id)objectForKey:(NSString *)key
{
  __block id obj;
  // 在当前线程向并行队列中添加同步读取数据的任务
  // 当在多个线程同时调用objectForKey:方法时,对[dic objectForKey:key]的调用是并行执行的,每个线程都会各自等待该方法执行完毕。
  dispatch_sync(concurrent_queue, ^{
    obj = [dic objectForKey:key];
  });
  return obj;
}

- (void)setObject:(id)object forKey:(NSString *)key
{
  // 在当前线程使用GCD栅栏函数向并行队列中添加异步写入数据的任务;
  // 使用栅栏函数添加到并行队列的任务,只有在前面添加到队列的任务调度执行完毕后,才会开始被调度执行。并且只有在该任务执行完毕后,后面添加的其他任务才会开始执行。
  dispatch_barrier_async(concurrent_queue, ^{
    [dic setObject:object forKey:key];
  });
}

dispatch_group_t

使用调度组可以实现在多个异步任务完成执行后再去执行某个任务,或者阻塞当前线程直到多个异步任务完成执行。

dispatch_group_t group = dispatch_group_create();

dispatch_group_async(group, concurrent_queue, ^{
  // 任务1
});

dispatch_group_async(group, concurrent_queue, ^{
  // 任务2
});

dispatch_group_async(group, concurrent_queue, ^{
  // 任务3
});

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
  // 任务1,任务2,任务3执行完毕之后执行任务4
});

dispatch_semaphore_t

如果提交给调度队列的任务会访问某些有限的资源,则可能需要使用信号量来调节可以同时访问该资源的任务数量。在创建信号量时,指定最大可用资源的数量。在访问资源时,首先调用dispatch_semaphore_wait函数来等待信号量,此时可用资源的数量会减1。如果结果值为负数,该函数会通知内核阻塞当前线程。否则,获取资源并完成要执行的工作。当完成工作并释放资源后,调用dispatch_semaphore_signal函数发出信号并将可用资源数量加1。如果有任务被阻塞并等待访问资源,它们中的一个随后会被解除阻塞并开始执行。

dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);

dispatch_async(concurrent_queue, ^{

  dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

  // 访问资源
  
  dispatch_semaphore_signal(semaphore);
});

NSOperationQueue和NSOperation

在将操作对象添加到操作队列之前,可以在操作对象之间建立依赖关系。依赖于其他操作的操作无法被调度到线程上运行,直到它所依赖的所有操作都已完成执行。

对于已经添加到操作队列中的操作,它们的调度顺序首先取决于其是否准备就绪,然后才取决于其相对优先级。是否准备就绪取决于操作对其他操作的依赖性,而优先级是操作对象本身的属性。默认情况下,所有新操作对象都具有“正常”优先级,但可以调用NSOperation类提供的方法来提高或降低该优先级。优先级仅适用于在同一操作队列中的操作,所以低优先级操作仍然可能会在不同队列中的高优先级操作之前被调度到线程上运行。

操作队列支持暂停调度正在排队的操作,并可以在以后某个时间点恢复,继续调度操作。

操作队列是一个并行队列,可以通过将操作队列的最大并行操作数量设置为1来使操作队列一次只调度一个操作。尽管一次只能调度一个操作,但调度顺序仍然基于其他因素,例如每个操作是否准备就绪及其分配的优先级。串行操作队列并不能提供与GCD中的串行调度队列完全相同的行为,串行调度队列总是按照先进先出的顺序调度任务。

控制NSOperation的状态

NSOperation对象有以下几种状态:

  • isReady:当前任务是否就绪;
  • isExecuting:当前任务是否正在执行;
  • isFinished:当前任务是否已完成执行;
  • isCancelled:当前任务是否已取消。

自定义NSOperation对象时,如果只重写了main方法,还是会由NSOperation底层去控制任务的执行状态以及任务的退出。如果重写了start方法,则需要自行控制任务的状态以及任务的退出。

NSOperation对象在Finished之后是怎样从NSOperationQueue中移除的?

NSOperation对象以KVO方式通知NSOperationQueue移除自己。

NSThread

使用NSThread创建一个线程时,必须为该线程指定要运行的任务,并调用start方法来启动线程。

在创建线程时,可以配置线程的以下属性:

  • 堆栈大小:堆栈管理栈帧,也是声明线程的任何局部变量的地方。
  • 局部存储:每个线程都维护着一个可以从任何位置访问的字典,可以使用该字典来存储希望在整个线程执行期间都存在的信息。
  • 分离状态:使用 Cocoa 和 POSIX 线程技术创建的线程默认都是分离的,分离线程完成其工作后,系统会立即释放其资源。相比之下,系统不会回收可连接线程的资源,直到另一个线程显示地与该线程连接。
  • 优先级:内核的调度算法在确定要运行哪些线程时会考虑线程优先级,优先级较高的线程比较低优先级的线程更可能运行。较高的优先级并不能保证线程的具体执行时间,只是与较低优先级的线程相比,调度程序更有可能选择它。

线程在内存使用和性能方面对应用程序和系统有实际的成本。每个线程都会在内核内存空间和应用程序的内存空间中请求内存分配,管理线程和协调线程调度所需的核心数据结构使用wired memory存储在内核中,线程的堆栈空间和pre-thread数据存储在应用程序的内存空间中。

由于底层内核的支持,GCD 和NSOperationQueue通常可以更快地创建线程。它们不是每次都从新开始创建线程,而是使用已驻留在内核中的线程池来节省分配时间的。

start方法内部实现机制

调用start方法来启动线程时,会创建并启动一个 pthread。在 pthread 的启动函数中会调用NSThreadmain方法,main方法内部会调用为线程指定的定义了所要执行任务的方法。在main方法执行完毕之后,会调用NSThreadexit方法来退出线程。

main方法执行过程中创建的对象直到退出线程时才会被释放,所以在长期存活的线程中应创建多个自动释放池来更频繁地释放对象,以便防止应用程序的内存占用过大,从而导致性能问题。

线程同步

使用多线程编程时,如果多个线程试图同时使用或者修改相同的资源,就会导致数据读写出错。为了避免这个问题,可以使用锁来同步多个线程对资源的访问。

NSLock

NSLock是一个互斥锁,在某个线程使用NSLock加锁时,该线程会持有这个NSLock。当另一个线程试图获取这个NSLock时,该线程会被阻塞,直到NSLock被释放。

- (void)threadA
{
  [myLock lock];
  count = 10;
  [myLock unlock];
}

- (void)threadB
{
  [myLock lock];
  count = 20;
  [myLock unlock];
}

NSRecursiveLock

NSRecursiveLock是一个递归锁,递归锁是互斥锁的一种变体,递归锁允许单个线程在释放它之前多次获取锁。其他线程会一直处于阻塞状态,直到锁的持有者释放该锁的次数与获取它的次数相同时。递归锁主要在递归调用方法期间使用,但是也可能在多个方法需要分别获取锁的情况下使用。

- (void)threadA
{
  [self doSomething];
}

- (void)doSomething
{
  [recursiveLock lock];
  [self doSomething];
  [recursiveLock unlock];
}

pthread_mutex_t

pthread_mutex_t是使用基于 C 语言的 POSIX API 创建的互斥锁。

pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
pthread_mutex_lock(&mutex);
// Do som work
pthread_mutex_unlock(&mutex);

OSSpinLock

OSSpinLock是一个自旋锁,自旋锁反复轮询其锁条件,直到该条件成立。自旋锁最常用于预期等待锁的时间较短的操作。在这些情况下,轮询通常比阻塞线程更有效,后者涉及上下文切换和线程数据结构的更新。

OSSpinLock是有 Bug 的,如果一个低优先级的线程获得锁并访问共享资源,与此同时,一个高优先级的线程也尝试获得这个锁。由于内核的线程调度算法是根据线程优先级来选择调度哪个线程去执行的,低优先级线程无法与高优先级线程争夺 CPU 时间,所以高优先级线程会抢占 CPU 时间,从而导致低优先级线程不能完成任务,低优先级线程也就无法释放 lock。这样,高优先级线程就会始终处于 spin lock 的忙等状态,并一直占用 CPU 时间。

NSCondition

NSCondition是一种特俗类型的锁,可以使用它来同步操作的执行顺序。等待条件的线程将一直处于阻塞状态,直到另一个线程发送信号给该条件。

NSCondition通常被用来解决数据生产者和数据消费者之间的数据同步问题。

- (void)threadA
{
  [cocoaCondition lock];

  while (timeToDoWork <= 0)
  {   
    [cocoaCondition wait];
  }
  
  timeToDoWork--;

  // Do real work here.

  [cocoaCondition unlock];
}


- (void)threadB
{
  [cocoaCondition lock];
  timeToDoWork++;
  [cocoaCondition signal];
  [cocoaCondition unlock];
}

线程之间的通信方式

  • 直接传递消息:使用NSObjectperformSelector: onThread:系列方法直接在其他线程上执行某个方法;
  • 共享变量:使用共享变量来传递信息时,必须使用锁或者其他同步机制以确保正确访问共享变量;
  • 条件:生产数据的线程可以使用条件来控制消费数据的线程在何时消费数据;
  • runloop source:在一个线程使用自定义 runloop source 传递事件到另一个线程上去执行;

iOS系统提供的多线程技术各自的特点是什么?

  • GCD:通常使用 GCD 来实现简单的同步和异步操作,以及多读单写;
  • NSOperation 和 NSOperationQueue:可以给任务添加依赖,控制任务的执行状态,设置最大并发数量;
  • NSThread:用于实现常驻线程。

Runloop

什么是Runloop?

Runloop 是一个对象,其内部维护着一个事件循环(Event Loop),Runloop 对象使用这个事件循环来管理线程需要处理的事件。

事件循环在没有事件需要处理时,会将操作系统的运行级别由用户态切换到内核态,以便让线程进入休眠状态。在有事件需要处理时,会将操作系统的运行级别由内核态切换到用户态,以便立即唤醒线程去处理事件。

操作系统在用户态运行的是用户程序,在内核态运行的是操作系统程序。

Runloop相关的类

苹果官方提供了NSRunLoopCFRunLoopRef这两种类型的 runloop 对象。NSRunLoop是线程不安全的,而CFRunLoopRef是线程安全的。

每个线程都有一个与之关联的 runloop 对象,可以使用NSRunLoopCFRunLoopRef类提供的currentRunLoop方法来获取当前线程关联的 runloop 对象,不需要我们手动创建。在应用程序启动完毕后,系统会自动运行主线程的 runloop。而由我们自己创建的辅助线程,需要我们手动运行其关联的 runloop。

runloop 使用定时器源(NSTimerCFRunLoopTimerRef)和输入源(CFRunLoopSourceRef)来帮助接收传递给线程的事件。有两种类型的输入源,一种是基于端口的输入源,另一种是非基于端口的自定义输入源。基于端口的输入源会接收由内核传递给线程的系统事件,非基于端口的自定义输入源会接收从另一线程传递给该线程的事件。NSObject类的performSelector系列方法是一种自定义输入源。

runloop 必须以特定的模式(NSRunLoopModeCFRunLoopMode)运行,runloop 在运行过程中只会处理与运行模式关联的定时器源和输入源中接收到的事件。未与当前运行模式关联的定时器源和输入源中接收的事件会被保留,直到 runloop 以与它们关联的模式运行时,才会处理这些事件。官方公开了defaultcommonevent trackingmodalconnection这几种模式,应用程序的主线程通常以default模式运行其 runloop。当用户触摸屏幕时,会切换为event tracking模式运行其 runloop。commom模式则是defaultevent trackingmodal模式的集合,还可以自定义 runloop 运行模式。

可以给 runloop 注册观察者(CFRunLoopObserverRef),以便其他对象可以监听 runloop 当前状态的变化。同样,只有与 runloop 当前运行模式关联的观察者才会收到通知。

Runloop的事件循环机制

线程进入 runloop 后,runloop 的内部逻辑为:

  1. 通知观察者已经进入 runloop;
  2. 进入一个do-while循环;
  3. 通知观察者即将处理定时器源接收的事件;
  4. 通知观察者即将处理自定义输入源(非基于端口)接收的事件;
  5. 处理自定义输入源(非基于端口)接收的事件;
  6. 如果基于端口的输入源有接收到系统事件,则立即跳到第10步;否则,继续执行第7步;
  7. 通知观察者线程即将进入休眠状态;
  8. 将线程置于休眠状态,直到发生以下事件:
    • 基于端口的输入源接收到系统事件;
    • 定时器源接收到定时器事件;
    • runloop 运行超时;
    • runloop 被手动显式唤醒;(从另一个线程传递事件给自定义输入源时,需要手动显式唤醒 runloop)
  9. 通知观察者线程刚被唤醒;
  10. 处理事件:
    • 如果定时器源有接收到定时器事件,则处理定时器事件;
    • 如果基于端口的输入源有接收到系统事件,则处理系统事件;
  11. 如果 runloop 运行超时,或者被强制停止,或者不存在输入源、定时器源和观察者了,则终止do-while循环;否则,继续do-while循环。
  12. 通知观察者已经退出 runloop。

Runloop与事件响应

由开发者所编写的代码通常是用来响应硬件事件的。用户点击屏幕后,系统内核会通过 mach port 传递触摸事件给当前处于前台运行的应用程序,应用程序主线程的 runloop 用于监听内核事件的 source1 (基于端口的输入源)会触发回调,source1 回调内部会触发 source0(不是基于端口的自定义输入源)回调,source0 回调内部会将这个触摸事件分发给第一响应者,由第一响应者去响应这个触摸事件。

Runloop实际运用

  • 使用 runloop 实现一个常驻线程。
  • 主线程的 runloop 一般是以 default 模式运行的,当发生屏幕触摸事件时,会切换到 event tracking 模式运行。将NSTimer与 runloop 的 common 模式关联起来,就能够在滑动 scrollview 时继续触发定时器事件。
  • 使用NSObjectperfromSelector:onThread:方法来在启动了 runloop 的子线程中执行某个方法。

使用NSTimer时需要注意什么?

创建NSTimer

NSTimer是一个定时器源,只有将NSTimer加入到线程的runloop中,并且将NSTimer与runloop当前运行模式相关联,才会触发定时器事件。

使用timerWith...方法创建的NSTimer,需要我们手动将其与特定的runloop运行模式关联,并添加到线程的runloop中。

使用scheduledTimer...方法创建的NSTimer,会自动将其与default模式关联,并添加到线程的runloop中。

使用scheduledTimer...方法创建的NSTimer,在用户滑动屏幕期间,不会触发定时器事件。这是因为用户滑动屏幕时,runloop会切换到event tracking模式运行,而使用scheduledTimer...方法创建的NSTimer并没有与该模式关联,runloop也就不会处理定时器源中的定时器事件。定时器事件会被一直保留,直到runloop以default模式运行时,runloop才会处理定时器事件。在这种场景下,应该使用timerWith...方法创建NSTimer,并将其与common模式关联,这样就能在用户滑动屏幕期间触发NSTimer了。

NSTimer触发时刻

NSTimer并不一定会准确地在我们指定的时间点生成定时器事件并触发。假设在0分0秒这个时刻创建一个每隔1s就重复生成一个定时器事件的NSTimer,如果这个NSTimer完全按照我们指定的时间点生成事件并触发的话,那么它会在0分1秒0分2秒0分3秒0分4秒0分5秒...时生成一个定时器事件并触发。但是,如果NSTimer0分1秒这个时刻生成一个定时器事件时,当前线程的runloop并非空闲,而是正在处理其他任务,那么runloop会在该其他任务处理完毕后,才会触发这个定时器事件。如果其他任务耗时2.5秒,那么在我们指定的0分2秒0分3秒时刻,NSTimer是不会生成定时器事件的。当在0分1秒时刻所生成的定时器事件被处理完毕之后,NSTimer会接着在0分4秒0分5秒0分6秒0分7秒...时间点生成定时器事件并触发。

CADisplayLink 的触发时刻与NSTimer类似,而dispatch_source_t定时器相对来说会更准确,因为它是由系统内核直接触发的,而不是由 runloop 触发的。

NSTimer引发的内存泄露

创建NSTimer对象时,NSTimer对象会强引用外界传递的target对象。将NSTimer对象添加到 runloop 中时,runloop 又会强引用这个NSTimer对象。如果NSTimer设置为只触发一次,那么 runloop 会在NSTimer触发一次后自动调用NSTimerinvalidate方法来销毁该NSTimer(销毁NSTimer时,会释放对target的强引用)。而如果NSTimer设置为重复触发,在NSTimer使用结束后,需要我们在创建NSTimer的线程中手动调用NSTimerinvalidate方法来销毁该NSTimer。如果只是将self.timer指向nil,而不手动调用invalidate方法去销毁NSTimer的话,runloop 就还是会强引用这个NSTimer,该NSTimer引用的target对象就无法被释放,从而产生内存泄露。

Runtime

实例对象,类对象,元类对象

实例对象(instance object)是由其所属的类实例化而来,而类本身也是一种对象,叫做类对象(class object)。类对象中存储着实例对象的成员变量、方法以及其所遵循的协议,它是由编译器在程序编译期间所生成的用于描述实例对象的对象,是一个单例。在程序运行时,Objective-C 的运行时系统会根据类对象来生成实例对象。类对象虽然没有自己的成员变量,但可以有自己的方法,所以还需要一个用于描述类对象的对象,这个对象就是元类对象(meta class object),其存储着类对象所定义的方法。

struct objc_object  {
    isa_t isa; // 其中存储着指向类对象或者元类对象的指针
};

union isa_t {
    Class cls; // 指向类对象或者元类对象的指针
    uintptr_t bits;
}

实例对象是一个objc_object结构体,其包含一个共用体isa_tisa_t中包含一个cls指针,cls指针指向其类对象。

struct objc_class : objc_object {
    Class super_class; // 指向父类类对象的指针
    cache_t cache;     // 存储着已经使用过一次的方法
    class_data_bits_t bits; // 存储着类名称、实例方法、类方法、协议、属性、成员变量
};

类对象是一个objc_class结构体,objc_class继承自objc_object,其cls指针指向元类对象,其super_class指针指向父类类对象。cache_t是一个方法缓存,其包含一个存储着bucket_t_buckets数组,bucket_t中封装有方法选择器sel和函数指针imp。在类对象被加载之前,class_data_bits_t是一个class_ro_t结构体,class_ro_t中存储的是编译期就已经确定的类名称、属性列表、成员变量列表、方法列表和协议列表。在类对象被加载之后,class_data_bits_t是一个class_rw_t结构体,class_rw_t包含一个class_rw_ext_t结构体,class_rw_ext_t中包含着class_ro_t、方法列表数组、属性列表数组和协议列表数组,方法列表数组中存储的是类对象和其所有 category 的方法列表,属性列表数组中存储的是类对象和其所有 category 的属性列表,协议列表数组中存储的是类对象和其所有 category 的协议列表。

在加载类对象的时候,会将编译期确定的class_ro_t保存到class_rw_ext_t中,并将class_ro_t中的方法、属性和协议分别拷贝到class_rw_ext_t的方法列表、属性列表和协议列表中。在加载 category 的时候,会将 category 中的方法、属性和协议分别插入到class_rw_ext_t的方法列表、属性列表和协议列表的最前面。另外,在程序运行时动态添加的方法、属性和协议也会分别被插入到在class_rw_ext_t的方法列表、属性列表和协议列表的最前面。

元类对象也是一个objc_class结构体,需要注意的是,元类对象的cls指针都是指向根元类对象(NSObject元类对象)的,根元类对象的cls指针指向其自身。另外,根元类对象的super_class指针是指向根类对象(NSObject类对象)的。当调用某个类方法时,如果在元类对象和根元类对象的方法列表中都没有查找到这个类方法,那么就会继续到根类对象的方法列表中查找有没有同名的实例方法,如果有,则会调用这个实例方法。

实例、类和元类之间的关系.png

协议对象

struct protocol_t : objc_object {
    // 协议名称
    const char *mangleName;
    // 
    struct protocol_list_t *protocols; 
    // 实例方法列表
    method_list_t *instanceMethods;
    // 类方法列表
    method_list_t *classMethods;
    // 可选的实例方法列表
    method_list_t *optionalInstanceMethods;
    // 可选的类方法列表
    method_list_t *optionalClassMethods;
    // 实例属性列表
    property_list_t *instanceProperties;
    // 还有其他参数,这里未列出
    ......
}

协议对象是一个protocol_t结构体,其继承自objc_object结构体。

category

struct category_t {
    // 分类的名称
    const char *name;
    // 指向宿主类的指针
    classref_t *cls;
    // 实例方法列表
    struct method_list_t *instanceMethods;
    // 类方法列表
    struct method_list_t *classMethods;
    // 协议列表
    struct protocol_list_t *protocols;
    // 实例属性列表
    struct property_list_t *instanceProperties;
    ...
}

分类是一个category_t结构体,结构体中包含分类的名称、指向其宿主类的指针、实例方法列表、类方法列表、协议列表和属性列表。

在 category 中添加的属性,编译器是不会为属性生成对应的实例变量的。

类、元类和协议的加载过程

dyld 初始化主程序时,会触发 libobjc 动态库的初始化函数_objc_init_objc_init函数会调用runtime_init函数,并向 dyld 注册 image 映射到内存时、image 初始化时和 image 终止时的回调。

runtime_init函数会初始化用于存储类的未加载 category 的unattachedCategories哈希表,以及初始化用于存储类和元类的allocatedClasses集合。

由于此时主程序和动态库已经映射到内存中了,所以 dyld 会立即调用 libobjc 的map_images回调函数,并将所有已加载的使用了 objc 的 image 的路径和 header 信息传递给 libobjc 动态库。

map_images函数会调用map_images_nolock函数,map_images_nolock函数会做以下事情:

  • 读取 dyld 共享缓存中的 sel 表(使用了 objc 的共享系统动态库的 sel 保存在这个 sel 表中)并保存,初始化用于存储不是共享系统动态库的 image 的 sel 的namedSelectors集合;
  • 设置线程绑定AutoreleasePoolPage时所用的 key,初始化SideTablesMap,初始化AssociationsHashMap
  • 调用_read_images函数来读取所有已经映射到内存的 image。

_read_images函数主要做了以下事情:

  • 初始化用于存储不在共享缓存中的命名类(在.h.m文件中声明的类)的哈希表gdb_objc_realized_classes,其 key 是类名称,value 是类;(在运行时使用objc_allocateClassPair(superClass,className)函数动态创建的类会被添加到allocatedClasses集合中)
  • 遍历 image header,如果当前 image 不是共享系统动态库,则会读取 image 的所有 sel 指针。遍历每个 sel 指针,如果指针指向的 sel 不在共享缓存中,则将 sel 添加到namedSelectors集合中。最后,修复 sel 指针,保证相同的 sel 指针指向的 sel 是一致的。
  • 遍历 image header,如果当前 image 不是共享系统动态库,则会读取 image 的类列表,类列表中包含所有指向命名类的指针。遍历类列表,并调用readClass函数来读取类。如果当前类的父类是弱链接的(类的父类是在高本版系统上实现的,而当前低版本系统还没有实现这个父类),则会将当前类的superclass指针指向nil;如果不是,则会将类添加到gdb_objc_realized_classes哈希表中,还会将类和类的元类添加到allocatedClasses集合中。
  • 遍历 image header,初始化用于存储协议对象的protocol_map哈希表,如果当前 image 不是共享系统动态库,则读取协议列表。然后遍历协议列表,并调用readProtocol函数来读取协议。readProtocol函数内部会首先根据当前协议对象的名称到 dyld 共享缓存和protocol_map哈希表中查找是否存在同名的协议对象,如果不存在,则会初始化当前协议对象的isa,然后将当前协议对象添加到protocol_map哈希表中。
  • 遍历 image header,如果当前 image 不是共享系统动态库,则会读取 image 的协议指针,并修复协议指针,保证名称相同的协议指针指向的协议对象是一致的。
  • 遍历 image header,读取 image 的非懒加载类列表,非懒加载类列表中包含所有指向非懒加载命名类的指针。遍历非懒加载类列表,将非懒加载类和其元类添加到allocatedClasses集合中,并调用realizeClassWithoutSwift函数去实现非懒加载类。(疑问:共享缓存中的 image 的懒加载类和其元类在被实现后,并没有被添加到allocatedClasses集合中。之前读取类时,也只是将不在共享缓存中的 image 的所有类和元类添加到了allocatedClasses集合中。详细信息,可以参看objc_getClass函数和isKnownClass函数。

realizeClassWithoutSwift函数主要做了以下事情:

  • 判断类对象是否已经被实现,如果已经被实现了,则直接返回。
  • 读取类对象的bitsclass_data_bits_t)中保存的ro数据(class_ro_t),为rwclass_rw_t)分配内存空间,将ro数据保存到rwrw_extclass_rw_ext_t)中,并将rw保存到类对象的bits中。
  • 递归调用realizeClassWithoutSwift函数实现父类类对象。
  • 递归调用realizeClassWithoutSwift函数实现元类对象。
  • ro中的方法列表、属性列表和协议列表分别添加到rw_ext的方法列表数组、属性列表数组和协议列表数组中。
  • unattachedCategories中取出类对象的 category 列表,如果 category 列表不为空,则遍历 category 列表。如果类对象是一个元类对象,则将 category 中的类方法列表、属性列表和协议列表分别插入到元类对象的类方法列表数组、属性列表数组和协议列表数组的最前面;如果不是,则将 category 中的实例方法列表、属性列表和协议列表分别插入到类对象的实例方法列表数组、属性列表数组和协议列表数组的最前面。(此时是还没有加载 category 的,这一步是在实现懒加载类时用来处理懒加载类的 category 的,非懒加载类的 category 是在首次调用load_images函数时添加到宿主类的

懒加载的类会在首次使用时被实现。使用类对象时,会调用objc_getClass函数来查找类对象。objc_getClass函数内部会调用look_up_class函数,look_up_class函数会首先根据类的字符串名称到gdb_objc_realized_classes哈希表中查找类对象,如果不存在,就会到 dyld 共享缓存中去查找。查找到类对象后,会判断当前类对象是否已经实现。如果没有,则会调用realizeClassWithoutSwift函数去实现类对象。

如果类实现了+load方法,那么这个类就是非懒加载类。在实现非懒加载类时,还会递归实现其所有的父类。如果类的 category 实现了+load方法,那么会在调用load_images函数时,在调用 category 的 +load方法之前,先实现 category 的宿主类和其所有的父类。

@selector()的原理就是根据方法名称到 dyld 共享缓存和namedSelectors集合中查找 sel。@protocol()的原理则是根据协议名称到 dyld 共享缓存和protocol_map哈希表中查找协议对象。

category 的加载过程

在 dyld 初始化主程序的过程中,在调用某个 image 的初始化函数后,会调用 libobjc 的load_images回调函数。

load_images函数会做以下事情:

  • 首先判断 category 是否已经被加载。如果没有,则会调用loadAllCategories函数来加载所有 category。loadAllCategories函数内部会遍历所有 image 的 header,并调用load_categories_nolock函数来加载每个 image 的 category。load_categories_nolock函数内部会遍历所有 category,并判断 category 的宿主类是否已经实现,如果宿主类已实现,则将每个 category 的方法列表中的方法按照其 sel 地址从小到大排序,然后将每个 category 的方法列表、属性列表和协议列表分别插入到宿主类的方法列表数组、属性列表数组和协议列表数组的最前面(最先编译的 category 会被最先添加);如果宿主类还没实现,则将 category 添加到unattachedCategories中存储的宿主类的 category 列表中。
  • 调用prepare_load_methods函数,该函数内部首先会获取当前 image 的非懒加载类列表,遍历非懒加载类列表,并调用schedule_class_load函数到非懒加载类对象和其所有父类类对象的class_ro_t中去查找其+load方法的函数指针。接着,获取当前 image 的非懒加载 category 列表,遍历非懒加载 category 列表,并调用realizeClassWithoutSwift函数去实现当前 category 的宿主类和其所有父类类对象,并将 category 中的实例方法列表、属性列表和协议列表分别插入到类对象的实例方法列表数组、属性列表数组和协议列表数组的最前面。然后调用add_category_to_loadable_list函数到 category 对象的类方法列表中去查找其+load方法的函数指针。
  • 调用call_load_methods函数,该函数内部会先调用objc_autoreleasePoolPush函数,然后依次调用之前查找到的所有的类的+load方法,再然后依次调用之前查找到的所有的 category 的+load方法,最后,会调用objc_autoreleasePoolPop函数。

schedule_class_load函数内部逻辑如下:

  • 递归调用schedule_class_load函数去查找父类对象的+load方法的函数指针。
  • 到类对象对应的元类对象的方法列表中去查找+load方法的函数指针。
  • 如果类对象实现了+load方法,则使用类对象和其+load方法的 imp 函数指针构建一个loadable_class对象,并将这个loadable_class对象添加到一个数组中去。

消息传递

编译器在编译时会将消息表达式转换为objc_msgSend函数的调用(如果方法有返回值,则会转换为objc_msgSend_stret函数调用),并向该函数传递消息接收者对象、方法选择器以及方法的参数。由于方法的参数个数和参数类型是不确定的,所以objc_msgSend函数是使用汇编语言实现的。

objc_msgSend函数内部会首先判断消息接收者对象是否为nil,如果是,则直接返回。如果不是,则获取消息接收者对象对应的类对象,然后进入缓存查找流程。缓存查找流程会遍历cache_t中的_buckets数组,将每个bucket_t的 sel 与 传递的 sel 进行比较。如果存在相匹配的bucket_t,则使用bucket_t中的函数指针 imp 去调用方法;如果不存在相匹配的bucket_t,则会进入慢速查找流程。

慢速查找流程会调用lookUpImpOrForward函数去查找给定 sel 对应的方法。该函数首先会判断是否需要到缓存中查找,如果需要(因为之前缓存查找已经失败了,所以这里是不需要的。之所以有这个逻辑,是因为后面动态解析方法的时候,会直接调用lookUpImpOrForward函数去查找+resolveInstanceMethod:类方法),则调用cache_getImp函数到cache_t缓存中去查找 sel 对应的函数指针。如果缓存命中,则直接返回这个方法的 imp 函数指针。否则,会判断类对象是否已经初始化。如果还没有初始化,则会先调用其所有的父类类对象的+initialize方法,然后再调用类对象的+initialize方法。之后,遍历类对象的方法列表(类对象的方法列表中存储的是类对象和其所有 category 各自的方法列表,且每个方法列表中的方法是按照 sel 地址从小到大排序的),并使用二分查找到每个方法列表中查找其 sel 的地址与给定 sel 的地址相同的方法。如果不存在相匹配的方法,则会到父类类对象的cache_t缓存中去查找是否存在对应的方法。如果不存在,则会到父类类对象的方法列表中去查找。如果还是不存在,则会沿着类的继承链一直查找。当找到对应的方法时,会把方法添加到当前类对象cache_t缓存中,然后返回对应的函数指针。

如果最终没有找到对应的函数指针,则会调用resolveMethod_locked函数去动态解析方法。

如果动态解析方法失败,则会进入消息转发流程。

使用super调用方法时,会被转换为objc_msgSendSuper函数的调用,但是消息接收者还是self对象本身。只是在查找方法时,会跳过当前类对象,直接从父类类对象开始查找。

动态方法解析

resolveMethod_locked函数会调用resolveInstanceMethod函数,resolveInstanceMethod函数内部首先会调用lookUpImpOrNil函数到元类对象和其所有的父元类对象中去查找+resolveInstanceMethod:类方法的函数指针,如果不存在,则会直接返回。如果存在,则会将+resolveInstanceMethod:类方法保存到当前元类对象的方法缓存中,并使用objc_msgSend函数去调用+resolveInstanceMethod:类方法去动态添加 sel 对应的方法。如果添加成功,则会调用lookUpImpOrForward函数去查找 sel 对应的函数指针,并返回这个函数指针。

消息转发

方法查找和动态方法解析都失败后,lookUpImpOrForward函数会返回_objc_msgForward_impcache函数的 imp 指针,然后进入消息转发流程。

_objc_msgForward_impcache函数也是用汇编语言实现的,它会调用当前对象的-forwardingTargetForSelector:方法询问是否存在当前消息的备用接收者对象。如果存在,则使用objc_msgSend函数去调用备用接收者对象的同名方法,消息转发完成。如果不存在,则会调用当前对象的-methodSignatureForSelector:方法获取该方法的方法签名。如果存在方法签名,则会根据方法签名创建一个NSInvocation对象,然后调用当前对象的-forwardInvocation:方法并将NSInvocation对象传递给它,在-forwardInvocation:方法实现中,我们可以让合适的对象去调用其同名的方法,消息转发机制完成。

Method-Swizzling

Method-Swizzling是使用method_exchangeImplementations函数来实现的,它将两个方法选择器所对应的方法实现给互相交换了,结果就是,在调用方法A时,实际上调用的是方法B。而在调用方法B时,实际上调用的方法A。

能否向编译后的类中添加实例变量?

不能。由编译器生成的类对象是不能被修改的。

能否向动态添加的类中添加实例变量?

可以。在向运行时系统注册新类之前,可以添加实例变量。

Objective-C中的反射机制

反射机制,也叫内省机制,可用于检验实例对象所属的类、所遵循的协议和能够响应的方法,并可以使用字符串来获取同名称的类对象、协议和方法。

+ (Class)class;                           // 返回本类类型
+ (Class)superclass;                      // 返回父类类型
+ (BOOL)isSubclassOfClass:(Class)aClass;  // 是否是某类型的子类
- (BOOL)isKindOfClass:(Class)aClass;      // 是否是某一种类
- (BOOL)isMemberOfClass:(Class)aClass;    // 是否是某一成员类

// 是否实现了某协议 
- (BOOL)conformsToProtocol:(Protocol *)aProtocol; 

// 是否实现了某方法
- (BOOL)respondsToSelector:(SEL)aSelector;    

// SEL和字符串转换
FOUNDATION_EXPORT NSString *NSStringFromSelector(SEL aSelector);
FOUNDATION_EXPORT SEL NSSelectorFromString(NSString *aSelectorName);

// Class和字符串转换
FOUNDATION_EXPORT NSString *NSStringFromClass(Class aClass);
FOUNDATION_EXPORT Class __nullable NSClassFromString(NSString *aClassName);

// Protocol和字符串转换
FOUNDATION_EXPORT NSString *NSStringFromProtocol(Protocol *proto) NS_AVAILABLE(10_5, 2_0);
FOUNDATION_EXPORT Protocol * __nullable NSProtocolFromString(NSString *namestr) NS_AVAILABLE(10_5, 2_0);

isKindOfClass和isMemberOfClass方法

-(BOOL)isMemberOfClass:方法只是将指定的类与实例对象的所属类进行比较,而-(BOOL)isKindOfClass:方法不仅将指定的类与实例对象的所属类进行比较,还会与实例对象所属类的父类进行比较。

KVC

KVC是一种间接访问对象属性的机制,兼容KVC的对象可以使用统一的接口和字符串参数来访问其属性。

如果对象的属性值本身也是一个可变对象,则可以使用keyPath来直接读写其属性值对象的属性。

Getter的查找方式

调用valueForKey:方法来获取对象的属性的值时,会首先在对象的方法列表中依次查找是否存在名称为get<Key><key>is<Key>或者_<key>的方法。如果存在其中一个,则直接调用该方法来获取属性的值。如果获取的值是一个对象类型,则直接返回该属性值。如果值是NSNumber所支持的标量类型,则将其存储在一个NSNumber实例对象中,并返回该NSNumber实例对象。如果值是NSNumber不支持的标量类型,则将其存储在NSValue实例对象中,并返回该NSValue实例对象。(class_getInstanceMethod函数查找实例方法。)

如果不存在这些方法,并且对象类是可以直接访问实例变量的,则继续依次查找名称为_<key>_is<Key><key>或者is<Key>的实例变量。如果存在其中一个,则直接获取该实例变量的值。如果获取的值是一个对象类型,则直接返回该属性值。如果值是NSNumber所支持的标量类型,则将其存储在一个NSNumber实例对象中,并返回该NSNumber实例对象。如果值是NSNumber不支持的标量类型,则将其存储在NSValue实例对象中,并返回该NSValue实例对象。(class_getInstanceVariable函数查找实例变量。)

如果以上查找都失败了,则会调用对象的valueForUndefinedKey:方法,该方法的默认实现会终止运行应用程序。

Setter的查找方式

调用setValue:forKey:方法来为对象的属性赋值时,会首先在对象的方法列表中依次查找名称为set<Key>:或者_set<Key>的方法。如果存在其中一个方法,则使用传递的值来为属性赋值。

如果不存在这些方法,并且对象的类可以直接访问实例变量,则依次查找名称为_<key>_is<Key><key>或者is<Key>的实例变量。如果存在其中一个实例变量,则使用传递的值来为属性赋值。

在设置值时,如果将nil对象赋值给非对象属性,会调用对象的setNilValueForKey:方法,该方法的默认实现会终止运行应用程序。

如果以上查找都失败了,则调用对象的setValue:forUndefinedKey:方法,该方法的默认实现会终止运行应用程序。

KVO

KVO,全称Key-Value Observing,是一种键值观察技术,其允许对象被告知其他对象的特定属性的更改,是Objective-C对观察者设计模式的实现,其是基于KVC实现的。

KVO的实现原理是 isa-swizzling 。

当给对象的属性注册观察者时,运行时系统会创建一个继承自被观察对象所属类的中间类,并将被观察对象的isa指针指向这个中间类,这样被观察对象实际上就成为了此中间类的一个实例。中间类重写了被观察属性的set方法以便在被观察属性的值改变时发出更改通知,当我们更改对象的属性值时,实际上调用的是中间类的set方法。(objc_allocateClassPair函数创建一个新类,class_addMethod函数将重写的setter动态绑定到setter方法选择器。)

同时,中间类还重写了class方法,该方法还是返回原本的类。因此,在判断被观察对象所属的类时,不应使用isa指针,而应使用class方法。

通过KVC更改属性的值能否触发KVO?

会触发。使用KVC更改属性的值时,会调用属性的set方法。

直接给与属性关联的成员变量赋值时是否会触发KVO?

不会触发。需要手动触发KVO。

[self willChangeValueForKey:@"name"];
_name = @"tom";
[self didChangeValueForKey:@"name"];

如何关闭KVO的自动触发

重写类的automaticallyNotifiesObserversForKey:类方法,对需要关闭自动触发KVO的属性返回NO

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
{
      if ([key isEqualToString:@"name"]) 
    {
        return NO;
    }else{
        return [super automaticallyNotifiesObserversForKey:key];
    }
}

- (void)setName:(NSString *)name
{
    if (_name!=name) {

        [self willChangeValueForKey:@"name"];
        _name = name;
        [self didChangeValueForKey:@"name"];
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容