版本记录
版本号 | 时间 |
---|---|
V1.0 | 2019.03.24 星期日 |
前言
定时器NSTimer大家都用过,包括轮询等都是通过定时器实现的,在定时器使用的时候大家不仅要知道使用原理还要知道其中的一些注意事项。接下来这个专题我们就一起走进定时器。
开始
首先看下写作环境
Swift 4.2, iOS 12, Xcode 10
在这个iOS计时器教程中,您将了解计时器工作原理,影响UI响应和电池以及如何使用CADisplayLink
处理动画。
想象一下,你正在开发一个应用程序,你需要在将来触发某个动作 - 甚至可能反复。 在Swift中提供一个提供此功能的概念会对您有所帮助,对吧? 这正是Swift的Timer
类可以做到的地方。
通常使用Timer
来调度应用程序中的内容。 例如,这可能是一次性事件或定期发生的事情。
在本教程中,您将了解Timer如何在iOS中工作,它如何影响UI响应,如何使用Timer提高设备电源使用率以及如何使用CADisplayLink
进行动画制作。
本教程将指导您构建ToDo
应用程序。 ToDo
应用程序跟踪任务的时间进度,直到任务完成。 任务完成后,应用程序会向用户表示简单的动画。
准备好在iOS中探索计时器的神奇世界了吗? 是时候潜入了!
打开入门项目并查看项目文件。 建立并运行。 你会看到一个简单的ToDo
应用程序:
首先,您将在应用中创建一个新任务。 点击+
标签栏按钮添加新任务。 输入任务的名称(例如,“购买食品”)。 点按OK
,很好!
添加的任务将反映时间签名。 您创建的新任务标记为零秒。 您会注意到秒标签此刻不会增加。
除了添加任务外,您还可以将其标记为完成。 点击您创建的任务。 这样做会删除任务名称并将任务标记为已完成。
Creating Your First Timer
对于您的第一个任务,您将创建应用程序的主计时器。 如上所述,Swift的Timer
类(也称为NSTimer
)是一种灵活的方式,可以在将来的某个时间点安排工作,定期或仅一次。
打开TaskListViewController.swift
并将以下变量添加到TaskListViewController
:
var timer: Timer?
然后,在TaskListViewController.swift
的底部声明一个扩展:
// MARK: - Timer
extension TaskListViewController {
}
并将以下代码添加到TaskListViewController
扩展:
@objc func updateTimer() {
// 1
guard let visibleRowsIndexPaths = tableView.indexPathsForVisibleRows else {
return
}
for indexPath in visibleRowsIndexPaths {
// 2
if let cell = tableView.cellForRow(at: indexPath) as? TaskTableViewCell {
cell.updateTime()
}
}
}
这个方法:
- 1) 检查
tableView
包含任务中是否有任何可见行。 - 2) 为每个可见单元调用
updateTime
。 此方法更新单元时间。 看看它在TaskTableViewCell.swift
文件中的作用。
现在,将以下代码添加到TaskListViewController
扩展:
func createTimer() {
// 1
if timer == nil {
// 2
timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(updateTimer),
userInfo: nil,
repeats: true)
}
}
在这里,您:
- 1) 检查
timer
是否包含定时器Timer
的实例。 - 2) 如果没有,请将
timer
设置为每秒调用updateTimer()
的重复Timer
。
接下来,您需要在用户添加任务时创建计时器。 为此,请将以下方法作为presentAlertController(_ :)
中的第一行代码调用:
createTimer()
构建并运行您的应用程序。
要测试您的工作,请使用与以前相同的步骤创建几个新任务。
您会注意到,table view
单元格的时间标签现在每秒更新经过的时间。
Adding Timer Tolerance
增加应用中的计时器数量会增加降低应用响应速度和降低功耗的风险。每个计时器都会尝试以每秒精确的一秒钟标记自己。这是因为Timer
的默认容差值为零。
增加计时器Timer
的容差是减少它对您的应用程序产生的能量影响的简单方法。它允许系统在计划的启动日期和计划的启动日期加上公差时间之间的任何时间启动计时器 - 不会在计划的启动日期之前。
对于重复计时器,系统会根据原始启动日期计算下一个启动日期,忽略在各个启动时间应用的公差。这是为了避免时间漂移(time drift)
。
为避免应用程序响应性降低和功耗增加导致的任何副作用,您可以将计时器属性的容差tolerance
设置为0.1。
在createTimer()
中,在将计时器设置为Timer
之后,添加以下代码行:
timer?.tolerance = 0.1
构建并运行。
这些变化可能在视觉上并不明显。 但是,您的用户将受益于应用响应能力和电源效率。
Trying Out Timers in the Background
您可能想知道当您的应用程序进入后台时计时器会发生什么。
要调查此问题,请将以下代码添加为updateTimer()
的第一行:
if let fireDateDescription = timer?.fireDate.description {
print(fireDateDescription)
}
这使您可以看到Timer
从控制台触发的时间。
建立并运行。 接下来,像以前一样添加任务。 返回设备主屏幕,然后再次打开ToDo
应用程序。
您将在控制台中看到与此类似的内容:
如您所见,当您的应用程序进入后台时,iOS会暂停所有正在运行的计时器。当应用程序再次进入前台时,iOS将恢复计时器。
Understanding Run Loops
运行循环是一个事件处理循环,它调度工作并管理传入事件的接收。当有工作时,运行循环使线程保持忙碌,并且当没有工作时它将线程置于休眠状态。
每次在iOS上启动应用程序时,系统都会创建一个Thread
- 主线程。每个线程都根据需要自动为其创建RunLoop
。
但为什么这对你有用?目前,每个Timer
都在主线程上触发并附加到RunLoop
。您可能知道,主线程负责绘制用户界面,监听触摸等。当主线程忙于其他事情时,您的应用程序的UI可能会变得无法响应并出现意外行为。
您是否注意到拖动table view
时任务单元格的时间标签会暂停?滚动表视图时,计时器不会触发延迟。
此问题的解决方案是将RunLoop
设置为使用不同模式mode
运行计时器。 更多关于下一个!
Utilizing Run Loop Modes
运行循环模式是输入源(例如屏幕触摸或鼠标单击)和可以观察的计时器的集合,以及在事件发生时要通知的运行循环观察器的集合。
iOS中有三种运行循环模式:
- 1)
default
:处理非NSConnectionObjects
的输入源。 - 2)
common
:处理一组运行循环模式,您可以为其定义一组源,计时器和观察器。 - 3)
tracking
:处理应用的响应式用户界面。
出于应用程序的目的,common
运行循环模式听起来像是最佳匹配。 要使用它,请转到createTimer()
并使用以下代码替换其内容:
if timer == nil {
let timer = Timer(timeInterval: 1.0,
target: self,
selector: #selector(updateTimer),
userInfo: nil,
repeats: true)
RunLoop.current.add(timer, forMode: .common)
timer.tolerance = 0.1
self.timer = timer
}
此代码段与前面的代码之间的主要区别在于,在设置TaskListViewController
的计时器之前,新代码会在运行循环中以common
模式添加计时器。
现在,构建并运行!
恭喜,即使您在滚动表格视图时,您的表格单元格的时间标签也会响应!
Adding a Task Completion Animation
现在,您将在用户完成所有任务时添加祝贺动画。
您将创建一个自定义动画 - 一个从屏幕底部到顶部的气球!
将以下变量添加到TaskListViewController
的顶部:
// 1
var animationTimer: Timer?
// 2
var startTime: TimeInterval?, endTime: TimeInterval?
// 3
let animationDuration = 3.0
// 4
var height: CGFloat = 0
这些变量的目的是:
- 1) 处理动画计时器。
- 2) 处理动画开始时间和结束时间。
- 3) 指定动画持续时间。
- 4) 处理动画高度。
现在,将以下TaskListViewController
扩展代码添加到TaskListViewController.swift
的末尾:
// MARK: - Animation
extension TaskListViewController {
func showCongratulationAnimation() {
// 1
height = UIScreen.main.bounds.height + balloon.frame.size.height
// 2
balloon.center = CGPoint(x: UIScreen.main.bounds.width / 2,
y: height + balloon.frame.size.height / 2)
balloon.isHidden = false
// 3
startTime = Date().timeIntervalSince1970
endTime = animationDuration + startTime!
// 4
animationTimer = Timer.scheduledTimer(withTimeInterval: 1 / 60,
repeats: true) { timer in
// TODO: Animation here
}
}
}
在上面的代码中,您:
- 1) 根据设备的屏幕高度计算动画的正确高度。
- 2) 将气球置于屏幕外部并设置其可见性。
- 3) 创建
startTime
并通过将animationDuration
添加到startTime
来计算endTime
。 - 4) 启动动画计时器,让它使用基于块的
Timer API
每秒更新动画进度60次。
接下来,您需要创建用于更新祝贺动画的逻辑。 为此,请在showCongratulationAnimation()
之后添加以下代码:
func updateAnimation() {
// 1
guard
let endTime = endTime,
let startTime = startTime
else {
return
}
// 2
let now = Date().timeIntervalSince1970
// 3
if now >= endTime {
animationTimer?.invalidate()
balloon.isHidden = true
}
// 4
let percentage = (now - startTime) * 100 / animationDuration
let y = height - ((height + balloon.frame.height / 2) / 100 *
CGFloat(percentage))
// 5
balloon.center = CGPoint(x: balloon.center.x +
CGFloat.random(in: -0.5...0.5), y: y)
}
在这里,您:
- 1) 检查
endTime
和startTime
不为nil
。 - 2) 将当前时间保存为常量。
- 3) 确保当前时间尚未超过结束时间。 如果有,则使计时器无效并隐藏气球。
- 4) 计算动画百分比和气球应移动到的所需y坐标。
- 5) 根据以前的计算设置气球的中心位置。
现在,使用以下代码替换showTongratulationAnimation()
中的// TODO:Animation
:
self.updateAnimation()
现在每次动画计时器触发时都会调用updateAnimation()
。
恭喜,您已经创建了一个自定义动画! 但是,在构建和运行应用程序时没有任何新的事情发生......
Showing the Animation
正如您可能已经猜到的那样,目前没有任何东西可以触发您新创建的动画。 要关闭它,你只需要一种方法。 在TaskListViewController
动画扩展中添加此代码:
func showCongratulationsIfNeeded() {
if taskList.filter({ !$0.completed }).count == 0 {
showCongratulationAnimation()
}
}
每次用户完成任务时都会调用此方法;它检查所有任务是否已完成。 如果是这样,它会调用showCongratulationAnimation()
。
要完成,请将以下方法添加为tableView(_:didSelectRowAt:)
的最后一行:
showCongratulationsIfNeeded()
构建并运行。
创建几个任务。
点击所有任务以将其标记为已完成。
你应该看到气球动画!
Stopping a Timer
如果您已经浏览了控制台,您可能已经注意到,即使用户已将所有任务标记为已完成,计时器仍会继续触发。 最好停止计时器完成任务以减少电池消耗。
首先,通过在// MARK: - Timer
扩展中添加以下代码来创建一个取消计时器的新方法:
func cancelTimer() {
timer?.invalidate()
timer = nil
}
这将使计时器无效。 并且,它会将其设置为nil
,以便您以后可以再次正确地重新初始化它。 invalidate()
是从RunLoop
中删除Timer
的唯一方法。RunLoop
在invalidate()
返回之前或稍后删除其对计时器的强引用。
接下来,使用以下代码替换showCongratulationsIfNeeded()
:
func showCongratulationsIfNeeded() {
if taskList.filter({ !$0.completed }).count == 0 {
cancelTimer()
showCongratulationAnimation()
} else {
createTimer()
}
}
现在,如果用户完成所有任务,应用程序将首先使计时器无效,然后显示动画;否则,如果尚未运行,您将尝试创建新计时器。 这将避免用户完成所有任务然后创建新任务时的错误。
构建并运行!
现在,计时器停止并根据需要重新启动。
Using CADisplayLink for Smoother Animations
定时器Timer
可能不是动画的理想解决方案。 您可能已经注意到动画期间出现了一些帧丢失 - 尤其是在模拟器上运行应用程序时。
您之前将计时器设置为60Hz(1/60)
。 因此,您的计时器将每16ms
调用一次动画。 看看下面的时间线:
通过使用Timer
,您无法确定触发操作的确切时间。它可能在帧的开始或结束。为了简单起见,请假设您将计时器设置在每个帧的中间(蓝点)。因为很难知道计时器的确切时间在哪里,所以你只能确保每16ms
就能得到一次回调。
你现在有8ms
来做你的动画;这可能是也可能没有足够的时间用于动画帧。查看上面时间线的第二帧。第二帧不能及时执行帧渲染。因此,您的应用将丢弃第二帧。您目前也只使用8毫秒而不是可用的16毫秒。
CADisplayLink to the Rescue!
CADisplayLink
每帧调用一次,并尝试尽可能与真实屏幕帧同步。有了这个,你可以完全访问所有可用的16ms
,你将确保iOS不会丢弃任何帧。即使在具有120Hz ProMotion
显示屏的新iPad上,您也不会错过一个画面!
要使用CADisplayLink
,必须使用新类型替换animationTimer
。
替换以下代码:
var animationTimer: Timer?
使用下面代码
var displayLink: CADisplayLink?
您已使用CADisplayLink
替换了Timer
。 CADisplayLink
是一个绑定到显示的vsync
的计时器表示。 这意味着设备的GPU将停止,直到物理屏幕准备好处理更多GPU命令。 这样,您可以确保更平滑的动画。
替换以下代码:
var startTime: TimeInterval?, endTime: TimeInterval?
使用下面的代码
var startTime: CFTimeInterval?, endTime: CFTimeInterval?
您已使用CFTimeInterval
选项替换了TimeInterval
选项以存储以秒为单位的时间,并且可以很好地使用CADisplayLink
。
用以下代码替换showCongratulationAnimation()
:
func showCongratulationAnimation() {
// 1
height = UIScreen.main.bounds.height + balloon.frame.size.height
balloon.center = CGPoint(x: UIScreen.main.bounds.width / 2,
y: height + balloon.frame.size.height / 2)
balloon.isHidden = false
// 2
startTime = CACurrentMediaTime()
endTime = animationDuration + startTime!
// 3
displayLink = CADisplayLink(target: self,
selector: #selector(updateAnimation))
displayLink?.add(to: RunLoop.main, forMode: .common)
}
在上面的代码中,您:
- 1) 设置动画高度,设置气球中心位置,并使动画可见 - 就像您之前一样。
- 2) 使用
CACurrentMediaTime()
(而不是Date()
)初始化startTime
。 - 3) 将
displayLink
设置为CADisplayLink
。 然后,使用common
模式将displayLink
添加到主RunLoop
。
接下来,使用以下代码替换updateAnimation()
:
// 1
@objc func updateAnimation() {
guard
let endTime = endTime,
let startTime = startTime
else {
return
}
// 2
let now = CACurrentMediaTime()
if now >= endTime {
// 3
displayLink?.isPaused = true
displayLink?.invalidate()
balloon.isHidden = true
}
let percentage = (now - startTime) * 100 / animationDuration
let y = height - ((height + balloon.frame.height / 2) / 100 *
CGFloat(percentage))
balloon.center = CGPoint(x: balloon.center.x +
CGFloat.random(in: -0.5...0.5), y: y)
}
在这里,您:
- 1) 将
@objc
添加到方法签名。 这是因为CADisplayLink
有一个需要Objective-C
选择器的选择器参数。 - 2) 用
CoreAnimation
日期替换Date()
初始化。CACurrentMediaTime
以秒为单位返回当前绝对时间。 - 3) 使用
CADisplayLink
的暂停和无效更改animationTimer.invalidate()
调用。 这也将从所有运行循环模式中删除display link
,并使display link
释放其目标。
最后一次构建和运行!
做得好! 您现在已成功使用CADisplayLink
替换基于Timer
的动画,以创建更流畅的动画。 差异很小,但用户真正享受流畅无缝的动画 - 即使在较旧的设备上也是如此。
在本教程中,您已经了解了Timer
类如何在iOS上运行,RunLoop
是什么以及它们如何帮助您应用程序的响应性,以及使用CADisplayLink
代替Timer
来实现流畅的动画。
后记
本篇主要讲述了NSTimer相关,感兴趣的给个赞或者关注~~~