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:

Defining the Experience

This is what I envisioned for my app’s experience:

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:

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:

  1. A session is created, configured, and prepared. It is ready to start.
  2. A session starts running, and it can transition between running and paused states.
  3. A session is stopped and cannot be restarted. This is a transient state where your Apple Watch app can remain active in the background.
  4. 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:

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:) and finishWorkout(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. The finishWorkout(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.