欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

iOS开发教程之Runloop使用技巧

程序员文章站 2022-05-18 18:33:58
在ios开发过程中,runloop的使用也是不容小觑的,虽然也是不太常用,但是这部分对于ios开发也是相当重要的,而且在面试找工作的时候也是面试官必考的部分。那么下来就来谈谈runloop的理论及使...

在ios开发过程中,runloop的使用也是不容小觑的,虽然也是不太常用,但是这部分对于ios开发也是相当重要的,而且在面试找工作的时候也是面试官必考的部分。那么下来就来谈谈runloop的理论及使用。

一、runloop概念

1.runloop概念:runloops是与线程相关的基础框架的一部分。一个runloop就是一个事件处理的循环,用来不停的调度工作以及处理输入事件。其实它内部就是do-while循环,这个循环内部不断地处理各种任务(比如timer,observer)。使用runloop的目的是让线程在有工作任务的时候忙于工作,在没工作任务的时候处于休眠状态。

2.nsrunloop和cfrunloopref

在开发的时候我们不能在一个线程中去操作另外一个线程的runloop对象,如果这样做很可能会造成无法估量的后果。不过值得庆幸的是corefundation中的不透明类cfrunloopref是线程安全的,而且这两种类型的runloop完全可以混合使用。

cocoa中的nsrunloop类可以通过实例方法:- (cfrunloopref)getcfrunloop;
获取对应的cfrunloopref类,来达到线程安全的目的。
cfrunloopref是在corefoundation框架内的,它提供了c语言函数的api,所有这些api都是关于线程安全的。
nsrunloop是基于cfrunloopref的封装,提供了面向对象的api,但这些api不是线程安全的。

3.runloop和线程的关系

runloop,见名知意,loop表示某种循环,和run放在一起就表示一直在运行着的循环。实际上,runloop和线程是密不可分的,可以说runloop是为了线程而生,没有线程,runloop就没有存在的必要。runloops是线程的基础架构部分,cocoa和corefundation都提供了runloop对象方便配置和管理线程的runloop(以下都已cocoa为例)。每个线程,包括程序的主线程(main thread)都有与之相应的runloop对象。

4.主线程中的runloop默认情况下是启动的

ios应用程序里面,程序启动后会有一个如下的main()函数:
int main(int argc,char *argv[]){
@autoreleasepool {
return uiapplicationmain(argc, argv, nil, nsstringfromclass([appdelegate class]));
}
}
重点是uiapplicationmain()函数,这个方法会为main thread设置一个nsrunloop对象,这就诠释了刚开始说的为啥我们的应用可以在无人操作的时候休息,需要让它干活的时候又能立马响应。对于其它线程来说,runloop默认是没有启动的,如果你需要更多的线程交互则可以手动配置和启动,如果线程只是去执行一个长时间的已确定的任务则不需要。

在任何一个cocoa程序的线程中,都可以通过:nsrunloop *runloop = [nsrunloop currentrunloop];来获取到当前线程的runloop。

5.runloop的接口和几个类

在 corefoundation 里面关于 runloop 有5个类:cfrunloopref、cfrunloopmoderef、cfrunloopsourceref、cfrunlooptimerref、cfrunloopobserverref,其中cfrunloopmoderef类并没有对外暴露,只是通过cfrunloopref 的接口进行了封装。它们的关系如下:

iOS开发教程之Runloop使用技巧

一个 runloop包含若干个mode,每个mode又包含若干个 source/timer/observer。每次调用runloop 的主函数时,只能指定其中一个mode,这个mode被称作currentmode。如果需要切换mode,只能退出 loop,再重新指定一个mode进入。这样做主要是为了分隔开不同组的 source/timer/observer,让其互不影响。
cfrunloopsourceref 是事件产生的地方。source有两个版本:source0 和 source1。
source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 cfrunloopsourcesignal(source),将这个 source 标记为待处理,然后手动调用 cfrunloopwakeup(runloop) 来唤醒 runloop,让其处理这个事件。
source1 包含了一个 mach_port和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 source 能主动唤醒 runloop 的线程,其原理在下面会讲到。
cfrunlooptimerref 是基于时间的触发器,它和 nstimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 runloop 时,runloop会注册对应的时间点,当时间点到时,runloop会被唤醒以执行那个回调。
cfrunloopobserverref 是观察者,每个observer 都包含了一个回调(函数指针),当 runloop的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

typedef cf_options(cfoptionflags, cfrunloopactivity) {

kcfrunloopentry = (1ul << 0), // 即将进入loop

kcfrunloopbeforetimers = (1ul << 1), // 即将处理 timer

kcfrunloopbeforesources = (1ul << 2), // 即将处理 source

kcfrunloopbeforewaiting = (1ul << 5), // 即将进入休眠

kcfrunloopafterwaiting = (1ul << 6), // 刚从休眠中唤醒

kcfrunloopexit = (1ul << 7), // 即将退出loop

};

上面的 source/timer/observer被统称为mode item,一个item可以被同时加入多个mode。但一个 item 被重复加入同一个mode时是不会有效果的。若一个mode中一个 item都没有,则runloop会直接退出,不进入循环。

二、runloop使用场景

1.autoreleasepool
app启动后,苹果在主线程 runloop 里注册了两个 observer,其回调都是 _wraprunloopwithautoreleasepoolhandler()。
第一个 observer 监视的事件是 entry(即将进入loop),其回调内会调用 _objc_autoreleasepoolpush() 创建自动释放池。其 order 是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。
第二个 observer 监视了两个事件: beforewaiting(准备进入休眠) 时调用_objc_autoreleasepoolpop() 和 _objc_autoreleasepoolpush() 释放旧的池并创建新池;exit(即将退出loop) 时调用 _objc_autoreleasepoolpop() 来释放自动释放池。这个 observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。
在主线程执行的代码,通常是写在诸如事件回调、timer回调内的。这些回调会被runloop创建好的 autoreleasepool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 pool了。


2.定时器
nstimer 其实就是 cfrunlooptimerref,他们之间是 toll-free bridged 的。一个 nstimer 注册到 runloop 后,runloop 会为其重复的时间点注册好事件。eg:10:10, 10:20 这几个时间点。runloop为了节省资源,并不会在非常准确的时间点回调这个timer。timer有个属性叫做tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。
如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。
cadisplaylink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 nstimer 并不一样,其内部实际是操作了一个 source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 nstimer 相似),造成界面卡顿的感觉。在快速滑动tableview时,即使一帧的卡顿也会让用户有所察觉。facebook 开源的 asyncdisplaylink 就是为了解决界面卡顿的问题,其内部也用到了 runloop,这个稍后我会再单独写一页博客来分析。


3.performselecter
当调用 nsobject 的 performselecter:afterdelay: 后,实际上其内部会创建一个 timer 并添加到当前线程的 runloop 中。所以如果当前线程没有 runloop,则这个方法会失效。当调用 performselector:onthread: 时,实际上其会创建一个 timer 加到对应的线程去,同样的,如果对应线程没有 runloop 该方法也会失效。


4.事件响应
苹果注册了一个 source1 (基于 mach port 的) 用来接收事件,其回调函数为 __iohideventsystemclientqueuecallback()。当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 iokit.framework 生成一个 iohidevent 事件并由 springboard 接收。springboard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 event,随后用 mach port 转发给需要的app进程。随后苹果注册的那个 source1 就会触发回调,并调用 _uiapplicationhandleeventqueue() 进行应用内部的分发。_uiapplicationhandleeventqueue() 会把 iohidevent 处理并包装成 uievent 进行处理或分发,其中包括识别 uigesture/处理屏幕旋转/发送给 uiwindow 等。通常事件比如 uibutton 点击、touchesbegin/move/end/cancel 事件都是在这个回调中完成的。

5.手势识别
当上面的 _uiapplicationhandleeventqueue() 识别了一个手势时,其首先会调用 cancel 将当前的 touchesbegin/move/end 系列回调打断。随后系统将对应的 uigesturerecognizer 标记为待处理。苹果注册了一个 observer 监测 beforewaiting (loop即将进入休眠) 事件,这个observer的回调函数是 _uigesturerecognizerupdateobserver(),其内部会获取所有刚被标记为待处理的 gesturerecognizer,并执行gesturerecognizer的回调。当有 uigesturerecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。


6.界面更新
当在操作 ui 时,比如改变了 frame、更新了 uiview/calayer 的层次时,或者手动调用了 uiview/calayer 的 setneedslayout/setneedsdisplay方法后,这个 uiview/calayer 就被标记为待处理,并被提交到一个全局的容器去。苹果注册了一个 observer 监听 beforewaiting(即将进入休眠) 和 exit (即将退出loop) 事件,回调去执行一个很长的函数:_zn2ca11transaction17observer_callbackep19__cfrunloopobservermpv()。这个函数里会遍历所有待处理的 uiview/calayer 以执行实际的绘制和调整,并更新 ui 界面。
这个函数内部的调用栈大概是这样的:

_zn2ca11transaction17observer_callbackep19__cfrunloopobservermpv()

quartzcore:ca::transaction::observer_callback:

ca::transaction::commit();

ca::context::commit_transaction();

ca::layer::layout_and_display_if_needed();

ca::layer::layout_if_needed();

[calayer layoutsublayers];

[uiview layoutsubviews];

ca::layer::display_if_needed();

[calayer display];

[uiview drawrect];

 

7.关于gcd
实际上 runloop 底层也会用到 gcd 的东西。但同时 gcd 提供的某些接口也用到了 runloop, 例如 dispatch_async()。当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libdispatch 会向主线程的 runloop 发送消息,runloop会被唤醒,并从消息中取得这个 block,并在回调 __cfrunloop_is_servicing_the_main_dispatch_queue__() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libdispatch 处理的。


8.关于网络请求
ios 中,关于网络请求的接口自下至上有如下几层:

cfsocket

cfnetwork ->asihttprequest

nsurlconnection ->afnetworking

nsurlsession ->afnetworking2, alamofire

? cfsocket 是最底层的接口,只负责 socket 通信。

? cfnetwork 是基于 cfsocket 等接口的上层封装,asihttprequest 工作于这一层。

? nsurlconnection 是基于 cfnetwork 的更高层的封装,提供面向对象的接口,afnetworking 工作于这一层。

? nsurlsession 是 ios7 中新增的接口,表面上是和 nsurlconnection 并列的,但底层仍然用到了 nsurlconnection 的部分功能 (比如 com.apple.nsurlconnectionloader 线程),afnetworking2 和 alamofire 工作于这一层。

下面主要介绍下 nsurlconnection 的工作过程。
通常使用 nsurlconnection 时,你会传入一个 delegate,当调用了 [connection start] 后,这个 delegate 就会不停收到事件回调。实际上,start 这个函数的内部会会获取 currentrunloop,然后在其中的 defaultmode 添加了4个 source0 (即需要手动触发的source)。cfmultiplexersource 是负责各种 delegate 回调的,cfhttpcookiestorage 是处理各种 cookie 的。
当开始网络传输时,我们可以看到 nsurlconnection 创建了两个新线程:com.apple.nsurlconnectionloader 和 com.apple.cfsocket.private。其中 cfsocket 线程是处理底层 socket 连接的。nsurlconnectionloader 这个线程内部会使用 runloop 来接收底层 socket 的事件,并通过之前添加的 source0 通知到上层的 delegate。
nsurlconnectionloader 中的 runloop 通过一些基于 mach port 的 source 接收来自底层 cfsocket 的通知。当收到通知后,其会在合适的时机向 cfmultiplexersource 等 source0 发送通知,同时唤醒delegate 线程的 runloop 来让其处理这些通知。cfmultiplexersource 会在 delegate 线程的 runloop 对 delegate 执行实际的回调。

三、runloop内部逻辑

iOS开发教程之Runloop使用技巧

由上图可以看到,实际上runloop就是这样一个函数,其内部是一个 do-while 循环。当你调用cfrunlooprun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。<