Apple's API's Are Truly Awful (At Least Some Of Them)
By MzFit
- 11 minutes read - 2148 wordsApple is a mindbogglingly big company and they make a lot of world class products. But for all the good stuff they make, sometimes they completely strike out.
I just launched an Apple Watch app that uses Apple’s HealthKit API to start/stop/measure workouts on the Apple Watch.
It was not a pleasant 3 months of my life, and I would like to share some of the pain I experienced. Because it didn’t need to be this way, and I hope someone at Apple is listening.
Just like Steve Yegge’s famous platforms rant was a love letter to Google about how they needed to do better to support developers, Apple, this is a love letter to you too. Or maybe an intervention.
To compare and contrast, let me show what I think is a reasonably good API I also use in my app: Sven Tiigi’s YouTubePlayerKit
https://github.com/SvenTiigi/YouTubePlayerKit
It was so easy to use it honestly took me about half a day to completely swap out my previous YouTube swift library for this one.
You can read the documentation on github, but just to give a taste, you get a youTubePlayer object, and that youTubePlayer object can do things. Like play and pause videos:
let youTubePlayer = YouTubePlayer(
source: .video(id: "psL_5RIBqnY"),
configuration: .init(
autoPlay: true
)
)
// Play video
youTubePlayer.play()
// Pause video
youTubePlayer.pause()
// Stop video
youTubePlayer.stop()
// Seek to 60 seconds
youTubePlayer.seek(to: 60, allowSeekAhead: false)
This is all well documented and is the sort of thing even a junior developer should be able to handle. Again, you get a YouTube player. You do things with it. The things are intuitive things (like play, pause, seek). There are very few WTF moments in this code.
So, when I went to develop my Apple Watch companion app, I went to Apple’s documentation hoping for something similarly easy to use.
The first thing I wanted to do was to be able to launch the watch app when I start a YouTube video and start tracking a workout.
So I find this handy documentation for the startWatchApp function in Apple’s HealthKit library. Promising!
https://developer.apple.com/documentation/HealthKit/hkhealthstore/1648358-startwatchapp
func startWatchApp(
with workoutConfiguration: HKWorkoutConfiguration,
completion: @escaping (Bool, (any Error)?) -> Void
)
OK, so it takes a closure to handle any errors, not my favorite way to do things… but, it looks like they cleaned that up with their general move from closures -> async to achieve concurrency. I’m good with this.
And in fact, it gives a helpful code sample that shows exactly how to use it the async way. Perfect!
let configuration = HKWorkoutConfiguration()
configuration.activityType = .running
configuration.locationType = .outdoor
do {
try await store.startWatchApp(toHandle: configuration)
}
catch {
// Handle the error here.
fatalError("*** An error occurred while starting a workout on Apple Watch: \(error.localizedDescription) ***")
}
Now, I assume we have some sort of code to implement on the watch app to process this call and do something useful in the watch?
OK, also good. This is roughly what I expected to see. There is some magic to start a workout, but yeah, how else could you do it? You have to implement a specially named handle function in a specially named class. No WTF’s there.
class AppDelegate: NSObject, WKApplicationDelegate {
func handle(_ workoutConfiguration: HKWorkoutConfiguration) {
Task {
await WorkoutManager.shared.startWorkout()
logger.debug("Successfully started workout")
}
}
}
Ok, so let’s look at this WorkoutManager.shared.startWorkout(). That’s a library right? Because that seems pretty straightfoward. Except, why doesn’t startWorkout() take in the HKWorkoutConfiguration? How does it know what type of workout to start?
And wait… WorkoutManager isn’t a built in library? I have to implement my own workout manager? For the love of all that is holy, why? Why not encapsulate all that and just give me a workoutManager, like my boy Sven gave me a youTubePlayer? I’m going to include the entire code snippet here because there is a lot going on.
extension WorkoutManager {
func startWorkout() async {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .running
configuration.locationType = .outdoor
let session: HKWorkoutSession
do {
session = try HKWorkoutSession(healthStore: store,
configuration: configuration)
} catch {
// Handle failure here.
fatalError("*** An error occurred: \(error.localizedDescription) ***")
}
let builder = session.associatedWorkoutBuilder()
let source = HKLiveWorkoutDataSource(healthStore: store,
workoutConfiguration: configuration)
source.enableCollection(for: HKQuantityType(.stepCount), predicate: nil)
builder.dataSource = source
session.delegate = self
builder.delegate = self
self.session = session
self.builder = builder
let start = Date()
// Start the mirrored session on the companion iPhone.
do {
try await session.startMirroringToCompanionDevice()
}
catch {
fatalError("*** Unable to start the mirrored workout: \(error.localizedDescription) ***")
}
// Start the workout session.
session.startActivity(with: start)
do {
try await builder.beginCollection(at: start)
} catch {
// Handle the error here.
fatalError("*** An error occurred while starting the workout: \(error.localizedDescription) ***")
}
logger.debug("*** Workout Session Started ***")
}
}
First of all, there are 71 lines of code here that expose a lot of the inner workings about how a watch app works. Like, I guess that could be useful. But… 99% of the time wouldn’t a developer just want something like:
var workout = workoutManager.startWorkout(workoutConfiguration: HKWorkoutConfiguration)
Why not just provide that? Why make me implement everything in workout manager? Do you think I will make better choices about how to handle the lifecycle of a workout session and a workout builder?
But putting that aside, let’s assume that there is some reason every single developer who builds a watch app needs to be exposed to the innards of collecting workout data… why not give them good code about how to do this? Like… why is this repeated twice, both in the phone app and the watch app?
let configuration = HKWorkoutConfiguration()
configuration.activityType = .running
configuration.locationType = .outdoor
Shouldn’t it really be done once?
And, pet peeve, why does Apple’s code contain such random newline characters? Always double line breaks, except where there are single line breaks. Just kind of randomly applied. Do they not think a standard style guide should be applied? Or does their style guide really entail random double line breaks? Or is it some weird quirk of their content management system for the developer documentation site? Who knows.
But, OK, I guess I can understand all of the above. At first glance it looks reasonable. Minor complaint about error handling: Why is error handling just “oh, log that an error happened”? Again, since this is sample code that millions of developers will be following, shouldn’t you take this opportunity to show how to do it right? I guess you can assume the caller can try/catch the exception, but if that’s the idiom, why catch and log? Just seems strange.
More importantly, shouldn’t there be an idiomatic way to get a workout object back that you can use to display status on the watch?
And shouldn’t there be a way to end the workout?
Reading this documentation and trying to actually implement a watch app, I was left with more questions than answers.
Fortunately, after some searching, I find that Apple had someone build a sample watch app that appears to answer most of those questions:
Slightly annoying that the code is not on github by default and their preferred method of referencing it is to download the whole zip file and open it in XCode. But, OK. Let’s open this thing up and see what it does.
OK. Wow. This is exactly what I wanted! It has a startWorkout(workoutType: selectedWorkout) just like I wanted! It has endWorkout() too! Perfect! Again, extremely weird that the way to do this is for every single developer to have to copy and paste this sample code into their watch app to get a WorkoutManager. But at least this works now, right?
Except… it doesn’t. It’s still crappy code. Not as crappy as the sample code on their docs, but, still crappy the more you peel it back.
Yeah, that startWorkout(workoutType: selectedWorkout)? Don’t use that. Even though it’s a public function on workout manager, don’t use it. You’re supposed to use workoutManager.selectedWorkout = workout and then it calls startWorkout(…) for you. Why? Who knows. Why isn’t startWorkout(…) private? Who knows. It sits there tempting you to call it. But if you call it, nothing else works correctly.
And endWorkout()? What is that doing? And why when I run it in the simulator does it not actually end the workout? (Sometimes. More on that later.)
And why does endWorkout() have to reach out and have side effects in the UI? Couldn’t it return a value like a sane API?
Like wouldn’t this be nicer for a developer to use:
let state = workoutManager.endWorkout()
Except, no… endWorkout is secretly asynchronous. It doesn’t have an async keyword, but if you look at the source on line 151 there is actually an extension that does the heavy lifting of session?.end().
You see, session?.end() doesn’t actually do the useful stuff like, oh, finalize the workout and collect the data. No, it just marks the session as ended and collecting the data and actually finalizing the workout is left as an exercise to the user. Again, Apple, why??
I guess they feel this is trivial, knowing to create a magic extension that detects if the session ended (line 151) and then collects and finalizes the workout. But, again, every developer writing a workout app has to cut and paste this same code.
And see on line 162, that’s the asynchronous part that secretly updates the WorkoutManager.workout field with the workout which is why you can’t just call endWorkout() and then check the workout, you have to endWorkout() and then wait for a bool to be set in the UI (line 107), except you can’t actually trust that bool.
showingSummaryView = true
Because this flag doesn’t actually mean the workout has ended. Because, again, endWorkout() is secretly asynchronous.
So you have to build other logic to handle detecting if the workout has actually ended, or you have to change this sample app so that the extension that ends the workout in line 151 also updates the UI (which is what I ended up doing). But if you look at Apple’s sample app, they actually use showSummaryView to display the summary view but then add another check to see if it’s really done. Like… really? Multiple checks to find out if a workout is actually done is the canonical reference material? What happed to DRY?
Side note: I really hate having to reach into the UI from back end API’s because that’s just dirty and makes the entire thing non-unit testable. But having to use an extension to detect the workout ending means I can’t get a return value. So… reaching into the UI is the way to do it. Or is it? Who knows any more with this spaghetti code.
And it just goes on and on from there. Like no canonical way to find out if a workout session/builder is still active (checking the workoutManager.running doesn’t do what you might think). Overall just a million little cuts that make developing an Apple Watch app a miserable experience. Layers of cruft each of which is not quite horrible in itself but together make up a fractal of bad design.
And on you go. Everything in the box is kind of weird and quirky, but maybe not enough to make it completely worthless. And there’s no clear problem with the set as a whole; it still has all the tools… That’s what’s wrong with HealthKit.
And let me not forget to mention the two weeks I lost because the simulator will sometimes just not process the extension on line 151 while real devices do. (Mostly. Probably???). And apparently the only way to reliably develop an Apple Watch app that tracks workouts is to restart the simulator every 3rd try or so. Because if you don’t the workout will still be running in the background and everything just silently fails and you will think you have a bug in your code until you finally find the correct stack overflow reference that just explains this is the way life is.
And it doesn’t have to be this way.
If Apple provided a reasonable WorkoutManager as part of their core HealthKit API, and that API had a reliable way to do:
try await workoutManager.startWorkout(workoutType: HKWorkoutConfiguration)
if workoutManager.workout.status == .active {
// do something
}
try await workoutManager.end()
if workoutManager.workout.status == .ended {
// do something
}
That’s all, say, 99% of developers would really need.
But that’s not what we got.
We got a half baked Sample App wrapping a half baked API with conflicting async paradigms and all the complexity of the API’s forced right on the poor developers.
And so Apple, I say, whoever had a hand in this should be ashamed of themselves. You can and should do better. I love SwiftUI. I love building iOS apps. But please clean this up. Maybe you could get Sven Tiigi to do it. That guy does good work.