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?
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).
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.