commit 07c2c972671cc7ff5e519bfddd9fc5305683683e
parent 08aa4e932cefe7aa40746e87568e4780611d9a46
Author: Michael Camilleri <[email protected]>
Date: Sat, 2 May 2026 09:06:16 +0900
Add performance logging to SwiftUI-related functions
This adds performance logging to SwiftUI code paths in an attempt to
more narrowly identify where problems are occurring.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 164 insertions(+), 1 deletion(-)
diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift
@@ -20,6 +20,9 @@ final class PerformanceMonitor {
private let maxEntries = 120
private var nextRenderProbeID = 1
private var pendingRenderProbes: [Int: RenderProbe] = [:]
+ @ObservationIgnored private var viewBodyCounts: [String: Int] = [:]
+ @ObservationIgnored private var viewBodyDetails: [String: String] = [:]
+ @ObservationIgnored private var scheduledViewBodyReports: Set<String> = []
private struct RenderProbe {
let name: String
@@ -68,6 +71,9 @@ final class PerformanceMonitor {
totalEvents = 0
suppressedEvents = 0
pendingRenderProbes.removeAll()
+ viewBodyCounts.removeAll()
+ viewBodyDetails.removeAll()
+ scheduledViewBodyReports.removeAll()
}
func beginRenderProbe(
@@ -111,6 +117,61 @@ final class PerformanceMonitor {
thresholdMS: 6
)
}
+
+ func recordDeferred(
+ _ name: String,
+ start: ContinuousClock.Instant,
+ detail: String = "",
+ thresholdMS: Double = 8
+ ) {
+ let milliseconds = Self.milliseconds(from: start.duration(to: .now))
+ guard milliseconds >= thresholdMS else { return }
+ Task { @MainActor [weak self] in
+ self?.record(
+ name,
+ durationMS: milliseconds,
+ detail: detail,
+ thresholdMS: thresholdMS
+ )
+ }
+ }
+
+ func markViewBodyDeferred(
+ _ name: String,
+ key: String = "",
+ detail: String = ""
+ ) {
+ Task { @MainActor [weak self] in
+ self?.markViewBody(name, key: key, detail: detail)
+ }
+ }
+
+ private func markViewBody(_ name: String, key: String, detail: String) {
+ let reportKey = key.isEmpty ? name : "\(name)|\(key)"
+ viewBodyCounts[reportKey, default: 0] += 1
+ viewBodyDetails[reportKey] = detail
+
+ guard !scheduledViewBodyReports.contains(reportKey) else { return }
+ scheduledViewBodyReports.insert(reportKey)
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .milliseconds(50))
+ self?.flushViewBodyReport(name: name, reportKey: reportKey)
+ }
+ }
+
+ private func flushViewBodyReport(name: String, reportKey: String) {
+ scheduledViewBodyReports.remove(reportKey)
+ let count = viewBodyCounts.removeValue(forKey: reportKey) ?? 0
+ guard count > 0 else { return }
+ let detail = viewBodyDetails.removeValue(forKey: reportKey) ?? ""
+ let countDetail = detail.isEmpty ? "count=\(count)" : "count=\(count) \(detail)"
+ note("\(name).body", detail: countDetail)
+ }
+
+ private static func milliseconds(from duration: Duration) -> Double {
+ Double(duration.components.seconds) * 1000
+ + Double(duration.components.attoseconds) / 1_000_000_000_000_000
+ }
}
struct SyncDiagnosticEntry: Identifiable, Sendable {
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -10,11 +10,19 @@ struct CellView: View {
let specialKind: Puzzle.Special?
var remoteWordTint: Color? = nil
var remoteOutline: Color? = nil
+ var diagnosticProbeID: Int?
+ var diagnosticPosition: GridPosition?
@Environment(PlayerPreferences.self) private var preferences
+ @Environment(PerformanceMonitor.self) private var performanceMonitor
private var playerColor: PlayerColor { preferences.color }
var body: some View {
+ let _ = performanceMonitor.markViewBodyDeferred(
+ "cell",
+ key: diagnosticProbeID.map { "probe=\($0)" } ?? "idle",
+ detail: diagnosticDetail()
+ )
ZStack(alignment: .topLeading) {
background
if !cell.isBlock {
@@ -53,6 +61,27 @@ struct CellView: View {
.contentShape(Rectangle())
}
+ private func diagnosticDetail() -> String {
+ var parts: [String] = []
+ if let diagnosticProbeID {
+ parts.append("probe=\(diagnosticProbeID)")
+ }
+ if let diagnosticPosition {
+ parts.append("lastRow=\(diagnosticPosition.row)")
+ parts.append("lastCol=\(diagnosticPosition.col)")
+ }
+ if isSelected {
+ parts.append("selected=1")
+ }
+ if isHighlighted {
+ parts.append("highlighted=1")
+ }
+ if !entry.isEmpty {
+ parts.append("entry=1")
+ }
+ return parts.joined(separator: " ")
+ }
+
/// Foreground style for the main entry letter. Pencil entries use the
/// hierarchical `.secondary` style so they render lighter and respect
/// dark mode; everything else — including revealed and checkedWrong
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -8,22 +8,49 @@ struct GridView: View {
private let spacing: CGFloat = 1
var body: some View {
+ let bodyDetail = diagnosticDetail()
+ let _ = performanceMonitor.markViewBodyDeferred(
+ "grid",
+ key: session.renderProbeID.map { "probe=\($0)" } ?? "idle",
+ detail: bodyDetail
+ )
let width = session.puzzle.width
let height = session.puzzle.height
// Index remote selections by cell so each CellView only receives the
// value it needs. Multiple peers landing on the same cell collapse
// to the most recent.
+ let overlayStart = ContinuousClock.now
let (outlineByCell, tintByCell) = remoteOverlays()
+ let _ = performanceMonitor.recordDeferred(
+ "grid.remoteOverlays",
+ start: overlayStart,
+ detail: bodyDetail,
+ thresholdMS: 2
+ )
+ let relatedStart = ContinuousClock.now
let relatedCells = session.puzzle.relatedCells(
atRow: session.selectedRow,
col: session.selectedCol,
direction: session.direction
)
+ let _ = performanceMonitor.recordDeferred(
+ "grid.relatedCells",
+ start: relatedStart,
+ detail: "\(bodyDetail) count=\(relatedCells.count)",
+ thresholdMS: 2
+ )
+ let wordStart = ContinuousClock.now
let currentWordCells = Set(session.puzzle.wordCells(
atRow: session.selectedRow,
col: session.selectedCol,
direction: session.direction
).map { GridPosition(row: $0.row, col: $0.col) })
+ let _ = performanceMonitor.recordDeferred(
+ "grid.currentWord",
+ start: wordStart,
+ detail: "\(bodyDetail) count=\(currentWordCells.count)",
+ thresholdMS: 2
+ )
PuzzleGridLayout(columns: width, rows: height, spacing: spacing) {
ForEach(0..<(width * height), id: \.self) { index in
let r = index / width
@@ -38,7 +65,9 @@ struct GridView: View {
isRelatedToFocus: relatedCells.contains(pos),
specialKind: session.puzzle.specialKind,
remoteWordTint: tintByCell[pos],
- remoteOutline: outlineByCell[pos]
+ remoteOutline: outlineByCell[pos],
+ diagnosticProbeID: session.renderProbeID,
+ diagnosticPosition: pos
)
.onTapGesture {
session.select(row: r, col: c)
@@ -87,6 +116,19 @@ struct GridView: View {
}
return (outline.mapValues { $0.1 }, tint.mapValues { $0.1 })
}
+
+ private func diagnosticDetail() -> String {
+ var parts: [String] = []
+ if let probeID = session.renderProbeID {
+ parts.append("probe=\(probeID)")
+ }
+ parts.append("selected=\(session.selectedRow),\(session.selectedCol)")
+ parts.append("direction=\(session.direction)")
+ if let roster {
+ parts.append("remoteSelections=\(roster.remoteSelections.count)")
+ }
+ return parts.joined(separator: " ")
+ }
}
// MARK: - Layout
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -371,7 +371,14 @@ private struct ClueBarContent: View {
var onNext: (() -> Void)?
var onClueTap: (() -> Void)?
+ @Environment(PerformanceMonitor.self) private var performanceMonitor
+
var body: some View {
+ let _ = performanceMonitor.markViewBodyDeferred(
+ "clueBarContent",
+ key: currentKey.map { "\($0.direction)-\($0.number)" } ?? "none",
+ detail: currentKey.map { "direction=\($0.direction) number=\($0.number)" } ?? "none"
+ )
HStack(alignment: .clueCenter, spacing: 12) {
ClueBarIcon(systemName: "chevron.left", action: onPrevious)
@@ -433,13 +440,27 @@ private struct ClueBarIcon: View {
private struct ClueBar: View {
@Bindable var session: PlayerSession
@Environment(PlayerPreferences.self) private var preferences
+ @Environment(PerformanceMonitor.self) private var performanceMonitor
@State private var slideEdge: Edge = .trailing
@State private var isShowingClueList = false
private var playerColor: PlayerColor { preferences.color }
var body: some View {
+ let bodyDetail = diagnosticDetail()
+ let _ = performanceMonitor.markViewBodyDeferred(
+ "clueBar",
+ key: session.renderProbeID.map { "probe=\($0)" } ?? "idle",
+ detail: bodyDetail
+ )
+ let clueStart = ContinuousClock.now
let clue = session.currentClue()
+ let _ = performanceMonitor.recordDeferred(
+ "clueBar.currentClue",
+ start: clueStart,
+ detail: bodyDetail,
+ thresholdMS: 1
+ )
let currentKey = clue.map { ClueKey(direction: session.direction, number: $0.number) }
ClueBarContent(
@@ -468,6 +489,16 @@ private struct ClueBar: View {
}
}
+ private func diagnosticDetail() -> String {
+ var parts: [String] = []
+ if let probeID = session.renderProbeID {
+ parts.append("probe=\(probeID)")
+ }
+ parts.append("selected=\(session.selectedRow),\(session.selectedCol)")
+ parts.append("direction=\(session.direction)")
+ return parts.joined(separator: " ")
+ }
+
private func label(for clue: Puzzle.Clue?) -> String {
let direction = session.direction == .across ? "Across" : "Down"
if let clue {