美文网首页
iOS之武功秘籍⑲: 内存管理与NSRunLoop

iOS之武功秘籍⑲: 内存管理与NSRunLoop

作者: 長茳 | 来源:发表于2021-03-09 22:07 被阅读0次

iOS之武功秘籍 文章汇总

写在前面

一个优秀的App必然是对内存"精打细算"的,本文就来探索一下内存管理中的一些门道与RunLoop的相关知识.

本节可能用到的秘籍Demo

一、内存布局

①. 五大区

接下来我从内存中的低地址往高地址依次介绍五大区:

  • 代码段(.text)
    • 存放着程序代码,直接加载到内存中
  • 初始化区域(.data)
    • 存放着初始化的全局变量、静态变量
    • 内存地址:一般以 0x1 开头
  • 未初始化区域(.bss)
    • bss段存放着未初始化的全局变量、静态变量
    • 内存地址:一般以 0x1 开头
  • 堆区(heap)
    • 堆区存放着通过alloc分配的对象、block copy后的对象
    • 堆区速度比较慢
    • 内存地址:一般以 0x6 开头
  • 栈区(stack)
    • 栈区存储着函数方法以及局部变量
    • 栈区比较小,但是速度比较快
    • 内存地址:一般以 0x7 开头

在这里提一句关于函数在内存中的分布:函数指针存在栈区函数实现存在堆区

除了五大区之外,内存中还有保留字段内核区

  • 内核区:以4GB手机为例,系统将其中的3GB给了五大区+保留区,剩余的1GB给内核区使用,它主要是系统用来进行内核处理操作的区域
  • 保留字段:保留一定的区域给保留字段,进行一些存储或预留给系统处理nil

这里有个疑问,为什么五大区的最后内存地址是从0x00400000开始的.其主要原因是0x00000000表示nil,不能直接用nil表示一个段,所以单独给了一段内存用于处理nil等情况.

以下的两张图,便于我们更好的理解内存分布.



平时在使用App过程中,栈区就会向下增长,堆区就会向上增长.

接下来看看堆区和栈区中的一些内容
  • 对于alloc创建的对象obj,分别打印了obj的对象地址 和 obj对象的指针地址(可以参考前文的汇总图)
    • obj对象地址是以0x6开头,说明是存放在堆区
    • obj对象的指针地址是以0x7开头,说明是存放在栈区

那么在堆区和栈区访问对象的顺序是怎样的呢?

  • 堆区访问对象的顺序是先拿到栈区的指针,再拿到指针指向的对象,才能获取到对象的isa、属性方法等
  • 栈区访问对象的顺序是直接通过寄存器访问到对象的内存空间,因此访问速度快

②. 内存布局相关面试题

面试题1:全局变量和局部变量在内存中是否有区别?如果有,是什么区别?

  • 有区别
  • 全局变量保存在内存的全局存储区(即bss+data段),占用静态的存储单元
  • 局部变量保存在栈区,只有在所在函数被调用时才动态的为变量分配存储单元
  • 两者访问的权限不一样

面试题2:Block中可以修改全局变量,全局静态变量,局部静态变量,局部变量吗?

  • 可以修改全局变量,全局静态变量,因为全局变量 和 静态全局变量是全局的,作用域很广,block可以访问到
  • 可以修改局部静态变量,不可以修改局部变量
    • 局部静态变量(static修饰的) 和 局部变量,被block从外面捕获,成为 __main_block_impl_0这个结构体的成员变量
    • 局部变量是以值方式传递到block的构造函数中的,只会捕获block中会用到的变量,由于只捕获了变量的值,并非内存地址,所以在block内部不能改变局部变量的值
    • 局部静态变量是以指针形式,被block捕获的,由于捕获的是指针,所以可以修改局部静态变量的值
  • ARC环境下,一旦使用__block修饰并在block中修改,就会触发copy操作,block就会从栈区copy到堆区,此时的block堆区block
  • ARC模式下,Block中引用id类型的数据,无论有没有__block修饰,都会retain,对于基础数据类型,没有__block修饰就无法修改变量值;如果有__block修饰,也是在底层修改__Block_byref_a_0结构体,将其内部的forwarding指针指向copy后的地址,来达到值的修改

面试题3:关于全局静态变量的误区

  • 全局静态变量是可变的
  • 全局静态变量的值只针对文件而言,不同文件的全局静态变量的内存地址是不一样的,也就是无论别的文件怎么修改,本文件使用时都拿原有值/本文件修改后的值

二、内存管理方案

①. taggedPointer

①.1 taggedPointer初探

分别调用下面两种方法,哪个会崩溃?为什么?

#import "ViewController.h"

@interface ViewController ()
@property (nonatomic, strong) dispatch_queue_t queue;
@property (nonatomic, strong) NSString *nameStr;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.queue = dispatch_queue_create("com.tcj.cn", DISPATCH_QUEUE_CONCURRENT);

    [self taggedPointerDemo];
    [self testNormal];
}

- (void)taggedPointerDemo {
  
    for (int i = 0; i<10000; i++) {
        dispatch_async(self.queue, ^{
            self.nameStr = [NSString stringWithFormat:@"tcj"];  // alloc 堆 iOS优化 - taggedpointer
             NSLog(@"%@",self.nameStr);
        });
    }
}

- (void)testNormal {
    for (int i = 0; i<10000; i++) {
        dispatch_async(self.queue, ^{
            self.nameStr = [NSString stringWithFormat:@"又一黑马诞生12345"];
            NSLog(@"%@",self.nameStr);
        });
    }
}

@end

经过运行测试之后,会发现testNormal会崩溃,而taggedPointerDemo方法正常运行

首先来分析下为什么会崩溃的原因?其实是多线程setter、getter操作造成的

  • 调用setter方法会objc_retain(newValue)+objc_release(oldValue)
  • 但是加上多线程就不一样了——在某个时刻线程1对旧值进行relese没有relese完毕),同时线程2也对旧值进行relese操作,即同一时刻对同一片内存空间释放多次,会造成野指针问题(访问坏的地址)

但是为什么testNormal会崩溃,而taggedPointerDemo方法正常运行?

  • testNormal中的对象为__NSCFString类型,存储在堆上
  • taggedPointerDemo中的对象为NSTaggedPointerString类型,存储在常量区.因为nameStralloc``分配时在堆区,由于较小,所以经过XcodeiOS的优化,成了NSTaggedPointerString类型,存储在常量区

其实之前在objc源码的方法中有看到过类似的身影——objc_retainobjc_release的对象如果是isTaggedPointer类型就直接返回(不操作)

小对象的地址分析
NSString为例,对于NSString来说

  • 一般的NSString对象指针,都是string值 + 指针地址,两者是分开的
  • 对于Tagged Pointer指针,其 指针 + 值,都能在小对象中体现.所以Tagged Pointer 既包含指针,也包含值

在之前的文章讲类的加载时,其中的_read_images源码有一个方法对小对象进行了处理,即initializeTaggedPointerObfuscator方法,我们下面介绍

①.2 taggedPointer深入

在推出iPhone 5s(iPhone首个采用64位架构)的时候,为了节省内存和提高执行效率,同时也提出了taggedPointer

底层也做了对objc_debug_taggedpointer_obfuscator进行异或的操作(两次异或同一个数相当于编码解码 -- iOS10.14之后做的混淆操作)

我们可以在objc源码(818.2版本)中通过objc_debug_taggedpointer_obfuscator查找taggedPointer的编码和解码,来查看底层是如何混淆处理的

通过实现,我们可以得知,在编码和解码部分,经过了两层异或,其目的是得到小对象自己,例如以 1010 0001为例,假设mask为 0101 1000

    1010 0001 
   ^0101 1000 mask(编码)
    1111 1001
   ^0101 1000 mask(解码)
    1010 0001

所以在外界,为了获取小对象的真实地址,我们也可以通过类似的方法对taggedPointer进行解码.我们可以将解码的源码拷贝到外面,将NSString混淆部分进行解码,如下所示

观察解码后的小对象地址,其中的 62 表示 bASCII 码,再以 NSNumber 为例,同样可以看出,1就是我们实际的值

到这里,我们验证了小对象指针地址中确实存储了值,那么小对象地址高位其中的0xa0xb又是什么含义呢?

//NSString
0xa000000000000621

//NSNumber
0xb000000000000012
0xb000000000000025

需要去源码中查看_objc_isTaggedPointer源码,主要是通过保留最高位的值(即64位的值),判断是否等于_OBJC_TAG_MASK(即2 ^ 63), 来判断是否是小对象

所以0xa0xb主要是用于判断是否是小对象taggedpointer,即判断条件,判断第64位是否为1taggedpointer指针地址即表示指针地址,也表示值)

  • 0xa 转换成二进制为 1 01064位为163~61后三位 表示 tagType类型 - 2),表示NSString类型
  • 0xb 转换为二进制为 1 01164位为163~61后三位 表示 tagType类型 - 3),表示NSNumber类型,这里需要注意一点,如果NSNumber的值是-1,其地址中的值是用补码表示的

这里可以通过_objc_makeTaggedPointer方法的参数tag类型objc_tag_index_t进入其枚举,其中 2 表示NSString3 表示NSNumber

同理,我们可以定义一个NSDate对象,来验证其tagType是否为 6.通过打印结果,其地址高位是0xe,转换为二进制为1 110,排除64位的1,剩余的3位正好转换为十进制是6,符合上面的枚举值

我们在来看看NSString的内存管理
我们可以通过NSString初始化的两种方式,来测试NSString的内存管理

  • 通过 WithString + @""方式初始化
  • 通过 WithFormat方式初始化

从上面可以总结出,NSString的内存管理主要分为3种

  • __NSCFConstantString字符串常量,是一种编译时常量,retainCount值很大,对其操作,不会引起引用计数变化存储在字符串常量区
  • __NSCFString:是在运行时创建的NSString子类,创建后引用计数会加1,存储在堆上
  • NSTaggedPointerString:标签指针,是苹果在64位环境下对NSStringNSNumber等对象做的优化.对于NSString对象来说
    • 字符串是由数字、英文字母组合且长度小于等于9时,会自动成为NSTaggedPointerString类型,存储在常量区
    • 当有中文或者其他特殊符号时,会直接成为__NSCFString类型,存储在堆区

①.3 taggedPointer总结

  • Tagged Pointer小对象类型(用于存储NSNumberNSDate小NSString),小对象指针不再是简单的地址,而是地址 + 值,即真正的值,所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已.所以可以直接进行读取.优点是占用空间小,节省内存
  • Tagged Pointer 小对象, 不会进入 retain 和 release,而是直接返回了,意味着不需要ARC进行管理,所以可以直接被系统自主的释放和回收
  • Tagged Pointer的内存并不存储在堆中,而是在常量区中,也不需要mallocfree,所以可以直接读取,相比存储在堆区的数据读取,效率上快了3倍左右.创建的效率相比堆区快了近100倍左右
  • taggedPointer的内存管理方案,比常规的内存管理,要快很多
  • Tagged Pointer64位地址中,前4位代表类型后4位主要适用于系统做一些处理中间56位用于存储值
  • 优化内存建议:对于NSString来说,当字符串较小时,建议直接通过@""初始化,因为存储在常量区,可以直接进行读取.会比WithFormat初始化方式更加快速

②. nonpointer_isa

nonpointer_isa在前面章节已经有提到过了,这是苹果优化内存的一种方案: isa是个8字节(64位)的指针,仅用来isa指向比较浪费,所以isa中就掺杂了一些其他数据来节省内存

③. SideTable

当引用计数存储到一定值时,并不会再存储到Nonpointer_isa的位域的extra_rc中,而是会存储到 SideTables 散列表中

③.1 散列表为什么在内存中有多张?最多能够多少张?

  • 如果散列表只有一张表,意味着全局所有的对象都会存储在一张表中,操作任意一个对象,都会进行开锁解锁(锁是锁整个表的读写).当开锁时,由于所有数据都在一张表,这意味着数据不安全
  • 如果每个对象都开一个表,会耗费性能,所以也不能有无数个表
  • 散列表的类型是SideTable,有如下定义
  • 通过查看 sidetable_unlock 方法定位 SideTables ,其内部是通过SideTablesMapget 方法获取. 而 SideTablesMap 是通过StripedMap<SideTable>定义的

从而进入StripedMap的定义,从这里可以看出,同一时间,真机中散列表最多只能有8张

③.2 为什么在用散列表,而不用数组、链表?

  • 数组:特点在于查询方便(即通过下标访问),增删比较麻烦,所以数组的特性是读取快,存储不方便
  • 链表:特点在于增删方便,查询慢(需要从头节点开始遍历查询),所以链表的特性是存储快,读取慢
  • 散列表的本质就是一张哈希表,哈希表集合了数组和链表的长处增删改查都比较方便,例如拉链哈希表(在之前锁的文章中,讲过的tls的存储结构就是拉链形式的),是最常用的,如下所示

三、ARC&MRC

面试中常常会问到ARCMRC,其实这两者在内存管理中才是核心所在

① MRC(手动内存管理)

  • MRC时代,系统是通过对对象的引用计数来判断是否销毁,有以下规则
    • 对象被创建时引用计数都为1
    • 当对象被其他指针引用时,需要手动调用[objc retain],使对象的引用计数+1
    • 当指针变量不再使用对象时,需要手动调用[objc release]释放对象,使对象的引用计数-1
    • 当一个对象的引用计数为0时,系统就会销毁这个对象
  • 所以,在MRC模式下,必须遵守:谁创建谁释放谁引用谁管理

② ARC(自动内存管理)

  • ARC模式是在WWDC2011iOS5引入的自动管理机制,即自动引用计数.是编译器的一种特性.其规则与MRC一致,区别在于
    • ARC禁止手动调用retain/release/retainCount/dealloc
    • 编译器会在适当的位置插入releaseautorelease
    • ARC新加了weakstrong关键字
  • ARCLLVMRuntime配合的结果

③ alloc

之前已经对alloc流程有了一个详细的介绍

④ retain

retain 会在底层调用 objc_retain

  • objc_retain 先判断是否为 isTaggedPointer,是就直接返回不需要处理,不是在调用 obj->retain()
  • objc_object::retain 通过 fastpath 大概率调用 rootRetain(),小概率通过消息发送调用对外提供的 SEL_retain
  • rootRetain调用rootRetain(false, false)
  • rootRetain内部实现其实是个do-while循环:
    • 先判断是否为nonpointer_isa(小概率事件)不是的话,则直接操作SideTables散列表中的引用计数表,此时的散列表并不是只有一张,而是有很多张
      • 找到对应的散列表进行+=SIDE_TABLE_RC_ONE,其中SIDE_TABLE_RC_ONE是左移两位找到引用计数表
    • 判断是否正在释放,如果正在释放,则执行dealloc流程
    • 调用addc函数执行extra_rc+1,即引用计数+1操作,并给一个引用计数的状态标识carry,用于表示extra_rc是否满了
      • isa中的第45位(RC_ONE在arm64中为45)extra_rc进行操作处理
    • 如果carray的状态表示extra_rc的引用计数满了,此时需要操作散列表,即 将满状态的一半拿出来存到extra_rc另一半存在 散列表的rc_half.这么做的原因是因为如果都存储在散列表,每次对散列表操作都需要开解锁,操作耗时,消耗性能大,这么对半分操作的目的在于提高性能
      • 这里为什么优先考虑使用isa进行引用计数存储是因为引用计数存储在isa的bits中

retain 总结:

  • retain在底层首先会判断是否是 Nonpointer isa,如果不是,则直接操作散列表 进行+1操作
  • 如果是Nonpointer isa,还需要判断是否正在释放,如果正在释放,则执行dealloc流程,释放弱引用表和引用计数表,最后free释放对象内存
  • 如果不是正在释放,则对Nonpointer isa进行常规的引用计数+1.这里需要注意一点的是,extra_rc在真机上只有8位用于存储引用计数的值,当存储满了时,需要借助散列表用于存储.需要将满了的extra_rc对半分一半(即2^7)存储在散列表中.另一半还是存储在extra_rc中,用于常规的引用计数的+1或者-1操作,然后再返回

⑤ release

releaseretain相似,会在底层调用objc_release

  • objc_release先判断是否为isTaggedPointer,是就直接返回不需要处理,不是在调用obj->release()
  • objc_object::release通过fastpath大概率调用rootRelease(),小概率通过消息发送调用对外提供的SEL_release
  • rootRelease调用rootRelease(true, false)
  • rootRelease内部实现也有个do-while循环
    • 先判断是否为nonpointer_isa(小概率事件)不是的话则直接对散列表中的引用计数进行-1操作
    • 如果是Nonpointer isa,则对extra_rc中的引用计数值进行-1操作,并存储此时的extra_rc状态到carry中
    • 如果此时的状态carray为0,则走到underflow流程
      • 判断散列表中是否存储了一半的引用计数
      • 如果,则从散列表中取出存储的一半引用计数,进行-1操作,然后存储到extra_rc中
      • 如果此时extra_rc没有值散列表中也是空的则直接进行析构,即dealloc操作,属于自动触发
所以,综上所述,release的底层流程如下图所示

⑥ retainCount

前面说了这么多引用计数,那么我们来看看retainCount和引用计数有什么关系呢?来看一个问题:

alloc创建的对象的引用计数为多少?

上述代码打印输出1,然而在alloc流程中并没有看到任何与retainCount相关的内容,这又是怎么一回事呢?接下来就来看看retainCount的底层实现

  • 进入retainCount -> _objc_rootRetainCount -> rootRetainCount源码,其实现如下

在这里我们可以通过源码断点调试,来查看此时的extra_rc的值,结果如下:

当来到953行断点时,此时的extra_rc为0,而过到954行代码,我们在来看extra_rc的值为多少.

此时的值却为1了.

isa_t bits = __c11_atomic_load((_Atomic uintptr_t *)&isa.bits, __ATOMIC_RELAXED)

以上代码将bits里面的extra_rc进行了+1操作.

答案:alloc创建的对象实际的引用计数为0,其引用计数打印结果为1,是因为在底层rootRetainCount方法中,引用计数默认+1了,但是这里只有对引用计数的读取操作,是没有写入操作的,简单来说就是:为了防止alloc创建的对象被释放(引用计数为0会被释放),所以在编译阶段,程序底层默认进行了+1操作.(新版objc源码)

  • alloc创建的对象没有retain和release
  • alloc创建对象的引用计数为0,会在编译时期,程序默认加1,所以读取引用计数时为1

⑦ autorealese

将在自动释放池章节讲到.

⑧ dealloc

retainrelease的底层实现中,都提及了dealloc析构函数,下面来分析dealloc的底层的实现

  • dealloc在底层会调用_objc_rootDealloc
  • _objc_rootDealloc调用rootDealloc
  • rootDealloc方法中
    • 判断是否为isTaggedPointer,是的话直接返回,不是的话继续往下走
    • 判断isa标识位中是否有弱引用关联对象c++析构函数额外的散列表,有的话调用object_dispose否则直接free
  • object_dispose
    • 先判空处理
    • 接着调用objc_destructInstance(核心部分)
    • 最后再free释放对象
  • objc_destructInstance
    • 判断是否有c++析构函数和关联对象,有的话分别调用object_cxxDestruct_object_remove_assocations进行处理
    • 然后再调用clearDeallocating
  • clearDeallocating
    • 判断是否是nonpointer,是的话调用sidetable_clearDeallocating清空散列表
    • 判断是否弱引用和额外的引用计数表has_sidetable_rc是的话调用clearDeallocating_slow进行弱引用表和引用计数表的处理

所以综上所述,dealloc的流程可以总结为:

  • 1:根据当前对象的状态是否直接调⽤free()释放
  • 2:是否存在C++的析构函数、移除这个对象的关联属性
  • 3:将指向该对象的弱引⽤指针置为nil
  • 4:从弱引⽤表中擦除对该对象的引⽤计数
    最后附上一张dealloc流程图

因此到目前为止,从最开始的alloc -> retain -> release -> dealloc就全部串联起来了.

四、弱引用

①. weak原理

笔者在之前的[iOS之武功秘籍⑩: OC底层题目分析]中已经讲过了.


②. NSTimer中的循环引用

众所周知使用NSTimer容易出现循环引用,那么我们就来分析并解决一下

假设此时有A、B两个界面,在B界面中有如下定时器代码.

代码运行起来所发生的问题就是 B界面 popA界面 时不会触发 B 界面dealloc函数.主要原因是B界面没有释放,即没有执行dealloc方法,导致timer也无法停止和释放

前面我们已经看到了release在引用计数为0时会调用dealloc消息发送,此时没有触发dealloc函数必然是出现了循环引用,那么循环引用出现在哪个环节?其实是NSTimerAPI是被强持有的,直到Timer invalidated.

即此时timer持有self,self也持有timer,构成了循环引用

那么能不能像block一样使用弱引用来解决循环引用呢?答案是不能的!

此时他们之间的持有关系如下:


之前在Block篇章说的是使用弱引用__weak typeof(self) weakSelf = self可以解决循环引用; 不处理引用计数,使用弱引用表管理,怎么在这里就不好使了呢?

到这我又有两个问题?

  • weakSelf会对引用计数进行+1操作吗?
  • weakSelfself 的指针地址相同吗,是指向同一片内存吗?

带着疑问,我们在weakSelf前后打印self的引用计数

运行后发现前后self的引用计数都是8.也就是 weakSelf没有对内存进行+1操作

继续打印weakSelfself对象,以及他们的指针地址:

从打印结果可以看出 weakSelfself 指向的都是 TCJTimerViewController对象,但是weakSelfself指针并不相同——两者并不是一个东东,只是指向同一个TCJTimerViewController对象.

通过block底层原理的方法 _Block_object_assign 可知,block捕获的是 对象的指针地址

block持有的是weakSelf的指针地址;timer持有的是weakSelf的指针指向的对象,这里间接持有了self,所以仍然存在循环引用导致释放不掉.

③. 解决NSTimer的循环引用

解决思路 : 我们需要打破这一层强持有 - self

③.1 思路一:pop时在其他方法中销毁timer

  • 既然dealloc不能来,就在dealloc函数调用前解决掉这层强引用
  • 可以在viewWillDisappearviewDidDisappear中处理NSTimer,但这样处理效果并不好,因为跳转到下一页定时器也会停止工作,与业务不符
  • 使用didMoveToParentViewController可以很好地解决这层强引用.这个方法是用于当一个视图控制器中添加或者移除viewController后,必须调用的方法.目的是为了告诉iOS,已经完成添加/删除子控制器的操作.
  • B界面中重写didMoveToParentViewController方法

③.2 思路二:中介者模式,即不使用self,依赖于其他对象

  • 使用其他全局变量,此时timer持有全局变量,self也持有全局变量,只要页面popself因为没有被持有就能正常走dealloc,在dealloc中再去处理timer
  • 此时的持有链分别是runloop->timer->target->timerself->targetself->timer
#ifdef DEBUG
#define CJNSLog(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ## __VA_ARGS__] UTF8String]);
#else
#define CJNSLog(format, ...);
#endif

#import "TCJTimerViewController.h"
#import <objc/runtime.h>

static int num = 0;

@interface TCJTimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) id  target;
@end

@implementation TCJTimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
     self.target = [[NSObject alloc] init];
     class_addMethod([NSObject class], @selector(fireHome), (IMP)fireHomeObjc, "v@:");
     self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self.target selector:@selector(fireHome) userInfo:nil repeats:YES];
}

void fireHomeObjc(id obj){
    CJNSLog(@"%s -- %@",__func__,obj);
}

- (void)fireHome{
    num++;
    CJNSLog(@"hello word - %d",num);
}

- (void)dealloc{
    [self.timer invalidate];
    self.timer = nil;
    CJNSLog(@"%s",__func__);
}

③.3 思路三:自定义封装timer(使用包装者)

  • 类似于方案二,但是使用更便捷
  • 如果传入的响应者target能响应传入的响应事件selector,就使用runtime动态添加方法并开启计时器
  • fireWapper中如果有wrapper.target,就让wrapper.target(外界响应者)调用wrapper.aSelector(外界响应事件)
  • fireWapper中没有了wrapper.target,意味着响应者释放了(无法响应了),此时定时器也就可以休息了(停止并释放)
  • 持有链分别是runloop->timer->TCJTimerWrappervc->TCJTimerWrapper-->vc
//*********** .h文件 ***********
@interface TCJTimerWapper : NSObject

- (instancetype)cj_initWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
- (void)cj_invalidate;

@end

//*********** .m文件 ***********
#import "TCJTimerWapper.h"
#import <objc/message.h>

@interface TCJTimerWapper ()

@property(nonatomic, weak) id target;
@property(nonatomic, assign) SEL aSelector;
@property(nonatomic, strong) NSTimer *timer;

@end

@implementation TCJTimerWapper

- (instancetype)cj_initWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo{
    if (self == [super init]) {
        //传入vc
        self.target = aTarget;
        //传入的定时器方法
        self.aSelector = aSelector;
        
        if ([self.target respondsToSelector:self.aSelector]) {
            Method method = class_getInstanceMethod([self.target class], aSelector);
            const char *type = method_getTypeEncoding(method);
            //给timerWapper添加方法
            class_addMethod([self class], aSelector, (IMP)fireHomeWapper, type);
            
            //启动一个timer,target是self,即监听自己
            self.timer = [NSTimer scheduledTimerWithTimeInterval:ti target:self selector:aSelector userInfo:userInfo repeats:yesOrNo];
        }
    }
    return self;
}

//一直跑runloop
void fireHomeWapper(TCJTimerWapper *wapper){
    //判断target是否存在
    if (wapper.target) {
        //如果存在则需要让vc知道,即向传入的target发送selector消息,并将此时的timer参数也一并传入,所以vc就可以得知`fireHome`方法,就这事这种方式定时器方法能够执行的原因
        //objc_msgSend发送消息,执行定时器方法
        void (*cj_msgSend)(void *,SEL, id) = (void *)objc_msgSend;
         cj_msgSend((__bridge void *)(wapper.target), wapper.aSelector,wapper.timer);
    }else{
        //如果target不存在,已经释放了,则释放当前的timerWrapper
        [wapper.timer invalidate];
        wapper.timer = nil;
    }
}

//在vc的dealloc方法中调用,通过vc释放,从而让timer释放
- (void)cj_invalidate{
    [self.timer invalidate];
    self.timer = nil;
}

- (void)dealloc
{
    NSLog(@"%s",__func__);
}

@end

#ifdef DEBUG
#define CJNSLog(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ## __VA_ARGS__] UTF8String]);
#else
#define CJNSLog(format, ...);
#endif

#import "TCJTimerViewController.h"
#import "TCJTimerWapper.h"

static int num = 0;

@interface TCJTimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) TCJTimerWapper *timerWapper;
@end

@implementation TCJTimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
     self.timerWapper = [[TCJTimerWapper alloc] cj_initWithTimeInterval:1 target:self selector:@selector(fireHome) userInfo:nil repeats:YES];
}

- (void)fireHome{
    num++;
    CJNSLog(@"hello word - %d",num);
}

- (void)dealloc{
    [self.timerWapper cj_invalidate];
    CJNSLog(@"%s",__func__);
}

这种方式看起来比较繁琐,步骤很多,而且针对timerWapper,需要不断的添加method,需要进行一系列的处理.

③.4 思路四:利用NSProxy虚基类的子类——NSProxy有着NSObject同等的地位,多用于消息转发

  • 使用NSProxy打破NSTimer的对vc的强持有,但是强持有依然存在,需要手动关闭定时器
  • 持有链分别是runloop->timer->TCJProxy->timervc->TCJProxy-->vc
//************TCJProxy.h文件************
@interface TCJProxy : NSProxy
+ (instancetype)proxyWithTransformObject:(id)object;
@end

//************TCJProxy.m文件************
@interface TCJProxy()
@property (nonatomic, weak) id object;
@end

@implementation TCJProxy
+ (instancetype)proxyWithTransformObject:(id)object{
    TCJProxy *proxy = [TCJProxy alloc];
    proxy.object = object;
    return proxy;
}
-(id)forwardingTargetForSelector:(SEL)aSelector {
    return self.object;
}

//************TCJTimerViewController.m文件************
#ifdef DEBUG
#define CJNSLog(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ## __VA_ARGS__] UTF8String]);
#else
#define CJNSLog(format, ...);
#endif

#import "TCJTimerViewController.h"
#import "TCJProxy.h"

static int num = 0;

@interface TCJTimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) TCJProxy *proxy;
@end

@implementation TCJTimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.proxy = [TCJProxy proxyWithTransformObject:self];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self.proxy selector:@selector(fireHome) userInfo:nil repeats:YES];
}

- (void)fireHome{
    num++;
    CJNSLog(@"hello word - %d",num);
}

- (void)dealloc{
    [self.timer invalidate];
    self.timer = nil;
    CJNSLog(@"%s",__func__);
}

思路一较为简便,思路二合理使用中介者但是很拉胯,思路三适合装逼,思路四更适合大型项目(定时器用的较多) 详细代码

五、AutoReleasePool 自动释放池

自动释放池OC中的一种内存自动回收机制,在MRC中可以用AutoReleasePool来延迟内存的释放,在ARC中可以用AutoReleasePool将对象添加到最近的自动释放池不会立即释放,会等到runloop休眠或者超出autoreleasepool作用域{}之后才会被释放.其机制可以通过下图来表示

  1. 从程序启动到加载完成主线程对应的runloop会处于休眠状态等待用户交互唤醒runloop
  2. 用户的每一次交互都会启动一次runloop,用于处理用户的所有点击触摸事件
  3. runloop监听交互事件后,就会创建自动释放池,并将所有延迟释放对象添加到自动释放池中
  4. 在一次完整的runloop结束之前,会向自动释放池中的所有对象发送release消息,然后销毁自动释放池

① Clang分析 autoreleasepool结构

通过clang命令对空白的main.m文件输出一份main.cpp文件来查看@autoreleasepool底层结构

  • clang命令为:xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp

  • 转成C++代码如下

  • 通过上图我们知道@autoreleasepool被转化成__AtAutoreleasePool __autoreleasepool,这是个结构体.__AtAutoreleasePool结构体定义如下:

通过上图我们可以知道以下几点:

  • __AtAutoreleasePool是一个结构体,有构造函数 + 析构函数,结构体定义的对象作用域结束后,会自动调用析构函数
  • 其中{}作用域,优点是结构清晰可读性强,可以及时创建销毁

关于涉及的构造函数和析构函数的调用时机,可以通过下面一个案例来验证

从运行结果可以得出,在TCJTest创建对象时,会自动调用构造函数,在出了{}作用域后,会自动调用析构函数.

② 汇编分析 autoreleasepool结构

main代码部分加断点,运行程序,并开启汇编调试:

通过调试结果发现,和我们clang分析的结果是一样的.

③ objc源码分析 autoreleasepool

objc源码中有一段对AutoreleasePool的注释.

从中可以得出几点:

  • 1.自动释放池是一个关于指针的栈结构
  • 2.其中的指针指向释放的对象或者pool_boundary哨兵(现在经常被称为边界
  • 3.自动释放池是一个页的结构(虚拟内存中提及过),而且这个页是一个双向链表(表示有父节点子节点,在类中提及过,即类的继承链)
  • 4.自动释放池线程是有关系

通过上面对自动释放池的说明,我们知道我们研究的几个方向:

  • 1.自动释放池什么时候创建?
  • 2.对象是如何加入自动释放池的?
  • 3.哪些对象才会加入自动释放池?

带着这些问题,我们出发来探索自动释放池的底层原理

③.1 AutoreleasePoolPage分析

从最初的clang或者汇编分析我们了解了自动释放池其底层调用的objc_autoreleasePoolPushobjc_autoreleasePoolPop这两个方法,其源码实现如下[图片上传失败...(image-d024be-1615298790653)]
从源码中我们可以发现,都是调用AutoreleasePoolPagepushpop实现,以下是其定义,从定义中可以看出,自动释放池是一个页,同时也是一个对象,并且AutoreleasePoolPage是继承于AutoreleasePoolPageData的.

从上面可以做出以下判断:

  • 1.自动释放池是一个,同时也是一个对象,这个页的大小是4096字节
  • 2.从其定义中发现,AutoreleasePoolPage是继承自AutoreleasePoolPageData,且该类的属性也是来自父类,以下是AutoreleasePoolPageData的定义

可以发现:

  • 其中有AutoreleasePoolPage对象,所以有以下一个关系链AutoreleasePoolPage -> AutoreleasePoolPageData -> AutoreleasePoolPage,从这里可以说明自动释放池除了是一个页,还是一个双向链表结构
  • AutoreleasePoolPageData结构体的内存大小为56字节
    • 属性magic的类型是magic_t结构体,所占内存大小为m[4]其内存(即4*4=16字节
    • 属性next(指针)thread(对象)parent(对象)child(对象)均占8字节(即4*8=32字节
    • 属性depthhiwat类型为uint32_t,实际类型是unsigned int类型,均占4字节(即2*4=8字节
      通过上面可以知道一个空的AutoreleasePoolPage的结构如下:
objc_autoreleasePoolPush 源码分析
进入push的源码实现:

有以下逻辑:

  • 首先进行判断是否存在pool
  • 如果没有,则通过autoreleaseNewPage方法创建
  • 如果有,则通过autoreleaseFast压栈哨兵对象
autoreleaseNewPage创建新页

先来看下autoreleaseNewPage创建新页的实现过程

通过上面的代码实现(autoreleaseFullPage后面会重点分析),我们可得到以下结论

  • 1.获取当前操作页,
  • 2.如果当前操作页存在,则通过autoreleaseFullPage方法进行压栈对象
  • 3.如果当前操作页不存在,则通过autoreleaseNoPage方法创建页
    • autoreleaseNoPage方法中可知当前线程的自动释放池是通过AutoreleasePoolPage创建
    • AutoreleasePoolPage构造方法通过实现父类AutoreleasePoolPageData的初始化方法实现的.
AutoreleasePoolPage构造方法

上面说了当前线程的自动释放池是通过AutoreleasePoolPage创建,看下AutoreleasePoolPage构造方法:

其中AutoreleasePoolPageData方法传入的参数含义为:

  • begin()表示压栈的位置(即下一个要释放对象的压栈地址).可以通过源码调试begin,发现其具体实现等于页首地址+56,其中的56就是结构体AutoreleasePoolPageData的内存大小.
    • 由于在ARC模式下,是无法手动调用autorelease,所以将Demo切换至MRC模式(Build Settings -> Objectice-C Automatic Reference Counting设置为NO
这个指针地址为什么要加上56呢?这个56是哪里来的呢?其实就是AutoreleasePoolPage中的固有属性

分析:AutoreleasePoolPageData中的指针和对象都占8字节uint4字节,只有magic_t未知(因为不是个指针,所以需要看具体类型);magic_t是个指针,由于静态变量的存储区域在全局段,所以magic_t占用4*4=16字节,即AutoreleasePoolPageData结构体的内存大小为56字节.

  • objc_thread_self()是表示当前线程,而当前线程是通过tls获取

  • newParent表示父节点

  • 后续两个参数是通过父节点的深度最大入栈个数计算的depth以及hiwat

查看自动释放池内存结构

接着我们使用_objc_autoreleasePoolPrint函数来打印一下自动释放池的相关信息(记得切换为MRC模式调试,这里前面我们已经切换了)

通过运行结果如下,我们发现release是6个,但是我们压栈的对象其实只有5个,其中的POOL表示哨兵对象,即边界,其目的是为了防止越界,我们再看下打印地址,发现页的首地址(PAGE)和哨兵对象(POOL)相差0x38,转成10进制正好是56.也就是AutoreleasePoolPage自己本身的内存大小.

那么是否可以无限往AutoreleasePool中添加对象呢?答案是不能!

将循环次数i的上限改为505,其内存结构如下,发现第一页满了存储了504个释放的对象第二页只存储了一个

在将循环次数i据改为505+506,来验证第二页是否也是存储504个对象?

通过运行发现,第一页存储504,第二页存储505,第三页存储2个.

通过上述测试,我们可以得出以下结论:

  • 第一页可以存放504个对象,且只有第一页有哨兵对象,当一页压栈满了,就会开辟新的一页
  • 第二页开始,最多可以存放505个对象
  • 一页的大小等于 505 * 8 = 4040

这个结论我们之前讲AutoreleasePoolPage中的SIZE的时候就说了,一页的大小是4096字节,而在其构造函数中对象的压栈位置,是从首地址+56字节开始的,所以可以一页中实际可以存储4096-56 = 4040字节,转换成对象是 4040 / 8 = 505个,即一页最多可以存储505个对象,其中第一页有哨兵对象(由于自动释放池在初始化时会POOL_BOUNDARY哨兵对象push到栈顶,所以第一页只能存放504个对象,接下来每一页都能存放505个对象)只能存储504个.其结构图示如下

通过上面的结论,我有一个疑问:哨兵对象在一个自动释放池有几个?

  • 一个自动释放池只有一个哨兵对象,且哨兵在第一页
  • 第一页最多可以存504个对象,第二页开始最多存505

③.2 哨兵对象 -- POOL_BOUNDARY

哨兵对象本质上是个nil,它的作用主要在调用objc_autoreleasePoolPop时体现:

  • 根据传入的哨兵对象地址找到哨兵对象所在的page
  • 在当前page中,将晚于哨兵对象插入的所有autorelese对象都发送一次release消息,并移动next指针到正确位置
  • 从最新加入的对象一直向前清理,可以向前跨越若干个page,直到哨兵对象所在的page

③.3 压栈对象autoreleaseFast

进入autoreleaseFast源码:

主要有以下几步:

  • 1.获取当前操作页,并判断页是否存在以及是否满了
  • 2.如果页存在且未满,则通过add方法压栈对象
  • 3.如果页存在且满了,则通过autoreleaseFullPage方法安排新的页面
  • 4.如果页不存在,则通过autoreleaseNoPage方法创建新页
autoreleaseFullPage方法
其源码为:

这个方法主要是用于判断当前页是否已经存储满了,如果当前页已经满了,通过do-while循环查找子节点对应的页,如果不存在开辟新的AutoreleasePoolPage并设为HotPage,然后压栈对象.从上面AutoreleasePoolPage初始化方法中可以看出,主要是通过操作child对象,将当前页的child指向新建页面,由此可以得出是通过双向链表连接.

add方法
查看源码:

这个方法主要是添加释放对象,其底层是实现是通过next指针存储释放对象,并将next指针递增,表示下一个释放对象存储的位置.从这里可以看出是通过栈结构存储

③.4 autorelease 底层分析

demo中,我们通过autorelease方法,在MRC模式下,将对象压栈到自动释放池,下面来分析其底层实现:

  • 查看autorelease方法源码
  • 进入对象的autorelease实现

从这里看出,无论是压栈哨兵对象还是普通对象,都会来到autoreleaseFast方法,只是区别标识不同而以.

③.5 objc_autoreleasePoolPop 源码分析&出栈

objc_autoreleasePoolPop 源码分析

objc_autoreleasePoolPop方法中有个参数,在clang分析时,发现传入的参数是push压栈后返回的哨兵对象,即ctxt,其目的是避免出栈混乱防止将别的对象出栈,其内部是调用AutoreleasePoolPagepop方法,我们看下pop源码:

pop源码实现,主要由以下几步:

  • 1.空页面的处理,并根据token获取page
  • 2.容错处理
  • 3.通过popPage出栈页
出栈 -- popPage
查看popPage源码:

进入popPage源码,其中传入的allowDebugfalse,则通过releaseUntil出栈当前页stop位置之前的所有对象,即向栈中的对象发送release消息,直到遇到传入的哨兵对象.

releaseUntil方法

看源码我们可以知道:

  • releaseUntil的实现,主要是通过循环遍历,判断对象是否等于stop,其目的是释放stop之前的所有的对象
  • 首先通过获取page的next释放对象(即page的最后一个对象),并对next进行递减获取上一个对象
  • 判断是否是哨兵对象,如果不是则自动调用objc_release释放
kill方法

通过kill实现我们知道,主要是销毁当前页将当前页赋值为父节点页并将父节点页的child对象指针置为nil

③.6 总结

  • 1.autoreleasepool其本质是一个结构体对象,一个自动释放池对象就是,是栈结构存储,符合先进后出的原则
  • 2.页的栈底是一个56字节大小的空占位符一页总大小为4096字节
  • 3.只有第一页有哨兵对象最多存储504个对象,从第二页开始最多存储505个对象
  • 4.autoreleasepool加入要释放的对象时,底层调用的是objc_autoreleasePoolPush方法(push操作)
    • 当没有pool,即只有空占位符(存储在tls中)时,则创建页,压栈哨兵对象
    • 在页中压栈普通对象主要是通过next指针递增进行的
    • 页满了时,需要设置页的child对象为新建页
    • objc_autoreleasePush的整体底层的流程图如下
  • 5.autoreleasepool调用析构函数释放时,内部的实现是调用objc_autoreleasePoolPop方法(pop操作)
    • 在页中出栈普通对象主要是通过next指针递减进行的
    • 页空了时,需要赋值页的parent对象为当前页
    • objc_autoreleasePoolPop出栈的流程图如下

④ 提出疑问

④.1 临时变量什么时候释放?

  • 1.如果在正常情况下,一般是超出其作用域就会立即释放
  • 2.如果将临时变量加入了自动释放池会延迟释放,即在runloop休眠或者autoreleasepool作用域之后释放

④.2 自动释放池原理 即AutoreleasePool原理

  • 1.自动释放池本质是一个AutoreleasePoolPage结构体对象,是一个栈结构存储的页,每一个AutoreleasePoolPage都是以双向链表的形式连接
  • 2.自动释放池压栈出栈主要是通过结构体的构造函数析构函数调用底层的objc_autoreleasePoolPushobjc_autoreleasePoolPop,实际上是调用AutoreleasePoolPagepushpop两个方法
  • 3.每次调用push操作其实就是创建一个新的AutoreleasePoolPage,而AutoreleasePoolPage的具体操作就是插入一个POOL_BOUNDARY,并返回插入POOL_BOUNDARY的内存地址.而push内部调用autoreleaseFast方法处理,主要有以下三种情况
    • page存在,且不满时,调用add方法对象添加至page的next指针处并next递增
    • page存在,且已满时,调用autoreleaseFullPage初始化一个新的page,然后调用add方法对象添加至page栈中
    • page不存在时,调用autoreleaseNoPage创建一个hotPage,然后调用add方法对象添加至page栈中
  • 4.当执行pop操作时,会传入一个值,这个值就是push操作的返回值,即POOL_BOUNDARY的内存地址token.所以pop内部的实现就是根据token找到哨兵对象所处的page中,然后使用 objc_release释放token之前的对象,并把next指针到正确位置

④.3 自动释放池能否嵌套使用?

  • 1.可以嵌套使用,其目的是可以控制应用程序的内存峰值,使其不要太高
  • 2.可以嵌套的原因是因为自动释放池是以栈为节点,通过双向链表的形式连接的,且是和线程一一对应的
  • 3.自动释放池的多层嵌套其实就是不停的push哨兵对象,在pop时,会先释放里面的,在释放外面的

④.4 哪些对象可以加入AutoreleasePool?alloc创建可以吗?

  • 1.在MRC下使用new、alloc、copy关键字生成的对象和retain了的对象需要手动释放,不会被添加到自动释放池中
  • 2.在MRC下设置为autorelease的对象不需要手动释放,会直接进入自动释放池
  • 3.所有autorelease的对象,在出了作用域之后,会被自动添加到最近创建的自动释放池
  • 4.在ARC下只需要关注引用计数,因为创建都是在主线程进行的,系统会自动为主线程创建AutoreleasePool,所以创建的对象会自动放入自动释放池

④.5 AutoreleasePool的释放时机是什么时候?

  • 1.App启动后,苹果在主线程RunLoop里注册了两个Observer,其回调都是_wrapRunLoopWithAutoreleasePoolHandler()
  • 2.第一个Observer监视的事件是Entry(即将进入 Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池.其order是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前
  • 3.第二个Observer监视了两个事件:BeforeWaiting(准备进入休眠) 时调用 _objc_autoreleasePoolPop()_objc_autoreleasePoolPush()释放旧的池并创建新池;Exit(即 将退出Loop)时调用_objc_autoreleasePoolPop()来释放自动释放池.这个Observerorder是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后

④.6 thread和AutoreleasePool的关系

每个线程都有与之关联的自动释放池堆栈结构,新的pool创建时会被压栈到栈顶pool销毁时,会被出栈,对于当前线程来说,释放对象会被压栈到栈顶线程停止时,会自动释放与之关联的自动释放池.

④.7 RunLoop和AutoreleasePool的关系

  • 1.主程序的RunLoop在每次事件循环之前,会自动创建一个autoreleasePool
  • 2.并且会在事件循环结束时,执行drain操作,释放其中的对象

六、NSRunLoop

① RunLoop介绍

RunLoop事件接收分发机制的一个实现,是线程相关的基础框架的一部分,一个RunLoop就是一个事件处理的循环,用来不停的调度工作以及处理输入事件.

RunLoop本质是一个do-while循环,没事做就休息,来活了就干活.与普通的while循环是有区别的,普通的while循环会导致CPU进入忙等待状态,即一直消耗cpu,而RunLoop则不会,RunLoop是一种闲等待,即RunLoop具备休眠功能.

RunLoop的作用

  • 保持程序的持续运行
  • 处理App中的各种事件(触摸定时器performSelector
  • 节省cpu资源,提供程序的性能,该做事就做事,该休息就休息

RunLoop源码的下载地址,在其中找到最新版下载即可

② RunLoop和线程的关系

②.1 获取RunLoop

一般在日常开发中,对于RunLoop的获取主要有以下两种方式

②.2 CFRunLoopGetMain源码

②.3 _CFRunLoopGet0源码

通过上面可以知道,Runloop只有两种,一种是主线程的,一个是其它线程的.即Runloop和线程一一对应的.

③ RunLoop的创建

通过上面的_CFRunLoopGet0可以知道Runloop是通过__CFRunLoopCreate创建(系统创建,开发者自己是无法创建的).我们查看下__CFRunLoopCreate源码:

我们发现__CFRunLoopCreate主要是对runloop属性的赋值操作.我们继续看CFRunLoopRef的源码

可以得出以下结论:

  • 1.根据定义得知,其实RunLoop也是一个对象.是__CFRunLoop结构体的指针类型
  • 2.一个RunLoop依赖于多个Mode,意味着一个RunLoop需要处理多个事务,即一个Mode对应多个Item,而一个item中,包含了timersourceobserver,可以用下图说明

③.1 Mode类型

其中mode在苹果文档中提及的有个,而在iOS中公开暴露出来的只有 NSDefaultRunLoopModeNSRunLoopCommonModes. NSRunLoopCommonModes实际上是一个Mode的集合,默认包括 NSDefaultRunLoopModeNSEventTrackingRunLoopMode.

  • NSDefaultRunLoopMode默认的mode,正常情况下都是在这个model下运行(包括主线程)
  • NSEventTrackingRunLoopModecocoa):追踪mode,使用这个mode跟踪来自用户交互的事件(比如UITableView上下滑动流畅,为了不受其他mode影响)UITrackingRunLoopMode(iOS)
  • NSModalPanelRunLoopMode:处理modal panels事件
  • NSConnectionReplyMode:处理NSConnection对象相关事件,系统内部使用,用户基本不会使用
  • NSRunLoopCommonModes:这是一个伪模式,其为一组runloop mode的集合,将输入源加入此模式意味着在Common Modes中包含的所有模式下都可以处理.在Cocoa应用程序中,默认情况下Common Modes包含default modes,modal modes,event Tracking modes.可使用CFRunLoopAddCommonMode方法将Common Modes中添加自定义modes.

③.2 Source & Timer & Observer

  • Source表示可以唤醒RunLoop的一些事件,例如用户点击了屏幕,就会创建一个RunLoop,主要分为Source0Source1
    • Source0表示非系统事件,即用户自定义的事件
    • Source1表示系统事件,主要负责底层的通讯具备唤醒能力
  • Timer就是常用NSTimer定时器这一类
  • Observer主要用于监听RunLoop的状态变化,并作出一定响应,主要有以下一些状态

④ 测试验证

④.1 验证:RunLoop和mode是一对多

上面我们说过RunLoopmode一对多的关系,下面我们通过运行代码来实操证明. 我们先通过lldb命令获取mainRunloopcurrentRunloopcurrentMode

运行结果表明runloop在运行时的mode只有一个.

下面我们获取mainRunLoop所有的模型

从上面的打印结果可以验证runloopCFRunloopMode具有一对多的关系.

④.2 验证:mode和Item也是一对多

我们继续在断点处,通过bt查看堆栈信息,从这里看出timer的item类型如下所示(截取部分)

RunLoop源码中查看Item类型,有以下几种:

  • block应用:__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__
  • 调用timer:__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__
  • 响应source0: __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
  • 响应source1:__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
  • GCD主队列:__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
  • observer源: __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__
我们下面以Timer为例,一般初始化timer时,都会将timer通过addTimer:forMode:方法添加到Runloop中,于是在源码中查找addTimer的相关方法,即CFRunLoopAddTimer方法,其源码实现如下
  • 1.其实现主要判断是否是kCFRunLoopCommonModes,然后查找runloopmode进行匹配处理
  • 2.其中kCFRunLoopCommonModes不是一种模式,是一种抽象的伪模式,比defaultMode更加灵活
  • 3.通过CFSetAddValue(rl->_commonModeItems, rlt);可以得知,runloopmode一对多的,同时可以得出modeitem也是一对多的.

⑤ RunLoop执行

我们都知道,RunLoop的执行依赖于run方法,从下面的堆栈信息中可以看出,其底层执行的是__CFRunLoopRun方法

进入__CFRunLoopRun源码:

通过__CFRunLoopRun源码可知,针对不同的对象,有不同的处理

  • 如果有observer,则调用__CFRunLoopDoObservers
  • 如果有block,则调用__CFRunLoopDoBlocks
  • 如果有timer,则调用__CFRunLoopDoTimers
  • 如果是source0,则调用__CFRunLoopDoSources0
  • 如果是source1,则调用__CFRunLoopDoSource1

_ _CFRunLoopDoTimers

查看下__CFRunLoopDoTimers源码

主要是通过for循环,对单个timer进行处理,下面继续看__CFRunLoopDoTimer源码:

通过源码可知:主要逻辑就是timer执行完毕后,会主动调用__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__函数,正好与timer堆栈调用中的一致.

timer执行总结

  • 1.为自定义的timer,设置Mode,并将其加入RunLoop
  • 2.在RunLooprun方法执行时,会调用__CFRunLoopDoTimers执行所有timer
  • 3.在__CFRunLoopDoTimers方法中,会通过for循环执行单个timer的操作
  • 4.在__CFRunLoopDoTimer方法中,timer执行完毕后,会执行对应的timer回调函数

以上,是针对timer的执行分析,对于observerblocksource0source1,其执行原理与timer是类似的,这里就不再重复说明以下是苹果官方文档针对RunLoop处理不同源的图示

⑥ RunLoop底层原理

从上述的堆栈信息中可以看出,run在底层的实现路径为CFRunLoopRun -> CFRunLoopRun -> __CFRunLoopRun 进入CFRunLoopRun源码,其中传入的参数1.0e10(科学计数)等于1* e^10,用于表示超时时间

进入CFRunLoopRunSpecific源码:
  • 首先根据modeName找到对应的mode,然后主要分为三种情况:
    • 如果是entry,则通知observer,即将进入runloop
    • 如果是exit,则通过observer,即将退出runloop
    • 如果是其他中间状态,主要是通过runloop处理各种源

上面说到会调用__CFRunLoopRun,上面讲了在这一步里面会根据不同的事件源进行不同的处理,当RunLoop休眠时,可以通过相应的事件唤醒RunLoop.

所以,综上所述,RunLoop的执行流程,如下所示

⑦ 提出疑问

⑦.1 当前有个子线程,子线程中有个timer。timer是否能够执行,并进行持续的打印?

不可以,因为子线程的runloop默认不启动, 需要runloop run手动启动.

⑦.2 RunLoop和线程的关系

1.每个线程都有一个与之对应的RunLoop,所以RunLoop线程一一对应的,其绑定关系通过一个全局的Dictionary存储线程为keyrunloop为value.
2.线程中的RunLoop主要是用来管理线程的,当线程的RunLoop开启后,会在执行完任务后进行休眠状态,当有事件触发唤醒时,又开始工作即有活时干活,没活就休息
3.主线程RunLoop默认开启的,在程序启动之后,会一直运行,不会退出
4.其他线程RunLoop默认是不开启的,如果需要,则手动开启

⑦.3 NSRunLoop和CFRunLoopRef区别

  • 1.NSRunLoop是基于CFRunLoopRef面向对象的API,是不安全
  • 2.CFRunLoopRef是基于C语言,是线程安全的

⑦.4 Runloop的mode作用是什么?

mode主要是用于指定RunLoop中事件优先级

⑦.5 以+scheduledTimerWithTimeInterval:的方式触发的timer,在滑动页面上的列表时,timer会暂停回调,为什么?如何解决?

  • 1.timer停止的原因是因为滑动scrollView时,主线程的RunLoop会从NSDefaultRunLoopMode切换到UITrackingRunLoopMode,而timer是添加在NSDefaultRunLoopMode。所以timer不会执行
  • 2.将timer放入NSRunLoopCommonModes中执行.

写在后面

和谐学习,不急不躁.我还是我,颜色不一样的烟火.

相关文章

网友评论

      本文标题:iOS之武功秘籍⑲: 内存管理与NSRunLoop

      本文链接:https://www.haomeiwen.com/subject/lygbqltx.html