swift NSTimer in Background

2019-01-09 18:43发布

问题:

I have come across a lot of issues with how to handle NSTimer in background here on stack or somewhere else. I've tried one of all the options that actually made sense .. to stop the timer when the application goes to background with

    NSNotificationCenter.defaultCenter().addObserver(self, selector: "appDidEnterBackground", name: UIApplicationDidEnterBackgroundNotification, object: nil)

and

    NSNotificationCenter.defaultCenter().addObserver(self, selector: "appDidBecomeActive", name: UIApplicationWillEnterForegroundNotification, object: nil)

At first I thought that my problem is solved, I just saved the time when the app did enter background and calculated the difference when the app entered foreground .. but later I noticed that the time is actually postponed by 3, 4 , 5 seconds .. that it actually is not the same .. I've compared it to the stopwatch on another device.

Is there REALLY any SOLID solution to running an NSTimer in background?

回答1:

You shouldn't be messing with any adjustments based upon when it enters background or resumes, but rather just save the time that you are counting from or to (depending upon whether you are counting up or down). Then when the app starts up again, you just use that from/to time when reconstructing the timer.

Likewise, make sure your timer handler is not dependent upon the exact timing that the handling selector is called (e.g. do not do anything like seconds++ or anything like that because it may not be called precisely when you hope it will), but always go back to that from/to time.


Here is an example of a count-down timer, which illustrates that we don't "count" anything. Nor do we care about the time elapsed between appDidEnterBackground and appDidBecomeActive. Just save the stop time and then the timer handler just compares the target stopTime and the current time, and shows the elapsed time however you'd like.

In Swift 3:

import UIKit
import UserNotifications

private let stopTimeKey = "stopTimeKey"

class ViewController: UIViewController {

    @IBOutlet weak var datePicker: UIDatePicker!
    @IBOutlet weak var timerLabel: UILabel!

    private var stopTime: Date?

    override func viewDidLoad() {
        super.viewDidLoad()

        registerForLocalNotifications()

        stopTime = UserDefaults.standard.object(forKey: stopTimeKey) as? Date
        if let time = stopTime {
            if time > Date() {
                startTimer(time, includeNotification: false)
            } else {
                notifyTimerCompleted()
            }
        }
    }

    private func registerForLocalNotifications() {
        if #available(iOS 10, *) {
            UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
                guard granted && error == nil else {
                    // display error
                    print("\(error)")
                    return
                }
            }
        } else {
            let types: UIUserNotificationType = [.badge, .sound, .alert]
            let settings = UIUserNotificationSettings(types: types, categories: nil)
            UIApplication.shared.registerUserNotificationSettings(settings)
        }
    }

    @IBAction func didTapStartButton(_ sender: AnyObject) {
        let time = datePicker.date
        if time > Date() {
            startTimer(time)
        } else {
            timerLabel.text = "timer date must be in future"
        }
    }

    // MARK: Timer stuff

    private var timer: Timer?

    private func startTimer(_ stopTime: Date, includeNotification: Bool = true) {
        // save `stopTime` in case app is terminated

        UserDefaults.standard.set(stopTime, forKey: stopTimeKey)
        self.stopTime = stopTime

        // start Timer

        timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(handleTimer(_:)), userInfo: nil, repeats: true)

        guard includeNotification else { return }

        // start local notification (so we're notified if timer expires while app is not running)

        if #available(iOS 10, *) {
            let content = UNMutableNotificationContent()
            content.title = "Timer expired"
            content.body = "Whoo, hoo!"
            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: stopTime.timeIntervalSinceNow, repeats: false)
            let notification = UNNotificationRequest(identifier: "timer", content: content, trigger: trigger)
            UNUserNotificationCenter.current().add(notification)
        } else {
            let notification = UILocalNotification()
            notification.fireDate = stopTime
            notification.alertBody = "Timer finished!"
            UIApplication.shared.scheduleLocalNotification(notification)
        }
    }

    private func stopTimer() {
        timer?.invalidate()
        timer = nil
    }

    private let dateComponentsFormatter: DateComponentsFormatter = {
        let _formatter = DateComponentsFormatter()
        _formatter.allowedUnits = [.hour, .minute, .second]
        _formatter.unitsStyle = .positional
        _formatter.zeroFormattingBehavior = .pad
        return _formatter
    }()

    // I'm going to use `DateComponentsFormatter` to update the
    // label. Update it any way you want, but the key is that
    // we're just using the scheduled stop time and the current
    // time, but we're not counting anything. If you don't want to
    // use `NSDateComponentsFormatter`, I'd suggest considering
    // `NSCalendar` method `components:fromDate:toDate:options:` to
    // get the number of hours, minutes, seconds, etc. between two
    // dates.

    func handleTimer(_ timer: Timer) {
        let now = Date()

        if stopTime! > now {
            timerLabel.text = dateComponentsFormatter.string(from: now, to: stopTime!)
        } else {
            stopTimer()
            notifyTimerCompleted()
        }
    }

    private func notifyTimerCompleted() {
        timerLabel.text = "Timer done!"
    }

}

Or in Swift 2:

private let stopTimeKey = "stopTimeKey"

class ViewController: UIViewController {

    @IBOutlet weak var datePicker: UIDatePicker!
    @IBOutlet weak var timerLabel: UILabel!

    var stopTime: NSDate?

    override func viewDidLoad() {
        super.viewDidLoad()

        registerForLocalNotifications()

        stopTime = NSUserDefaults.standardUserDefaults().objectForKey(stopTimeKey) as? NSDate
        if let time = stopTime {
            if time.compare(NSDate()) == .OrderedDescending {
                startTimer(time)
            } else {
                notifyTimerCompleted()
            }
        }
    }

    func registerForLocalNotifications() {
        let types: UIUserNotificationType = [.Badge, .Sound, .Alert]

        let settings = UIUserNotificationSettings(forTypes: types, categories: nil)

        UIApplication.sharedApplication().registerUserNotificationSettings(settings)
    }

    @IBAction func didTapStartButton(sender: AnyObject) {
        let time = datePicker.date
        if time.compare(NSDate()) == .OrderedDescending {
            startTimer(time)
        } else {
            timerLabel.text = "timer date must be in future"
        }
    }

    // MARK: Timer stuff

    var timer: NSTimer?

    func startTimer(stopTime: NSDate) {
        // save `stopTime` in case app is terminated

        NSUserDefaults.standardUserDefaults().setObject(stopTime, forKey: stopTimeKey)
        self.stopTime = stopTime

        // start NSTimer

        timer = NSTimer.scheduledTimerWithTimeInterval(0.1, target: self, selector: "handleTimer:", userInfo: nil, repeats: true)

        // start local notification (so we're notified if timer expires while app is not running)

        let notification = UILocalNotification()
        notification.fireDate = stopTime
        notification.alertBody = "Timer finished!"
        UIApplication.sharedApplication().scheduleLocalNotification(notification)
    }

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

    let dateComponentsFormatter: NSDateComponentsFormatter = {
        let _formatter = NSDateComponentsFormatter()
        _formatter.allowedUnits = [.Hour, .Minute, .Second]
        _formatter.unitsStyle = .Positional
        _formatter.zeroFormattingBehavior = .Pad
        return _formatter
    }()

    // I'm going to use `NSDateComponentsFormatter` to update the 
    // label. Update it any way you want, but the key is that
    // we're just using the scheduled stop time and the current
    // time, but we're not counting anything. If you don't want to 
    // use `NSDateComponentsFormatter`, I'd suggest considering
    // `NSCalendar` method `components:fromDate:toDate:options:` to 
    // get the number of hours, minutes, seconds, etc. between two 
    // dates.

    func handleTimer(timer: NSTimer) {
        let now = NSDate()

        if stopTime!.compare(now) == .OrderedDescending {
            timerLabel.text = dateComponentsFormatter.stringFromDate(now, toDate: stopTime!)
        } else {
            stopTimer()
            notifyTimerCompleted()
        }
    }

    func notifyTimerCompleted() {
        timerLabel.text = "Timer done!"
    }

}

By the way, the above also illustrates the use of a local notification (in case the timer expires while the app isn't currently running).



回答2:

Unfortunately, there is no reliable way to periodically run some actions while in background. You can make use of background fetches, however the OS doesn't guarantee you that those will be periodically executed.

While in background your application is suspended, and thus no code is executed, excepting the above mentioned background fetches.