+(void)load 和 +(void)initialize 方法有什么用处?
在应用程序启动过程中,向运行时系统注册Objective-C类时,会调用类的load
方法。父类的load
方法会在子类的load
方法之前被调用,类的load
方法会在其category的load
方法之前被调用。load
方法是线程安全的,因为其内部使用了锁。load
方法的一个常见使用场景是我们通常会在该方法中实现方法交换。
+ (void)load {
Method originalFunc = class_getInstanceMethod([self class], @selector(originalFunc));
Method swizzledFunc = class_getInstanceMethod([self class], @selector(swizzledFunc));
method_exchangeImplementations(originalFunc, swizzledFunc);
}
在类或者其子类首次被使用时(调用该类的某个方法时),会调用类的initialize
方法。在使用子类时,不仅会调用子类的initialize
方法,还会调用父类的initialize
方法,父类的initialize
方法调用先于子类的initialize
方法调用,类的catergory的initialize
方法会覆盖掉该类的initialize
方法。initialize
方法也是线程安全的,其主要用来对一些不方便在应用程序启动过程中初始化的对象进行赋值操作。
static int someNumber = 0; // int类型可以在编译期赋值
static NSMutableArray *someObjects;
+ (void)initialize {
if (self == [Parent class]) {
// 不方便编译期赋值的对象在这里赋值
someObjects = [[NSMutableArray alloc] init];
}
}
沙盒机制
安装应用程序到iOS系统上时,系统会为每个应用程序开辟一个与之对应的存储区域,这个存储区域被称为沙盒。所有的非代码文件都保存在沙盒中,例如图片、 音频、属性列表和文本文件等。每个沙盒之间是相互独立的,应用程序只能在与其对应的沙盒中读写文件,不能直接访问其他应用程序的沙盒。要访问其他应用程序的沙盒,必须先请求访问权限(如访问系统应用程序“照片”和“通讯录”时,必须先请求权限)。
应用程序的沙盒中包括Documents、Library(内有Caches和Preferences目录)和tmp三个目录:
- Documents:运行应用程序时生成的一些需要长久保存的数据(例如游戏进度存档、应用程序个人设置等)会保存在此目录中。iOS系统在进行iCloud备份时,会备份此目录下的数据。
- Library/Caches:从远程下载的文件和图片等数据保存在该目录中,该目录下的数据不会被自动删除,需要我们手动进行清理。iOS系统在进行iCloud备份时,不会备份该目录下的数据。该目录主要用于保存运行应用程序时生成的需要长期使用的,体积较大且不需要备份的数据。
- Library/Preferences:保存通过“偏好设置”写入的数据。iOS系统在进行iCloud备份时,会备份此目录下的数据。该目录由系统自动管理,通常用来存储一些基本的应用程序配置信息,例如是否自动登录。
- tmp:保存运行应用程序时产生的一些临时数据,应用程序退出、 系统磁盘空间不足或者手机重启时,会自动清除该目录中的数据,无需我们手动清除。iOS系统在进行iCloud备份时,也不会备份该目录下的数据。
数据持久化的几种方式
将数据存储到本地可以在重启设备或者应用程序时避免重要数据的丢失,Cocoa框架提供了以下几种数据持久化方式:
- 直接写入文件
- 偏好设置
- 归档
- SQLite数据库
- Core Data
Cocoa框架为NSString
、NSArray
、NSDictionary
、NSData
类提供了writeToFile:atomically:
和writeToURL:atomically:
方法来将数据直接写入文件中。这两个方法只能存储NSString
、NSArray
、NSDictionary
、NSData
、NSDate
、NSNumber
类型的数据,不支持存储自定义对象。通常将NSArray
、NSDictionary
类型的数据存储为 plist (属性列表)文件,NSString
类型的数据存储为 txt 文件,图片和音视频的NSData
类型的二进制数据被写成相应编码格式的文件。这种方式适用于存储小型并且很少需要更改的数据,例如省市列表和图片之类的数据。
偏好设置是专门用来保存应用程序的配置信息的,例如字体大小、是否自动登录等。偏好设置本质上是以plist(属性列表)文件的形式存储的,数据被保存在应用程序沙盒 Library/Preferences 目录下的以此应用包名来命名的 plist 文件中。
对模型对象进行归档可以轻松将复杂对象写入文件中,然后再从文件中读取它们。要对模型对象归档,则模型对象类中声明的属性必须是标量数据类型或者是遵循并实现了NSCoding
协议的自定义对象类型。
SQLite是基于C语言开发的数据库,提供C语言API来对数据库执行读写操作,使用起来较为繁琐。其适合存储大量且经常需要更改的数据内容。
Core Data封装了数据库的操作过程以及数据库中的数据和OC对象的转换过程。使用Core Data来存储数据时,不需要手动编写任何SQL语句。
对MVC和MVVM设计模式的理解
MVC(Model - View - Controller)即模型 - 视图 - 控制器。
- 模型对象负责封装应用程序的数据,并定义处理数据的逻辑和运算。
- 视图对象负责展示数据。
- 控制器对象负责协调模型对象和视图对象之间的通信,响应用户交互。
不允许模型和视图直接通信,必须通过控制器来协调视图和模型之间的通信。控制器对模型和视图的访问是不受限的,但不允许模型和视图直接访问控制器,模型通过KVO或Notification来通知控制器,视图通过delegate和target-action来通知控制器。
MVC的不足之处:控制器不仅要协调模型和视图之间的通信,还要管理视图层次结构和响应用户交互,这样就会导致控制器中的代码非常臃肿,不易于管理和维护。
MVVM(Model - View - ViewModel)即模型 - 视图 - 视图模型,其衍生于MVC。
- 视图负责数据的展示。
- 模型负责封装数据,存储数据。
- 视图模型封装了响应用户交互事件的逻辑、视图显示的逻辑、发起网络请求的逻辑以及其他逻辑。
不允许视图直接与模型通信,而是使用视图模型来协调视图与模型之间的通信。允许视图直接访问视图模型,但不允许视图模型直接访问视图,允许视图模型使用回调来通知视图。允许视图模型直接访问模型,但不允许模型直接访问视图模型,允许模型使用回调来通知视图模型。
MVVM的弊端:数据绑定和数据转化需要花费更多的成本。
项目中网络层是如何做安全处理的?
移动端应用程序安全问题一般分为以下三类:
- 代码安全,包括代码混淆、加密或者应用程序加壳。
- 数据存储安全,主要指在磁盘做数据持久化的时候所做的加密。
- 网络传输安全,指对从客户端传输到服务端的数据进行加密,防止网络世界当中其他节点对数据的窃听。
网络安全相关的算法:
- 对称加密算法,代表算法AES。
- 非对称加密算法,代表算法RSA,ECC。
- 电子签名,用于确认消息发送方的身份。
- 消息摘要生成算法,用于检测消息是否被第三方修改过,例如MD5,SHA。
对于服务端来说,只要满足以下三点就说明收到的请求是安全的:
- 请求有客户端的电子签名,表面请求确实是来自客户端。
- 请求没有被篡改过。
- 请求被某种加密算法加密过,只有客户端和服务端知道如何解密。
对于保证网络传输的安全,有以下几点建议:
- 应尽量使用 HTTPS 协议,HTTPS 协议可以过滤掉大部分的安全问题。
- 不要明文传输账号/密码信息。
- POST请求和GET请求都不安全,使用HTTP时,应该对参数进行加密和签名处理。
- 使用HTTP时,不要使用301跳转,301跳转很容易被劫持而重定向到其他地址。
- 客户端发送的请求都带上Message Authentication Code -- 消息认证码。这样不但能保证请求没有被篡改,还能保证请求确实来自合法客户端。带上消息认证码之后,服务器就可以过滤掉绝大部分的非法请求。
- HTTP请求使用临时密钥。在不具备HTTPS条件或对网络性能要求较高且缺乏HTTPS优化经验的场景下,HTTP的流量也应该使用AES进行加密。AES的密钥可以由客户端来临时生成,不过这个临时的AES key需要使用服务器的公钥进行加密,确保只有自己的服务器才能解开这个请求的信息,当然服务器的response也需要使用同样的AES key进行加密。由于HTTP的应用场景都是由客户端发起,服务器响应,所以这种由客户端单方生成密钥的方式可以一定程度上便捷的保证通信安全。
内存分类和内存分区
内存分类
iOS的内存分为RAM内存和ROM内存:
- RAM:运行内存,CPU可以直接访问,读写速度非常快,但是不能掉电存储。其又分为:
- 静态SRAM,速度快,我们常说的一级缓存,二级缓存就是指它,价格相对要高。
- 动态DRAM,速度相对较慢,需要定期的刷新(充电),我们常说的内存条就是指它,价格相对要低,手机中运行内存也是指它。
- ROM:存储性内存,可以掉电存储,例如SD卡。
内存分区
iOS的内存存储区域分为栈区(stack),堆区(heap),静态/全局区(static),常量区,代码区。
栈区:栈是向低地址扩展的数据结构,是一块连续的内存区域。栈的空间很小,大概1-2M。函数(方法)在执行时,会向系统申请一块栈区内存。函数中的局部变量和参数会存储在栈区,它们由编译器分配和释放。函数执行时分配,执行结束后释放。当栈的剩余空间小于所申请的空间时,会出现异常,并提示栈的溢出。所以大量的局部变量和函数循环调用可能会耗尽栈内存而造成程序崩溃。
堆区:堆是向高地址扩展的数据结构,是不连续的内存区域。堆区用来存储实例对象,一般由程序员自己管理。例如,使用alloc
申请内存,使用free
释放内存。
静态/全局区:初始化的和未初始化的静态变量和全局变量都存储在静态/全局区中,程序结束时由系统释放。
常量区:常量存储在常量区中,程序结束时由系统释放。
代码区:代码区用于存放函数的二进制代码。
堆和栈的区别
- 管理方式:栈由编译器自动管理,无需程序员自己手动控制。堆由程序员自己手动管理,容易产生内存泄漏。
- 申请空间大小:栈的空间只有1-2M,如果申请的空间超过了栈的剩余空间,会提示栈溢出。而堆的大小取决于计算机系统中有效的虚拟内存,所以堆获得的空间比较大。
- 碎片问题:对于堆来说,频繁的new/delete会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来说,由于栈是先进后出的队列,所以不会产生碎片。
- 分配方式:堆都是动态分配的。而栈有两种分配方式:静态分配和动态分配。静态分配是编译器完成的,例如局部变量的分配。动态分配是由
alloc
函数进行分配的,但是栈的动态分配和堆是不同的,栈的动态分配由编译器进行释放,无需程序员手动释放。 - 分配效率:栈是由机器系统提供的数据结构,计算机会在底层堆栈提供支持,分配专门的寄存器存放栈的地址,压栈和出栈都有专门的指令执行,这就决定了栈的效率比较高。而堆是C/C++函数库提供的,其机制很复杂。
Block
block本质上是一个Objective-C对象。
有三种类型的block,分别为NSStackBlock(栈区block)、NSMallocBlock(堆区block)、NSGlobalBlock(全局block)。声明并创建一个block,如果在block中没有访问外部变量,则该block是一个NSGlobalBlock,存储在静态/全局区,对NSGlobalBlock执行copy
操作是无效的;如果在block中有访问外部变量,则该block是一个NSStackBlock,存储在栈区。当block的作用域(栈帧)被释放时,block也会一起被释放。如果想要延迟调用block,则需要对block执行copy
操作以便将block从栈区复制到堆区,从而延长block的生命周期,此时该block是一个NSMallocBlock。
可以在block中访问全局变量、局部变量、实例变量、C++对象以及其他block。
在block中访问局部变量时,局部变量会被捕获为常量(const
)变量。在block内部对其执行更改操作时,编译器会报错。要在block内部对局部变量进行修改,可以使用__block
修饰符来修饰该变量。使用__block
修饰的变量,在block被从栈区复制到堆区时,该变量也会被从栈区复制到堆区。
在block中访问实例变量,block被从栈区复制到堆区时,block会创建对实例变量的强引用。如果在block中通过self
引用来访问实例变量,则block会强引用self
。如果根据值来访问实例变量,则会强引用该实例变量。要覆盖这种行为,可以使用__block
修饰符来修饰该实例变量。
在block被从栈区复制到堆区时,如果需要,该block中访问的任何其他block也将被复制到堆区。
Grand Central Dispatch(GCD)
GCD是异步执行任务的技术之一。该技术将在应用程序中编写的线程管理代码移至系统级别,我们只需要定义要执行的任务并将其添加到合适的调度队列中即可。由于系统会为我们处理所有的线程创建和管理,并可以根据可用资源和当前系统条件动态扩展线程数量,所以系统能够比任何单个应用程序更有效地管理线程。另外,由于底层内核的支持,相比我们自己在应用程序中创建线程,GCD的效率更快。GCD并不是每次都从头开始创建线程,而是使用已驻留在内核中的线程池来节省分配时间的。
Dispatch Queue(调度队列)
添加到调度队列中的任务总是按照先进先出的顺序被调度到合适的线程上运行。
使用dispatch_sync
函数向调度队列中添加一个同步任务,调度队列会将该任务调度到调用dispatch_sync
函数的线程上运行,并且dispatch_sync
函数会等该任务执行完毕后才会返回。使用dispatch_async
函数向调度队列中添加一个异步任务,调度队列会将该任务调度到新线程(并非调用dispatch_async
函数的线程)上运行,并且dispatch_async
函数会在该任务被添加到调度队列后就直接返回。
串行调度队列需要等待前一个任务执行完毕后,才会继续调度下一个任务。并行调度队列不会等待前一个任务执行完毕,就继续开始调度下一个任务。
调度队列相对于其他调度队列并行调度其任务,任务的序列化仅限于单个调度队列中的任务。在选择调度哪些任务时,系统会考虑队列的优先级,并且由系统确定在任何时间点调度队列能够调度的任务的总数。
GCD为每个应用程序都提供了一个主调度队列和四个并行调度队列来供我们直接使用,这些队列对于应用程序来说是全局的。主调度队列是一个串行队列,可以使用该队列将任务调度到应用程序的主线程上运行。四个并行调度队列是通过优先级来区分的,分别为默认、高、低优先级队列和后台运行队列。
还可以根据需要由我们自己手动创建串行和并行调度队列,这些调度队列是被引用计数的数据类型,它们与Cocoa对象的内存管理是一样的。
可以通过挂起队列来暂时阻止其调度任务,并能在之后某个时间点恢复队列来让其继续调度任务。
Dispatch Semaphore(信号量)
如果提交给调度队列的任务会访问某些有限的资源,则可能需要使用信号量来调节可以同时访问该资源的任务数量。在创建信号量时,指定最大可用资源的数量。在访问资源时,首先调用dispatch_semaphore_wait
函数来等待信号量,此时可用资源的数量会减1。如果结果值为负数,该函数会通知内核阻塞当前线程。否则,获取资源并完成要执行的工作。当完成工作并释放资源后,调用dispatch_semaphore_signal
函数发出信号并将可用资源数量加1。如果有任务被阻塞并等待访问资源,它们中的一个随后会被解除阻塞并开始执行。
dispatch_barrier_async 和 dispatch_barrier_sync 栅栏函数
使用栅栏函数向队列中添加的任务只能在前面添加的任务被调度执行完毕后才会被队列调度到合适的线程上执行,这只对并行队列有意义。可以使用栅栏函数将对数据的读写分离(多读单写),即使用dispatch_async
函数向队列中异步添加读取数据的任务,使用dispatch_barrier_sync
函数向队列中同步添加写入数据的任务,这样既提高了效率又保证了数据的安全。
Dispatch Group (调度组)
调度组是阻塞当前线程直到一个或多个异步任务完成执行的一种方式。例如,在当前线程向与调度组相关联的一个或者多个调度队列异步调度用来计算一些数据的任务,然后在当前线程等待调度组以便阻塞当前线程直到这些任务完成执行,再根据它们的计算结果在当前线程执行其他任务。也可以不用阻塞当前线程,而只是为调度组配置一个需要在计算数据的任务完成后才开始执行的任务,调度组会在这些计算数据的任务完成后才通知关联的调度队列调度该任务。
Dispatch Source(调度源)
调度源能够用于协调处理特定底层系统相关的事件,它取代了用于处理系统相关事件的异步回调函数。
调度源是被引用计数的数据类型,GCD支持以下类型的调度源:
- 定时器调度源定期生成通知。
- 信号调度源在UNIX信号到达时通知我们。
- 描述符源通知我们各种基于文件和基于套接字的操作。
- 进程调度源通知我们与进程相关的事件。
- Mach Port调度源通知我们Mach相关的事件。
- 自定义调度源由我们自己定义并触发。
配置调度源时,指定需要监听的系统事件以及用于处理这些事件的调度队列。当监听到事件触发时,调度源会将指定的事件处理程序提交到指定的调度队列执行。
为防止事件在调度队列中积压,调度源实现了一个事件合并策略。如果新事件在旧事件的事件处理程序已经被取出队列并且实际执行之前到达,那么调度源会将新事件数据中的数据与旧事件中的数据合并。根据事件的类型,合并可能会取代旧事件或更新其保存的信息。例如,基于信号的调度源仅提供关于最新信号的信息,但也报告自从最后一次调用事件处理程序以来已传递了多少信号。
在启动调度源后,还可以随时更改调度源的调度队列。如果事件处理程序已经在排队等待处理,它将在前一个队列上执行。但是,在进行更改时到达的其他事件可能会在任一队列中处理。
Operation Queue
操作队列是并行调度队列的同等Cocoa技术,操作队列通常和操作对象一起配合使用。
操作对象是NSOperation
类的实例,用于封装需要并行执行的工作。NSOperation
类本身是一个抽象类,不能直接使用。Foundation框架提供了NSInvocationOperation
和NSBlockOperation
这两个具体的子类来供我们使用,我们也可以自己实现NSOperation
类的子类来完全控制操作的实现(包括更改操作执行的默认方式并报告其状态的功能)。
在将操作对象添加到操作队列之前,可以在操作对象之间建立依赖关系。依赖于其他操作的操作无法被调度到线程上运行,直到它所依赖的所有操作都已完成执行。
对于已经添加到操作队列中的操作,它们的调度顺序首先取决于其是否准备就绪,然后才取决于其相对优先级。是否准备就绪取决于操作对其他操作的依赖性,而优先级是操作对象本身的属性。默认情况下,所有新操作对象都具有“正常”优先级,但可以调用NSOperation
类提供的方法来提高或降低该优先级。优先级仅适用于在同一操作队列中的操作,所以低优先级操作仍然可能会在不同队列中的高优先级操作之前被调度到线程上运行。
操作队列支持暂停调度正在排队的操作,并可以在以后某个时间点恢复,继续调度操作。
操作队列是一个并行队列,可以通过将操作队列的最大并行操作数量设置为1来使操作队列一次只调度一个操作。尽管一次只能调度一个操作,但调度顺序仍然基于其他因素,例如每个操作是否准备就绪及其分配的优先级。串行操作队列并不能提供与GCD中的串行调度队列完全相同的行为,串行调度队列总是按照先进先出的顺序调度任务。
什么是线程?什么是进程?二者有什么区别和联系?
进程是正在运行的应用程序的实例,是系统进行资源分配的基本单位。打开一个iOS应用程序,就是启动了一个进程,一个进程中可以包含多个线程。
线程是单独的代码执行路径,是CPU独立运行和独立调度的基本单位。从技术角度讲,线程是管理代码执行所需的内核级和应用级数据结构的组合。内核级数据结构协调事件到达线程的调度和在某个可用内核上的线程的抢先调度。应用级数据结构包含用于存储函数调用的调用堆栈和应用程序需要用于管理和操作线程的属性和状态的结构。
进程和线程都是应用程序运行的基本单元。
进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其他进程产生影响。
线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉。
多进程的程序要比多线程的程序健壮,但在切换进程时,资源消耗较大且效率差。
对于一些要求同时运行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
线程
线程的底层实现机制是Mach线程,但很少在Mach层面上使用线程。相反,我们通常使用更方便的POSIX API或者其衍生工具。可以在iOS应用程序中使用的线程技术包括以下两种:
- Cocoa线程:Cocoa使用
NSThread
类来实现线程,还在NSObject
类中提供了方法来创建新线程并在该线程上执行代码。 - POSIX线程:POSIX线程提供了基于C语言的接口来创建线程,这些接口为配置线程提供了足够的灵活性。
当创建一个新的线程时,必须为该线程指定一个入口函数,该入口函数构成了我们想要在线程上运行的代码。启动线程后,线程将处于运行中
、准备就绪
和阻塞
这三种状态中的一种。如果一个线程当前没有运行,那么它可能处于阻塞状态并等待输入,或者它已准备好运行,但尚未安排执行。线程持续在这些状态之间来回切换,直到它最终退出并切换到终止状态。
线程在内存使用和性能方面对应用程序和系统有实际的成本。每个线程都会在内核内存空间和应用程序的内存空间中请求内存分配,管理线程和协调线程调度所需的核心数据结构使用wired memory存储在内核中,线程的堆栈空间和pre-thread数据存储在应用程序的内存空间中。
由于底层内核的支持,GCD和操作对象通常可以更快地创建线程。它们不是每次都从头开始创建线程,而是使用已驻留在内核中的线程池来节省分配时间。
对于多线程的应用程序,Cocoa框架使用锁和其他形式的内部同步来确保它们的行为正确。但是为了防止这些锁在单线程的情况下降低性能,Cocoa不会在应用程序使用NSThread
类生成其第一个新线程之前创建它们。如果我们仅使用POSIX API创建新线程,则Cocoa不会收到告知它应用程序现在是多线程的通知。当发生这种情况时,涉及Cocoa框架的操作可能会破坏应用程序的稳定性或者崩溃。
在创建线程时,可以配置线程的以下属性:
- 堆栈大小:堆栈管理栈帧,也是声明线程的任何局部变量的地方。
- 局部存储:每个线程都维护着一个可以从任何位置访问的字典,可以使用该字典来存储希望在整个线程执行期间都存在的信息。
- 分离状态:使用Cocoa和POSIX线程技术创建的线程默认都是分离的,分离线程完成其工作后,系统会立即释放其资源。相比之下,系统不会回收可连接线程的资源,直到另一个线程显示地与该线程连接,并且进程可能会阻塞执行连接的线程。
- 优先级:内核的调度算法在确定要运行哪些线程时会考虑线程优先级,优先级较高的线程比较低优先级的线程更可能运行。较高的优先级并不能保证线程的具体执行时间,只是与较低优先级的线程相比,调度程序更有可能选择它。
由于线程的入口函数内部使用的会被引用计数的对象直到退出线程时才会被释放,所以在长期存活的线程中应创建多个自动释放池来更频繁地释放对象,以便防止应用程序的内存占用过大,从而导致性能问题。
线程之间的通信方式有以下几种:
- 直接传递消息:使用
NSObject
类的提供的performSelector...
系列方法直接在其他线程上执行方法选择器。 - 共享变量:使用共享变量来传递信息时,必须使用锁或者其他同步机制以确保代码的正确性。
- 条件:条件是一个同步工具,可以使用过它来控制线程何时执行代码的特定部分。可以将条件视为守门员,让线程只有在符合条件时才能运行。
- runloop源:自定义runloop源是为了在线程上接收专用消息而设置的。当没有任何事件可以执行时,run loop source会将线程置于休眠状态,这可以提高线程的效率。
- 端口和套接字:基于端口的通信是两个线程之间通信的更复杂的方式,但它是一种非常可靠的技术。为了提高效率,端口是使用run loop source实现的,所以当没有数据在端口上等待时,线程会休眠。
Run Loop
run loop 是一个事件处理循环,用于协调传入事件的接收和处理事件。run loop 的目的是在有工作做时让线程忙碌,并在没有工作可做时让线程进入休眠状态。在需要长期存活的线程中执行周期性任务时,使用 run loop 能够避免线程空转,从而节省CPU资源,提高程序性能。
run loop 从两种不同类型的源中接收事件,并使用特定的处理程序来处理到达的事件。输入源传递异步事件,这些事件通常是来自另一个线程或者不同应用程序的消息。定时器源传递在预定的时间或者重复的间隔发生的同步事件。
在线程上运行 run loop 之前,必须至少添加一个输入源或者定时器源到 run loop 中。如果 run loop 没有任何要监听的源,当我们尝试运行 run loop 时,它会立即退出。
创建输入源和定时器源时,需要将其分配给 run loop 的一个或者多个模式。run loop 模式是要监听的输入源和定时器源的集合以及要通知的 run loop 观察者的集合。每次运行 run loop 时,都要指定要运行的特定模式。在 run loop 的事件处理循环过程中,只会监听与该模式关联的源,并允许其传递事件。同样,只有与该模式关联的观察者才会收到 run loop 的进度通知。与其他模式关联的源会保留任何新事件,直到随后以与该源关联的模式进入循环为止。
系统定义了以下几种 run loop 模式:
- NSDefaultRunLoopMode(默认模式):默认模式是用于大多数操作的模式。大多数情况下,应该使用此模式启动run loop和配置输入源。
- NSConnectionReplyMode(连接模式):Cocoa将此模式与
NSConnection
对象一起使用来监听应答。很少需要自己使用这种模式。 - NSModalPanelRunLoopMode(模态模式):Cocoa使用这种模式来识别用于modal panel的事件。
- NSEventTrackingRunLoopMode(事件追踪模式):Cocoa使用这种模式来限定在鼠标拖拽循环和其他类型的用户界面跟踪循环期间传入的事件。
- NSRunLoopCommonModes(常用模式):这是一个常用模式的可配置组。将输入源与此模式相关联也会将其与组中的每个模式相关联。对于Cocoa应用程序,默认情况下,此集合包含默认、 模态和事件跟踪模式。Core Foundation最初只包含默认模式。可以使用CFRunLoopAddCommonMode函数将自定义模式添加到该集合中。
输入源分为基于端口的输入源和自定义输入源这两种类型。基于端口的输入源监听应用程序的Mach端口,自定义输入源监听自定义事件源。两种源之间的唯一区别是它们如何发出信号,基于端口的输入源由内核自动发出信号,自定义输入源必须从另一个线程手动发出信号。NSObject
类提供的performSelector...
系列方法的内部实现使用的就是自定义输入源,执行选择器源在执行其选择器后会将自己从 run loop 中移除。
定时器源生成基于时间的通知,是线程通知自己做某事的一种方式,但这不是实时的。如果定时器源不在当前正在被 run loop 监听的模式中,其不会触发,直到 run loop 以定时器源支持的模式运行。如果定时器源在 run loop 处于执行处理例程的过程中触发,则定时器源会等到下一次循环时调用其处理例程。重复定时器源根据预定的触发时间自动重新安排自身,而不是实际的触发时间。例如,如果定时器源预定在特定时间以及之后每隔5秒触发一次,则即使实际触发时间延迟了,预定的触发时间也会始终以5秒时间间隔进行。如果触发时间延迟太多以至于错过了一个或多个预定触发时间,则定时器源在错过的时间段内仅被触发一次。在错过的时间内触发后,定时器源重新安排下一个预定触发时间。
每当运行线程的 run loop 时,它都会处理接收到的事件,并为任何附加的观察者生成通知。其事件处理循环过程为:
- 通知观察者已经进入run loop。
- 通知观察者任何准备好的定时器即将触发。
- 通知观察者任何自定义(不是基于端口的)输入源即将触发。
- 触发任何可以触发的自定义(不是基于端口的)输入源来处理传入的事件。
- 如果一个基于端口的输入源已经准备好并且正在等待触发,则立即触发该输入源。跳到第9步。
- 通知观察者线程即将进入休眠状态。
- 将线程置于休眠状态,直到发生以下事件之一:
- 基于端口的输入源的事件到达。
- 定时器触发。
- 为run loop设置的超时值已过期。
- run loop被显式地唤醒。
- 通知观察者线程刚被唤醒。
- 处理未决事件:
- 如果用户定义的定时器触发,处理定时器事件并重新启动循环。跳到第2步。
- 如果基于端口的输入源触发,则处理事件。
- 如果run loop被显式唤醒但尚未超时,则重新启动循环。跳到第2步。
- 通知观察者已经退出run loop。
Core Foundation中的CFRunloop
相关的API是线程安全的,而Cocoa提供的NSRunLoop
类不是。如果使用NSRunLoop
类来修改 run loop,则应该仅仅在持有该 run loop 的线程中这样做。在不同的线程中将输入源或定时器源添加到 run loop 可能会导致代码崩溃或者 run loop 以意外的方式运行。
Run Loop实际使用:
- 主线程的run loop通常情况下以默认模式(NSEventTrackingRunLoopMode)运行,当发生触摸事件时,会切换到事件追踪模式(NSEventTrackingRunLoopMode)运行。将定时器源与run loop的常用模式(NSRunLoopCommonModes)关联起来,就能够在发生触摸事件时继续触发定时器事件。
- 使用
perfromSelector
方法在主线程的默认模式下执行setImage:
方法来渲染图片,可以避免在列表滚动时直接渲染多张图片而导致界面卡顿。
线程同步
多线程编程的一个风险是多个线程之间的资源争夺。如果多个线程同时试图使用或修改相同的资源,则可能会出现问题。例如,一个线程可能会覆盖另一个线程的更改或者将应用程序置于未知和可能无效的状态。为防止不同的线程出乎意料地更改数据,可以设计应用程序来避免同步问题,也可以使用同步工具。虽然完全避免同步问题是更可取的,但并非总是可行。
iOS系统提供了以下几种同步工具:
- 锁:可以使用锁来保护代码的关键部分,这段关键代码一次只允许一个线程访问。例如,关键部分可能会操作特定的数据结构或者使用一次最多支持一个客户端的资源。通过在该部分放置一个锁,可以拒绝其他线程执行可能影响代码正确性的更改。
- 互斥锁:互斥锁充当资源周围的保护屏障。互斥锁是一种信号量,一次只允许一个线程访问。如果一个互斥锁正在被使用,而另一个线程试图获取它,则该线程将阻塞,直到互斥锁被其原始持有者释放。如果多个线程竞争相同的互斥锁,则一次只允许一个线程访问它。
- 递归锁:递归锁是互斥锁的一种变体。递归锁允许单个线程在释放它之前多次获取锁。其他线程会一直处于阻塞状态,直到锁的拥有者释放该锁的次数与获取它的次数相同时。递归锁主要在递归迭代期间使用,但是也可能在多个方法需要分别获取锁的情况下使用。
- 读写锁:读写锁也被称为共享互斥锁。这种类型的锁通常被用于较大规模的操作,如果受保护的数据结构被频繁读取并且仅偶尔被修改,则使用读写锁能够显著提高性能。在正常操作期间,当一个线程想要写入结构时,它会阻塞,直到所有正在读取结构的线程释放锁,在此时写入线程获取锁并可以更新结构。当写入线程正在使用锁时,新的读取线程将阻塞,直到写入线程完成操作并释放锁。系统仅支持使用POSIX线程的读写锁。
- 自旋锁:自旋锁反复轮询其锁条件,直到该条件成立。自旋锁最常用于预期等待锁的时间较短的多处理系统。在这些情况下,轮询通常比阻塞线程更有效,后者涉及上下文切换和线程数据结构的更新。
- 条件:条件是另一种类型的信号量,它允许线程在特定条件为真时互相发送信号。条件通常用于指示资源的可用性或者确保任务按照特定顺序执行。当线程验证一个条件时,它会阻塞,除非该条件已成立。它会一直阻塞,直到其他线程明确更改条件并向条件发出信号。条件和互斥锁之间的区别在于多个线程可以同时访问条件。条件更像是看门人,其依靠一些特定的标准来允许不同的线程通过门。
- 原子操作:原子操作是一种简单的同步形式,其优点是它们不会阻塞竞争线程。原子操作允许我们对32位或者64位值执行简单的数学和逻辑运算。这些操作依赖于特殊的硬件指令(以及可选的内存屏障)来确保在受影响的内存被再次访问之前完成给定的操作。对于简单的操作,例如递增一个计数器变量,相对于加锁,这样做可能会带来更好的性能。
- 内存屏障:内存屏障是一种非阻塞同步工具,用于确保内存操作以正确的顺序执行。内存屏障就像栅栏一样,强制处理器完成位于栅栏前面的任何加载和存储操作,然后才允许其执行位于栅栏后面的加载和存储操作。内存屏障通常用于确保一个线程(但对另一个线程可见)的内存操作始终按照预期的顺序进行。在缺少内存屏障的情况下,可能会让其他线程看到看似不可能的结果。
- 易变(Volatile)变量:编译器通常通过将变量的值加载到寄存器中来优化代码。对于局部变量,这通常不是问题。但是如果变量在另一个线程是可见的,这样的优化可能会阻止其他线程注意到该变量的任何更改。将volatile关键字应用于变量会强制编译器在每次使用变量时从内存加载该变量。如果变量的值可以随时被编译器可能无法检测到的外部源更改,则可以将该变量声明为volatile。
Runtime
消息发送
每个对象都包含一个指向其类结构体的isa
指针实例变量,每个类结构体中都包括一个指向父类结构体的指针,一个类调度表和用来记录已经使用过一次的方法的缓存。
Objective-C中调用对象的某个方法被称为向对象发送消息。
在运行时动态地将方法实现绑定到消息的过程如下:
编译器将消息表达式转换为objc_msgSend
函数的调用,并向该函数传递消息接收者对象和方法选择器这两个参数。objc_msgSend
函数跟随消息接收者对象的isa
指针到类结构的缓存中根据方法选择器查找方法,如果有对应的方法,则会直接调用该方法。如果没有,则会到类调度表中根据方法选择器查找方法。如果没有找到对应的方法,objc_msgSend
函数会跟随类结构中指向其父类的指针到父类的类调度表中查找方法。objc_msgSend
函数会沿着类层次结构一直查找,直到到达NSObject
类。一旦找到方法,objc_msgSend
函数就会调用该方法,并将接收者对象的数据结构传递给它。
绕过动态绑定的唯一方法是获取方法的地址并直接调用它。
动态方法解析
当某个类声明了一个方法却没有实现该方法时,调用这个类的该方法。此时,在消息发送过程中,在类调度表中无法找到与该方法选择器对应的方法。这时运行时系统就会调用该类的resolveInstanceMethod:
或者resolveClassMethod:
方法,提供一个机会来让我们动态提供方法的实现。在这些方法的实现中,可以使用class_addMethod
函数将函数作为方法添加到类中。
消息转发
将消息发送给一个不能处理该消息的对象会引发错误,但在报告错误之前,运行时系统提供了第二次机会去处理该消息。
在消息发送过程中,在类调度表中无法找到与该方法选择器对应的方法。运行时系统会调用resolveInstanceMethod:
方法来动态解析方法,如果动态解析失败,则会启动消息转发机制。
首先,运行时系统调用forwardingTargetForSelector:
方法询问是否存在该消息的后备接收者。如果存在,则将消息发送给这个后备接收者,消息转发完成。如果不存在,运行时系统会调用methodSignatureForSelector:
方法获取该方法的签名并将其封装成一个NSInvocation
对象,然后调用forwardInvocation:
方法并将NSInvocation
对象传递给它,在forwardInvocation:
方法实现中将这个消息发送给合适的对象,消息转发机制完成。
KVC
KVC是一种间接访问对象属性的机制,兼容KVC的对象可以使用简洁、统一的接口和字符串参数来访问其属性。
Getter的查找方式
向对象发送一个valueForKey:
消息来读取指定的键所标识的属性的值时,valueForKey:
方法的默认实现首先在消息的接收对象中按顺序依次查找名为get
、
、is
或者_
的访问器方法。如果存在某个简单的访问器方法,则直接调用该方法检索属性的值。如果检索到的属性值是一个对象指针,则直接返回该结果。如果属性值是NSNumber
支持的标量类型,则将其存储在一个NSNumber
实例中并返回该实例。如果属性值是NSNumber
不支持的标量类型,则将其存储在NSValue
实例中并返回该实例。(class_getInstanceMethod
函数查找实例方法。)
如果不存在简单的访问器方法,则查找名为countof
、objectIn
和
的方法。如果存在第一个方法和其他两个方法中的至少一个,则创建一个能够响应NSArray
类所有方法的集合代理对象,并返回该代理对象。
如果不存在这些方法,则查找名为countOf
、enumeratorOf
和memberOf
的方法。如果三个方法都存在,则创建一个能够响应NSSet
类所有方法的集合代理对象,并返回该代理对象。
如果不存在这些方法,并且对象类是可以直接访问实例变量的,则按顺序依次查找名为_
、_is
、
或者is
的实例变量。如果存在实例变量,则直接获取实例变量的值。如果值是一个对象指针,则直接返回该结果。如果值是NSNumber
支持的标量类型,则将其存储在一个NSNumber
实例中并返回该实例。如果值是NSNumber
不支持的标量类型,则将其存储在NSValue
实例中并返回该实例。(class_getInstanceVariable
函数查找实例变量。)
如果以上查找都失败了,则向接收对象发送一个valueForUndefinedKey:
消息。默认情况下,valueForUndefinedKey:
方法的实现会引发一个异常。
Setter的查找方式
向对象发送一个setValue:forKey:
消息来设置指定的键所标识的属性的值时,setValue:forKey:
方法的默认实现首先在接收对象中按顺序查找名为set
或者_set
的访问器方法。如果存在某个方法,则使用输入的值调用该方法来设置属性值。
如果不存在访问器方法,并且接收对象的类可以直接访问实例变量,则按顺序查找名为_
、_is
、
或者is
的实例变量。如果存在,则直接使用输入的值来设置实例变量。
在设置值时,如果将nil
对象赋值给非对象属性,会向接收对象发送一个setNilValueForKey:
消息。默认情况下,这会引发一个异常。
如果以上查找都失败了,则向接收对象发送一个setValue:forUndefinedKey:
消息。默认情况下,这会引发一个异常。
KVO
KVO提供了一种机制以允许对象被告知其他对象的特定属性的改变,其是基于KVC实现的。
自动键值观察是使用 isa-swizzling 技术实现的。
isa
指针指向对象的类,类维护着一个调度表,该调度表基本上包含指向该类实现的方法的指针以及其他数据。
当为对象的一个属性注册观察者时,运行时系统会创建一个继承自被观察对象类的中间类,将被观察对象的isa
指针指向这个中间类,这样被观察对象实际上就成为了此中间类的一个实例。中间类重写了被观察属性的setter以便在被观察属性的值改变时发出更改通知,当我们更改对象的属性值时,实际上调用的是中间类的setter。(objc_allocateClassPair
函数创建一个新类,class_addMethod
函数将重写的setter绑定到setter方法选择器。)
同时,中间类还重写了class
方法并返回原本的类。因此,在判断被观察对象的类时,不应使用isa
指针,而应使用class
方法。