From b0b9528ebf5e6abdb5896e0e55386e7eaa23443a Mon Sep 17 00:00:00 2001 From: Kishan Date: Sun, 7 Jun 2026 12:54:07 -0400 Subject: [PATCH 1/2] perf: reduce main-thread load from timers, recomputation, and redundant publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActivitySessionModel: lower workout timer from 60 Hz to 1 Hz; each tick updates 4+ @Published properties, so 60 Hz was driving 60 SwiftUI re-renders per second on the main thread for the duration of any activity session. 1 Hz is sufficient precision for a stopwatch display; timer tolerance raised from 2 ms to 50 ms to let the system coalesce timer firings. - HomeDashboardView: compute landingSnapshots (→ healthStore.landingSnapshots) once per body pass instead of 9 times. Previously every call to landingSnapshot(for:), homeSnapshot(for:), scoreSnapshots, and scorePickerSnapshots each triggered a separate full evaluation of the expensive healthStore.landingSnapshots(…) call. - GooseAppModel: add equality guards on applyHeartRateTimelineSnapshot so heartRateHourlyRanges (updated at 1 Hz by the pipeline) only fires objectWillChange when the data actually changes, preventing unnecessary full-view re-renders of all GooseAppModel @EnvironmentObject observers. - GooseAppModel: equality guard on activityDetectionStatus (set on every movement packet) and movementPacketValidationStatus to suppress redundant objectWillChange publications when the string value is unchanged. - GooseMessageStore: replace O(n) insert-at-0 with array concatenation to avoid unnecessary buffer-copy churn on every 500 ms message flush. Co-Authored-By: Claude Sonnet 4.6 --- GooseSwift/ActivitySessionModel.swift | 6 ++- GooseSwift/GooseAppModel+Lifecycle.swift | 10 +++- .../GooseAppModel+PacketPublishing.swift | 8 +++- GooseSwift/GooseMessageStore.swift | 8 ++-- GooseSwift/HomeDashboardView.swift | 47 ++++++++++--------- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/GooseSwift/ActivitySessionModel.swift b/GooseSwift/ActivitySessionModel.swift index f71ec2abe..9d736d996 100644 --- a/GooseSwift/ActivitySessionModel.swift +++ b/GooseSwift/ActivitySessionModel.swift @@ -113,13 +113,15 @@ final class ActivitySessionModel: ObservableObject { private func scheduleTimer() { timer?.invalidate() - let newTimer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + // 1 Hz is sufficient for a workout stopwatch; 60 Hz caused constant @Published updates + // that triggered SwiftUI re-renders 60×/s on the main thread. + let newTimer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in guard let self else { return } self.tick(now: Date(), heartRate: self.heartRateProvider?()) } - newTimer.tolerance = 0.002 + newTimer.tolerance = 0.05 RunLoop.main.add(newTimer, forMode: .common) timer = newTimer } diff --git a/GooseSwift/GooseAppModel+Lifecycle.swift b/GooseSwift/GooseAppModel+Lifecycle.swift index 5a693b8fd..11db315ea 100644 --- a/GooseSwift/GooseAppModel+Lifecycle.swift +++ b/GooseSwift/GooseAppModel+Lifecycle.swift @@ -73,8 +73,14 @@ extension GooseAppModel { } func applyHeartRateTimelineSnapshot(_ snapshot: HeartRateTimelineSnapshot) { - heartRateHourlyRanges = snapshot.ranges - heartRateStorageStatus = snapshot.status + // Equality guard: the pipeline fires every 1 s; avoid a spurious objectWillChange + // (and full-view re-render of all GooseAppModel observers) when the data is unchanged. + if snapshot.ranges != heartRateHourlyRanges { + heartRateHourlyRanges = snapshot.ranges + } + if snapshot.status != heartRateStorageStatus { + heartRateStorageStatus = snapshot.status + } } func handleBLEConnectionStateChange(_ state: String) { diff --git a/GooseSwift/GooseAppModel+PacketPublishing.swift b/GooseSwift/GooseAppModel+PacketPublishing.swift index 7a02f68c4..964e058a3 100644 --- a/GooseSwift/GooseAppModel+PacketPublishing.swift +++ b/GooseSwift/GooseAppModel+PacketPublishing.swift @@ -666,7 +666,10 @@ extension GooseAppModel { } movementPacketValidation.ingest(sample) - movementPacketValidationStatus = movementPacketValidation.statusSummary + let newValidationStatus = movementPacketValidation.statusSummary + if newValidationStatus != movementPacketValidationStatus { + movementPacketValidationStatus = newValidationStatus + } ble.record( level: .debug, source: "activity.detect", @@ -703,6 +706,9 @@ extension GooseAppModel { for event in events { switch event { case .status(let status): + // Guard prevents @Published objectWillChange from firing when the status string + // repeats unchanged across consecutive movement packets. + guard status != activityDetectionStatus else { break } activityDetectionStatus = status case .primeGPS(let reason): activityDetectionStatus = "Movement detected; priming GPS" diff --git a/GooseSwift/GooseMessageStore.swift b/GooseSwift/GooseMessageStore.swift index 434a3e7cd..7e006c4af 100644 --- a/GooseSwift/GooseMessageStore.swift +++ b/GooseSwift/GooseMessageStore.swift @@ -47,10 +47,10 @@ final class GooseMessageStore: ObservableObject { return } - messages.insert(contentsOf: pendingMessages.reversed(), at: 0) + // Concatenation is O(n+k); insert-at-0 with shift is also O(n) but forces a copy of the + // entire backing store. Using + avoids mutating the existing buffer in-place. + let merged = pendingMessages.reversed() + messages pendingMessages.removeAll(keepingCapacity: true) - if messages.count > maximumMessages { - messages.removeLast(messages.count - maximumMessages) - } + messages = merged.count > maximumMessages ? Array(merged.prefix(maximumMessages)) : Array(merged) } } diff --git a/GooseSwift/HomeDashboardView.swift b/GooseSwift/HomeDashboardView.swift index 542898413..c45eee696 100644 --- a/GooseSwift/HomeDashboardView.swift +++ b/GooseSwift/HomeDashboardView.swift @@ -11,10 +11,12 @@ struct HomeDashboardView: View { @State private var selectedHealthMonitorTrend: HealthMetricSnapshot? var body: some View { + // Compute once per render — avoids calling healthStore.landingSnapshots(…) 9× per body pass. + let cached = landingSnapshots ScrollView { LazyVStack(alignment: .leading, spacing: 18) { HomeDailyScoreCard( - scores: scoreSnapshots, + scores: scoreSnapshots(using: cached), actionSummary: dailyActionSummary, coachTip: CoachTipFactory.homeTip(healthStore: healthStore, appModel: model), openScore: openHealth, @@ -22,13 +24,13 @@ struct HomeDashboardView: View { ) HomeStressEnergySection( - stress: landingSnapshot(for: .stress), - energy: landingSnapshot(for: .energyBank), + stress: landingSnapshot(for: .stress, in: cached), + energy: landingSnapshot(for: .energyBank, in: cached), openStress: { openHealth(.stress) } ) HomeCardioLoadWidget( - snapshot: landingSnapshot(for: .cardioLoad), + snapshot: landingSnapshot(for: .cardioLoad, in: cached), days: healthStore.cardioLoadWeeklyPoints() ) { showingCardioLoadSheet = true @@ -41,9 +43,9 @@ struct HomeDashboardView: View { ) HomeTimelineSection( - sleep: homeSnapshot(for: .sleep), - activity: homeSnapshot(for: .strain), - recovery: homeSnapshot(for: .recovery), + sleep: homeSnapshot(for: .sleep, in: cached), + activity: homeSnapshot(for: .strain, in: cached), + recovery: homeSnapshot(for: .recovery, in: cached), activities: model.homeActivityTimelineItems, openSleep: { openHealth(.sleep) }, openActivity: { openHealth(.strain) }, @@ -100,10 +102,11 @@ struct HomeDashboardView: View { model.refreshActivityTimeline(for: newValue) } .sheet(isPresented: $showingScoreDatePicker) { + let cached = landingSnapshots ScoreDatePickerSheet( title: "Daily Scores", routes: [.sleep, .recovery, .strain], - snapshots: scorePickerSnapshots, + snapshots: scorePickerSnapshots(using: cached), selectedDate: $selectedDate ) } @@ -115,19 +118,19 @@ struct HomeDashboardView: View { } } - private var scoreSnapshots: [HealthMetricSnapshot] { + private func scoreSnapshots(using cached: [HealthMetricSnapshot]) -> [HealthMetricSnapshot] { [ - datedHomeSnapshot(for: .sleep), - datedHomeSnapshot(for: .recovery), - datedHomeSnapshot(for: .strain), + datedHomeSnapshot(for: .sleep, in: cached), + datedHomeSnapshot(for: .recovery, in: cached), + datedHomeSnapshot(for: .strain, in: cached), ] } - private var scorePickerSnapshots: [HealthMetricSnapshot] { + private func scorePickerSnapshots(using cached: [HealthMetricSnapshot]) -> [HealthMetricSnapshot] { [ - homeSnapshot(for: .sleep), - homeSnapshot(for: .recovery), - homeSnapshot(for: .strain), + homeSnapshot(for: .sleep, in: cached), + homeSnapshot(for: .recovery, in: cached), + homeSnapshot(for: .strain, in: cached), ] } @@ -165,12 +168,12 @@ struct HomeDashboardView: View { ) } - private func landingSnapshot(for route: HealthRoute) -> HealthMetricSnapshot { - landingSnapshots.first { $0.route == route } ?? healthStore.snapshot(for: route) + private func landingSnapshot(for route: HealthRoute, in snapshots: [HealthMetricSnapshot]) -> HealthMetricSnapshot { + snapshots.first { $0.route == route } ?? healthStore.snapshot(for: route) } - private func homeSnapshot(for route: HealthRoute) -> HealthMetricSnapshot { - let snapshot = landingSnapshot(for: route) + private func homeSnapshot(for route: HealthRoute, in snapshots: [HealthMetricSnapshot]) -> HealthMetricSnapshot { + let snapshot = landingSnapshot(for: route, in: snapshots) guard route == .strain, snapshot.unit != "%" else { return snapshot } @@ -193,8 +196,8 @@ struct HomeDashboardView: View { ) } - private func datedHomeSnapshot(for route: HealthRoute) -> HealthMetricSnapshot { - ScoreDateTimeline.datedSnapshot(from: homeSnapshot(for: route), date: selectedDate) + private func datedHomeSnapshot(for route: HealthRoute, in snapshots: [HealthMetricSnapshot]) -> HealthMetricSnapshot { + ScoreDateTimeline.datedSnapshot(from: homeSnapshot(for: route, in: snapshots), date: selectedDate) } private func openHealth(_ route: HealthRoute) { From be0bbd3466885ca1a94d38c502c788f6d6c4a654 Mon Sep 17 00:00:00 2001 From: Kishan Date: Sun, 7 Jun 2026 13:01:29 -0400 Subject: [PATCH 2/2] perf(ActivitySessionModel): decouple 60 Hz sampling from SwiftUI publishes The 60 Hz timer is intentional and needed for accurate HR-zone accounting and motion-intensity tracking for athletes. The problem was that every tick also wrote to @Published properties, driving a SwiftUI re-render on every sample. Fix: accumulate all metrics (elapsed, zoneDurations, averageHeartRate, maxHeartRate) into private backing stores at full 60 Hz, and flush to the @Published properties at 4 Hz (every 250 ms). SwiftUI coalesces the four property assignments inside flushToUI into a single re-render because they happen synchronously in the same runloop turn. State-transition calls (pause, end) always call flushToUI immediately so the UI reflects the final sample before the session stops. Co-Authored-By: Claude Sonnet 4.6 --- GooseSwift/ActivitySessionModel.swift | 60 ++++++++++++++++++++------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/GooseSwift/ActivitySessionModel.swift b/GooseSwift/ActivitySessionModel.swift index 9d736d996..2c63e69d9 100644 --- a/GooseSwift/ActivitySessionModel.swift +++ b/GooseSwift/ActivitySessionModel.swift @@ -14,6 +14,16 @@ final class ActivitySessionModel: ObservableObject { @Published private(set) var maxHeartRate: Int? @Published private(set) var zoneDurations: [Int: TimeInterval] = [:] + // Private backing stores updated at full 60 Hz sample rate. + // @Published properties are only flushed at uiPublishInterval to avoid + // driving a SwiftUI re-render on every sample tick. + private var _elapsed: TimeInterval = 0 + private var _averageHeartRate: Int? + private var _maxHeartRate: Int? + private var _zoneDurations: [Int: TimeInterval] = [:] + private var lastPublishedAt: Date = .distantPast + private static let uiPublishInterval: TimeInterval = 1.0 / 4.0 + private var lastTick: Date? private var heartRateWeightedTotal: Double = 0 private var heartRateMeasuredSeconds: TimeInterval = 0 @@ -71,6 +81,7 @@ final class ActivitySessionModel: ObservableObject { return } tick(now: now, heartRate: heartRate) + flushToUI(now: now) // ensure latest samples are visible before pausing isPaused = true lastTick = nil timer?.invalidate() @@ -82,6 +93,7 @@ final class ActivitySessionModel: ObservableObject { return } tick(now: now, heartRate: heartRate) + flushToUI(now: now) // flush final sample before clearing state isActive = false isPaused = false endedAt = now @@ -97,31 +109,42 @@ final class ActivitySessionModel: ObservableObject { } let previousTick = lastTick ?? now let delta = max(0, now.timeIntervalSince(previousTick)) - elapsed += delta + _elapsed += delta lastTick = now - guard delta > 0, let heartRate else { - return + if delta > 0, let heartRate { + let zoneID = HeartRateZone.zoneID(for: heartRate) + _zoneDurations[zoneID, default: 0] += delta + heartRateWeightedTotal += Double(heartRate) * delta + heartRateMeasuredSeconds += delta + _averageHeartRate = Int((heartRateWeightedTotal / max(heartRateMeasuredSeconds, 1)).rounded()) + _maxHeartRate = max(_maxHeartRate ?? heartRate, heartRate) + } + + if now.timeIntervalSince(lastPublishedAt) >= Self.uiPublishInterval { + flushToUI(now: now) } - let zoneID = HeartRateZone.zoneID(for: heartRate) - zoneDurations[zoneID, default: 0] += delta - heartRateWeightedTotal += Double(heartRate) * delta - heartRateMeasuredSeconds += delta - averageHeartRate = Int((heartRateWeightedTotal / max(heartRateMeasuredSeconds, 1)).rounded()) - maxHeartRate = max(maxHeartRate ?? heartRate, heartRate) + } + + // Copies backing-store values into the @Published properties in a single + // synchronous block so SwiftUI coalesces them into one re-render. + private func flushToUI(now: Date) { + lastPublishedAt = now + elapsed = _elapsed + averageHeartRate = _averageHeartRate + maxHeartRate = _maxHeartRate + zoneDurations = _zoneDurations } private func scheduleTimer() { timer?.invalidate() - // 1 Hz is sufficient for a workout stopwatch; 60 Hz caused constant @Published updates - // that triggered SwiftUI re-renders 60×/s on the main thread. - let newTimer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + let newTimer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in guard let self else { return } self.tick(now: Date(), heartRate: self.heartRateProvider?()) } - newTimer.tolerance = 0.05 + newTimer.tolerance = 0.002 RunLoop.main.add(newTimer, forMode: .common) timer = newTimer } @@ -132,13 +155,18 @@ final class ActivitySessionModel: ObservableObject { if !keepingSelection { selectedActivity = .run } + _elapsed = 0 + _averageHeartRate = nil + _maxHeartRate = nil + _zoneDurations = [:] + heartRateWeightedTotal = 0 + heartRateMeasuredSeconds = 0 + lastTick = nil + lastPublishedAt = .distantPast elapsed = 0 averageHeartRate = nil maxHeartRate = nil zoneDurations = [:] - heartRateWeightedTotal = 0 - heartRateMeasuredSeconds = 0 - lastTick = nil startedAt = nil endedAt = nil isActive = false