listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit cdabb255639aa9fe539cfedef586273318354820
parent 18c4f2203f1c888ad2861a9d00366075d271f90d
Author: Michael Camilleri <[email protected]>
Date:   Tue, 21 Apr 2026 23:52:11 +0900

Add performance logging

Diffstat:
MListless.xcodeproj/project.pbxproj | 8++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 22+++++++---------------
MListless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme | 25++++++++-----------------
AListlessiOS/Helpers/PerfSampler.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Helpers/TappableTextField.swift | 76+++++++++++++++++++++++++++++++++++++++++++++-------------------------------
AListlessiOS/Views/PerfDebugView.swift | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/SettingsView.swift | 3+++
7 files changed, 397 insertions(+), 63 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ 6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 73C05B273DC49DE48B82822E /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; }; 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; }; + 7770D06CBDE3E87B7FDE7C21 /* PerfDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10806B0AD984F8E480019FD /* PerfDebugView.swift */; }; 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; }; 785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; }; 790843E40F28B4E186F88F16 /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; }; @@ -70,6 +71,7 @@ 851F46417FE40D6BC765BC70 /* ItemListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */; }; 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; }; 889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; }; + 88C2303FCAD99AC5D1D1C81C /* PerfSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 361665491B2313B7A06A94B9 /* PerfSampler.swift */; }; 89C4109374BD64464B0018B7 /* ItemListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CC030045617DBC90812D79 /* ItemListView+NavigationHeader.swift */; }; 8FB18395E5436F6C91A0F077 /* ItemRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7CB071709951F52C7742A3 /* ItemRowView.swift */; }; 9082E96001188E516B7F903B /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3116A37F1353BF6E18308DD2 /* ItemListView.swift */; }; @@ -200,6 +202,7 @@ 3048ACA1CAF1284F99E1400E /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; }; 3116A37F1353BF6E18308DD2 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; }; 3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; + 361665491B2313B7A06A94B9 /* PerfSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfSampler.swift; sourceTree = "<group>"; }; 37F80F7BB632A04A687890F0 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; }; 3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColorTests.swift; sourceTree = "<group>"; }; 3DD31E8962F7EEC22EFC0CA9 /* Credits.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = Credits.html; sourceTree = "<group>"; }; @@ -257,6 +260,7 @@ C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; }; + D10806B0AD984F8E480019FD /* PerfDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfDebugView.swift; sourceTree = "<group>"; }; D3E995954787F0A14CCFF348 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; D43D37CE25806380C0B13466 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; }; D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; }; @@ -293,6 +297,7 @@ E71FBC3E19D32F527B5FE9E6 /* ItemRowMetrics.swift */, DCCE85438FE23E43095B2C25 /* ItemRowSwipeGesture.swift */, E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */, + 361665491B2313B7A06A94B9 /* PerfSampler.swift */, FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */, E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */, 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */, @@ -381,6 +386,7 @@ 67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */, 3116A37F1353BF6E18308DD2 /* ItemListView.swift */, C0DF205300C6B51A53B256D6 /* ItemRowView.swift */, + D10806B0AD984F8E480019FD /* PerfDebugView.swift */, 567DBAC2A39FA2760D006AAB /* PullToClear.swift */, BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */, C611E04943F1D82D6F975592 /* SettingsView.swift */, @@ -913,6 +919,8 @@ F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */, 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */, F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */, + 7770D06CBDE3E87B7FDE7C21 /* PerfDebugView.swift in Sources */, + 88C2303FCAD99AC5D1D1C81C /* PerfSampler.swift in Sources */, DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */, 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */, 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */, diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme @@ -4,8 +4,7 @@ version = "1.7"> <BuildAction parallelizeBuildables = "YES" - buildImplicitDependencies = "YES" - runPostActionsOnFailure = "NO"> + buildImplicitDependencies = "YES"> <PreActions> <ExecutionAction ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> @@ -16,7 +15,7 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB" - BuildableName = "Listless macOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless macOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> @@ -34,7 +33,7 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB" - BuildableName = "Listless macOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless macOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> @@ -45,13 +44,12 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB" - BuildableName = "Listless macOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless macOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> @@ -82,8 +80,6 @@ </BuildableReference> </TestableReference> </Testables> - <CommandLineArguments> - </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" @@ -100,13 +96,11 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB" - BuildableName = "Listless macOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless macOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -119,13 +113,11 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB" - BuildableName = "Listless macOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless macOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme @@ -4,8 +4,7 @@ version = "1.7"> <BuildAction parallelizeBuildables = "YES" - buildImplicitDependencies = "YES" - runPostActionsOnFailure = "NO"> + buildImplicitDependencies = "YES"> <PreActions> <ExecutionAction ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> @@ -16,7 +15,7 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "9BDC1B2175AB9CE26790448D" - BuildableName = "Listless watchOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless watchOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> @@ -34,7 +33,7 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "9BDC1B2175AB9CE26790448D" - BuildableName = "Listless watchOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless watchOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> @@ -45,21 +44,19 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "9BDC1B2175AB9CE26790448D" - BuildableName = "Listless watchOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless watchOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </MacroExpansion> <Testables> <TestableReference - skipped = "NO" - parallelizable = "NO"> + skipped = "NO"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "FAC694EFB69F0D9F12557137" @@ -69,8 +66,6 @@ </BuildableReference> </TestableReference> </Testables> - <CommandLineArguments> - </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" @@ -87,13 +82,11 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "9BDC1B2175AB9CE26790448D" - BuildableName = "Listless watchOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless watchOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -106,13 +99,11 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "9BDC1B2175AB9CE26790448D" - BuildableName = "Listless watchOS.app" + BuildableName = "Listless.app" BlueprintName = "Listless watchOS" ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> diff --git a/ListlessiOS/Helpers/PerfSampler.swift b/ListlessiOS/Helpers/PerfSampler.swift @@ -0,0 +1,144 @@ +import Foundation +import UIKit + +/// Lightweight timing collector for diagnosing cold-launch hitches without +/// needing Instruments. Samples live in memory during a launch and are flushed +/// to disk when the app backgrounds, so the next launch can display prior data +/// from the in-app debug screen. Call indexes restart at 0 per launch so the +/// first-invocation cost of each label is easy to spot. +@MainActor +final class PerfSampler { + static let shared = PerfSampler() + + struct Sample: Codable, Identifiable { + var id = UUID() + let launchID: UUID + let label: String + let callIndex: Int + let durationMs: Double + let msSinceLaunch: Double + let timestamp: Date + } + + struct Launch: Codable, Identifiable { + var id: UUID { launchID } + let launchID: UUID + let startedAt: Date + } + + private let storageURL: URL + private let maxTotalSamples = 1000 + private let maxPerLabelPerLaunch = 40 + + private(set) var currentLaunch: Launch + private var launches: [Launch] = [] + private var samples: [Sample] = [] + private var callCounts: [String: Int] = [:] + private let launchClockStart: DispatchTime + private var dirty = false + + private init() { + let dir = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + storageURL = (dir ?? URL(fileURLWithPath: NSTemporaryDirectory())) + .appendingPathComponent("PerfSamples.json") + + let launch = Launch(launchID: UUID(), startedAt: Date()) + currentLaunch = launch + launchClockStart = DispatchTime.now() + load() + launches.append(launch) + dirty = true + + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in PerfSampler.shared.flush() } + } + NotificationCenter.default.addObserver( + forName: UIApplication.willTerminateNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in PerfSampler.shared.flush() } + } + } + + @discardableResult + func measure<T>(_ label: String, _ work: () -> T) -> T { + let start = DispatchTime.now() + let result = work() + let elapsedNs = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds + record(label: label, durationMs: Double(elapsedNs) / 1_000_000) + return result + } + + func record(label: String, durationMs: Double) { + let index = callCounts[label, default: 0] + callCounts[label] = index + 1 + guard index < maxPerLabelPerLaunch else { return } + + let sinceLaunchNs = DispatchTime.now().uptimeNanoseconds + &- launchClockStart.uptimeNanoseconds + let sample = Sample( + launchID: currentLaunch.launchID, + label: label, + callIndex: index, + durationMs: durationMs, + msSinceLaunch: Double(sinceLaunchNs) / 1_000_000, + timestamp: Date() + ) + samples.append(sample) + if samples.count > maxTotalSamples { + samples.removeFirst(samples.count - maxTotalSamples) + } + dirty = true + } + + func allSamples() -> [Sample] { samples } + func allLaunches() -> [Launch] { launches } + + func samplesForCurrentLaunch() -> [Sample] { + samples.filter { $0.launchID == currentLaunch.launchID } + } + + func clear() { + samples.removeAll() + launches = [currentLaunch] + callCounts.removeAll() + dirty = true + flush() + } + + func flush() { + guard dirty else { return } + let payload = StoredPayload(launches: launches, samples: samples) + do { + let data = try JSONEncoder().encode(payload) + try data.write(to: storageURL, options: .atomic) + dirty = false + } catch { + // Best-effort: swallow errors; debug data is non-critical. + } + } + + private func load() { + guard let data = try? Data(contentsOf: storageURL), + let payload = try? JSONDecoder().decode(StoredPayload.self, from: data) + else { return } + launches = payload.launches.suffix(20) + let keepIDs = Set(launches.map(\.launchID)) + samples = payload.samples.filter { keepIDs.contains($0.launchID) } + } + + private struct StoredPayload: Codable { + let launches: [Launch] + let samples: [Sample] + } +} diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift @@ -15,35 +15,43 @@ struct TappableTextField: UIViewRepresentable { var initialCursorPoint: CGPoint? = nil func makeUIView(context: Context) -> UITextView { - let textView = UITextView() - textView.accessibilityIdentifier = uiAccessibilityIdentifier - textView.delegate = context.coordinator - textView.font = ItemRowMetrics.bodyUIK - textView.backgroundColor = .clear - textView.textContainerInset = .zero - textView.textContainer.lineFragmentPadding = 0 - textView.isScrollEnabled = false - textView.autocorrectionType = .default - textView.autocapitalizationType = .sentences - textView.returnKeyType = returnKeyType - - let placeholder = UILabel() - placeholder.text = "Enter text" - placeholder.font = ItemRowMetrics.bodyUIK - placeholder.textColor = .placeholderText - placeholder.tag = 100 - placeholder.translatesAutoresizingMaskIntoConstraints = false - textView.addSubview(placeholder) - NSLayoutConstraint.activate([ - placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor), - placeholder.topAnchor.constraint(equalTo: textView.topAnchor), - ]) - - context.coordinator.textView = textView - return textView + PerfSampler.shared.measure("TappableTextField.makeUIView") { + let textView = UITextView() + textView.accessibilityIdentifier = uiAccessibilityIdentifier + textView.delegate = context.coordinator + textView.font = ItemRowMetrics.bodyUIK + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.isScrollEnabled = false + textView.autocorrectionType = .default + textView.autocapitalizationType = .sentences + textView.returnKeyType = returnKeyType + + let placeholder = UILabel() + placeholder.text = "Enter text" + placeholder.font = ItemRowMetrics.bodyUIK + placeholder.textColor = .placeholderText + placeholder.tag = 100 + placeholder.translatesAutoresizingMaskIntoConstraints = false + textView.addSubview(placeholder) + NSLayoutConstraint.activate([ + placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor), + placeholder.topAnchor.constraint(equalTo: textView.topAnchor), + ]) + + context.coordinator.textView = textView + return textView + } } func updateUIView(_ textView: UITextView, context: Context) { + PerfSampler.shared.measure("TappableTextField.updateUIView") { + updateUIViewBody(textView, context: context) + } + } + + private func updateUIViewBody(_ textView: UITextView, context: Context) { if !textView.isFirstResponder { applyStyle(to: textView, text: text, isCompleted: isCompleted) } else if text.isEmpty && !textView.text.isEmpty { @@ -81,11 +89,13 @@ struct TappableTextField: UIViewRepresentable { } func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { - let proposedWidth = proposal.width ?? uiView.bounds.width - let width = proposedWidth > 0 ? proposedWidth : (uiView.window?.bounds.width ?? 0) - guard width > 0 else { return nil } - let fitted = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) - return CGSize(width: width, height: fitted.height) + PerfSampler.shared.measure("TappableTextField.sizeThatFits") { + let proposedWidth = proposal.width ?? uiView.bounds.width + let width = proposedWidth > 0 ? proposedWidth : (uiView.window?.bounds.width ?? 0) + guard width > 0 else { return nil } + let fitted = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + return CGSize(width: width, height: fitted.height) + } } func makeCoordinator() -> Coordinator { @@ -149,6 +159,10 @@ struct TappableTextField: UIViewRepresentable { } func textViewDidBeginEditing(_ textView: UITextView) { + PerfSampler.shared.record( + label: "TappableTextField.didBeginEditing", + durationMs: 0 + ) if let point = initialCursorPoint { initialCursorPoint = nil textView.layoutIfNeeded() diff --git a/ListlessiOS/Views/PerfDebugView.swift b/ListlessiOS/Views/PerfDebugView.swift @@ -0,0 +1,182 @@ +import SwiftUI +import UIKit + +struct PerfDebugView: View { + @State private var refreshTick = 0 + + var body: some View { + List { + Section("Summary (current launch)") { + let stats = perLabelStats(for: PerfSampler.shared.samplesForCurrentLaunch()) + if stats.isEmpty { + Text("No samples yet. Interact with the app and return here.") + .foregroundStyle(.secondary) + } else { + ForEach(stats, id: \.label) { stat in + StatRow(stat: stat) + } + } + } + + ForEach(launchesMostRecentFirst()) { launch in + Section(sectionTitle(for: launch)) { + let launchSamples = samples(for: launch.launchID) + if launchSamples.isEmpty { + Text("No samples recorded.") + .foregroundStyle(.secondary) + } else { + ForEach(launchSamples) { sample in + SampleRow(sample: sample) + } + } + } + } + } + .id(refreshTick) + .navigationTitle("Perf Samples") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("Refresh") { refreshTick &+= 1 } + Button("Copy Summary") { copySummary() } + Button("Clear All", role: .destructive) { + PerfSampler.shared.clear() + refreshTick &+= 1 + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + + private func launchesMostRecentFirst() -> [PerfSampler.Launch] { + PerfSampler.shared.allLaunches().sorted { $0.startedAt > $1.startedAt } + } + + private func samples(for launchID: UUID) -> [PerfSampler.Sample] { + PerfSampler.shared.allSamples() + .filter { $0.launchID == launchID } + .sorted { $0.msSinceLaunch < $1.msSinceLaunch } + } + + private func sectionTitle(for launch: PerfSampler.Launch) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, HH:mm:ss" + let isCurrent = launch.launchID == PerfSampler.shared.currentLaunch.launchID + return formatter.string(from: launch.startedAt) + (isCurrent ? " (current)" : "") + } + + private func perLabelStats(for samples: [PerfSampler.Sample]) -> [LabelStat] { + let grouped = Dictionary(grouping: samples, by: \.label) + return grouped.map { label, entries in + let sorted = entries.sorted(by: { $0.callIndex < $1.callIndex }) + let durations = sorted.map(\.durationMs) + return LabelStat( + label: label, + count: sorted.count, + firstCallMs: durations.first ?? 0, + medianMs: median(durations), + maxMs: durations.max() ?? 0, + firstCallMsSinceLaunch: sorted.first?.msSinceLaunch ?? 0 + ) + } + .sorted { $0.firstCallMs > $1.firstCallMs } + } + + private func median(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let mid = sorted.count / 2 + if sorted.count % 2 == 0 { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid] + } + + private func copySummary() { + var lines: [String] = [] + for launch in launchesMostRecentFirst() { + lines.append("=== Launch \(launch.startedAt) ===") + let stats = perLabelStats(for: samples(for: launch.launchID)) + for stat in stats { + lines.append(String( + format: "%@ n=%d first=%.2fms median=%.2fms max=%.2fms firstAt=%.0fms", + stat.label, + stat.count, + stat.firstCallMs, + stat.medianMs, + stat.maxMs, + stat.firstCallMsSinceLaunch + )) + } + lines.append("") + } + UIPasteboard.general.string = lines.joined(separator: "\n") + } +} + +private struct LabelStat { + let label: String + let count: Int + let firstCallMs: Double + let medianMs: Double + let maxMs: Double + let firstCallMsSinceLaunch: Double +} + +private struct StatRow: View { + let stat: LabelStat + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(stat.label) + .font(.callout.monospaced()) + HStack(spacing: 12) { + Text("n=\(stat.count)") + Text(String(format: "1st=%.1fms", stat.firstCallMs)) + .foregroundStyle(stat.firstCallMs > 16 ? .red : .primary) + Text(String(format: "med=%.1f", stat.medianMs)) + Text(String(format: "max=%.1f", stat.maxMs)) + } + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + Text(String(format: "first call at %.0f ms since launch", stat.firstCallMsSinceLaunch)) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + } +} + +private struct SampleRow: View { + let sample: PerfSampler.Sample + + var body: some View { + HStack(spacing: 8) { + Text(String(format: "%5.0fms", sample.msSinceLaunch)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + Text("#\(sample.callIndex)") + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + .frame(width: 32, alignment: .leading) + Text(shortLabel(sample.label)) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Text(String(format: "%.2f ms", sample.durationMs)) + .font(.caption.monospaced()) + .foregroundStyle(sample.durationMs > 16 ? .red : .primary) + } + } + + private func shortLabel(_ label: String) -> String { + if let range = label.range(of: ".") { + return String(label[range.upperBound...]) + } + return label + } +} diff --git a/ListlessiOS/Views/SettingsView.swift b/ListlessiOS/Views/SettingsView.swift @@ -65,6 +65,9 @@ struct SettingsView: View { NavigationLink("iCloud Diagnostics") { SyncDiagnosticsView(syncMonitor: syncMonitor) } + NavigationLink("Perf Samples") { + PerfDebugView() + } Button("Reset Tutorial") { didCompleteTutorial = false dismiss()