Skip to content

Commit 0aeaf32

Browse files
committed
perf: cache LocalStatsLog derived data instead of refetching per render
LocalStatsLog read `localStats` (a SwiftData fetch via node.safeTelemetries, limit 500) through a chain of computed properties — chartData, noiseFloorReadings, chartYDomain, chartDataDuration, chartVisibleDuration, chartXAxisFormat — plus node.hasLocalStats (a fetchCount) and the Table. SwiftUI doesn't memoize computed getters, so a single `body` pass fired ~8 fetches and repeated sorts. The chart binds its scroll offset to @State via .chartScrollPosition(x:), so `body` re-evaluates on every scroll frame — turning a chart scroll (or table selection, or range change) into ~8 main-thread DB fetches + sorts per frame and janking on nodes with many readings. Compute the derived data once in refreshData() and cache it in @State (localStats, noiseFloorReadings, chartYDomain, chartDataDuration). Refresh only on appear, when node.lastHeard changes (new packets, incl. local-stats telemetry), and after a clear — never from `body`. Scrolling now reuses the cached arrays with zero fetches or sorts. Same pattern already used by NodeListItem.rowSummary and DeviceHardwareImage. Gates (chart/table vs ContentUnavailable, Clear/Save buttons) now read the cached array instead of re-querying node.hasLocalStats each render.
1 parent d92626a commit 0aeaf32

1 file changed

Lines changed: 56 additions & 39 deletions

File tree

Meshtastic/Views/Nodes/LocalStatsLog.swift

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,30 +26,16 @@ struct LocalStatsLog: View {
2626
@State private var selectedChartRange: LocalStatsChartRange = .day
2727
@State private var chartScrollPosition = Date()
2828

29-
private var localStats: [TelemetryEntity] {
30-
node.safeTelemetries(ofType: 4)
31-
}
32-
33-
private var chartData: [TelemetryEntity] {
34-
return localStats
35-
.filter { $0.time != nil }
36-
.sorted { ($0.time ?? .distantPast) < ($1.time ?? .distantPast) }
37-
}
38-
39-
private var noiseFloorReadings: [LocalStatsChartPoint] {
40-
chartData.compactMap { point in
41-
guard let time = point.time, let noiseFloor = point.noiseFloor else { return nil }
42-
return LocalStatsChartPoint(time: time, noiseFloor: noiseFloor)
43-
}
44-
}
45-
46-
private var chartDataDuration: TimeInterval {
47-
guard let firstTime = noiseFloorReadings.first?.time,
48-
let lastTime = noiseFloorReadings.last?.time else {
49-
return LocalStatsChartRange.minimumVisibleDuration
50-
}
51-
return max(lastTime.timeIntervalSince(firstTime), LocalStatsChartRange.minimumVisibleDuration)
52-
}
29+
// Derived data is cached in @State and recomputed only when the underlying telemetry
30+
// changes (refreshData), NOT in `body`. `localStats` is a SwiftData fetch
31+
// (node.safeTelemetries), and the chart binds its scroll offset to @State
32+
// (chartScrollPosition), so `body` re-evaluates on every scroll frame. Recomputing
33+
// these getters there fired ~8 fetches + sorts per frame and janked scrolling.
34+
@State private var localStats: [TelemetryEntity] = []
35+
@State private var noiseFloorReadings: [LocalStatsChartPoint] = []
36+
@State private var chartYDomain: ClosedRange<Int> = -130 ... -60
37+
@State private var chartDataDuration: TimeInterval = LocalStatsChartRange.minimumVisibleDuration
38+
@State private var didLoad = false
5339

5440
private var chartVisibleDuration: TimeInterval {
5541
chartVisibleDuration(for: selectedChartRange)
@@ -71,29 +57,19 @@ struct LocalStatsLog: View {
7157
.day(.defaultDigits)
7258
}
7359

74-
private var chartYDomain: ClosedRange<Int> {
75-
let values = noiseFloorReadings.map { Int($0.noiseFloor) }
76-
guard let minValue = values.min(), let maxValue = values.max() else {
77-
return -130 ... -60
78-
}
79-
let lower = min(minValue - 5, -115)
80-
let upper = max(maxValue + 5, -75)
81-
return lower ... upper
82-
}
83-
8460
private var dateFormatString: String {
8561
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMdjmma", options: 0, locale: Locale.current)
8662
return (localeDateFormat ?? "M/d/YY j:mma").replacingOccurrences(of: ",", with: "")
8763
}
8864

8965
var body: some View {
9066
VStack {
91-
if node.hasLocalStats {
67+
if !localStats.isEmpty {
9268
if !noiseFloorReadings.isEmpty {
9369
chartView
9470
}
9571
tableView
96-
} else {
72+
} else if didLoad {
9773
ContentUnavailableView("No Local Stats", systemImage: "waveform")
9874
}
9975
buttonView
@@ -143,8 +119,14 @@ struct LocalStatsLog: View {
143119
}
144120
)
145121
.onAppear {
122+
refreshData()
146123
resetChartViewToLatest()
147124
}
125+
.onChange(of: node.lastHeard) {
126+
// New packets (including local-stats telemetry) update lastHeard; refetch then.
127+
// Scrolling the chart does not touch lastHeard, so it never triggers a refetch.
128+
refreshData()
129+
}
148130
}
149131

150132
private var chartView: some View {
@@ -311,7 +293,7 @@ struct LocalStatsLog: View {
311293

312294
private var buttonView: some View {
313295
HStack(spacing: 8) {
314-
if node.hasLocalStats {
296+
if !localStats.isEmpty {
315297
Button(role: .destructive) {
316298
isPresentingClearLogConfirm = true
317299
} label: {
@@ -331,9 +313,10 @@ struct LocalStatsLog: View {
331313
titleVisibility: .visible
332314
) {
333315
Button("Delete all local stats?", role: .destructive) {
334-
Task {
316+
Task { @MainActor in
335317
if await MeshPackets.shared.clearTelemetry(destNum: node.num, metricsType: 4) {
336318
Logger.data.notice("Cleared Local Stats for \(node.num, privacy: .public)")
319+
refreshData()
337320
} else {
338321
Logger.data.error("Clear Local Stats Log Failed")
339322
}
@@ -352,7 +335,7 @@ struct LocalStatsLog: View {
352335
.buttonBorderShape(.capsule)
353336
.controlSize(idiom == .phone ? .regular : .large)
354337

355-
if node.hasLocalStats {
338+
if !localStats.isEmpty {
356339
Button {
357340
exportString = telemetryToCsvFile(telemetry: localStats, metricsType: 4)
358341
isExporting = true
@@ -455,6 +438,40 @@ struct LocalStatsLog: View {
455438
}
456439
}
457440

441+
private extension LocalStatsLog {
442+
/// Single source of the view's derived data. Runs one SwiftData fetch and recomputes
443+
/// the cached chart inputs. Called on appear, when the node hears new packets, and
444+
/// after a clear — never from `body`, so chart scrolling does no fetching or sorting.
445+
func refreshData() {
446+
let stats = node.safeTelemetries(ofType: 4)
447+
localStats = stats
448+
449+
let readings = stats
450+
.filter { $0.time != nil }
451+
.sorted { ($0.time ?? .distantPast) < ($1.time ?? .distantPast) }
452+
.compactMap { point -> LocalStatsChartPoint? in
453+
guard let time = point.time, let noiseFloor = point.noiseFloor else { return nil }
454+
return LocalStatsChartPoint(time: time, noiseFloor: noiseFloor)
455+
}
456+
noiseFloorReadings = readings
457+
458+
let values = readings.map { Int($0.noiseFloor) }
459+
if let minValue = values.min(), let maxValue = values.max() {
460+
chartYDomain = min(minValue - 5, -115) ... max(maxValue + 5, -75)
461+
} else {
462+
chartYDomain = -130 ... -60
463+
}
464+
465+
if let firstTime = readings.first?.time, let lastTime = readings.last?.time {
466+
chartDataDuration = max(lastTime.timeIntervalSince(firstTime), LocalStatsChartRange.minimumVisibleDuration)
467+
} else {
468+
chartDataDuration = LocalStatsChartRange.minimumVisibleDuration
469+
}
470+
471+
didLoad = true
472+
}
473+
}
474+
458475
private struct CopyableLocalStatsField: View {
459476
let value: String
460477
let title: String

0 commit comments

Comments
 (0)