Reputation: 946
I have a functional workout app on watchOS for tracking indoor and outdoor runs. I'm trying to add the outdoor running route to HealthKit using HKWorkoutRouteBuilder. Practical tests during actual outdoor runs are showing only partial route updates on a Map as illustrated below as a small series of blue dots;
Route updates come from CoreLocation which I have set up in the following class.
class LocationManager: NSObject, CLLocationManagerDelegate {
public var globalLocationManager: CLLocationManager?
private var routeBuilder: HKWorkoutRouteBuilder?
public func setUpLocationManager() {
globalLocationManager = CLLocationManager()
globalLocationManager?.delegate = self
globalLocationManager?.desiredAccuracy = kCLLocationAccuracyBest
// Update every 13.5 meters in order to achieve updates no faster than once every 3sec.
// This assumes runner is running at no faster than 6min/mile - 3.7min/km
globalLocationManager?.distanceFilter = 13.5
// Can use `kCLDistanceFilterNone` 👆 which will give more updates but still only at wide intervals.
globalLocationManager?.activityType = .fitness
/*
from the docs
...if your app needs to receive location events while in the background,
it must include the UIBackgroundModes key (with the location value) in its Info.plist file.
*/
routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: .local())
globalLocationManager?.startUpdatingLocation()
globalLocationManager?.allowsBackgroundLocationUpdates = true
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// Filter the raw data, excluding anything greater than 50m accuracy
let filteredLocations = locations.filter { isAccurateTo -> Bool in
isAccurateTo.horizontalAccuracy <= 50
}
guard !filteredLocations.isEmpty else { return }
routeBuilder?.insertRouteData(filteredLocations, completion: { success, error in
if error != nil {
// throw alert due to error in saving route.
print("Error in \(#function) \(error?.localizedDescription ?? "Error in Route Builder")")
}
})
}
// Called in class WorkoutController when workout session ends.
public func addRoute(to workout: HKWorkout) {
routeBuilder?.finishRoute(with: workout, metadata: nil, completion: { workoutRoute, error in
if workoutRoute == nil {
fatalError("error saving workout route")
}
})
}
}
The route is then added to a SwiftUI Map using HKAnchoredObjectQuery
with the following;
public func getRouteFrom(workout: HKWorkout) {
let mapDisplayAreaPadding = 1.3
let runningObjectQuery = HKQuery.predicateForObjects(from: workout)
let routeQuery = HKAnchoredObjectQuery(type: HKSeriesType.workoutRoute(), predicate: runningObjectQuery, anchor: nil, limit: HKObjectQueryNoLimit) { (query, samples, deletedObjects, anchor, error) in
guard error == nil else {
fatalError("The initial query failed.")
}
// Make sure you have some route samples
guard samples!.count > 0 else {
return
}
let route = samples?.first as! HKWorkoutRoute
// Create the route query from HealthKit.
let query = HKWorkoutRouteQuery(route: route) { (query, locationsOrNil, done, errorOrNil) in
// This block may be called multiple times.
if let error = errorOrNil {
print("Error \(error.localizedDescription)")
return
}
guard let locations = locationsOrNil else {
fatalError("*** NIL found in locations ***")
}
let latitudes = locations.map {
$0.coordinate.latitude
}
let longitudes = locations.map {
$0.coordinate.longitude
}
// Outline map region to display
guard let maxLat = latitudes.max() else { fatalError("Unable to get maxLat") }
guard let minLat = latitudes.min() else { return }
guard let maxLong = longitudes.max() else { return }
guard let minLong = longitudes.min() else { return }
if done {
let mapCenter = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, longitude: (minLong + maxLong) / 2)
let mapSpan = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * mapDisplayAreaPadding,
longitudeDelta: (maxLong - minLong) * mapDisplayAreaPadding)
DispatchQueue.main.async {
// Push to main thread to drop dots on the map.
// Without this a warning will occur.
self.region = MKCoordinateRegion(center: mapCenter, span: mapSpan)
locations.forEach { (location) in
self.overlayRoute(at: location)
}
}
}
// stop the query by calling:
// store.stop(query)
}
healthStore.execute(query)
}
routeQuery.updateHandler = { (query, samples, deleted, anchor, error) in
guard error == nil else {
// Handle any errors here.
fatalError("The update failed.")
}
// Process updates or additions here.
}
healthStore.execute(routeQuery)
}
I cannot determine why the map annotations are only displaying in bursts. I have changed the CLLocationManager.distanceFilter
to varying values as well as kCLDistanceFilterNone
. For CoreLocation authorization I have used whileInUse as well as Always authorization with no change in update frequency. It does appear the updates come in intervals of time, not distance traveled but I can't be entirely certain. Having the app active on screen as well as in the background appears to have no effect on location updates.
Any insight is greatly appreciated.
Below is the HealthKit types to read and types to share setup code:
public func setupWorkoutSession() {
let authorizationStatus = healthStore.authorizationStatus(for: HKWorkoutType.workoutType())
let typesToShare: Set = [HKQuantityType.workoutType(), HKSeriesType.workoutRoute()]
let typesToRead: Set = [
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!,
HKSeriesType.workoutType(),
HKSeriesType.workoutRoute()
]
if authorizationStatus == .sharingDenied {
showAlertView()
return
}
// Request authorization for those quantity types.
healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in
if success {
self.beginWorkout()
} else if error != nil {
// Handle errors.
}
}
}
Upvotes: 5
Views: 1386
Reputation: 946
I have since found the cause of the intermittent location updates was due to a somewhat unrelated timer which I had set up incorrectly. That timer was killing background updates and affecting the location services. The posted code actually works.
Upvotes: 0
Reputation: 7367
It's not clear from your question if these are foreground or background updates, but given the gaps that also seem to come with a regular frequency, I suspect you're primarily getting background location updates.
You've already got the activityType
set to .fitness
, which I believe gives the highest resolution, but in any case iOS still periodically sleeps the location manager to preserve energy.
Since you're collecting what you expect to be a more-or-less-constant movement scenario, it would also be a good idea to set pausesLocationUpdatesAutomatically
to false
- explicitly controlling the CLManager's operation with the start and stop of the fitness/run scenario. That will chew through more battery, but should give you more consistent updates.
You will still likely have some gaps and probably the best solution will be to interpolate route positions between those gaps based on the timestamp of when you received them. In some dense urban areas, you may also need or want to smooth or otherwise constrain the route points, as I've found it's "easy" to get a route position of walking through a building instead of showing the street or sidewalk you're walking on. I haven't seen that issue nearly as much in places with more open skylines and lower buildings.
Upvotes: 1