Search code examples
swiftgrand-central-dispatchhealthkit

Stuck at completion handlers / dispatch groups with HealthKit


I have a function that I call in the init of my class:

doLongTask(forPast: 6)

This is the playground I made with the dispatch groups:

//MAKE THE STRUCT
struct Calorie: Identifiable {

    private static var idSequence = sequence(first: 1, next: {$0 + 1})

    var id: Int
    var day: Date
    var activeCal: CGFloat
    var restingCal: CGFloat
    var dietaryCal: CGFloat

    init?(id: Int, day: Date, activeCal:CGFloat, restingCal:CGFloat, dietaryCal:CGFloat) {
        guard let id = Calorie.idSequence.next() else { return nil}
        self.id = id
        self.day = day
        self.activeCal = activeCal
        self.restingCal = restingCal
        self.dietaryCal = dietaryCal
    }
}

//CREATE HEALTHSTORE
let healthStore = HKHealthStore()

//MAKE TEST ARRAY
var testCalorieArray = [Calorie]()


func doLongTask(forPast days: Int) {

    print("Enter the function!")
    print("---")

    func getTempEnergy (for type:HKQuantityType!, unit u:HKUnit!, start fromDate:Date, end endDate:Date, completion: @escaping (Double) -> Void) {

        let countQuantityType = type

        let predicate = HKQuery.predicateForSamples(withStart: fromDate, end: endDate, options: .strictStartDate)

        let query = HKStatisticsQuery(quantityType: countQuantityType!, quantitySamplePredicate: predicate, options: .cumulativeSum) { (_, result, error) in

            var resultCount = 0.0

            guard let result = result else {
                return
            }

            if let sum = result.sumQuantity() {
                resultCount = sum.doubleValue(for: u)
            }

            DispatchQueue.main.async {
                print(resultCount)
                completion(resultCount)
            }


            }
        healthStore.execute(query)
    }


    let queue = DispatchQueue(label: "com.WILDFANGmedia.queues.serial")
    let group = DispatchGroup()

    let now = Calendar.current.startOfDay(for: Date())

    //Initialize to test values to see if they get overwritten
    var _activeEnergyBurned:CGFloat = 99.9
    var _restingEnergyBurned:CGFloat = 99.9
    var _dietaryEnergyConsumed:CGFloat = 99.9


    //EACH DAY
    for day in 0...days {

        group.enter()
        queue.async(group: group) {

            // Start und Enddatum
            let fromDate = Calendar.current.date(byAdding: .day, value: -day-1, to: now)!
            let endDate = Calendar.current.date(byAdding: .day, value: -day, to: now)!

            getTempEnergy(for: HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), unit: HKUnit.kilocalorie(), start: fromDate, end: endDate, completion: { (activeEnergyBurned) in
                print(activeEnergyBurned)
                _activeEnergyBurned = CGFloat(activeEnergyBurned)
            })

            print("End Datum: \(endDate)")
            print("Active Cal: \(_activeEnergyBurned)")

            print("Day \(day) done")
            print("---")

            testCalorieArray.append(Calorie(id: 1, day: endDate, activeCal: CGFloat(_activeEnergyBurned), restingCal: CGFloat(_restingEnergyBurned), dietaryCal: CGFloat(_dietaryEnergyConsumed))!)
            group.leave()
        }
    }


    //SHOW WHAT'S IN THE ARRAY
    group.notify(queue: queue) {

        print("All tasks done")
        print(testCalorieArray)
        print("---")
    }

    //AFTER LOOP, GO ON WITH BUSINESS
    print("Continue execution immediately")
}


doLongTask(forPast: 6)

print("AFTER THE FUNCTION")

//TEST LOOP TO RUN LONGER
for i in 1...7 {
    sleep(arc4random() % 2)
    print("Outter Row Task \(i) done")
}

print(testCalorieArray)

What it should do, is, to make the HKStatisticsQuery call (there will be 3 calls later) and write the result back into my array.

However, it writes to the array BEFORE the function is finished, hence not returning the correct values. I tried with the dispatch groups but I am stuck.

The print(value) in the completion handler of getEnergyTemp() is printed out in the very end after the test loop is finished and it prints out the correct values.

Where am I going wrong? I thought I had understood this principle but I just can't make it work.


Solution

  • The main issue is that you’re calling leave in the wrong place. So, instead of:

    for day in 0...days {
        group.enter()
        queue.async(group: group) {
    
            ...
    
            getTempEnergy(for: HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), unit: HKUnit.kilocalorie(), start: fromDate, end: endDate, completion: { (activeEnergyBurned) in
                print(activeEnergyBurned)
                _activeEnergyBurned = CGFloat(activeEnergyBurned)
            })
    
            ...
    
            testCalorieArray.append(Calorie(id: 1, day: endDate, activeCal: CGFloat(_activeEnergyBurned), restingCal: CGFloat(_restingEnergyBurned), dietaryCal: CGFloat(_dietaryEnergyConsumed))!)
            group.leave()
        }
    }
    

    Remember that the getTempEnergy closure is called asynchronously (i.e. later). You need to move the leave call and the appending of the results inside that closure:

    for day in 0...days {
        group.enter()
        queue.async {
    
            ...
    
            getTempEnergy(for: .quantityType(forIdentifier: .activeEnergyBurned), unit: .kilocalorie(), start: fromDate, end: endDate) { (activeEnergyBurned) in
                testCalorieArray.append(Calorie(id: 1, day: endDate, activeCal: CGFloat(activeEnergyBurned), restingCal: CGFloat(_restingEnergyBurned), dietaryCal: CGFloat(_dietaryEnergyConsumed))!)
                group.leave()
            }
        }
    }
    

    Note that this renders that old variable, _activeEnergyBurned, obsolete and you can now remove it.


    Unrelated, but:

    1. Note I have eliminated group reference as parameter to queue.async. When you’re manually calling enter and leave, the group parameter to async is now redundant.

      You generally either use enter/leave pattern when dealing with calls that are, themselves, asynchronous (which is the case here) or you use group parameter to async when the dispatched code is synchronous. But not both.

    2. Be very careful that all paths within getTempEnergy must call the completion handler (otherwise your DispatchGroup may never be resolved). So, in that guard statement inside getTempEnergy must also call completion.

      That begs the question of what value to supply to that completion closure. One approach is to make the Double parameter optional and return nil upon error. Or, the more robust approach is to use Result type:

      func getTempEnergy (for type: HKQuantityType?, unit u: HKUnit, start fromDate: Date, end endDate: Date, completion: @escaping (Result<Double, Error>) -> Void) {
          guard let countQuantityType = type else {
              completion(.failure(HKProjectError.invalidType))
              return
          }
      
          let predicate = HKQuery.predicateForSamples(withStart: fromDate, end: endDate, options: .strictStartDate)
      
          let query = HKStatisticsQuery(quantityType: countQuantityType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, error in
              DispatchQueue.main.async {
                  guard error == nil else {
                      completion(.failure(error!))
                      return
                  }
      
                  guard let resultCount = result?.sumQuantity()?.doubleValue(for: u) else {
                      completion(.failure(HKProjectError.noValue))
                      return
                  }
      
                  completion(.success(resultCount))
              }
          }
      
          healthStore.execute(query)
      }
      

      and then

      for day in 0...days {
          group.enter()
          queue.async {
              let fromDate = Calendar.current.date(byAdding: .day, value: -day-1, to: now)!
              let endDate = Calendar.current.date(byAdding: .day, value: -day, to: now)!
      
              getTempEnergy(for: .quantityType(forIdentifier: .activeEnergyBurned), unit: .kilocalorie(), start: fromDate, end: endDate) { result in
                  switch result {
                  case .failure(let error):
                      print(error)
      
                  case .success(let activeEnergyBurned):
                      testCalorieArray.append(Calorie(id: 1, day: endDate, activeCal: CGFloat(activeEnergyBurned), restingCal: CGFloat(_restingEnergyBurned), dietaryCal: CGFloat(_dietaryEnergyConsumed))!)
                  }
      
                  group.leave()
              }
          }
      }
      

      Where your custom errors are:

      enum HKProjectError: Error {
          case noValue
          case invalidType
      }
      
    3. Do you care about whether the contents of your array is in order or not? Be aware that these asynchronous methods might not complete in the same order that you initiated them. If order matters, you might want to save the results in a dictionary and then retrieve the values by the numerical day as an index. So, perhaps replace your array with

      var calorieResults = [Int: Calorie]()
      

      And then, when saving the results:

      calorieResults[day] = Calorie(...)
      

      And then, when you’re all done, retrieve the results like so:

      group.notify(queue: queue) {
          print("All tasks done")
          for day in 0...days {
              print(day, calorieResults[day] ?? "No data found")
          }
          print("---")
      }
      

      This also has the virtue that if one or more days failed, you know for which ones. If you only had and array and you, for example, got data for 5 of the 7 days in the last week, you wouldn’t know which days are missing. But by using the dictionary for your model, you now know for which days you have data and for which you don’t.

    4. Avoid using sleep. That blocks the current thread. Instead use a timer if you want to periodically check what’s going on.