Create Publishers for HealthKit

- 8 mins en | pt_BR |

Combine is a powerful framework for writing asynchronous code. It provides building blocks like Publishers and operators that allow programmers to write complex data pipelines, network calls, and so on. HealthKit is another framework provided by Apple, and it manages reading and writing health-related data. It’s available on iOS and watchOS.

In this blog post, I will explore how Combine can be used to implement some HealthKit basic functionalities: requesting access to read user data and querying that data.


Requesting authorization

Requesting authorization is an important step, and we need to do it before querying any health data. Users can change permissions at any moment through the Health app or Settings — and our apps won’t get notified of these changes.

To accomplish this task we pass a set of HKSampleType to the instance method requestAuthorization(toShare:read:completion:). We can specify different types we would like to read or write.

Inspired by the latest Apple Women’s Health Study update, we’ll be querying HealthKit for symptoms data. Specifically, abdominal cramps — aka the most frequently logged symptom by the participants of the study. Here’s a simple implementation of the request authorization method.

// the HKHealthStore handles requesting authorization, querying, etc.
let healthStore = HKHealthStore()
private let healthTypes = Set([HKSampleType.categoryType(forIdentifier: .abdominalCramps)!])

func requestHealthDataAccess(_ completion: @escaping (Bool) -> Void) {
    healthStore.requestAuthorization(toShare: healthTypes, read: healthTypes) { success, error in
        if let error = error {
            print("HealthKit authorization error:", error.localizedDescription)
        }
        completion(success)
    }
}

This is a nice simple approach for requesting access but let’s see how Combine can help us make that even nicer with a Future.

Requesting authorization with a Future

According to the official documentation, a Future is a publisher that eventually produces a single value and then finishes or fails. It’s perfect to wrap a one-time authorization request. And because it’s a Publisher, we’ll be able to use Combine operators to apply any transformations we want.

Futures are also great for converting code written in an imperative fashion to a declarative one. We initialize it with a promise. In Combine, a promise is just a plain closure that takes a Result. Let’s see it in action in the following example.

import Combine 

func authorizationPublisher() -> Future<Bool, Error> {
    Future { [unowned self] promise in
        self.healthStore.requestAuthorization(toShare: healthTypes, read: healthTypes) { authSuccess, error in
            // check for errors
            guard error == nil else {
                print("HealthKit authorization error:", error!.localizedDescription)

                // fulfill the promise with an error
                promise(.failure(error!))
                return
            }
                
            promise(.success(authSuccess))
        }
    }
}

Let’s take a look at the signature of the authorizationPublisher() method. It returns a Future<Bool, Error> and note that it doesn’t take any completion handlers.

Inside the function, we initialize a Future with a closure. In this closure, we’ll perform the asynchronous task of requesting authorization. The difference is that instead of calling a completion, we’ll call the promise to fulfill the Future. If any errors occurred, we call the promise with the result .failure(error!). If not, we call the promise and pass the authSuccess boolean to it.

If you run this code, you’ll notice that the Future immediately executes — as soon as it’s created. This is how they work, unlike other Combine publishers. In some scenarios, we may want to initialize our publisher whenever we want, and only trigger the authorization flow when we subscribe to it. We can achieve that by wrapping the Future in a Deferred publisher.

func authorizationPublisher() -> Deferred<Future<Bool, Error>> {
    Deferred {
        Future { [unowned self] promise in
            // .. 
    }
}

Now the execution of the Future closure will be deferred until it receives a subscriber, allowing us to call the authorizationPublisher() method and pass our publisher around without triggering the HealthKit authorization flow. Deferred will also guarantee that each new subscription to the same publisher instance will cause the closure to execute again.

Checking if HealthKit is available

So far, this authorization request implementation doesn’t take into account supported device families. As I mentioned earlier, HealthKit is only available on iOS and watchOS. To check if the current device is supported by HealthKit, we simply call isHealthDataAvailable(). If it isn’t, we’ll use the unavailableOnDevice case of a custom HealthDataError enum.

func authorizationPublisher() -> Deferred<Future<Bool, Error>> {
    Deferred {
        Future { [unowned self] promise in
            // Check availability on the device. 
            guard HKHealthStore.isHealthDataAvailable() else {
                promise(.failure(HealthDataError.unavailableOnDevice))
                return
            }
                
            // Request authorization
            self.healthStore.requestAuthorization(toShare: healthTypes, read: healthTypes) { authSuccess, error in
                guard error == nil else {
                    print("HealthKit authorization error:", error!.localizedDescription)

                    // If there was an error, call the promise with a .failure
                    promise(.failure(HealthDataError.authorizationRequestError))
                    return
                }
                    
                // Only call the promise with a .success if `authSuccess` is true
                if authSuccess {
                    promise(.success(true))
                } else {
                    promise(.failure(HealthDataError.authorizationRequestError))
                }
            }
        }
    }
}

// Possible errors that can occur
enum HealthDataError: Error {
    case unavailableOnDevice
    case authorizationRequestError
}

It’s important to note that when authSuccess is true, it doesn’t exactly mean the user has granted access. It means we successfully requested it. To protect user privacy, HealthKit won’t inform us that a user denied the request.

Querying symptoms data

The same Combine-ish approach can be applied to HealthKit queries. This is when Combine really shines, allowing us to transform HealthKit sample types into the models used by our apps. If you want to go into the details of how to implement queries, there is a great guide by Apple. The TL;DR version is that we’ll be using a HKSampleQuery, the simplest way to access health data.

We’ll use a PassthroughSubject, a built-in publisher that provides a way for us to inject values into the stream by calling .send(_:).

extension HKHealthStore {
    func subject(for sampleType: HKSampleType) -> AnyPublisher<[HKCategorySample], Error> {
        
        // Create the subject.
        let subject = PassthroughSubject<[HKCategorySample], Error>()
        
        // Initialize the query and provide a completion block.
        let query = HKSampleQuery(
            sampleType: sampleType,
            predicate: nil,
            limit: HKObjectQueryNoLimit,
            sortDescriptors: nil
        ) { _, samplesOrNil, error in
            
            // If something went wrong, send a completion to the subject.
            if let error = error {
                subject.send(completion: .failure(error))
                return
            }
            
            // Unwrap the queried samples and send them to the subject.
            // If the samples are nil, send a completion instead.
            guard let samples = samplesOrNil as? [HKCategorySample] else {
                subject.send(completion: .finished)
                return
            }
            
            subject.send(samples)
            subject.send(completion: .finished)
        }
        
        // Execute the query and return the subject
        execute(query)
        return subject.eraseToAnyPublisher()
    }
}

A PassthroughSubject will not store any of the values. It will only send them to the subscribers. Unlike CurrentValueSubject, which will store the most recently published value.

Subjects are another tool to integrate imperative code into declarative APIs. We could also define a custom Publisher for HealthKit queries, but that requires a lot more boilerplate to accomplish roughly the same behavior. Subjects and Futures will do the trick most of the time, but if there is more flexibility needed, then a custom Publisher might be worth a shot.

Now we can combine our two publishers and build a data pipeline that asynchronously requests authorization, queries HealthKit, and maps the queried samples into our custom model type.

func symptomsPublisher() -> AnyPublisher<[Symptom], Never> {
    // Start with the authorization publisher
    authorizationPublisher() 
        .flatMap { _ in 
            // combine it with the query publisher 
            self.healthStore.subject(for: self.healthTypes.first!) 
        } 
        .map { samples in samples.map { Symptom($0) } }
        .assertNoFailure()
        .eraseToAnyPublisher()
}

The .flatMap operator will replace the emitted authentication boolean with the query subject and flatten them, so we won’t end up with nested publishers.

Disclaimer: I’m using .assertNoFailure() to indicate that this Publisher will never fail, thus changing its error type to Never. In a real scenario, we should catch errors and properly handle them before asserting no failure.

Batch querying HealthKit

The Women’s Health study update I’ve mentioned earlier reported a bunch of other symptom types like bloating, tiredness, acne, and headaches. Let’s add them to our example.

private let symptomTypes = Set([
    HKSampleType.categoryType(forIdentifier: .abdominalCramps)!,
    HKSampleType.categoryType(forIdentifier: .bloating)!,
    HKSampleType.categoryType(forIdentifier: .fatigue)!,
    HKSampleType.categoryType(forIdentifier: .acne)!,
    HKSampleType.categoryType(forIdentifier: .headache)!
])

There isn’t a “batch query” type in HealthKit, but we can achieve that behavior using the extension we’ve written before and some Combine operators.

symptomTypes.map { 
    self.healthStore.subject(for: $0) // 1
        .compactMap { $0.compactMap(Symptom.init) } // 2
        .eraseToAnyPublisher()
}

There are some new bits of code here.

  1. Iterate on the symptomTypes array and create one subject for each symptom.
  2. CompactMap the samples to the Symptom model.

The resulting type is an array of publishers [AnyPublisher<[Symptom], Error>], not quite what we want. Working with an array of publishes means subscribing to every single one, handling multiple subscriptions, etc. Luckily, Combine has great tools for merging publishers. Let’s take a look at a couple of them, Publisher.Merge and reduce.

Merge is publisher that can take one, two, three, or many publishers and combine them into a single one. We’ll use the later variation, MergeMany, and extend the Sequence protocol with a method for merging its elements into a single publisher.

extension Sequence where Element: Publisher {
    /// Merges a sequence of Publishers into a single Publisher.
    func merge() -> Publishers.MergeMany<Element> {
        Publishers.MergeMany(self)
    }
}

With that in place, we’ll no longer need to deal with an array of publishers. However, the illusion of a “batch query” kinda gets shattered as this publisher will emit values when each query returns. This is where the reduce operator comes into play. It works similarly to the Sequence reduce – we provide an initial value and a closure to combine subsequent values. Since our goal is a “batch” of symptoms, the initial value will be an empty array, and we’ll add each emitted array together.

func batchSymptomPublisher() -> AnyPublisher<[Symptom], Error> {
    symptomTypes.map { self.healthStore.subject(for: $0) } // 1 
        .merge() // 2
        .compactMap { $0.compactMap(Symptom.init) }
        .reduce([], +) // 3
        .eraseToAnyPublisher() // 4
}

There’s quite a lot going on in a few lines of code, so let’s look at it step by step.

  1. Again, iterate on symptomTypes and create a subject for each symptom. The resulting type of this step is an array of publishers.
  2. Merge the array of publishers into a single publisher.
  3. Use reduce to collect the elements of the stream and publish a single array containing all of them.
  4. Erase the type of this publisher for a cleaner signature.

That’s it! The publisher will emit one array with all the symptoms that were queried. For this example, I’ve simplified the pipeline, and all the samples are converted into a single Symptom type. However, you can use the same approach to combine multiple queries and map them to different model types.

Where to go from here

To recap, we’ve used Futures to write an authorization request flow. Then, using subjects, we created a publisher for HealthKit samples. We’ve seen how to combine multiple publishers with MergeMany and how to expand their behavior with operators like flatMap and reduce.

These are simple yet expressive bits of code that accomplish normal day-to-day tasks. Requesting authorization, asynchronously reading and transforming data, are common for many types of apps, regardless of whether they use HealthKit. Try to identify imperative APIs that could benefit from a bit of declarativeness, experiment, and tell me about it!

Thank you for reading. 🙂

Resources

  1. Women’s Health study update
  2. Request authorization documentation
  3. Great Futures article by Donny Walls
  4. Apple’s guide about reading data from HealthKit
  5. WWDC20 session – Getting started with HealthKit
  6. HealthKit symptom type identifiers list
rss facebook twitter github gitlab youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora quora