crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate/Services/DebuggingMonitors.swift | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/CellView.swift | 29+++++++++++++++++++++++++++++
MCrossmate/Views/GridView.swift | 44+++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Views/PuzzleView.swift | 31+++++++++++++++++++++++++++++++
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 {