前言
曾经在写项目的时候遇到过这么一个问题。:
项目中添加了一个tableview,然后还有一个计时器,当滑动tableview的时候会阻塞计时器,你得执行这么一段代码后,计时器才能正常运行。
Swift
RunLoop.current.add(timer, forMode: .common)
发生这种情况是因为我在defaultRunLoopMode上隐式创建计时器,这实际上是我们应用程序的主线程。然后,当用户积极与我们的用户界面互动时,这将暂停,然后在他们停止时重新激活。
什么是runloop呢?
RunLoop是一个**事件循环机制,用于管理线程中的事件和消息。**它允许线程在没有任务的情况下休眠,并在有任务需要处理时唤醒线程。 RunLoop主要负责以下几个方面:
-
处理输入源:RunLoop负责处理输入源,包括用户界面事件、触摸事件、定时器事件、网络事件等,通过RunLoop能够有效地处理这些事件,让应用程序的响应更加及时。
-
保持线程活动(线程保活):RunLoop能够保持线程活动,即使在没有任务时,RunLoop也会让线程休眠而不会退出,以便随时处理来自输入源的事件。
-
定时器功能:RunLoop提供了一些定时器功能,例如延迟执行和重复执行某个任务。通过定时器功能,可以很方便地在指定时间执行任务。
-
优化性能:RunLoop能够优化应用程序的性能,通过RunLoop能够让应用程序在有任务需要处理时及时唤醒线程,而在没有任务时让线程休眠,从而避免了线程的空转,减少了CPU的占用,提高了应用程序的性能。
runloop和线程之间的关系
- 每条线程都有唯一的一个与之对应的RunLoop对象
- RunLoop保存在一个全局的Dictionary里,线程作为key,RunLoop作为value
- 线程刚创建时并没有RunLoop对象,RunLoop会在第一次获取它时创建
- RunLoop会在线程结束时销毁
- 主线程的RunLoop已经自动获取(创建,用于响应UI和用户交互事件),子线程默认没有开启RunLoop
runloop的基本组成
- Input Source(输入源):
异步事件的通道,如用户交互(手势、触摸)、网络请求等。
- Timer Source(定时器源):
定时任务,如 NSTimer,它会定期触发某个操作。RunLoop 会定期检查定时器,并在时间到达时触发相应的操作。
- Observer(观察者):
监听 RunLoop 状态的变化,如启动、休眠、唤醒、退出等。可以用于监控 RunLoop 的执行过程,便于调试或优化。
以下是Observer(观察者)的常见状态:
- kCFRunloopEntry (runloop准备启动)
- kCFRunloopBeforeTimers (通知观察者,runloop将要对Timer的一些相关事件进行处理了)
- kCFRunloopBeforeSources (将要处理一些Sources事件)
- kCFRunloopBeforeWaiting( 即将要发生用户态到内核态的切换 用户态 ---> 内核态)没事做进入内核态避免资源浪费
- kCFRunloopAfterWaiting (内核态---转--->用户态)
- kCFRunloopExit (runloop退出通知)
简单来说就是,runloop从用户态切换到内核态可以节省系统资源,使得线程在没有任务时不会浪费 CPU 时间。
RunLoop 通过监听输入源、定时器源、观察者来处理和调度事件,确保线程可以在处理完事件后及时进入休眠。
runloop的不同模式
runloop可以根据所需切换成不同的模式:
- NSDefaultRunLoopMode :默认模式,处理大多数应用事件,如 Timer定时器触发、网络事件等。
- UITrackingRunLoopMode :UI模式,专门处理UI事件
- NSRunLoopCommonModes :常用模式,允许 RunLoop 同时处理多种事件类型。使用场景包括当用户滚动 UIScrollView 时仍能处理定时器事件。
- UIInitializationRunLoopMode :在刚启动App时第进入的第一个Mode,启动完成后就不再使用
- GSEventReceiveRunLoopMode :接受系统事件的内部Mode
示例:
Swift
let timer = Timer(timeInterval: 1.0, repeats: true) { _ in
print("Timer fired during scroll")
}
//将定时器添加到 .common 模式中,确保在滚动时仍能触发
RunLoop.current.add(timer, forMode: .common)
将Timer定时器添加到.common模式中才能在ScrollView滚动时响应的原理:
- 当应用处于空闲状态时,RunLoop 处于 NSDefaultRunLoopMode(默认模式),它处理正常任务,包括 Timer 的回调、用户输入等。
- 当用户与 ScrollView 交互时,RunLoop 切换到 UITrackingRunLoopMode(UI模式),在这种模式下,RunLoop 只处理与用户交互相关的任务,确保滑动的流畅性。此时,未被标记为 "Common" 的 Timer 和其他事件源会被暂时忽略。
- 如果 Timer 只添加到 NSDefaultRunLoopMode(默认模式) ,滑动时(UITrackingRunLoopMode)它将不会触发,直到滑动结束后 RunLoop 切换回 DefaultMode,Timer才会被继续触发。
- 使用 NSRunLoopCommonModes(常用模式) 可以让 Timer 在所有标记为 "Common" 的模式下执行,保证用户滑动时依然能触发回调。
tips:Common模式默认将runloop添加到NSDefaultRunLoopMode和UITrackingRunLoopMode模式。
runloop的循环流程
- 检查并处理输入源事件。
- 检查并处理定时器事件。
- 如果没有任务,进入休眠等待新的事件。
- 当有事件到来时,唤醒并处理事件。
- 重复以上过程,直到 RunLoop 退出。
• 用户态: 是指应用程序执行的状态。RunLoop 在这个状态下负责处理任务(如用户交互、定时器)。
• 内核态: 当 RunLoop 没有任务时,它会进入内核态,等待新的事件。这个时候,线程会进入休眠,操作系统负责监控任务的到达,以减少 CPU 资源的消耗。
runloop的Input Source(输入源)分类
1.Source0**(非基于** port的事件源):
Source0 是应用程序主动触发的事件源 。它不依赖系统的 port 机制,也不会自动唤醒 RunLoop。这类事件通常是应用内部主动触发的事件,比如用户点击了按钮、触摸屏幕或某个任务完成后调用的回调函数。
特点:
- 需要手动触发。
- RunLoop 无法通过 Source0 自动唤醒,必须配合其他机制(如 Source1)来激活。
2.Source1**(基于** port的事件源):
Source1 是基于 port 机制的事件源,用于处理操作系统内核级别的事件 。它负责监听操作系统内核发出的事件或消息 (如网络 I/O、文件 I/O 等),并通过 port 机制唤醒 RunLoop,然后分发这些事件进行处理。
特点:
- 基于 port 通信,可以自动唤醒 RunLoop。
- 用于系统内部和外部进程的消息传递。
总的来说,Source0负责处理应用程序内部的主动事件(用户点击、任务回调等),需要其他机制(Soruce1)唤醒 RunLoop。而Source1 处理基于 port 的系统内核事件(例如进程间通信、网络事件等),并负责唤醒 RunLoop。
举个例子:
我们触摸屏幕,屏幕表面的事件会先包装成Event,Event先告诉source(输入源)(通过mach_port机制),Source1唤醒RunLoop,然后将事件Event分发给Source0,然后由Source0来处理。
事件处理完成后,如果没有新的事件或定时器触发,RunLoop 会再次进入休眠状态,直到下一个事件发生。
iOS使用RunLoop实现的功能
AutoreleasePool
自动释放池的创建和释放,销毁的时机如下所示:
- kCFRunLoopEntry; // 进入runloop之前,创建一个自动释放池
- kCFRunLoopBeforeWaiting; // 休眠之前,销毁自动释放池,创建一个新的自动释放池
- kCFRunLoopExit; // 退出runloop之前,销毁自动释放池
RunLoop 机制的事件响应流程
1. 硬件事件的产生和传递
当一个硬件事件发生时(例如用户触摸屏幕、按下按钮、摇晃设备等),硬件事件被系统底层的 IOKit.framework 捕获,并生成一个 IOHIDEvent 事件。这个事件首先被 SpringBoard 进程接收。SpringBoard 是 iOS 的系统进程,负责处理与设备交互相关的硬件事件,例如锁屏、静音等。
然后,SpringBoard 通过 mach_port 将事件传递给目标 App 的进程。App 中的 RunLoop 会监听来自系统的这些事件。当事件到达时,RunLoop 中注册的 Source1 事件源被触发,唤醒 RunLoop,并执行回调来处理这些事件。
2. _UIApplicationHandleEventQueue() 函数
在事件进入应用进程后,_UIApplicationHandleEventQueue() 函数负责对事件进行处理和分发。例如:
• 将触摸事件包装成 UIEvent 对象。
• 识别触摸的手势是否属于点击、拖拽、缩放等。
• 处理 UI 的交互,比如按钮点击、屏幕旋转等。
如果是常规事件,比如用户点击了一个按钮,系统会通过此函数找到事件的响应目标,比如 UIButton,并触发相应的回调函数。
手势识别
手势事件的识别过程:
• 当 _UIApplicationHandleEventQueue() 识别到某个手势时,它会取消当前的触摸事件处理(比如 touchesBegan、touchesMoved),并标记对应的 UIGestureRecognizer 为"待处理"状态。
• 苹果系统内部注册了一个 RunLoop 观察者,用来监听 BeforeWaiting 阶段(即 RunLoop 即将进入休眠状态)。当 RunLoop 进入这个状态时,观察者的回调 _UIGestureRecognizerUpdateObserver() 会被触发。
• 在这个回调中,系统会处理所有刚标记为"待处理"的手势识别器,执行它们的相应回调函数。比如,用户的双击、滑动等手势就是在这个阶段被识别并处理的。
总结: 手势识别是在 RunLoop 准备进入休眠前的最后阶段进行处理的,这样可以保证手势识别的优先级较高。
页面更新
当我们更新界面(例如修改视图的 frame,或调用 setNeedsLayout / setNeedsDisplay 方法)时,UIView 或 CALayer 会被标记为"待处理",并加入到系统的待处理队列中。
苹果系统内部同样注册了一个 RunLoop 观察者,用来监听 BeforeWaiting 和 Exit 阶段。当 RunLoop 进入这些状态时,回调 _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv() 被触发,系统会遍历所有标记为待处理的视图或图层,执行布局更新和重绘操作。
优化提示:
通过将界面更新操作推迟到 RunLoop 休眠前的阶段,可以避免在用户交互时立即刷新界面,从而提高界面响应速度。
定时器NSTimer
NSTimer工作原理:
• NSTimer 是基于 RunLoop 的定时器,底层实现为 CFRunLoopTimerRef。当你创建一个 NSTimer 并将其添加到 RunLoop 中时,RunLoop 会为定时器注册好未来的触发时间点。
• 定时器回调并不总是会在非常准确的时间点触发,因为 RunLoop 会对定时器进行一些优化。定时器有一个 tolerance 参数,允许指定触发时间的最大误差。这样可以在保证性能的前提下,避免系统资源的浪费。
时间点的错过:
如果在某个时间点执行了一个较长的任务,RunLoop 可能会错过该时间点,直接跳到下一个时间点,而不会延后执行。例如,如果 NSTimer 设置为每 10 秒触发一次,但是某次因为执行长时间任务导致错过了 10:00 的触发点,那么定时器会直接在下一个 10:10 的时间点触发。
总的来说:
-
事件响应:通过 Source1 唤醒 RunLoop,然后通过 Source0 分发事件。
-
手势识别:在 BeforeWaiting 阶段处理,优先级较高。
-
界面更新:在 BeforeWaiting 或 Exit 阶段进行 UI 布局和重绘优化。
-
定时器:NSTimer 和 CADisplayLink 都通过 RunLoop 处理,注意定时器的容忍度和长时间任务对动画和定时器的影响。
RunLoop实践
1.runloop可以做什么
- 处理Crash(程序崩溃不退出)
- 保持线程存活(线程保活)
- 监测和优化App的卡顿
线程保活 (NSOperation和GCD一样可以)(NSCondition加锁保活,不涉及RunLoop)
如果项目需求比较复杂,很多操作都需要在子线程进行,比如有很多耗时操作(图片绘制,视频下载等等),子线程执行完任务之后会自动销毁,频繁的线程创建和销毁会导致资源浪费,此时就可以使用RunLoop进行线程保活而不被销毁。我们知道,当子线程中的任务执行完毕之后就被销毁了,那么如果我们需要开启一个子线程,在程序运行过程中永远都存在,那么我们就会面临一个问题,如何让子线程永远活着,这时就要用到常驻线程:给子线程开启一个RunLoop 注意:子线程执行完操作之后就会立即释放,即使我们使用强引用子线程使子线程不被释放,也不能给子线程再次添加操作,或者再次开启。 子线程开启RunLoop的代码,先点击屏幕开启子线程并开启子线程RunLoop,然后点击button。
2.runloop组成
sources/timer/observer(卡顿检测)
其中我们可以通过 RunLoop 的 Observer 机制来检测卡顿。具体思路是:
• 监听 RunLoop 的状态变化。当 RunLoop 处于不同状态时,比如准备处理 Timer、Source、等待事件(进入休眠)、处理事件后等,都可以通过注册相应的回调函数来监听。
• 监控 RunLoop 在处理任务时的时间消耗。如果某个状态持续时间过长(例如在 BeforeWaiting 或 AfterWaiting 状态之间执行任务时超过了一定的阈值),可以认为主线程出现了卡顿。
卡顿优化的话,可以通过将耗时任务移到后台线程、合理使用 NSTimer(适当调整 tolerance(宽容度),可以减少系统资源消耗)、推迟界面更新(线程保活)等方式,可以有效优化卡顿现象,提升应用的流畅度。
影响卡顿的因素:
卡顿跟硬件有关:CPU、GPU
影响CPU性能:IO任务,过多的线程抢占CPU资源、温度过高降频
影响GPU性能:显存频率、渲染算法、大计算量
参考:
https://github.com/yanmingLiu/iOSNotes?tab=readme-ov-file#3-runloop