NSTimer应用解析(一) —— NSTimer的基本使用(一)

版本记录

版本号 时间
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) 检查endTimestartTime不为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的唯一方法。RunLoopinvalidate()返回之前或稍后删除其对计时器的强引用。

接下来,使用以下代码替换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替换了TimerCADisplayLink是一个绑定到显示的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相关,感兴趣的给个赞或者关注~~~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容