如何让基于定时器的MacOS应用在屏保、显示器关闭时保持正常运行速率
问题描述
我开发了一款使用NSTimer的准实时模拟MacOS应用,需要连续运行数小时甚至数天。目前遇到的问题是:当屏保开启、电脑进入休眠状态或显示器关闭时,应用无法保持实时运行;只有通过工具阻止电脑/显示器休眠,同时通过「触发角」禁用屏保时,应用才能按预期实时运行。
问题复现步骤
- 新建一个MacOS 「App」项目
- 将
ViewController.swift中的代码替换为以下代码 - 运行应用
- 激活屏保或关闭显示器
class ViewController: NSViewController { var startTime:Date? override func viewDidLoad() { super.viewDidLoad() var maxValue:TimeInterval = -1e20 // Create a Timer that fires every second let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in if let delta = self?.startTime?.timeIntervalSinceNow { let value = abs(delta) if value > maxValue { maxValue = value } print("delta: \(value) max: \(maxValue)") } self?.startTime = Date() } RunLoop.current.add(timer, forMode: RunLoop.Mode.default) } }
按预期闭包代码应每秒触发一次,但实际屏保开启时,代码约每11秒才执行一次。
定时器并非实时机制。如果定时器的触发时间恰逢运行循环执行长时间调用,或是运行循环处于未监听该定时器的模式下,定时器只会等到运行循环下一次检查定时器时才会触发。因此定时器的实际触发时间可能会大幅延后。
——苹果官方NSTimer文档
请问如何开发基于时间的应用,使其在屏保开启、显示器关闭的场景下也能保持稳定的触发速率?
运行环境:MacOS 10.15.3,SDK:Xcode 11.3
解决方案
1. 调整NSTimer的RunLoop监听模式
你当前将Timer绑定到了RunLoop.Mode.default,该模式在屏保激活、App退到后台时会被系统暂停调度。可以改为绑定RunLoop.Mode.common,该模式覆盖了绝大多数系统运行场景,屏保激活时也会保持调度:
// 替换原有添加Timer的代码即可 RunLoop.current.add(timer, forMode: .common)
该方案改动最小,但还是受NSTimer本身的调度精度限制,极端场景下还是可能出现小幅延迟。
2. 替换为GCD DispatchSourceTimer
NSTimer完全依赖RunLoop调度,天生精度有限,更推荐使用不受RunLoop模式影响的GCD定时器,屏保、息屏场景下的触发稳定性远高于NSTimer:
class ViewController: NSViewController { var startTime: Date? // 持有定时器避免被释放 private var gcdTimer: DispatchSourceTimer? override func viewDidLoad() { super.viewDidLoad() var maxValue: TimeInterval = -1e20 // 初始化定时器,可根据需要指定执行队列,不需要UI操作的话可以用后台队列 gcdTimer = DispatchSource.makeTimerSource(queue: .main) // 配置每秒触发一次,leeway参数设为0表示要求最高精度,可根据业务接受度调大降低功耗 gcdTimer?.schedule(deadline: .now(), repeating: 1, leeway: .milliseconds(0)) // 配置触发回调 gcdTimer?.setEventHandler { [weak self] in guard let self = self else { return } if let delta = self.startTime?.timeIntervalSinceNow { let value = abs(delta) if value > maxValue { maxValue = value } print("delta: \(value) max: \(maxValue)") } self.startTime = Date() } // 启动定时器 gcdTimer?.resume() } }
3. 申请系统空闲休眠豁免
如果你的应用需要保证即使系统处于长时间空闲状态也不会进入休眠打断任务,可以通过系统API申请休眠豁免权限:
// 申请空闲休眠豁免,reason字段需要如实填写,会在系统活动监视器中展示 let holdActivity = ProcessInfo.processInfo.beginActivity( options: [.idleSystemSleepDisabled, .automaticTerminationDisabled], reason: "准实时模拟任务需要持续运行" ) // 任务结束后需要手动释放权限,避免系统一直保持运行浪费功耗 // ProcessInfo.processInfo.endActivity(holdActivity)
注意该配置仅能阻止系统因为空闲自动进入休眠,如果用户手动让电脑休眠,该权限不会生效,休眠状态下所有用户态进程都会被暂停,定时器无法触发。
内容的提问来源于stack exchange,提问作者Epsilon

