I recently added an Apple Watch app to my weight training app Liiift. Since it’s my first Apple Watch app, I want to distill and share my learnings here. I’ll start with a few key takeaways:
- Building an Apple Watch workout app turned out to be easier than I had thought. I was also able to keep core features on iPhone, and only expose a very minimal set of features on Apple Watch by carefully packaging the experience.
- In iOS 17 and watchOS 10 and up, I could leverage Health Kit’s new mirroring session APIs to really level up the Watch app experience. This framework assumes much of the responsibilities of keeping Apple Watch and iPhone in sync, and allows you to communicate between the devices easily, without using the Watch Connectivity framework.
Defining the Experience
This is what I envisioned for my app’s experience:
- The user needs to pick a workout plan and start the workout from iPhone.
- When the user begins a workout on iPhone, the Apple Watch app shall launch, tracking and displaying key metrics such as heart rate, workout progress, and rest timer.
- During the workout, the user can check and control the rest timer on Apple Watch and on iPhone. The rest timer stays in sync on the devices.
- When the user is finished, they can end the workout from either Apple Watch or iPhone.
When the user has no in-session workout, they are greeted with a message on Apple Watch that they should start a workout on their iPhone first.
Understanding Mirrored Sessions
Mirrored workout sessions are a new-ish thing introduced in watchOS 10 in 2023. If you haven’t, checkout the WWDC 2023 session and the sample project. (There are some issues with the sample project — I’ll expand in upcoming sections.)
In the simplest terms, these are what mirrored sessions do:
- iPhone and Apple Watch are obviously separate devices that don’t share RAM or disk storage. They each retain an
HKWorkoutSession
object. - Apple Watch is the primary device, and iPhone is the mirrored device. This makes sense: Apple Watch has the sensors to produce the most accurate and up-to-date vital metrics.
- During the workout, your devices can send data to each other for you to display live stats in both places.
- The user can end the workout on either of the devices. Session state change is “mirrored” to the other device. There are some key differences that I’ll expand in a section below.
The Lifecycle of a Simple Workout
There are two key classes to understand: Workout Session and Workout Builder.
HKWorkoutBuilder
, and its subclass we use here, HKLiveWorkoutBuilder
, adds events and samples associated with a workout, so that it incrementally “builds” the workout. At the end of the workout, you’ll need to call the builder’s finishWorkout(at:)
method to save the workout (and stats) to Health Kit. The HKLiveWorkoutBuilder
class is only available on watchOS, not iOS.
An HKWorkoutSession
object — available both on watchOS and iOS — handles the lifecycle of the workout. The lifecycle can be simplified and described by the enum HKWorkoutSessionState
:
- A session is created, configured, and prepared. It is ready to start.
- A session starts running, and it can transition between running and paused states.
- A session is stopped and cannot be restarted. This is a transient state where your Apple Watch app can remain active in the background.
- A session is ended and your app no longer runs in the background.
We’ll go through these phases (except for paused) below.
Starting a workout
As mentioned in the previous section, while the workout runs primarily on Apple Watch, the user can start a workout in either device.
To start the workout on iPhone, your app merely sends a signal to Apple Watch with a workout configuration and be done with it. watchOS, upon receiving the signal, launches your Apple Watch app and calls the app delegate. To demonstrate that in code, on iPhone:
let configuration = HKWorkoutConfiguration()
configuration.activityType = .traditionalStrengthTraining
configuration.locationType = .indoor
try await healthStore.startWatchApp(toHandle: configuration)
On Apple Watch, you’ll need to set up your app delegate:
class AppDelegate: AppDelegate: NSObject, WKApplicationDelegate {
// ...
func handle(_ workoutConfiguration: HKWorkoutConfiguration) {
Task {
// We'll do `WorkoutManager` code below
let workoutManager = WorkoutManager.shared
do {
workoutManager.resetWorkout()
try await workoutManager.startWorkout(with: workoutConfiguration)
} catch {
// Handle errors
}
}
}
// ...
}
Then code the part where you actually start the workout session, in your custom WorkoutManager
class:
func startWorkout(with workoutConfiguration: HKWorkoutConfiguration) async throws {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: workoutConfiguration)
let builder = session.associatedWorkoutBuilder()
session.delegate = self
builder.delegate = self
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: workoutConfiguration)
// Mirror the session to iPhone
try await session.startMirroringToCompanionDevice()
// Start the session, and start collection data with the builder
let startDate = Date()
session.startActivity(with: startDate)
try await builder.beginCollection(at: startDate)
// Retain session and builder objects
self.session = session
self.builder = builder
}
In my app Liiift I don’t allow the experience where the user starts the workout from Apple Watch. But you can easily do that by calling startWorkout(with:)
in WorkoutManager
.
Collecting data
Recall that statistics are collected on Apple Watch, by the builder object. The builder is optimized by the workout configuration to collect and add to workout the appropriate set of metrics (that you have read access to), automatically.
You can optionally send the data off to iPhone to display them (discussed later). To see what data samples the builder collects, you need to implement the HKLiveWorkoutBuilderDelegate
protocol. Specifically, handle statistics collected in workoutBuilder(_: didCollectDataOf:)
.
Getting lifecycle updates
Lifecycle updates are sent to the workout session’s delegate. To act on changes of state, for example, you’ll need implement the workoutSession(\_:didChangeTo:from:date:)
method from the HKWorkoutSessionDelegate
protocol.
Syncing data
“Syncing” data is broken down to two parts: sending and receiving data. Sending data is done with the session’s sendToRemoteWorkoutSession(data:)
method, and receiving is done with the session delegate’s workoutSession(\_:, didReceiveDataFromRemoteWorkoutSession)
protocol method:
nonisolated
func workoutSession(_ workoutSession: HKWorkoutSession, didReceiveDataFromRemoteWorkoutSession data: [Data]) {
Task { @MainActor in
do {
for anElement in data {
// do things with data element
}
} catch {
// Handle the error
}
}
}
Im my apps I structured the data objects to be “messages”:
enum WorkoutManagerMessage: Codable, Hashable, Equatable {
case requestWorkoutMetadata
case updateWorkoutName(_: String)
case updateWorkoutImageName(_: String)
case updateWorkoutProgress(_: Double)
case updateRestTimerStatus(_: RestTimerStatus)
}
This way I can encode the message as binary to send off to the remote device, and decode it back into the message for processing.
Recover a Workout Session
Apple Watch relaunches your app automatically if a workout is still in session, and calls your App Delegate’s handleActiveWorkoutRecovery()
method.
In your App Delegate, you want to fetch the workout session and builder as soon as you can. Upon fetching the recovered session, inspect if it’s still running, and configure the session’s delegate — as well as the session’s associated builder’s delegate and data source — as soon as possible:
extension WorkoutManager {
func recoverWorkout(with recoveredSession: HKWorkoutSession) async throws {
guard recoveredSession.state == .running else {
recoveredSession.end()
return
}
// Assign delegates
let recoveredBuilder = recoveredSession.associatedWorkoutBuilder()
recoveredSession.delegate = self
recoveredBuilder.delegate = self
// Assign data source for builder
config = HKWorkoutConfiguration()
config.activityType = .traditionalStrengthTraining
config.locationType = .indoor
recoveredBuilder.dataSource = .init(healthStore: healthStore, workoutConfiguration: config)
// Restart session mirroring
await recoveredSession.startMirroringToCompanionDevice()
// Retain the session and the builder
self.session = recoveredSession
self.builder = recoveredBuilder
}
}
Ending the Workout
When using mirroring session APIs, the sessions are indeed “mirrored”, so you can end the workout in either of the places:
- You can end the session on iPhone, and Apple Watch will be notified.
- You can end the session on Apple Watch directly, and iPhone will receive updated session states.
The devil’s in the details, though.
If you end the workout on iPhone, stop your iPhone session with session.stopActivity(with: Date())
, and the Apple Watch session is stopped by way of “mirroring” the iPhone session.
If your end the workout on Apple Watch, stop the Apple Watch session with session.stopActivity(with: Date())
.
Upon receiving stopped
state on Apple Watch, end the session on Apple Watch directly with session.end()
. Do not end the workout session on iPhone.
Once the session is ended, iPhone is notified of the session’s ended
state.
Quick discussion on Apple’s demo project
Apple has a demo project and a WWDC session to demonstrate the use of mirroring workout session APIs introduced in iOS 17. They messed up one important thing in the demo project: it got the order in reverse of ending the session and finishing workout on the builder:
let finishedWorkout: HKWorkout?
do {
try await builder.endCollection(at: change.date)
finishedWorkout = try await builder.finishWorkout() // 1
session?.end() // 2
} catch {
Logger.shared.log("Failed to end workout: \(error))")
return
}
workout = finishedWorkout
This didn’t quite work for me because session.end()
was called after an await call on builder.finishWorkout()
: The latter would “await” indefinitely the session to end, but it won’t ever this way!
What the sample project should have is this: first end the session, then finish the builder’s workout. My modified code below:
let finishedWorkout: HKWorkout?
do {
session?.end() // 1
try await builder.endCollection(at: change.date)
finishedWorkout = try await builder.finishWorkout() // 2
} catch {
Logger.shared.log("Failed to end workout: \(error))")
return
}
workout = finishedWorkout
This is supported by Apple’s own documentation, in Running workout session (presumably written before Concurrency was introduced):
After the user finishes the workout, end the session and call the builder’s
endCollection(withEnd:completion:)
andfinishWorkout(completion:)
methods.session.end() builder.endCollection(withEnd: Date()) { (success, error) in guard success else { // Handle errors. } builder.finishWorkout { (workout, error) in guard workout != nil else { // Handle errors. } DispatchQueue.main.async() { // Update the user interface. } } }
The
endCollection(withEnd:completion:)
method sets the workout’s end date and deactivates the builder. ThefinishWorkout(completion:)
method saves the workout and its associated data to the HealthKit store.
The intent here is perhaps more apparent if we apply Concurrency:
Task { @MainActor in
var finishedWorkout: HKWorkout? = nil
do {
session.end()
try await builder.endCollection(at: endDate)
finishedWorkout = try await builder.finishWorkout()
} catch {
print(error.localizedDescription)
}
// Update UI with `finishedWorkout` for review
}
Parting Thoughts
In this long article, we went through the setup and key objects of a mirrored workout experience between iPhone and Apple Watch. We also walked through the lifecycle of a simple workout. I hope this is helpful!
My takeaway from learning all these: multi-device coding is a different beast, but don’t fret about the unknown! You can put together a minimal app that offers core features and take the workout experience to the next level.