28 Nov 2018
Belle

Debouncing in Swift while your iOS app is in the background

I recently had to find a way to debounce a function call in Swift, while my app was running in the background. Most solutions I found only worked in the foreground, so it took some fiddling to come up with a solution.

Debouncing is when an event is triggered multiple times in quick succession, but rather than acting on that event every time, you hold off until the event isn't triggered again within a certain period. Here's a really good explanation of debouncing, with interactive examples.

The background

Skip this part if you're only interested in how I made the debounce work. This is just some background on why it was necessary for my app.

A big part of Exist for iOS is the HealthKit component. The app takes your data from HealthKit (Apple Health) and syncs it to your Exist account. This sync takes place when you open the app, and in the background if you allow background app refresh.

But to keep your data as up-to-date as possible, I also take advantage of being able to observe when you make changes to your data in Apple Health. For most categories of data you set to sync from HealthKit to Exist, I set up an observer which tells HealthKit I want to be notified when you change that category of data. When you do make a change, iOS wakes up (if necessary) and notifies my app, and I sync that category of data to your Exist account to keep it up-to-date.

In the past, however, I've noticed that HealthKit will send many, many notifications for the same category of data within the same second. This can be due to a particular app syncing data into HealthKit hundreds of times in the same second for some reason. But you can easily replicate this problem by, for example, manually deleting a whole bunch of records in Apple Health very quickly. iOS will notify you via the observer you set up for every one of those changes, even if they're all within a second or a minute of each other, and they're all for the same category.

I've also noticed in the past that HealthKit would sometimes notify me about changes to a category that actually didn't have changes at all. When I manually added a workout record to Apple Health for testing, for example, I might get notified that workouts, food, and sleep all had changes, even though they didn't. So I don't rely on the changes reported by HealthKit—I simply use the observer to know when to check for new data, and do my own anchor query to see if anything has changed that I want to sync (anchor queries let you query HealthKit's data to check what's changed since the last time you queried, using an anchor object, rather than querying all the data in HealthKit).

Now here's where debouncing comes in: because I'm not using HealthKit's reported changes each time it notifies me, all I care about is which category of data it's telling me has changed. And I don't want to sync that data to our server a hundred times in a minute just because that's how many times HealthKit notified me of changes.

So I needed to debounce my syncing process. In short, that means when I get notified by iOS that some HealthKit data has changed, I want to only sync that data to our server once in a certain period—in my case, two minutes. Even if HealthKit keeps notifying me over and over, I don't want to act on all of those callbacks.

(Side note: you can set a max period when enabling these callbacks so your app is only called, for example, once per hour about changes to a certain type of data. However, for most of my callbacks, I actually want to get notified about that data immediately. For sleep data, for example, I want to sync that to the user's Exist account as soon as they've synced their sleep data to HealthKit, so I request to be notified as soon as any data changes. But that means I also have to deal with getting way too many notifications in a short period sometimes.)

The problem

I have a function I want to call. I want it to be called a maximum of once per two minutes. Any calls to the same function within that two minute period should be ignored.

Extra problem: this has to work in the background. When the user (or an app on their phone) updates data in HealthKit, it's very unlikely Exist for iOS will happen to be running in the foreground at that moment. Which means I need to be able to debounce syncing their data to our server while my app is running in the background (iOS wakes my app up to receive notifications about HealthKit changes).

The initial solution

I found two easy solutions to debouncing, but unfortunately neither of them worked in the background.

The first was to simply use scheduledTimer(withTimeInterval:repeats:block:). Each time HealthKit notifies my app of a change in data, I cancel the current timer, and call this function to start a new timer. Eventually, no more callbacks will come from HealthKit, so the timer won't be cancelled and rescheduled, and will instead run through its time interval and fire, calling the function to sync that data to our server. This will only happen the last time—or if there's a pause in callbacks from HealthKit longer than my time interval, which is two minutes.

It was really simple to set this up, but iOS stops timers when your app is in the background, so my timer would never fire until I brought the app to the foreground, which doesn't work for my use case.

The other option was to use the NSObject class method cancelPreviousPerformRequestsWithTarget(_:) and performSelector(_:withObject:afterDelay:). I'd already been using this approach previously, but had trouble getting it to work, and now I know why. performSelector(_:withObject:afterDelay:) uses a Timer under the hood, so it has the exact same shortcomings as the approach mentioned above—the timer wouldn't keep running and wouldn't fire when the app was in the background.

For tasks that happen in the foreground, though, this would have been a really easy solution. Each time the callback from HealthKit comes, you simply call the cancel function mentioned above, and then call performSelector(_:withObject:afterDelay:). Eventually, the callbacks stop coming, the cancel function doesn't get called, the delay runs out, and the selector is performed.

The final solution

So I had to find a way to replicate the process of debouncing, but without using a Timer at all. Here's what I came up with:

Each time a callback arrives from HealthKit, I increment a simple count: Int property by one, to keep track of how many callbacks I've received, and I call DispatchQueue.main.asyncAfter with a block that calls another function. This other function first decrements the count property. Then, if the count is still greater than zero, it does nothing, because that means more callbacks have arrived and are still running through their asyncAfter delays.

If the count is zero, however, then no new calls have been made in our period, and we've successfully debounced our original function. Now we can sync the data.

Here's some real code so you can see how I've put this solution to work. I have an instance of the Debouncer class below for each category of HealthKit data I want to sync, so that I'm only debouncing multiple calls for the same type of data.

class Debouncer {
    
    typealias Handler = () -> Void
    private let timeInterval: TimeInterval
    private var count: Int = 0
    // handler is the closure to run when all the debouncing is done
    // in my case, this is where I sync the data to our server
    var handler: Handler? {
        didSet {
            if self.handler != nil {
              // increment count of callbacks, since each time I get a
              // callback, I update the handler
                self.count += 1
                // start a new asyncAfter call
                self.renewInterval()
            }
        }
    }
    
    init(timeInterval: TimeInterval) {
        self.timeInterval = timeInterval
    }

    func renewInterval() {
        DispatchQueue.main.asyncAfter(deadline: .now() + self.timeInterval) {
            self.runHandler()
        }
    }

    private func runHandler() {
    // first, decrement count because a callback delay has finished and called runHandler
        self.count -= 1
        // only continue to run self.handler if the count is now zero
        if self.count <= 0 {
            self.handler?()
            self.handler = nil
        }
    }
}

Hopefully that makes sense! I didn't find any resources about creating a Swift debounce that works in the background, so hopefully it's useful to share what I came up with.