25 May 2018

Syncing weight from Apple Health to Exist

Our most recent update to Apple Health syncing in Exist for iOS includes the ability to sync weight data. At face value, this might seem no more tricky than syncing cycling distance, which was also added in this update. But in fact, weight syncing took about 10x as much development time as cycling distance did.

This is probably the most tricky Apple Health data type to sync to Exist apart from sleep, so I thought I'd explain a bit about what was involved in building this feature.

What makes weight special

The reason weight is tricky to sync to Exist compared to other attributes is because many people don't weigh in every day, but on days you don't weigh in your weight isn't zero or N/A. As far as you know, your weight is the same as your last weigh-in until you step on the scales again.

This is how weight works in Exist from other sources. For example, Fitbit will report your weight to Exist as being the same value as yesterday if you didn't weigh in today.

Because Exist relies on external services reporting a valid weight for every day, Exist for iOS needs to do this too.

So, unlike other Apple Health data where I simply request data for a date period from HealthKit and sync to Exist any non-null values, syncing weight required more calculations to ensure Exist for iOS is always sending a valid weight to your Exist account, even if you haven't weighed in for several days.

Getting the data from HealthKit

Requesting the data from HealthKit is fairly similar to requesting data for other attributes. In particular, I use the same method as when I request heartrate, since both attributes hold an average value per day, rather than a cumulative sum like steps or workout minutes.

Here's what that method looks like:

I really embraced Objective-C's verbose method naming conventions when I wrote this code!

What this method does is ask HealthKit for an average of all the user's weight records that have a start date within the range I've asked for. Usually that just means I'll get one value—or none, if the user didn't weigh in. But if there were multiple weight values in HealthKit for that day, I'll just get an average of them all.

Formatting the data from HealthKit

I call the code above from within this method:

- (void)weightDataForDates:(NSArray <NSDate *>*)dates completionBlock:(void (^)(NSError *error, NSArray <NSDictionary <NSString *, NSNumber *>*>*data))completionBlock

(Side note: this method definition is horrible to read. I'm so sorry! I've started using a lot more Swift in this originally-Objective-C project, and have been adding all these type annotations to my Objective-C code along the way, which make things more explicit and safe, but awful to look at.)

Right now we only look for weight data up to 30 days ago. This means if you haven't weighed in for 31 days, you won't get any weight data syncing into Exist from today onwards, unless you add a new weight value into HealthKit. For most users, 30 days is plenty of leeway, so that's what we're working with now.

Before calling weightDataForDates, I create an array of NSDate objects for today and the 29 days prior. I pass these dates into weightDataForDates, which looks up the relevant HealthKit information about weight (e.g. the HKQuantityTypeIdentifier, the HKUnit, and the HKStatisticsOptions). Using dispatch groups, weightDataForDates then loops through all the dates it was given, and calls the readAverageValueFromHealthKitWithIdentifier method above with the next date in the array as the endDate parameter.

If the readAverage method returns any data in its completion block, the dictionary we saw above in readAverageValueFromHealthKitWithIdentifier (that looked like {"2018-01-15": 90.5}) gets turned into something like this:

{"date": "2018-01-15",
"name": "weight",
"value": 90.5}

This dictionary, formatted to match what's expected by the Exist API, is added to an NSMutableArray. Each time a day period within the past 30 days returns a value from HealthKit, that data is turned into a dictionary like above, and added to the same mutable array. I use dispatch_group_notify to tell me when all the tasks I added to the dispatch group are done, and then I call the weightDataForDates completion block with a copy of the mutable array.

Add missing values

Now that I've got all the data I can from HealthKit, I have to fill in the gaps. If the user weighs in twice a week, there'll be a lot of days in that 30-day period where HealthKit won't return me any data.

Here's the method I use to fill in those gaps:

I added lots of comments as I wrote this code, so how it works is explained throughout. One thing to note is that the healthKitData parameter passed in is the array returned by weightDataForDates containing dictionaries of the data returned by HealthKit, formatted for our API.

The numberOfValues int parameter is dependent on how and why the user is syncing their weight data. If it's the first time they've ever synced, I'll want to sync the full 30 days' worth to their Exist account. But for regular syncing throughout the day I only sync today and yesterday to keep their data up-to-date.

As you can see from the weightValues method above, I have to work through 30 days' worth of data every time, even if I'm only syncing today and yesterday, but I need to work forward in time, filling in the user's last known weight value on any empty days until I come to a more recent weight value.

Hopefully that made sense! It took me quite a while to get my head around how it needed to work, and to iron out all the bugs when I built it.