iOS 面试知识点(二)

2018-09-11  本文已影响0人  渐z

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

在应用程序启动过程中,初始化一个 image 后,会调用 image 中的类和 category 的+load方法。首先调用父类的+load方法,接着调用子类的+load方法,然后调用 category 的+load方法。运行时系统是直接到类对象的 ro (class_ro_t)的方法列表中去查找+load方法对应的函数指针的,并没有使用objc_msgSend函数,所以 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);
}

在首次调用类的某个类方法时,在慢速查找类方法的函数指针时,会先调用这个类的+initialize方法。在首次使用子类时,如果父类也还未被使用过,此时不仅会调用子类的+initialize方法,还会调用父类的+initialize方法,且父类的+initialize方法调用先于子类的+initialize方法调用。如果 catergory 实现了+initialize方法,那么宿主类的+initialize方法会被覆盖掉。

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

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

沙盒机制

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

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

数据持久化的几种方式

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

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

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

网络安全相关的算法:

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

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

内存分类和内存分区

内存分类

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

内存分区

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

内存分区.png

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

堆区:堆是向高地址扩展的数据结构,是不连续的内存区域。堆区用来存储实例对象,一般由程序员自己管理。例如,使用alloc申请内存,使用free释放内存。

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

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

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

堆和栈的区别

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 中直接对局部变量执行赋值操作。如果这样做的话,编译器会报错。

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 正确姿势?

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

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函数来实现数据的多读单写:

- (id)objectForKey:(NSString *)key
{
  __block id obj;
  // 同步读取数据
  dispatch_sync(concurrent_queue, ^{
    obj = [dic objectForKey:key];
  });
  return obj;
}

- (void)setObject:(id)object forKey:(NSString *)key
{
  // 异步写入数据
  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对象有以下几种状态:

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

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

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

NSThread

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

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

线程在内存使用和性能方面对应用程序和系统有实际的成本。每个线程都会在内核内存空间和应用程序的内存空间中请求内存分配,管理线程和协调线程调度所需的核心数据结构使用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];
}

线程之间的通信方式

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

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实际运用

使用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。如果当前线程是主线程,不手动销毁NSTimertarget对象就无法被释放,从而产生内存泄露。

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函数会做以下事情:

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

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

懒加载的类会在首次使用时被实现。使用类对象时,会调用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函数会做以下事情:

schedule_class_load函数内部逻辑如下:

消息传递

编译器在编译时会将消息表达式转换为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"];
    }
}
上一篇 下一篇

猜你喜欢

热点阅读