Timer的使用

这篇文章将介绍计时器(Timer)如何使用,影响 UI 响应速度,对电量的影响,以及如何使用CADisplayLink处理动画。

1. 什么是计时器

Timer继承自NSObject,用以提供延迟(周期性)触发动作的方法。通常,定时器会在指定间隔后自动触发,向指定对象发送消息。如果 timer 是非重复定时器,则触发一次后自动失效,从而防止定时器再次触发;如果是重复定时器,则会在指定间隔后重复触发,需手动调用invaliate()方法使计时器无效。

这篇文章将创建一个 ToDo app。ToDo app 跟踪任务完成所用时间,所有任务都完成时 app 会弹出恭喜完成的动画。

下载这篇文章所用模版,运行后如下:

TimerPreview.png

点击右上角 + 按钮创建任务,点击任务将其标记为完成,完成的任务名称会用横线提示。目前,任务的时间会一直显示为0s,不会增加。

2. 创建计时器

下面创建一个 timer。打开ViewController.swift文件,添加以下变量:

    var timer: Timer?

在文件底部添加一个 extension,并添加以下方法:

// MARK: - Timer
extension ViewController {
    func updateTimer() {
        if let fireDateDescription = timer?.fireDate.description {
            print(fireDateDescription)
        }
        
        guard let visibleRowsIndexPaths = tableView.indexPathsForVisibleRows else {
            return
        }
        
        for indexPath in visibleRowsIndexPaths {
            if let cell = tableView.cellForRow(at: indexPath) as? TaskTableViewCell {
                cell.updateTime()
            }
        }
    }
}

上述方法会更新所有可见 cell 的时间。

使用下面方法创建计时器:

    func createTimer() {
        if timer == nil {
            timer = Timer.scheduledTimer(timeInterval: 1.0,
                                         target: self,
                                         selector: #selector(updateTimer),
                                         userInfo: nil,
                                         repeats: true)
        }
    }

timer计时器每隔1秒钟触发一次。如果间隔小于等于0.0秒,计时器会采用0.1毫秒作为间隔。

在用户点击添加按钮时创建计时器。在presentAlertController(_:)方法第一行调用创建计时器方法:

        createTimer()

运行 demo 并添加任务,如下所示:

TimerCreate.gif

现在,cell 可以更新显示当前已用时间。

3. 优化计时器

3.1 tolerance

随着 app 内计时器数量变多,app 响应会变慢、电量消耗会变快。这是因为Timertolerance默认为0,每个计时器都尽可能在精准时间触发。

使用tolerance属性可以减少电量消耗、提高响应速度。Timer可以在指定时间和指定时间加tolerance之间触发,timer 不会在指定时间之前触发。对于重复计时器,下一个触发时间根据原始触发时间计算得到,与tolerance无关。tolerance默认为0,但系统保留对某些计时器应用少量tolerance的权利。

开发者可以指定tolerance的值。通常,tolerance值至少为间隔的10%,即使很小的tolerance也会对性能产生积极影响。系统可能会限制tolerance的最大值。

createTimer()方法内设置tolerance值:

            timer?.tolerance = 0.15

App 不会因此产生明显变化,但其可以提高响应速度、减少电量消耗。

3.2 fireDate

如果计时器已经失效,fireDate属性返回计时器上次触发时间。

通过设置该属性可以修改 timer 的触发时间。尽管重设计时器的触发时间是一个昂贵的操作,但在某些情况下会相对高效。例如,想要重复某个操作多次,但timeInterval没有规律。这时可以创建多个 timer,分别添加到 run loop,使用完毕后销毁。相比之下,创建一个计时器,修改触发时间更为高效。

不能修改已经无效的 timer,包括已触发的单次计时器。另外,修改fireDate时,需在添加计时器的线程修改。修改之前使用isValid方法判断计时器是否有效。

        if timer.isValid {
            guard let fireDate = Calendar.current.date(byAdding: .minute, value: 1, to: Date()) else {
                return
            }
            timer.fireDate = fireDate
        }

上述代码可以将计时器设置为1分钟触发,其间隔保持不变。

4. Run Loop 与 Timer 关系

App 进入后台计时器是否还会工作?为了解这个问题,在updateTimer()方法添加以下代码,在计时器触发时输出当前时间:

        if let fireDateDescription = timer?.fireDate.description {
            print(fireDateDescription)
        }

运行demo并添加任务,此时可以看到控制台输出;将 app 置于后台,控制台停止输出;app 再次进入前台,输出继续。如下所示:

2020-01-20 12:55:33 +0000
2020-01-20 12:55:34 +0000
2020-01-20 12:55:35 +0000
2020-01-20 12:55:36 +0000
2020-01-20 20:55:37.584780+0800 Timer[7536:481577] [Snapshotting] Snapshotting a view (0x7fa1e2892600, UIKeyboardImpl) that is not in a visible window requires afterScreenUpdates:YES.
2020-01-20 20:55:40.913962+0800 Timer[7536:481577] Can't end BackgroundTask: no background task exists with identifier 1 (0x1), or it may have already been ended. Break in UIApplicationEndBackgroundTaskError() to debug.
2020-01-20 12:55:37 +0000
2020-01-20 12:55:41 +0000
2020-01-20 12:55:46 +0000
2020-01-20 12:55:47 +0000

4.1 理解 Run Loop

运行循环(Run Loop)是与线程相关的基础功能,用于计划工作和协调接收传入的事件。Run loop 的目的在于有工作时让线程忙碌,没有工作时让线程休眠。

计时器与 run loop 配合使用,run loop 对计时器强引用。因此,在将计时器添加到 run loop 后,自己不必维护对计时器的强引用。想要在自己创建的线程上使用Timer,必须手动创建 run loop。如果你对 run loop 还不熟悉,可以查看RunLoop从入门到进阶这篇文章。

在调用applicationDidFinishLaunching(_:)方法时,主线程已经启动 run loop。Timer必须添加到 run loop 才可以工作,其中,下面三种方法创建的计时器默认添加到主线程的 default mode:

  • scheduledTimer(withTimeInterval:repeats:block:)
  • scheduledTimer(withInterval:target:selector:userInfo:repeats)
  • scheduledTimer(timeInterval:invocation:repeats:)

下面五种方法创建的计时器需使用add(_:forMode:)方法手动添加到 run loop:

  • init(timeInterval:repeats:block:)
  • init(timeInterval:target:selector:userInfo:repeats:)
  • init(timeInterval:invocation:repeats:)
  • init(fire; interval:repeats:block:)
  • init(fireAt:interval:target:selector:userInfo:repeats:)

主线程用于绘制用户界面、监听手势等。当主线程忙于其他工作时,滑动视图会出现掉帧。

当滑动 table view 时,计时器会停止工作。这是因为此时主线程 run loop 进入了trackingmode,而使用scheduledTimer方法创建的计时器添加到了 run loop defaultmode。

4.2 使用 Run Loop Mode

Run loop mode 集合包含了要监控的 input source、timer,以及要通知的 observer。每次启动 run loop 时需要显式或隐式指定其运行的 mode。在运行期间,仅监视与该 mode 关联的 source,且只允许与该 mode 关联的 source 传递事件,同时 run loop 的进度也只通知与该 mode 关联的 observer。与其他 mode 关联的 source 持有事件,直到运行其 mode。这样做可以隔离不同组 source、timer、observer,让其互不影响。

有以下三个公开 run loop mode:

  • default:最为常用的 mode。大部分情况下,使用此 mode 配置 run loop 即可。
  • tracking:UIScrollView滑动时,处于UITrackingRunLoopMode
  • common:使用 common modes 做为模式添加到 run loop 的对象,会受 common modes 所有 mode 的监视。Default mode 和 tracking mode 均将自身标记为 commond modes。

要解决 scroll view 滑动时,计时器无法工作,只需将计时器添加到 common mode 即可。

更新createTimer(),如下所示:

    func 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.15
            
            self.timer = timer
        }
    }

运行 demo,滑动 table view 时,Timer会继续更新时间。

5. 使用 Timer 控制动画

当用户完成所有任务时,从屏幕底部向上弹出恭喜动画。

ViewController.swift文件顶部添加以下变量:

    var animationTimer: Timer?
    var startTime: TimeInterval?, endTime: TimeInterval?
    let animationDuration = 3.0
    var height: CGFloat = 0

ViewController.swift文件底部添加以下 extension:

// MARK: - Animation
extension ViewController {
    func showCongratulationAnimation() {
        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
        
        startTime = Date().timeIntervalSince1970
        endTime = animationDuration + startTime!
        
        animationTimer = Timer.scheduledTimer(withTimeInterval: 1 / 60, repeats: true, block: { (timer) in
            self.updateAnimation()
        })
    }
}

在上述代码中,使用计时器每秒60次更新 balloon 位置。

实现updateAnimation()方法,更新 balloon 位置:

    func updateAnimation() {
        guard let endTime = endTime,
        let startTime = startTime else { return }
        
        let now = Date().timeIntervalSince1970
        
        if now >= endTime {
            animationTimer?.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)
    }

添加以下代码,在所有任务均完成时触发动画:

    func showCongratulationsIfNeeded() {
        if taskList.filter({ !$0.completed }).count == 0 {
            showCongratulationAnimation()
        }
    }

tableView(_:didSelectRowAt:)方法内调用showCongratulationsIfNeeded()方法:

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        ...
        showCongratulationsIfNeeded()
    }
}

运行demo,所有任务完成后会弹出如下动画:

TimerAnimation.gif

6. 终止 Timer

所有任务均标记为完成后,控制台还在继续输出。因此,计时器还在触发事件。终止计时器可以节约电量。

在 Timer extension 添加以下方法:

    func cancelTimer() {
        timer?.invalidate()
        timer = nil
    }

invalidate()方法会停止计时器,并请求从 run loop 移除。invalidate()方法是将计时器从 run loop 移除的唯一方法。Run loop 会在invalidate()方法返回前、或稍后,将对 timer 的强引用移除。如果配置了 target、userInfo,计时器会移除对这些对象的强引用。

必须在安装Timer的线程调用invalidate()方法。如果在其他线程调用,timer 的input source可能无法从 run loop 移除,进而导致线程无法正常退出。

更新showCongratulationsIfNeeded()方法:

    func showCongratulationsIfNeeded() {
        if taskList.filter({ !$0.completed }).count == 0 {
            cancelTimer()
            showCongratulationAnimation()
        } else {
            createTimer()
        }
    }

目前,用户完成所有任务时,app 先终止计时器后显示动画;反之,尝试创建计时器。

7. 使用 CADisplayLink 显示动画

Timer并不适合控制动画,其很容易出现掉帧。

目前,timer 间隔是1/60秒,即 timer 每16毫秒调用一次动画。

TimerTimeLine.png

使用Timer时,无法掌握精准触发时间。其可能在 frame 开始时触发,也可能在结束时触发。为使问题简单,假设 timer 在每一帧中间触发(绿点位置)。由于很难知道计时器确切时间,因此只能确保每16毫秒收到一次回调。现在,只有8毫秒时间执行动画,这对于动画帧来说可能是不够的。通过上图可以看到,第二帧可能没有足够时间进行绘制,进而导致掉帧。可用时间也从16毫秒变为了8毫秒。

CADisplayLink每帧调用一次,并将尽可能与屏幕帧率同步。这样就可以拥有16毫秒可用时间,进而避免掉帧。即使在 ProMotion 显示屏且频率为 120Hz 的新款 iPad 上,也可以做到一帧不掉。

使用CADisplayLink替换animationTimer

//    var animationTimer: Timer?
//    var startTime: TimeInterval?, endTime: TimeInterval?
    var displayLink: CADisplayLink?
    var startTime: CFTimeInterval?, endTime: CFTimeInterval?

CADisplayLink是与显示器绑定的计时器,这意味着 GPU 将停滞,直到屏幕准备好处理更多 GPU 命令为止。这样,可以确保动画更加流畅。使用CFTimeInterval替换TimeIntervalCFTimeIntervalCADisplayLink可以很好配合使用。

更新showCongratulationAnimation()如下:

func showCongratulationAnimation() {
        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
        
        startTime = Date().timeIntervalSince1970
        endTime = animationDuration + startTime!
        
        displayLink = CADisplayLink(target: self,
                                    selector: #selector(updateAnimation))
        displayLink?.add(to: RunLoop.main, forMode: .common)
    }

上面设置了displayLink,并添加到主线程运行循环的 common mode。

更新updateAnimation()方法如下:

    @objc func updateAnimation() {
        guard let endTime = endTime,
        let startTime = startTime else { return }
        
        let now = CACurrentMediaTime()
        
        if now >= endTime {
            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)
    }

由于CADisplayLink需要 Objective-C selector方法,在updateAnimation()方法前添加了@objc。使用CACurrentMediaTime替换Date()。使用CADisplayLink暂停、终止动画,这样会将 displayLink 从 run loop 移除,并释放自身持有的 target。

再次运行demo,其会比使用Timer更为顺畅。

Demo名称:Timer
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/Timer

参考资料:

  1. 定时器NSTimer的使用
  2. iOS Timer Tutorial

欢迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/Timer%E7%9A%84%E4%BD%BF%E7%94%A8.md

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