引言
在 iOS 开发中,你一定遇到过这个经典问题:
页面上有一个
NSTimer,正常情况下每秒触发一次。但是当你滑动UIScrollView时,Timer 突然就不触发了,手指一松开又恢复正常。
这个现象背后的原因就是 RunLoop Mode 的切换机制。
一、什么是 RunLoop Mode?
1.1 Mode 的定义
从源码看 CFRunLoopMode 的结构:
c
// CFRunLoop.c
struct __CFRunLoopMode {
CFStringRef _name; // Mode 名称,如 "kCFRunLoopDefaultMode"
Boolean _stopped; // 是否已停止
CFMutableSetRef _sources0; // Source0 集合(非端口事件)
CFMutableSetRef _sources1; // Source1 集合(端口事件)
CFMutableArrayRef _observers; // Observer 数组
CFMutableArrayRef _timers; // Timer 数组
CFMutableDictionaryRef _portToV1SourceMap; // 端口到 Source1 的映射
mach_port_t _portSet; // 端口集合
CFIndex _observerMask; // Observer 掩码
// ... 其他字段
};
Mode 本质上是一个"容器",装着一组 Source、Timer、Observer。
1.2 RunLoop 与 Mode 的关系
c
struct __CFRunLoop {
CFMutableSetRef _modes; // 所有 Mode 的集合
CFRunLoopModeRef _currentMode; // 当前正在运行的 Mode
CFMutableSetRef _commonModes; // Common Mode 名称集合
CFMutableSetRef _commonModeItems; // Common Mode 中的所有 Item
// ... 其他字段
};
关系图示:
┌─────────────────────────────────────────────────────────────────────┐
│ CFRunLoop │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ _currentMode ──▶ 当前只能运行一个 Mode │
│ │
│ _modes (集合): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ DefaultMode │ │ TrackingMode │ ... │ │
│ │ ├─────────────────┤ ├─────────────────┤ │ │
│ │ │ • sources0 │ │ • sources0 │ │ │
│ │ │ • sources1 │ │ • sources1 │ │ │
│ │ │ • timers │ │ • timers │ │ │
│ │ │ • observers │ │ • observers │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ _commonModes: { "kCFRunLoopDefaultMode", "UITrackingRunLoopMode" }│
│ _commonModeItems: { 被标记为 Common 的 Timer/Source/Observer } │
│ │
└─────────────────────────────────────────────────────────────────────┘
二、系统定义的 Mode
2.1 五种常见 Mode
| Mode 名称 | 说明 | 使用场景 |
|---|---|---|
kCFRunLoopDefaultMode |
默认模式 | App 默认状态,无特殊交互 |
UITrackingRunLoopMode |
追踪模式 | 滑动 ScrollView 时 |
kCFRunLoopCommonModes |
通用模式(伪模式) | 包含多个 Mode 的集合 |
UIInitializationRunLoopMode |
启动模式 | App 启动时,启动完成后不再使用 |
GSEventReceiveRunLoopMode |
系统事件模式 | 接收系统内部事件 |
2.2 Mode 的代码定义
objc
// Foundation
NSString * const NSDefaultRunLoopMode = @"kCFRunLoopDefaultMode";
NSString * const NSRunLoopCommonModes = @"kCFRunLoopCommonModes";
// UIKit
NSString * const UITrackingRunLoopMode = @"UITrackingRunLoopMode";
2.3 打印主线程 RunLoop 的所有 Mode
objc
- (void)printMainRunLoopModes {
CFRunLoopRef mainLoop = CFRunLoopGetMain();
CFArrayRef allModes = CFRunLoopCopyAllModes(mainLoop);
NSLog(@"=== 主线程 RunLoop 的所有 Mode ===");
for (int i = 0; i < CFArrayGetCount(allModes); i++) {
CFStringRef mode = CFArrayGetValueAtIndex(allModes, i);
NSLog(@"Mode %d: %@", i, mode);
}
CFRelease(allModes);
}
输出:
=== 主线程 RunLoop 的所有 Mode ===
Mode 0: UITrackingRunLoopMode
Mode 1: GSEventReceiveRunLoopMode
Mode 2: kCFRunLoopDefaultMode
Mode 3: kCFRunLoopCommonModes
三、Mode 切换机制
3.1 核心规则:同一时刻只能运行一个 Mode
看源码中的关键逻辑:
c
SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl,
CFStringRef modeName, // 指定要运行的 Mode
CFTimeInterval seconds,
Boolean returnAfterSourceHandled) {
// 1. 根据 modeName 查找对应的 Mode 对象
CFRunLoopModeRef currentMode = __CFRunLoopFindMode(rl, modeName, false);
// 2. 如果 Mode 不存在,或者 Mode 中没有任何 Source/Timer,直接返回
if (NULL == currentMode || __CFRunLoopModeIsEmpty(rl, currentMode, rl->_currentMode)) {
return kCFRunLoopRunFinished;
}
// 3. ★ 设置当前 Mode ★
CFRunLoopModeRef previousMode = rl->_currentMode;
rl->_currentMode = currentMode;
// 4. 运行这个 Mode
result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);
// 5. 恢复之前的 Mode
rl->_currentMode = previousMode;
return result;
}
关键点:rl->_currentMode = currentMode;
这意味着:RunLoop 在某一时刻只会处理当前 Mode 中的事件,其他 Mode 中的事件会被"忽略"。
3.2 Mode 切换时机
┌──────────────────────────────────────────────────────────────────┐
│ Mode 切换流程图 │
└──────────────────────────────────────────────────────────────────┘
用户操作 App 系统自动切换 Mode
│ │
▼ ▼
┌───────────┐ ┌───────────────┐
│ 正常状态 │◀──────────────────────▶│ DefaultMode │
└───────────┘ └───────────────┘
│ │
│ 手指触摸屏幕 │ 系统检测到滚动手势
│ 开始滑动 ScrollView │
▼ ▼
┌───────────┐ ┌───────────────┐
│ 滑动状态 │◀──────────────────────▶│ TrackingMode │
└───────────┘ └───────────────┘
│ │
│ 手指离开屏幕 │ 滚动结束
│ │
▼ ▼
┌───────────┐ ┌───────────────┐
│ 正常状态 │◀──────────────────────▶│ DefaultMode │
└───────────┘ └───────────────┘
四、Timer 失效问题详解
4.1 问题复现
objc
- (void)viewDidLoad {
[super viewDidLoad];
// 创建一个 Timer,添加到 DefaultMode
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
repeats:YES
block:^(NSTimer *timer) {
static int count = 0;
NSLog(@"Timer 触发: %d, 当前 Mode: %@", ++count,
[[NSRunLoop currentRunLoop] currentMode]);
}];
// 添加一个 ScrollView
UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
scrollView.contentSize = CGSizeMake(self.view.bounds.size.width, 2000);
scrollView.backgroundColor = [UIColor lightGrayColor];
[self.view addSubview:scrollView];
}
现象:
Timer 触发: 1, 当前 Mode: kCFRunLoopDefaultMode
Timer 触发: 2, 当前 Mode: kCFRunLoopDefaultMode
Timer 触发: 3, 当前 Mode: kCFRunLoopDefaultMode
// 开始滑动... Timer 停止触发
// 停止滑动后
Timer 触发: 4, 当前 Mode: kCFRunLoopDefaultMode
Timer 触发: 5, 当前 Mode: kCFRunLoopDefaultMode
4.2 原因分析
┌─────────────────────────────────────────────────────────────────────┐
│ Timer 失效原因图解 │
└─────────────────────────────────────────────────────────────────────┘
【正常状态】
┌─────────────────────────────────────────┐
│ DefaultMode (当前) │
├─────────────────────────────────────────┤
│ timers: [Timer A] ◀── 会被处理 │
│ sources: [...] │
│ observers: [...] │
└─────────────────────────────────────────┘
【滑动状态】
┌─────────────────────────────────────────┐
│ DefaultMode │
├─────────────────────────────────────────┤
│ timers: [Timer A] ◀── 被忽略! │ ← RunLoop 不再处理这个 Mode
│ sources: [...] │
│ observers: [...] │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ TrackingMode (当前) │
├─────────────────────────────────────────┤
│ timers: [] ◀── 空的,没有 Timer │ ← RunLoop 只处理这个 Mode
│ sources: [滚动事件 Source] │
│ observers: [...] │
└─────────────────────────────────────────┘
Timer A 在 DefaultMode 中,但 RunLoop 当前运行在 TrackingMode,
所以 Timer A 不会被触发!
4.3 源码层面的验证
看 __CFRunLoopDoTimers 的实现:
c
static Boolean __CFRunLoopDoTimers(CFRunLoopRef rl,
CFRunLoopModeRef rlm, // 当前 Mode
uint64_t limitTSR) {
Boolean timerHandled = false;
CFMutableArrayRef timers = NULL;
// ★ 只遍历当前 Mode 的 timers ★
for (CFIndex idx = 0, cnt = rlm->_timers ? CFArrayGetCount(rlm->_timers) : 0;
idx < cnt;
idx++) {
CFRunLoopTimerRef rlt = (CFRunLoopTimerRef)CFArrayGetValueAtIndex(rlm->_timers, idx);
if (__CFIsValid(rlt) && !__CFRunLoopTimerIsFiring(rlt)) {
if (rlt->_fireTSR <= limitTSR) {
// 触发 Timer
// ...
}
}
}
return timerHandled;
}
关键:rlm->_timers 只包含当前 Mode 的 Timer,其他 Mode 的 Timer 根本不会被遍历到!
五、解决方案:CommonModes
5.1 什么是 CommonModes?
kCFRunLoopCommonModes 并不是一个真正的 Mode,而是一个"占位符"或"标记"。
当你把 Timer/Source/Observer 添加到 CommonModes 时,实际上是把它添加到所有被标记为 "Common" 的 Mode 中。
c
// 查看哪些 Mode 被标记为 Common
void CFRunLoopAddCommonMode(CFRunLoopRef rl, CFStringRef modeName) {
// 将 modeName 添加到 _commonModes 集合
CFSetAddValue(rl->_commonModes, modeName);
// 将 _commonModeItems 中的所有 Item 同步到这个新的 Common Mode
// ...
}
默认情况下,主线程的 _commonModes 包含:
objc
- (void)printCommonModes {
CFRunLoopRef mainLoop = CFRunLoopGetMain();
NSLog(@"Common Modes: %@", (__bridge NSSet *)CFRunLoopCopyCommonModes(mainLoop));
}
// 输出:
// Common Modes: {
// UITrackingRunLoopMode,
// kCFRunLoopDefaultMode
// }
5.2 添加 Timer 到 CommonModes
方法一:使用 NSRunLoop API
objc
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
repeats:YES
block:^(NSTimer *timer) {
NSLog(@"Timer 触发");
}];
// 添加到 CommonModes
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
方法二:使用 scheduledTimer + 手动调整
objc
// scheduledTimer 默认添加到 DefaultMode
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0
repeats:YES
block:^(NSTimer *timer) {
NSLog(@"Timer 触发");
}];
// 从 DefaultMode 移除,添加到 CommonModes
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
5.3 源码分析:添加到 CommonModes 的过程
c
void CFRunLoopAddTimer(CFRunLoopRef rl, CFRunLoopTimerRef rlt, CFStringRef modeName) {
if (modeName == kCFRunLoopCommonModes) {
// ★ 如果是 CommonModes ★
// 1. 获取所有 Common Mode 的名称
CFSetRef set = rl->_commonModes;
// 2. 把 Timer 添加到 _commonModeItems
CFSetAddValue(rl->_commonModeItems, rlt);
// 3. 遍历所有 Common Mode,把 Timer 添加到每个 Mode
CFTypeRef modes[256];
CFIndex cnt = CFSetGetCount(set);
CFSetGetValues(set, modes);
for (CFIndex i = 0; i < cnt; i++) {
CFStringRef mName = (CFStringRef)modes[i];
CFRunLoopModeRef rlm = __CFRunLoopFindMode(rl, mName, true);
if (rlm) {
// 添加到这个 Mode 的 timers 数组
CFMutableArrayRef timers = rlm->_timers;
CFArrayAppendValue(timers, rlt);
}
}
} else {
// 普通 Mode,只添加到指定的 Mode
CFRunLoopModeRef rlm = __CFRunLoopFindMode(rl, modeName, true);
if (rlm) {
CFMutableArrayRef timers = rlm->_timers;
CFArrayAppendValue(timers, rlt);
}
}
}
5.4 图解 CommonModes 工作原理
┌─────────────────────────────────────────────────────────────────────┐
│ CommonModes 工作原理 │
└─────────────────────────────────────────────────────────────────────┘
添加 Timer 到 CommonModes:
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
│
▼
┌─────────────────────────────────────────┐
│ 检测 modeName == CommonModes │
└─────────────────────────────────────────┘
│
┌─────────────────────────┴─────────────────────────┐
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ 添加到 _commonModeItems │ │ 遍历 _commonModes 集合 │
│ │ │ {DefaultMode, TrackingMode} │
└───────────────────────────────┘ └───────────────────────────────┘
│
┌───────────────────────────────────┴───────────────────────────────────┐
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ DefaultMode │ │ TrackingMode │
├─────────────────────────────┤ ├─────────────────────────────┤
│ timers: [Timer] ◀── 添加 │ │ timers: [Timer] ◀── 添加 │
│ sources: [...] │ │ sources: [滚动事件] │
│ observers: [...] │ │ observers: [...] │
└─────────────────────────────┘ └─────────────────────────────┘
现在无论 RunLoop 运行在哪个 Mode,Timer 都能被触发!
六、完整解决方案对比
6.1 方案一:添加到 CommonModes(推荐)
objc
// ✅ 推荐方式
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
repeats:YES
block:^(NSTimer *timer) {
NSLog(@"Timer 触发, Mode: %@", [[NSRunLoop currentRunLoop] currentMode]);
}];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
输出(滑动时也会触发):
Timer 触发, Mode: kCFRunLoopDefaultMode
Timer 触发, Mode: kCFRunLoopDefaultMode
Timer 触发, Mode: UITrackingRunLoopMode ← 滑动时
Timer 触发, Mode: UITrackingRunLoopMode ← 滑动时
Timer 触发, Mode: kCFRunLoopDefaultMode
6.2 方案二:使用 GCD Timer
GCD Timer 不依赖 RunLoop:
objc
// ✅ GCD Timer 不受 Mode 影响
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER,
0, 0,
dispatch_get_main_queue());
dispatch_source_set_timer(timer,
DISPATCH_TIME_NOW,
1.0 * NSEC_PER_SEC, // 间隔
0.1 * NSEC_PER_SEC); // 允许的误差
dispatch_source_set_event_handler(timer, ^{
NSLog(@"GCD Timer 触发");
});
dispatch_resume(timer);
// 保持引用
self.gcdTimer = timer;
6.3 方案三:使用 CADisplayLink
如果是与屏幕刷新相关的定时任务:
objc
CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self
selector:@selector(update:)];
// 添加到 CommonModes
[displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
- (void)update:(CADisplayLink *)link {
NSLog(@"帧刷新");
}
6.4 方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| CommonModes | 简单直接 | 稍微影响滑动性能 | 大多数场景 |
| GCD Timer | 精度高,不受 Mode 影响 | 需要手动管理生命周期 | 精确计时 |
| CADisplayLink | 与屏幕刷新同步 | 回调频率固定 | 动画、游戏 |
七、深入:自定义 Mode
7.1 创建自定义 Mode
objc
// 定义自定义 Mode 名称
static NSString * const MyCustomRunLoopMode = @"com.example.MyCustomMode";
- (void)testCustomMode {
// 创建 Timer
NSTimer *timer = [NSTimer timerWithTimeInterval:0.5
repeats:YES
block:^(NSTimer *timer) {
NSLog(@"自定义 Mode 中的 Timer 触发");
}];
// 添加到自定义 Mode
[[NSRunLoop currentRunLoop] addTimer:timer forMode:MyCustomRunLoopMode];
// 显式运行自定义 Mode
// 注意:这会阻塞当前线程,直到 Mode 退出
[[NSRunLoop currentRunLoop] runMode:MyCustomRunLoopMode
beforeDate:[NSDate dateWithTimeIntervalSinceNow:5]];
}
7.2 将自定义 Mode 加入 CommonModes
objc
// 将自定义 Mode 标记为 Common
CFRunLoopAddCommonMode(CFRunLoopGetCurrent(),
(__bridge CFStringRef)MyCustomRunLoopMode);
// 现在添加到 CommonModes 的 Item 也会被添加到 MyCustomRunLoopMode
八、实际应用案例
8.1 案例:图片轮播器不受滑动影响
objc
@interface ImageCarouselView : UIView
@property (nonatomic, strong) NSTimer *autoScrollTimer;
@property (nonatomic, strong) UIScrollView *scrollView;
@end
@implementation ImageCarouselView
- (void)setupAutoScroll {
// 使用 CommonModes,滑动时也能自动轮播
self.autoScrollTimer = [NSTimer timerWithTimeInterval:3.0
repeats:YES
block:^(NSTimer *timer) {
[self scrollToNextPage];
}];
[[NSRunLoop currentRunLoop] addTimer:self.autoScrollTimer
forMode:NSRunLoopCommonModes];
}
- (void)scrollToNextPage {
CGFloat nextOffset = self.scrollView.contentOffset.x + self.scrollView.bounds.size.width;
if (nextOffset >= self.scrollView.contentSize.width) {
nextOffset = 0;
}
[self.scrollView setContentOffset:CGPointMake(nextOffset, 0) animated:YES];
}
@end
8.2 案例:滑动时暂停 Timer(有时这才是需求)
有时候你希望滑动时 Timer 暂停,使用 DefaultMode 反而是正确的选择:
objc
// 只在 DefaultMode 运行,滑动时自动暂停
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0
repeats:YES
block:^(NSTimer *timer) {
// 这个 Timer 在滑动时会自动暂停
// 适用于:视频播放进度更新等场景
[self updatePlaybackProgress];
}];
8.3 案例:监听 Mode 切换
objc
- (void)observeRunLoopModeChange {
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault,
kCFRunLoopEntry | kCFRunLoopExit,
YES,
0,
^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
CFRunLoopMode mode = CFRunLoopCopyCurrentMode(CFRunLoopGetCurrent());
if (activity == kCFRunLoopEntry) {
NSLog(@"🟢 进入 Mode: %@", mode);
} else if (activity == kCFRunLoopExit) {
NSLog(@"🔴 退出 Mode: %@", mode);
}
CFRelease(mode);
}
);
// 添加到 CommonModes 以观察所有 Mode 的切换
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
滑动 ScrollView 时的输出:
🟢 进入 Mode: kCFRunLoopDefaultMode
// 开始滑动
🔴 退出 Mode: kCFRunLoopDefaultMode
🟢 进入 Mode: UITrackingRunLoopMode
// 停止滑动
🔴 退出 Mode: UITrackingRunLoopMode
🟢 进入 Mode: kCFRunLoopDefaultMode
九、总结
核心要点
┌────────────────────────────────────────────────────────────────────┐
│ RunLoop Mode 核心要点 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Mode 是什么? │
│ • 一个容器,装着 Source、Timer、Observer │
│ • RunLoop 同一时刻只能运行在一个 Mode │
│ │
│ 2. 为什么滑动时 Timer 失效? │
│ ┌────────────────────────────────────────┐ │
│ │ 滑动时 Mode 切换: │ │
│ │ DefaultMode ──▶ TrackingMode │ │
│ │ │ │
│ │ Timer 在 DefaultMode 中 │ │
│ │ RunLoop 只处理 TrackingMode 的事件 │ │
│ │ 所以 Timer 不触发 │ │
│ └────────────────────────────────────────┘ │
│ │
│ 3. 如何解决? │
│ • 添加到 CommonModes(推荐) │
│ • 使用 GCD Timer │
│ • 使用 CADisplayLink + CommonModes │
│ │
│ 4. CommonModes 原理 │
│ • 不是真正的 Mode,是一个"标记" │
│ • 添加到 CommonModes = 添加到所有被标记为 Common 的 Mode │
│ • 默认 Common Modes: DefaultMode + TrackingMode │
│ │
│ 5. 主要 Mode 列表 │
│ • kCFRunLoopDefaultMode - 默认状态 │
│ • UITrackingRunLoopMode - 滑动状态 │
│ • kCFRunLoopCommonModes - 通用标记 │
│ │
└────────────────────────────────────────────────────────────────────┘
一句话总结
RunLoop 在同一时刻只能运行在一个 Mode,滑动时会切换到 TrackingMode,导致 DefaultMode 中的 Timer 不触发。解决方案是将 Timer 添加到 CommonModes,这样它会被自动添加到所有 Common Mode 中。