iOS--RunLoop原理

前言

曾经在写项目的时候遇到过这么一个问题。:

项目中添加了一个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(观察者)的常见状态:

  1. kCFRunloopEntry (runloop准备启动)
  2. kCFRunloopBeforeTimers (通知观察者,runloop将要对Timer的一些相关事件进行处理了)
  3. kCFRunloopBeforeSources (将要处理一些Sources事件)
  4. kCFRunloopBeforeWaiting( 即将要发生用户态到内核态的切换 用户态 ---> 内核态)没事做进入内核态避免资源浪费
  5. kCFRunloopAfterWaiting (内核态---转--->用户态)
  6. kCFRunloopExit (runloop退出通知)

简单来说就是,runloop从用户态切换到内核态可以节省系统资源,使得线程在没有任务时不会浪费 CPU 时间。

RunLoop 通过监听输入源、定时器源、观察者来处理和调度事件,确保线程可以在处理完事件后及时进入休眠。

runloop的不同模式

runloop可以根据所需切换成不同的模式:

  1. NSDefaultRunLoopMode :默认模式,处理大多数应用事件,如 Timer定时器触发、网络事件等。
  2. UITrackingRunLoopMode :UI模式,专门处理UI事件
  3. NSRunLoopCommonModes :常用模式,允许 RunLoop 同时处理多种事件类型。使用场景包括当用户滚动 UIScrollView 时仍能处理定时器事件。
  4. UIInitializationRunLoopMode :在刚启动App时第进入的第一个Mode,启动完成后就不再使用
  5. 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的循环流程

  1. 检查并处理输入源事件。
  2. 检查并处理定时器事件。
  3. 如果没有任务,进入休眠等待新的事件。
  4. 当有事件到来时,唤醒并处理事件。
  5. 重复以上过程,直到 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 的时间点触发。

总的来说:

  1. 事件响应:通过 Source1 唤醒 RunLoop,然后通过 Source0 分发事件。

  2. 手势识别:在 BeforeWaiting 阶段处理,优先级较高。

  3. 界面更新:在 BeforeWaiting 或 Exit 阶段进行 UI 布局和重绘优化。

  4. 定时器: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/miaoqiu/RunLoop?tab=readme-ov-file#%E6%8C%89%E7%85%A7%E5%AE%98%E6%96%B9%E6%96%87%E6%A1%A3source%E7%9A%84%E5%88%86%E7%B1%BB

https://github.com/yanmingLiu/iOSNotes?tab=readme-ov-file#3-runloop

Runloop解析_objective-c runloop-CSDN博客

RunLoop in details | kyryl horbushko

相关推荐
恋猫de小郭14 小时前
什么?Flutter 可能会被 SwiftUI/ArkUI 化?全新的 Flutter Roadmap
flutter·ios·swiftui
网安墨雨18 小时前
iOS应用网络安全之HTTPS
web安全·ios·https
福大大架构师每日一题20 小时前
37.1 prometheus管理接口源码讲解
ios·iphone·prometheus
BangRaJun2 天前
LNCollectionView-替换幂率流体
算法·ios·设计
刘小哈哈哈2 天前
iOS 多个输入框弹出键盘处理
macos·ios·cocoa
靴子学长2 天前
iOS + watchOS Tourism App(含源码可简单复现)
mysql·ios·swiftui
一如初夏丿2 天前
xcode15 报错 does not contain ‘libarclite‘
ios·xcode
杨武博2 天前
ios 混合开发应用白屏问题
ios
BangRaJun3 天前
LNCollectionView
android·ios·objective-c
二流小码农3 天前
鸿蒙元服务项目实战:终结篇之备忘录搜索功能实现
android·ios·harmonyos