steve1951
steve1951

Reputation: 193

Why doesn't HKAnchoredObjectQuery with enableBackgroundDeliveryForType always fire when app is in the background?

I'm experimenting a bit to familiarize myself with the HKAnchoredObjectQuery and getting results when my app is inactive. I start the app, switch away to Apple Health, enter a blood glucose result; sometimes the results handler is called right away (as evidenced by the print to the console) but other times the handler isn't called until I switch back to my app. Same is true for deleted results as well as added results. Anybody have any guidance?

Most of this code is from a question from thedigitalsean adapted here to get updates while app is in the background and logging to the console. See: Healthkit HKAnchoredObjectQuery in iOS 9 not returning HKDeletedObject

class HKClient : NSObject {

  var isSharingEnabled: Bool = false
  let healthKitStore:HKHealthStore? = HKHealthStore()
  let glucoseType : HKObjectType = HKObjectType.quantityTypeForIdentifier(HKQuantityTypeIdentifierBloodGlucose)!

  override init(){
      super.init()
  }

  func requestGlucosePermissions(authorizationCompleted: (success: Bool, error: NSError?)->Void) {

      let dataTypesToRead : Set<HKObjectType> = [ glucoseType ]

      if(!HKHealthStore.isHealthDataAvailable())
      {
          // let error = NSError(domain: "com.test.healthkit", code: 2, userInfo: [NSLocalizedDescriptionKey: "Healthkit is not available on this device"])
          self.isSharingEnabled = false
          return
      }

      self.healthKitStore?.requestAuthorizationToShareTypes(nil, readTypes: dataTypesToRead){(success, error) -> Void in
          self.isSharingEnabled = true
          authorizationCompleted(success: success, error: error)
      }
  }

  func getGlucoseSinceAnchor(anchor:HKQueryAnchor?, maxResults:uint, callback: ((source: HKClient, added: [String]?, deleted: [String]?, newAnchor: HKQueryAnchor?, error: NSError?)->Void)!) {
      let queryEndDate = NSDate(timeIntervalSinceNow: NSTimeInterval(60.0 * 60.0 * 24))
      let queryStartDate = NSDate.distantPast()
      let sampleType: HKSampleType = glucoseType as! HKSampleType
      let predicate: NSPredicate = HKAnchoredObjectQuery.predicateForSamplesWithStartDate(queryStartDate, endDate: queryEndDate, options: HKQueryOptions.None)
      var hkAnchor: HKQueryAnchor

      if(anchor != nil){
          hkAnchor = anchor!
      } else {
          hkAnchor = HKQueryAnchor(fromValue: Int(HKAnchoredObjectQueryNoAnchor))
      }

      let onAnchorQueryResults : ((HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, NSError?) -> Void)! = {
          (query:HKAnchoredObjectQuery, addedObjects:[HKSample]?, deletedObjects:[HKDeletedObject]?, newAnchor:HKQueryAnchor?, nsError:NSError?) -> Void in

          var added = [String]()
          var deleted = [String]()

          if (addedObjects?.count > 0){
              for obj in addedObjects! {
                  let quant = obj as? HKQuantitySample
                  if(quant?.UUID.UUIDString != nil){
                      let val = Double( (quant?.quantity.doubleValueForUnit(HKUnit(fromString: "mg/dL")))! )
                      let msg : String = (quant?.UUID.UUIDString)! + " " + String(val)
                      added.append(msg)
                  }
              }
          }

          if (deletedObjects?.count > 0){
              for del in deletedObjects! {
                  let value : String = del.UUID.UUIDString
                  deleted.append(value)
              }
          }

          if(callback != nil){
              callback(source:self, added: added, deleted: deleted, newAnchor: newAnchor, error: nsError)
          }
      }

      // remove predicate to see deleted objects
      let anchoredQuery = HKAnchoredObjectQuery(type: sampleType, predicate: nil, anchor: hkAnchor, limit: Int(maxResults), resultsHandler: onAnchorQueryResults)

      // added - query should be always running
      anchoredQuery.updateHandler = onAnchorQueryResults

      // added - allow query to pickup updates when app is in backgroun
      healthKitStore?.enableBackgroundDeliveryForType(sampleType, frequency: .Immediate) {
          (success, error) in
          if (!success) {print("enable background error")}
          }

      healthKitStore?.executeQuery(anchoredQuery)
  }

  let AnchorKey = "HKClientAnchorKey"
  func getAnchor() -> HKQueryAnchor? {
      let encoded = NSUserDefaults.standardUserDefaults().dataForKey(AnchorKey)
      if(encoded == nil){
          return nil
      }
      let anchor = NSKeyedUnarchiver.unarchiveObjectWithData(encoded!) as? HKQueryAnchor
      return anchor
  }

  func saveAnchor(anchor : HKQueryAnchor) {
      let encoded = NSKeyedArchiver.archivedDataWithRootObject(anchor)
      NSUserDefaults.standardUserDefaults().setValue(encoded, forKey: AnchorKey)
      NSUserDefaults.standardUserDefaults().synchronize()
  }
}


class ViewController: UIViewController {
  let debugLabel = UILabel(frame: CGRect(x: 10,y: 20,width: 350,height: 600))

  override func viewDidLoad() {
      super.viewDidLoad()

      self.view = UIView();
      self.view.backgroundColor = UIColor.whiteColor()


      debugLabel.textAlignment = NSTextAlignment.Center
      debugLabel.textColor = UIColor.blackColor()
      debugLabel.lineBreakMode = NSLineBreakMode.ByWordWrapping
      debugLabel.numberOfLines = 0
      self.view.addSubview(debugLabel)

      let hk = HKClient()
      hk.requestGlucosePermissions(){
          (success, error) -> Void in

          if(success){
              let anchor = hk.getAnchor()

              hk.getGlucoseSinceAnchor(anchor, maxResults: 0)
                  { (source, added, deleted, newAnchor, error) -> Void in
                      var msg : String = String()

                      if(deleted?.count > 0){
                          msg += "Deleted: \n" + (deleted?[0])!
                          for s in deleted!{
                              msg += s + "\n"
                          }
                      }

                      if (added?.count > 0) {
                          msg += "Added: "
                          for s in added!{
                              msg += s + "\n"
                          }
                      }

                      if(error != nil) {
                          msg = "Error = " + (error?.description)!
                      }

                      if(msg.isEmpty)
                      {
                          msg = "No changes"
                      }
                      debugPrint(msg)

                      if(newAnchor != nil && newAnchor != anchor){
                          hk.saveAnchor(newAnchor!)
                      }

                      dispatch_async(dispatch_get_main_queue(), { () -> Void in
                          self.debugLabel.text = msg
                      })
              }
          }
      }
  }

  override func didReceiveMemoryWarning() {
      super.didReceiveMemoryWarning()
      // Dispose of any resources that can be recreated.
  }
}

I also added print()'s at the various application state changes. A sample of the console log (this is running on iPhone 6s device from XCode) shows the handler being called sometimes after I entered background but before reentering foreground and other times only after reentering foreground.

app did become active
"No changes"
app will resign active
app did enter background
app will enter foreground
"Added: E0340084-6D9A-41E4-A9E4-F5780CD2EADA 99.0\n"
app did become active
app will resign active
app did enter background
"Added: CEBFB656-0652-4109-B994-92FAA45E6E55 98.0\n"
app will enter foreground
"Added: E2FA000A-D6D5-45FE-9015-9A3B9EB1672C 97.0\n"
app did become active
app will resign active
app did enter background
"Deleted: \nD3124A07-23A7-4571-93AB-5201F73A4111D3124A07-23A7-4571-93AB-5201F73A4111\n92244E18-941E-4514-853F-D890F4551D76\n"
app will enter foreground
app did become active
app will resign active
app did enter background
app will enter foreground
"Added: 083A9DE4-5EF6-4992-AB82-7CDDD1354C82 96.0\n"
app did become active
app will resign active
app did enter background
app will enter foreground
"Added: C7608F9E-BDCD-4CBC-8F32-94DF81306875 95.0\n"
app did become active
app will resign active
app did enter background
"Deleted: \n15D5DC92-B365-4BB1-A40C-B870A48A70A415D5DC92-B365-4BB1-A40C-B870A48A70A4\n"
"Deleted: \n17FB2A43-0828-4830-A229-7D7DDC6112DB17FB2A43-0828-4830-A229-7D7DDC6112DB\n"
"Deleted: \nCEBFB656-0652-4109-B994-92FAA45E6E55CEBFB656-0652-4109-B994-92FAA45E6E55\n"
app will enter foreground
"Deleted: \nE0340084-6D9A-41E4-A9E4-F5780CD2EADAE0340084-6D9A-41E4-A9E4-F5780CD2EADA\n"
app did become active

Upvotes: 2

Views: 2315

Answers (1)

thedigitalsean
thedigitalsean

Reputation: 299

I suggest using an HKObserverQuery and setting it up carefully.

There is an algorithm that watches how and when you call the "completion" handler of the HKObserverQuery when you have background delivery enabled. The details of this are vague unfortunately. Someone on the Apple Dev forums called it the "3 strikes" rule but Apple hasn't published any docs that I can find on it's behavior.

https://forums.developer.apple.com/thread/13077

One thing I have noticed is that, if your app is responding to a background delivery with an HKObserverQuery, creating an HKAnchoredObjectQuery, and setting the UpdateHandler in that HKAnchoredObjectQuery, this UpdateHandler will often cause multiple firings of the callback. I suspected that perhaps since these additional callbacks are being executed AFTER you have already told Apple that you have completed you work in response to the background delivery, you are calling the completion handler multiple times and maybe they ding you some "points" and call you less often for bad behavior.

I had the most success with getting consistent callbacks by doing the following:

  1. Using an ObserverQuery and making the sure the call of the "completion" handler gets called once and at the very end of your work.
  2. Not setting an update handler in my HKAnchoredObjectQuery when running in the background (helps achieve 1).
  3. Focusing on making my query handlers, AppDelegate, and ViewController are as fast as possible. I noticed that when I reduced all my callbacks down to just a print statement, the callbacks from HealthKit came immediately and more consistently. So that says Apple is definitely paying attention to execution time. So try to statically declare things where possible and focus on speed.

I have since moved on to my original project which uses Xamarin.iOS, not swift, so I haven't kept up with the code I originally posted. But here is an updated (and untested) version of that code that should take these changes into account (except for the speed improvements):

//
//  HKClient.swift
//  HKTest

import UIKit
import HealthKit

class HKClient : NSObject {

var isSharingEnabled: Bool = false
let healthKitStore:HKHealthStore? = HKHealthStore()
let glucoseType : HKObjectType = HKObjectType.quantityTypeForIdentifier(HKQuantityTypeIdentifierBloodGlucose)!

override init(){
    super.init()
}

func requestGlucosePermissions(authorizationCompleted: (success: Bool, error: NSError?)->Void) {

    let dataTypesToRead : Set<HKObjectType> = [ glucoseType ]

    if(!HKHealthStore.isHealthDataAvailable())
    {
        // let error = NSError(domain: "com.test.healthkit", code: 2, userInfo: [NSLocalizedDescriptionKey: "Healthkit is not available on this device"])
        self.isSharingEnabled = false
        return
    }

    self.healthKitStore?.requestAuthorizationToShareTypes(nil, readTypes: dataTypesToRead){(success, error) -> Void in
        self.isSharingEnabled = true
        authorizationCompleted(success: success, error: error)
    }
}

func startBackgroundGlucoseObserver( maxResultsPerQuery: Int, anchorQueryCallback: ((source: HKClient, added: [String]?, deleted: [String]?, newAnchor: HKQueryAnchor?, error: NSError?)->Void)!)->Void {
    let onBackgroundStarted = {(success: Bool, nsError : NSError?)->Void in
        if(success){
            //Background delivery was successfully created.  We could use this time to create our Observer query for the system to call when changes occur.  But we do it outside this block so that even when background deliveries don't work,
            //we will have the observer query working when are in the foreground at least.
        } else {
            debugPrint(nsError)
        }


        let obsQuery = HKObserverQuery(sampleType: self.glucoseType as! HKSampleType, predicate: nil) {
            query, completion, obsError in

            if(obsError != nil){
                //Handle error
                debugPrint(obsError)
                abort()
            }

            var hkAnchor = self.getAnchor()
            if(hkAnchor == nil) {
                hkAnchor = HKQueryAnchor(fromValue: Int(HKAnchoredObjectQueryNoAnchor))
            }

            self.getGlucoseSinceAnchor(hkAnchor, maxResults: maxResultsPerQuery, callContinuosly:false, callback: { (source, added, deleted, newAnchor, error) -> Void in
                anchorQueryCallback(source: self, added: added, deleted: deleted, newAnchor: newAnchor, error: error)

                //Tell Apple we are done handling this event.  This needs to be done inside this handler
                completion()
            })
        }

        self.healthKitStore?.executeQuery(obsQuery)
    }

    healthKitStore?.enableBackgroundDeliveryForType(glucoseType, frequency: HKUpdateFrequency.Immediate, withCompletion: onBackgroundStarted )
}

func getGlucoseSinceAnchor(anchor:HKQueryAnchor?, maxResults:Int, callContinuosly:Bool, callback: ((source: HKClient, added: [String]?, deleted: [String]?, newAnchor: HKQueryAnchor?, error: NSError?)->Void)!){

    let sampleType: HKSampleType = glucoseType as! HKSampleType
    var hkAnchor: HKQueryAnchor;

    if(anchor != nil){
        hkAnchor = anchor!
    } else {
        hkAnchor = HKQueryAnchor(fromValue: Int(HKAnchoredObjectQueryNoAnchor))
    }

    let onAnchorQueryResults : ((HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, NSError?) -> Void)! = {
        (query:HKAnchoredObjectQuery, addedObjects:[HKSample]?, deletedObjects:[HKDeletedObject]?, newAnchor:HKQueryAnchor?, nsError:NSError?) -> Void in

        var added = [String]()
        var deleted = [String]()

        if (addedObjects?.count > 0){
            for obj in addedObjects! {
                let quant = obj as? HKQuantitySample
                if(quant?.UUID.UUIDString != nil){
                    let val = Double( (quant?.quantity.doubleValueForUnit(HKUnit(fromString: "mg/dL")))! )
                    let msg : String = (quant?.UUID.UUIDString)! + " " + String(val)
                    added.append(msg)
                }
            }
        }

        if (deletedObjects?.count > 0){
            for del in deletedObjects! {
                let value : String = del.UUID.UUIDString
                deleted.append(value)
            }
        }

        if(callback != nil){
            callback(source:self, added: added, deleted: deleted, newAnchor: newAnchor, error: nsError)
        }
    }
    let anchoredQuery = HKAnchoredObjectQuery(type: sampleType, predicate: nil, anchor: hkAnchor, limit: Int(maxResults), resultsHandler: onAnchorQueryResults)
    if(callContinuosly){
        //The updatehandler should not be set when responding to background observerqueries since this will cause multiple callbacks
        anchoredQuery.updateHandler = onAnchorQueryResults
    }
    healthKitStore?.executeQuery(anchoredQuery)
}

let AnchorKey = "HKClientAnchorKey"
func getAnchor() -> HKQueryAnchor? {
    let encoded = NSUserDefaults.standardUserDefaults().dataForKey(AnchorKey)
    if(encoded == nil){
        return nil
    }
    let anchor = NSKeyedUnarchiver.unarchiveObjectWithData(encoded!) as? HKQueryAnchor
    return anchor
}

func saveAnchor(anchor : HKQueryAnchor) {
    let encoded = NSKeyedArchiver.archivedDataWithRootObject(anchor)
    NSUserDefaults.standardUserDefaults().setValue(encoded, forKey: AnchorKey)
    NSUserDefaults.standardUserDefaults().synchronize()
}
}

Upvotes: 7

Related Questions