3

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?

kalafun
  • 3,512
  • 6
  • 35
  • 49

2 Answers2

10

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.

For example:

import UIKit
import UserNotifications

private let stopTimeKey = "stopTimeKey"

class ViewController: UIViewController {

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

    private weak var timer: Timer?
    private var stopTime: Date?

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

    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()
            }
        }
    }

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

// MARK: Timer stuff

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

    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)
        }
    }

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

    // 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 `DateComponentsFormatter`, I'd suggest considering
    // `Calendar` method `dateComponents(_:from:to:)` to
    // get the number of hours, minutes, seconds, etc. between two
    // dates.

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

        if stopTime! > now {
            timerLabel.text = dateComponentsFormatter.string(from: now, to: 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).


For Swift 2 rendition, see previous revision of this answer.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • I have a timer that counts seconds and I sub these seconds from some solid time, then I compare it to 0 to know whether the timer is done. How else should I sub from the timer? – kalafun Dec 28 '15 at 16:58
  • Timers shouldn't "count" anything. Timers should get the current time (e.g. from `CFAbsoluteTimeGetCurrent()` or `CACurrentMediaTime()` or `[NSDate date]`) and compare that against the baseline time that you're down counting to, in order to know the time remaining. – Rob Dec 28 '15 at 17:23
  • But even when I did calculate the time difference between the appDidEnterBackground and appDidBecomeActive .. the time difference missed for 3 or more seconds so I don't think that is accurate – kalafun Dec 30 '15 at 16:39
  • You shouldn't be calculating that difference. There's no need to do so (and introduces problems). Just save the original end time stamp and then when the app restarts, retrieve and use that saved end time stamp when you restart the timer. – Rob Dec 30 '15 at 16:42
  • @kalafun I'm gathering that I'm not communicating my thoughts effectively, so I updated my answer with example to illustrate my point. If you do it this way, there is no loss of a few seconds due to latency in starting up the app. – Rob Dec 30 '15 at 17:35
  • Thanks for the code @Rob ! That is the right way how to ensure your timer ends precisely the time you want. I however, have a small additional problem, I am interchanging 2 kind of timers. They are following one after another, When the app goes to background, thanks to you, now it has the precise time when it comes back, but when the current timer expires, I also want the following timer(s) to show the right time, because the app now only knows to run the next timer from the beginning. Any thoughts on that? Im adding and UpVote to your answer, I'll mark it as the correct answer later ;) – kalafun Jan 05 '16 at 09:53
  • I've answered your original question. I'll think about your new question later... – Rob Jan 05 '16 at 17:53
  • 1
    @kalafun OK, regarding that separate timer, the reality is that if the app isn't running, you can't start another timer until the user fires up the app again (either by tapping on the notification or just happen to restart the app). So, you have two options. Either create both timers up front (before the user leaves the app) or create the second timer when the user restarts the app. And with that latter approach, you have to calculate the details of that second timer from the time you saved in persistent storage. – Rob Jan 05 '16 at 22:37
  • 1
    @kalafun - It comes down to whether you can create both timers/notifications up front, or whether the second timer is somehow dependent upon something that you'll only know when the first timer finishes. But if I knew up front that I wanted a second timer that would fire _x_ minutes after the first one (like some "snooze" alarm), I'd personally be inclined to create the both local notifications up front (and cancel the second one if the app is restarted started in response to the first timer). – Rob Jan 05 '16 at 22:37
  • Thanks for the quick response, my current implementation relies on calculation of the stopTime of the timer, calculated from the timers before, when App becomes active. So pretty much your second suggestion. I suppose I'll create another question with some code and let you know. I really appreciate your help! – kalafun Jan 06 '16 at 20:50
  • If it's based upon the stop time of the first timer, why not schedule the second notification at the same time you schedule the first one. That way, the user will see the second notification even if they choose to ignore the first one (and therefore the app is not restarted). – Rob Jan 06 '16 at 20:54
  • I can and probably will schedule the notifications when starting the first timer, but I need to figure out how to properly calculate the accurate time and timer type, when the app will enter foreground. – kalafun Jan 06 '16 at 21:45
  • Yep (or save that information somewhere when you first create the two notifications). But the alternative (not creating the second notification until the app is restarted) means that if the user doesn't respond to the first notification, they may never get the second one unless they just happen to open the app in the intervening period. – Rob Jan 06 '16 at 21:59
  • I have created another question to fully describe the issue http://stackoverflow.com/questions/34664955/swift-calculate-time-for-timers-running-in-background – kalafun Jan 07 '16 at 20:54
  • Cannot convert value of type '(UnsafeMutablePointer?) -> time_t' (aka '(Optional>) -> Int') to expected argument type 'Date' startTimer(time) – J A S K I E R Jun 25 '20 at 16:18
  • 1
    @Oleksandr - You're obviously passing a parameter that is not a `Date` object. The above uses `Date`. – Rob Jun 25 '20 at 23:44
1

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.

Cristik
  • 30,989
  • 25
  • 91
  • 127
  • What about scheduling local notifications in background? I want to synchronize the fireDate of a local notification with the fireDate of the timer that repeats .. so if I schedule all the notifications in advance .. and user opens the app in the middle of the session .. the timer time gets postponed and the notification time will vary from the end of the timer – kalafun Dec 28 '15 at 16:56
  • Local notifications don't carry code to execute, unless the user chooses one of the actions that are reported by the local notification. – Cristik Dec 28 '15 at 17:04
  • But a local notification is precisely how an app can notify a user that the timer has expired, whether the app is running or not. Yes, the user decides whether the app should be restarted and take action or not, but you definitely want to use local notifications for count-down app. – Rob Dec 30 '15 at 17:49