Updating an app to use Swift concurrency
At a glance
| Item | Summary |
|---|---|
| Purpose | Refactor the Coffee Tracker watchOS app from completion handlers and dispatch queues to async/await, actors, and a main-actor model. |
| App architecture | Two parallel copies of one app, Starting Point and Updated. Each has a SwiftUI watch extension whose views, complication data source, and background-refresh delegate all reach one shared CoffeeData model. That model delegates disk I/O to CoffeeDataStore and HealthKit I/O to HealthKitController. |
| Main patterns | Actor isolation, Publisher-backed observable state, SwiftUI environment injection, Delegate or data-source callbacks, Singleton, Callback-to-async continuation bridge |
| Project style | Flat, file-per-type WatchKit extension folder; no View/Model subfolders. Each variant ships an iOS container app, a WatchKit app, and a WatchKit extension target. |
| Execution model | Updated: @MainActor on CoffeeData, two actor types, four unretained plain Task {} creations, and one checked continuation. Starting Point: a private serial DispatchQueue plus DispatchQueue.main.async hand-backs. Neither variant uses Task.detached, task groups, or async sequences. |
| State/event model | ObservableObject with one @Published array, injected through .environmentObject; WatchKit and ClockKit call back into a delegate and a data source. |
| Key frameworks/packages | SwiftUI, WatchKit, ClockKit, HealthKit, os, and Foundation imports. No Swift package manifest. |
Platform note: the catalog lists iOS, iPadOS, Mac Catalyst, and watchOS. In the project file, the shipping code lives in watchOS targets (SDKROOT = watchos, WATCHOS_DEPLOYMENT_TARGET = 8.0). The project-level SDKROOT = iphoneos setting applies to the watchapp2-container target, which has no Swift sources (Updated/Coffee Tracker.xcodeproj/project.pbxproj:215, Updated/Coffee Tracker.xcodeproj/project.pbxproj:500).
Project structure
Updating an app to use Swift concurrency/
├── Configuration/SampleCode.xcconfig
├── Starting Point/ completion handlers + DispatchQueue
│ ├── Coffee Tracker.xcodeproj
│ ├── Coffee Tracker WatchKit App/ Info.plist and assets only
│ └── Coffee Tracker WatchKit Extension/
│ └── (same 11 Swift files as Updated)
└── Updated/ async/await + actors
├── Coffee Tracker.xcodeproj 3 targets: container, watch app, extension
├── Coffee Tracker WatchKit App/ Info.plist and assets only
└── Coffee Tracker WatchKit Extension/
├── CoffeeTrackerApp.swift @main App + delegate adaptor
├── ExtensionDelegate.swift background refresh handling
├── ContentView.swift root view, scene-phase reactions
├── CoffeeTrackerView.swift main screen
├── DrinkListView.swift add-drink sheet
├── CoffeeData.swift @MainActor model + private CoffeeDataStore actor
├── HealthKitController.swift HealthKit actor
├── ComplicationController.swift CLKComplicationDataSource
├── Drink.swift / DrinkType.swift value types
├── HostingController.swift WKHostingController (not referenced)
└── Info.plist principal-class wiring
Structure observations
- The two variants have the same file names and type names. The sample teaches by diff: compare
Starting Point/…/CoffeeData.swiftwithUpdated/…/CoffeeData.swiftto see the migration. - Only the extension target compiles Swift. The WatchKit App folder holds only
Info.plistand assets (Updated/Coffee Tracker.xcodeproj/project.pbxproj:232,Updated/Coffee Tracker.xcodeproj/project.pbxproj:249). Info.plistsets the two framework entry classes by name.CLKComplicationPrincipalClassselectsComplicationController, andWKExtensionDelegateClassNameselectsExtensionDelegate(Updated/Coffee Tracker WatchKit Extension/Info.plist:23,Updated/Coffee Tracker WatchKit Extension/Info.plist:39).HostingControlleris compiled but nothing in the source references it. The SwiftUI@mainapp supplies the UI instead (Updated/Coffee Tracker WatchKit Extension/HostingController.swift:11).- In the
Updatedvariant,CoffeeData.swiftholds two types: theprivatepersistence actor and the implicitlyinternalCoffeeDatamodel (only itscurrentDrinksmember ispublic private(set)). The file boundary keepsCoffeeDataStorehidden from the rest of the module.
Overall architecture
flowchart LR
App["CoffeeTrackerApp (@main)"] --> Root["ContentView"]
App -. adaptor .-> Delegate["ExtensionDelegate"]
Root -- environmentObject --> Views["CoffeeTrackerView / DrinkListView"]
Root --> Model["CoffeeData (@MainActor, shared)"]
Complication["ComplicationController"] --> Model
Delegate --> Model
Model --> Store["CoffeeDataStore (actor)"]
Model --> HK["HealthKitController (actor)"]
HK -. weak model .-> Model
HK --> HealthKit["HKHealthStore"]
Reference code
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:98 — CoffeeData
@MainActor
class CoffeeData: ObservableObject {
// ...
static let shared = CoffeeData()
lazy var healthKitController = HealthKitController(withModel: self)
// An actor used to save and load the model data away from the main thread.
private let store = CoffeeDataStore()
// ...
@Published public private(set) var currentDrinks: [Drink] = []
// ...
}Interpretation
Observed: three framework entry points reach the model through the same static CoffeeData.shared: the SwiftUI root view (Updated/Coffee Tracker WatchKit Extension/ContentView.swift:19), the complication data source (Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:13), and the background-refresh delegate (Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:33). The model owns two actor collaborators. HealthKitController keeps a weak back-reference so it can push HealthKit changes into the model.
Inference: the singleton exists because ClockKit and WatchKit create ComplicationController and ExtensionDelegate from Info.plist class names, so SwiftUI cannot inject the model into them. The comment at Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:103 says the model “needs to be accessed both from the app extension and from the complication controller”.
Ownership and state
classDiagram
CoffeeData *-- CoffeeDataStore : private let store
CoffeeData *-- HealthKitController : lazy var
HealthKitController o-- CoffeeData : weak model
CoffeeTrackerApp *-- ExtensionDelegate : WKExtensionDelegateAdaptor
ContentView o-- CoffeeData : shared
CoffeeTrackerView --> CoffeeData : EnvironmentObject
ComplicationController o-- CoffeeData : lazy var data
Ownership evidence
Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:26 — HealthKitController
actor HealthKitController {
// ...
// A weak link to the main model.
private weak var model: CoffeeData?
// Indicates whether the user has authroized access to their health data
private lazy var isAuthorized = false
// ...
init(withModel model: CoffeeData) {
self.model = model
}
// ...
}| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
CoffeeData (static shared) |
itself | private init() plus static let shared makes this the only instance (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:105, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:248) |
n/a |
CoffeeData |
currentDrinks: [Drink] |
Stores it as @Published public private(set) (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114) |
Only CoffeeData: addDrink, updateModel, and load |
CoffeeData |
CoffeeDataStore |
Creates and retains it with private let (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:109) |
CoffeeData awaits save and load |
CoffeeDataStore |
savedValue: [Drink] |
Actor-isolated private state (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:18) |
CoffeeDataStore.save and load only |
CoffeeData |
HealthKitController |
Creates it lazily and passes self (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:106) |
Callers await its methods |
HealthKitController |
model |
weak back-reference (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:34) |
Calls await model?.updateModel(...) |
HealthKitController |
anchor, isAuthorized |
Actor-isolated; anchor is a computed property backed by UserDefaults (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:40) |
HealthKitController only |
File scope in HealthKitController.swift |
HKHealthStore, caffeine type, units |
private let globals (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:16) |
Immutable bindings |
CoffeeTrackerApp |
ExtensionDelegate |
@WKExtensionDelegateAdaptor private var (Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerApp.swift:13) |
SwiftUI and WatchKit |
ContentView |
CoffeeData.shared |
Holds a reference and injects it with .environmentObject(data) (Updated/Coffee Tracker WatchKit Extension/ContentView.swift:19, Updated/Coffee Tracker WatchKit Extension/ContentView.swift:24) |
CoffeeData |
CoffeeTrackerView |
showDrinkList |
@State that drives the sheet (Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift:14) |
The view |
Observed weak point: CoffeeData owns HealthKitController strongly, and the controller points back with weak. This breaks the reference cycle the two-way relationship would otherwise create.
Concurrency, scheduling, and thread safety
Evidence limit: actor isolation, async/await, or Task creation does not by itself prove background-thread execution; Sendable conformance alone does not prove thread-safe mutation.
Build settings: SWIFT_VERSION = 5.0, and the project file contains no strict-concurrency setting (Updated/Coffee Tracker.xcodeproj/project.pbxproj:498). The compiler therefore applies Swift 5 language-mode checking. Some cross-isolation accesses below compile without visible await, and the source does not show which SDK isolation makes them legal.
| Concern | Source mechanism | Verified placement or handoff | Evidence |
|---|---|---|---|
| Main-actor isolation | @MainActor class CoffeeData |
All CoffeeData state is main-actor isolated, including currentDrinks, the lazy healthKitController, and the computed caffeine values. Other isolation domains must await to call into it. |
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:98 |
| Persistence isolation | private actor CoffeeDataStore |
savedValue and the encode/write and read/decode work are actor-isolated. CoffeeData crosses into the actor with await store.save(...) and await store.load(). The comment says “away from the main thread”. The source establishes a separate isolation domain, not a specific thread. |
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:126, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:256 |
| HealthKit isolation | actor HealthKitController |
Protects anchor reads and writes, isAuthorized, and the weak model. The controller returns results to the main actor by awaiting model?.updateModel(...). The comment above that call still says “main queue”, left over from the dispatch version. |
Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:26, Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:152 |
| Callback bridge | withCheckedThrowingContinuation |
Wraps the HKAnchoredObjectQuery results handler. Each branch resumes exactly once, either resume(throwing:) or resume(returning:), and then store.execute(query) starts the query. |
Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:98 |
| Task creation (model) | Plain Task {} in addDrink |
Created inside a @MainActor method, so it inherits main-actor context. It awaits the HealthKit actor’s save(drink:) and then drinksUpdated(). The handle is not stored and nothing cancels it. |
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:217 |
| Task creation (init) | Task { await load() } in private init() |
Starts the initial disk load and HealthKit refresh. The handle is not stored and nothing cancels it. | Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:251 |
| Task creation (UI) | Plain Task {} in .onChange(of: scenePhase) |
When the scene becomes active: request authorization, then load new data. The source does not annotate the closure’s actor; the handle is not stored. | Updated/Coffee Tracker WatchKit Extension/ContentView.swift:34 |
| Task creation (background refresh) | Plain Task {} in handle(_:) |
Awaits loadNewDataFromHealthKit(), then reschedules and calls setTaskCompletedWithSnapshot(success) inside the task. WatchKit’s background task is completed only after the async work finishes. The handle is not stored. |
Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:31, Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:44 |
| Framework async callbacks | async CLKComplicationDataSource methods |
ClockKit calls async requirements such as currentTimelineEntry(for:). These replace the withHandler: versions in Starting Point. They read CoffeeData synchronously through lazy var data. ComplicationController has no actor annotation in source. |
Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:38, Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:134 |
| Scheduling/liveness | WKExtension.scheduleBackgroundRefresh(withPreferredDate:userInfo:) |
Requests a refresh 15 minutes ahead with a completion-handler callback that only logs. This callback API was not migrated. | Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:81 |
| Starting Point queue | DispatchQueue(label: "Background Queue", qos: .userInitiated) |
A private serial queue for save and load. save() runs synchronously when the app is in the background, otherwise with background.async. |
Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift:25, Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift:193 |
| Starting Point main hand-back | DispatchQueue.main.async |
load() hops back to the main queue to assign currentDrinks. HealthKitController does the same before mutating the model, and updateModel checks the hop at runtime with assert(Thread.main == Thread.current, …). |
Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift:224, Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift:145, Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift:229 |
Not present in either variant: Task.detached, TaskGroup, AsyncSequence/for await, MainActor.run, nonisolated, Sendable annotations, locks, and RunLoop scheduling.
Reference code
Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:97 — queryHealthKit()
private func queryHealthKit() async throws -> ([HKSample]?, [HKDeletedObject]?, HKQueryAnchor?) {
return try await withCheckedThrowingContinuation { continuation in
// ...
let query = HKAnchoredObjectQuery(
type: caffeineType,
predicate: datePredicate,
anchor: anchor,
limit: HKObjectQueryNoLimit) { (_, samples, deletedSamples, newAnchor, error) in
// When the query ends, check for errors.
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: (samples, deletedSamples, newAnchor))
}
}
store.execute(query)
}
}Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:201 — addDrink(mgCaffeine:onDate:): synchronous main-actor mutation first, then an unretained task for the effects
public func addDrink(mgCaffeine: Double, onDate date: Date) {
// ...
currentDrinks = drinks
// Save drink information to HealthKit.
Task {
await self.healthKitController.save(drink: drink)
await self.drinksUpdated()
}
}Observed design shift: Starting Point saved from a didSet observer on currentDrinks. Updated removes the observer and calls await drinksUpdated() explicitly after each mutation (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:219, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:241, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:263). A property observer cannot await, so the save effect moved into the async methods (inference).
Task lifetime: no Task handle is retained, so none can be cancelled. Each task runs until its awaited work completes. Inference: this is acceptable here because every task is short-lived I/O, but no owner can stop the work when the scene changes.
State propagation, frameworks, and dependencies
Evidence limit: an import proves a source-level compilation dependency at the cited line; it does not prove runtime use, architectural adoption, or whether a Swift package is a direct application dependency.
| Category | Mechanism or module | Verified role | Evidence |
|---|---|---|---|
| State propagation | @Published public private(set) var currentDrinks |
The model publishes changes and keeps write access to itself. | Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114 |
| State propagation | .environmentObject(data) / @EnvironmentObject |
ContentView injects the shared model. CoffeeTrackerView and DrinkListView read it, and the sheet re-injects it explicitly. |
Updated/Coffee Tracker WatchKit Extension/ContentView.swift:24, Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift:13, Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift:46 |
| State propagation | @Environment(\.scenePhase) + onChange |
Scene-phase changes start HealthKit refresh (active) or schedule background refresh (background). | Updated/Coffee Tracker WatchKit Extension/ContentView.swift:16, Updated/Coffee Tracker WatchKit Extension/ContentView.swift:51 |
| Framework push | CLKComplicationServer.reloadTimeline(for:) |
After every model change, drinksUpdated() asks ClockKit to reload all active complications. |
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:120 |
| Combine scheduling | None | No receive(on:), subscribe(on:), or import Combine. @Published is used only through SwiftUI. |
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:8 |
| Framework import | SwiftUI, ClockKit, os |
Compilation dependencies of CoffeeData.swift. |
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:9 |
| Framework import | HealthKit |
Compilation dependency of the HealthKit actor. | Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:9 |
| Framework import | WatchKit |
Compilation dependency of the extension delegate. | Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:8 |
| Package declaration | None | The bundle contains no Package.swift; the targets use system frameworks only. |
Updated/Coffee Tracker.xcodeproj/project.pbxproj:249 |
Class and protocol design
classDiagram
class WKExtensionDelegate {
<<protocol>>
}
class CLKComplicationDataSource {
<<protocol>>
}
ExtensionDelegate ..|> WKExtensionDelegate
ComplicationController ..|> CLKComplicationDataSource
CoffeeData ..|> ObservableObject
ExtensionDelegate --> CoffeeData
ComplicationController --> CoffeeData
Reference code
Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:10 — ComplicationController
class ComplicationController: NSObject, CLKComplicationDataSource {
// The Coffee Tracker app's data model
lazy var data = CoffeeData.shared
// ...
// Return the current timeline entry.
func currentTimelineEntry(for complication: CLKComplication) async -> CLKComplicationTimelineEntry? {
createTimelineEntry(forComplication: complication, date: Date())
}
// ...
}| Type | Responsibility | Depends on |
|---|---|---|
CoffeeTrackerApp |
@main scene and delegate adaptor |
ContentView, ExtensionDelegate |
ContentView |
Root wrapper: injects the model and reacts to scene phase | CoffeeData.shared, HealthKitController |
CoffeeTrackerView, DrinkListView |
Display caffeine state; record a drink | CoffeeData (environment) |
CoffeeData |
Main-actor app state, derived caffeine math, effect orchestration | CoffeeDataStore, HealthKitController, CLKComplicationServer |
CoffeeDataStore |
Actor-isolated plist persistence with change detection | PropertyListEncoder/Decoder, FileManager |
HealthKitController |
Actor-isolated HealthKit authorization, anchored query, and save | HKHealthStore, CoffeeData (weak) |
ComplicationController |
ClockKit timeline and template construction | CoffeeData.shared |
ExtensionDelegate |
WatchKit background-task dispatch | CoffeeData.shared, scheduleBackgroundRefreshTasks() |
Drink, DrinkType |
Hashable, Codable value record; CaseIterable drink catalog |
Foundation |
No local protocol is declared. All protocol conformances are to framework protocols, so the design is not protocol-oriented.
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
CoffeeDataStore (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14) |
private (file scope) |
Only CoffeeData.swift can name the persistence actor. |
Inference: persistence is an implementation detail of the model. |
currentDrinks (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114) |
public private(set) |
Readable everywhere, writable only inside CoffeeData. Starting Point declared it public var, and HealthKitController assigned model?.currentDrinks directly (Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift:248). |
Inference: forces HealthKit changes through the updateModel async method. In an app target, public adds nothing beyond internal. |
drinksUpdated() (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:116) |
private |
Only CoffeeData can trigger the reload-and-save effect. |
Inference: keeps effect sequencing inside the owner. |
init() (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:248) |
private |
Prevents any second CoffeeData instance. |
Enforces the singleton. |
model (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:34) |
private weak var |
Only the actor reads it; it does not retain the model. | Breaks the owner/back-reference cycle. |
HealthKit globals (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:16) |
file-scope private let |
HKHealthStore and the types are visible only in this file and sit outside actor state. |
Inference: immutable shared configuration. |
removeOutdatedDrinks() (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:277) |
fileprivate extension method |
Only CoffeeData.swift can call this Array<Drink> helper. |
Inference: avoids widening the API of Array. |
Reference code
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14 — CoffeeDataStore
private actor CoffeeDataStore {
// ...
// Use this value to determine whether you have changes that can be saved to disk.
private var savedValue: [Drink] = []
// Begin saving the drink data to disk.
func save(_ currentDrinks: [Drink]) {
// ...
}
// ...
}Unmarked declarations elsewhere are implicit Swift internal, which in this single-target app means visible to every file in the extension.
Logic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
| Caffeine half-life math | Drink.caffeineRemaining(at:) (Updated/Coffee Tracker WatchKit Extension/Drink.swift:31) |
Pure value computation lives on the value type. |
| Aggregates and color thresholds | CoffeeData (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:143) |
Derived from published state, so views and complications share one implementation. |
| Merge of HealthKit changes | CoffeeData.updateModel(newDrinks:deletedDrinks:) (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:224) |
The only path from HealthKitController into model state. Observed: the guard returns unless both inputs are non-empty, and the filter keeps drinks whose UUID is in deletedDrinks. Starting Point has the same logic, so the migration preserved it. |
| Disk encode/decode and dirty check | CoffeeDataStore |
Isolated with the savedValue it compares against. |
| Sample conversion and anchor persistence | HealthKitController |
Kept with the HealthKit query that produces the samples. |
| Background refresh scheduling | scheduleBackgroundRefreshTasks() free function (Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:69) |
Shared by ContentView and ExtensionDelegate without a type. |
| Template construction | ComplicationController private factories |
Kept with the ClockKit data source. |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| Actor isolation | Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14 CoffeeDataStore; Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:26 HealthKitController |
Replaces the serial DispatchQueue with compiler-checked isolation of savedValue, anchor, and isAuthorized. The cost is an await on every call. |
| Publisher-backed observable state | Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114 |
@Published with private(set) keeps one writer and many SwiftUI readers. @MainActor keeps those writes on the main actor. |
| SwiftUI environment injection | Updated/Coffee Tracker WatchKit Extension/ContentView.swift:24 |
Views receive the model without initializer parameters. |
| Delegate or data-source callbacks | Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:12; Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:10 |
WatchKit and ClockKit call into app objects named in Info.plist. In Updated, the data-source requirements are async functions instead of completion handlers. |
| Singleton | Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:105 and private init() at Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:248 |
Gives framework-instantiated objects a shared model. Tradeoff: hidden global dependency; previews also use CoffeeData.shared. |
| Callback-to-async continuation bridge | Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:98 |
Adapts the callback-only HKAnchoredObjectQuery into async throws. Other HealthKit calls use SDK async overloads directly (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:88, Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:185). |
Main application flow
sequenceDiagram
participant WK as WatchKit
participant Del as ExtensionDelegate
participant HK as HealthKitController (actor)
participant Model as CoffeeData (@MainActor)
participant Store as CoffeeDataStore (actor)
WK->>Del: handle(backgroundTasks)
Del->>HK: Task { await loadNewDataFromHealthKit() }
HK->>HK: await queryHealthKit() (continuation)
HK->>Model: await updateModel(newDrinks:deletedDrinks:)
Model->>Store: await drinksUpdated() → store.save
HK-->>Del: success
Del->>WK: setTaskCompletedWithSnapshot(success)
Reference code
Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:20 — handle(_:)
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
// ...
for task in backgroundTasks {
// ...
switch task {
// Handle background refresh tasks.
case let backgroundTask as WKApplicationRefreshBackgroundTask:
Task {
// Check for updates from HealthKit.
let model = CoffeeData.shared
let success = await model.healthKitController.loadNewDataFromHealthKit()
if success {
// Schedule the next background update.
scheduleBackgroundRefreshTasks()
self.logger.debug("Background Task Completed Successfully!")
}
// Mark the task as ended, and request an updated snapshot, if necessary.
backgroundTask.setTaskCompletedWithSnapshot(success)
}
// ...
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}Naming conventions
- Types: role suffixes describe framework roles.
…Appis the entry,…Viewa SwiftUI view,…Controllera framework-facing collaborator (HealthKitControlleris an actor andComplicationControllera data source, not view controllers), and…Delegatethe WatchKit delegate.CoffeeDataStoreis a private persistence actor, not an app-wide state store. - Data:
CoffeeDatais the observable model,Drinka record,DrinkTypea catalog enum. - Methods: verb-first commands (
addDrink(mgCaffeine:onDate:),requestAuthorization(),loadNewDataFromHealthKit(),scheduleBackgroundRefreshTasks()). Past-tense effect hooks (drinksUpdated()). Unit-bearing names (mgCaffeine,currentMGCaffeineString,totalCupsToday). - Migration naming: the async API keeps the synchronous name and drops the
completionHandler:parameter. The ClockKit async requirements drop theget…withHandler:prefix, sogetCurrentTimelineEntry(for:withHandler:)becomescurrentTimelineEntry(for:)(Starting Point/Coffee Tracker WatchKit Extension/ComplicationController.swift:39). - Files: one primary type per file, named after the type. The exception is
CoffeeData.swift, which also hosts the private actor and afileprivateArrayextension.
Architecture takeaways
- Isolate state, not threads. The migration replaced one serial queue and manual
DispatchQueue.mainhops with three explicit isolation domains: the main actor for UI state,CoffeeDataStorefor disk state, andHealthKitControllerfor HealthKit state. - Make the UI model
@MainActorand narrow its writers withprivate(set). This turned a runtimeassert(Thread.main == …)into a compile-time requirement toawaitinto the model. - Use SDK
asyncoverloads where they exist, and wrap the remaining callback API with one checked continuation that resumes on every path. - Effects previously hidden in
didSetbecame explicitawait drinksUpdated()calls, so the ordering of mutation, complication reload, and save is visible at each call site. - Unstructured
Task {}calls are fire-and-forget here. No owner retains or cancels them. A larger app would need an owner for task lifetime.
Source map
| Source file | Relevant symbols |
|---|---|
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift |
CoffeeDataStore, CoffeeData, shared, currentDrinks, drinksUpdated(), addDrink, updateModel, load(), removeOutdatedDrinks() |
Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift |
HealthKitController, model, anchor, requestAuthorization(), queryHealthKit(), loadNewDataFromHealthKit(), save(drink:) |
Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift |
ExtensionDelegate, handle(_:), scheduleBackgroundRefreshTasks() |
Updated/Coffee Tracker WatchKit Extension/ContentView.swift |
ContentView, scenePhase, .environmentObject |
Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift |
CoffeeTrackerView, @EnvironmentObject coffeeData, showDrinkList |
Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerApp.swift |
CoffeeTrackerApp, @WKExtensionDelegateAdaptor |
Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift |
ComplicationController, data, async data-source methods |
Updated/Coffee Tracker WatchKit Extension/HostingController.swift |
HostingController |
Updated/Coffee Tracker WatchKit Extension/Drink.swift |
Drink, caffeineRemaining(at:) |
Updated/Coffee Tracker WatchKit Extension/Info.plist |
CLKComplicationPrincipalClass, WKExtensionDelegateClassName |
Updated/Coffee Tracker.xcodeproj/project.pbxproj |
Targets, SWIFT_VERSION, WATCHOS_DEPLOYMENT_TARGET |
Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift |
background queue, save(), load(), DispatchQueue.main.async |
Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift |
Completion-handler API, DispatchQueue.main.async, updateModel assert |
Starting Point/Coffee Tracker WatchKit Extension/ComplicationController.swift |
getCurrentTimelineEntry(for:withHandler:) |