ios-performance-engineering
Measure and fix iOS/macOS performance with Instruments (Time Profiler, Allocations, Hangs, App Launch), `xctrace` in CI, `OSSignposter`, MetricKit field telemetry (`MXMetricManager`, `MXHangDiagnostic`), `XCTMetric` baselines, launch time, memory footprint, binary size, and crash
Install
npx skills add https://github.com/wei18/apple-dev-skills/tree/main/apple-dev-skills/skills/ios-performance-engineering
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wei18-apple-dev-skills@llmmart
git clone https://github.com/wei18/apple-dev-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wei18/apple-dev-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
iOS Performance Engineering
When to invoke
- Diagnosing UI slowness, scroll hitches, or app hangs.
- Investigating high memory usage, leaks, or large binary size.
- Wiring MetricKit to receive field performance data from real devices.
- Setting up
XCTMetric/measure {}baselines in CI. - Deciding whether to move work off
@MainActorand how to do it safely. - Evaluating launch time before a release.
Instruments — the primary measurement tool
Never guess at a performance problem; profile first. Instruments ships with Xcode.
| Symptom | Instrument / template | Metric to read |
|---|---|---|
| High CPU / slow interactive path | Time Profiler | inverted call tree, self time > 5 ms on main thread |
| Unbounded memory growth | Allocations (Generation) | allocations that grow across repeated actions |
| Scroll / animation stutter | Animation Hitches | hitch rate (ms of hitch per second) |
| Main-thread freeze / spin | Hangs | block duration ≥ 250 ms |
| Slow cold launch | App Launch | time to first committed frame |
| Excessive SwiftUI re-renders | SwiftUI instrument | body invocation count, triggering property |
Key templates, condensed (full walkthrough of each template's UI: references/instruments-templates.md):
- Time Profiler: a function taking >5 ms on the main thread in an interactive path is a candidate for offloading.
- Allocations: the "Leaks" instrument detects reference cycles automatically but misses logical leaks (objects kept alive longer than needed).
- Hangs instrument (Xcode 14+): default threshold 250 ms.
- Hitches: use the Animation Hitches template — the standalone "Core Animation" template no longer exists. Hitch rate (ms of hitch per second of scrolling): <5 ms/s is good; 5–10 ms/s is concerning; >10 ms/s is critical.
os_signpost — annotate your own intervals
import os
let log = OSLog(subsystem: "com.example.MyApp", category: .pointsOfInterest)
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "ImageDecode", signpostID: id)
let image = decodeImage(data)
os_signpost(.end, log: log, name: "ImageDecode", signpostID: id)
Signpost intervals appear in the Instruments timeline as coloured spans. Use .event for instantaneous markers (user taps, cache misses). Prefer OSSignposter (iOS 15+/macOS 12+, introduced WWDC 2021; Swift-only wrapper over C os_signpost) from the os framework — it supports structured metadata:
let signposter = OSSignposter(subsystem: "com.example.MyApp", category: "Render")
let state = signposter.beginInterval("TileRender", id: signposter.makeSignpostID())
// ... work ...
signposter.endInterval("TileRender", state)
xctrace — Instruments from CI
xctrace record --template 'Time Profiler' --output trace.trace --time-limit 30s --launch -- /path/App.app
--launch -- command must come last: everything after -- is passed through to the launched process, so --output / --time-limit have to precede it or they get swallowed as app launch arguments instead of being read by xctrace itself.
xctrace can drive any built-in or custom Instruments template headlessly and export the trace as a .trace file. Post-process with xctrace export to pull out human-readable XML. Wire this into a CI step on a dedicated Mac runner to catch regressions before they reach users.
Hangs and hitches
The system classifies a main-thread block of 250 ms or more as a hang and surfaces it in the Organizer → Hang Reports (Xcode 14+) and via MetricKit's MXDiagnosticPayload.hangDiagnostics (an array of MXHangDiagnostic — there is no MXHangDiagnosticPayload type). The scroll hitch budget depends on display refresh rate (see above). A hang that the watchdog ends (EXC_CRASH (SIGKILL), code 0x8badf00d) arrives as a crash, not a hang report — triage it via references/crash-triage.md.
Moving work off @MainActor:
// Wrong — blocks the main thread
func loadData() {
let json = try! Data(contentsOf: remoteURL) // network I/O on main thread
items = try! JSONDecoder().decode([Item].self, from: json)
}
// Right — async, main actor only for the final UI update
func loadData() async throws {
let json = try await URLSession.shared.data(from: remoteURL).0
let decoded = try JSONDecoder().decode([Item].self, from: json)
await MainActor.run { items = decoded }
}
For CPU-heavy processing (image decoding, compression, sorting large arrays), use Task.detached(priority: .userInitiated) or dispatch to a background Actor. Never use DispatchQueue.global().async in new Swift 6 code — prefer structured concurrency.
One-shot bootstrapping on first appearance belongs in .task — the correct Apple-recommended modifier for async work tied to view lifetime. See swiftui-interaction-footguns for .task re-fire semantics on view identity changes.
Launch time
Launch time splits into two phases:
Pre-main (dyld) — loading and linking dylibs before main() runs. Minimise by: keeping the embedded dylib count low (prefer static libraries for non-system frameworks), avoiding +load methods, and not registering large numbers of @objc classes at startup. The App Launch Instruments template shows the pre-main timeline. Target: under 400 ms on a cold launch on the slowest supported device.
Post-main / first frame — everything from application(_:didFinishLaunchingWithOptions:) through the first committed frame. Defer every initialisation that is not required to display the initial screen. CloudKit containers, network prefetches, and analytics SDKs should be lazy. Measure with the App Launch template and the os_signpost .begin/.end around your own startup phases.
Common traps: eager CKContainer.default() on the main thread (hangs until entitlement check completes), synchronous keychain reads at app start, and large SQLite PRAGMA operations before the first view renders.
Memory
Footprint vs leaks: Instruments Allocations shows the heap; use vmmap or the Memory Debugger in Xcode to see the full virtual memory map (dirty pages, compressed pages, mapped files). The OS terminates apps that exceed their footprint budget silently — a JetsamEvent log entry whose reason reads per-process-limit (or highwater). Reduce by:
- Image downsampling: never decode a 4K image to display it at 100 pt. Use
ImageIOwithkCGImageSourceThumbnailMaxPixelSizeorUIGraphicsImageRendererto decode at display resolution. For thedownsample(imageAt:to:scale:)sample, readreferences/samples.md. autoreleasepoolin tight loops that allocate many Objective-C objects (e.g. iteratingNSManagedObjectfetches, callingUIImage(named:)in a loop). The pool drains at the end of eachautoreleasepool { }block rather than at the runloop turn boundary.- Retain cycles:
[weak self]in closures stored onself;weak var delegatein delegation patterns. The Leaks instrument and the Memory Graph Debugger (product menu → Debug Memory Graph) visualise the reference graph and highlight cycles in red.
Binary size
Large binaries increase download time and App Store review scrutiny. Two primary levers:
- Dead code stripping (
DEAD_CODE_STRIPPING = YESin Xcode build settings, default on for Release). Removes unreachable functions and data sections. -Osize(SWIFT_OPTIMIZATION_LEVEL = -Osize): optimises for binary size rather than speed. Typically 5–30% smaller than-O, with a runtime cost below 5% for most apps.
For asset catalog / app thinning, Link Map analysis, and trimming unused SDK resource bundles, read references/official-docs.md.
MetricKit — field performance telemetry
MetricKit delivers on-device aggregated performance metrics to your app once per day (diagnostic payloads are delivered immediately, with no disconnect-from-Xcode condition, since iOS 15 / macOS 12). For the full MXMetricManagerSubscriber receiver sample, read references/samples.md. MXMetricManager / MXMetricManagerSubscriber are deprecated from iOS / macOS 27 in favour of MetricManager().metricReports (for await) — the catalog floor is 26, so the sample below still applies; see apple-three-piece-analytics and telemetry-facade-pattern for the 27+ shape.
MetricKit data reflects real user conditions (actual device, network, battery state), making it the authoritative source for field performance signals. Key metric classes:
| Class | What it measures |
|---|---|
MXCPUMetric |
Cumulative CPU time (user + system) |
MXMemoryMetric |
Peak memory (peakMemoryUsage) and average suspended memory (averageSuspendedMemory) — there is no average-memory property |
MXDisplayMetric |
Average pixel luminance (not the hitch signal) |
MXAnimationMetric |
scrollHitchTimeRatio — field-measured ratio of hitch time while scrolling (the hitch signal) |
MXDiskIOMetric |
Cumulative logical write bytes |
MXHangDiagnostic |
Call tree for a main-thread hang > 250 ms |
MXCrashDiagnostic |
Crash reason + call tree — for intake channels, symbolication and the exception-type cheat sheet, read references/crash-triage.md |
MXCPUExceptionDiagnostic |
CPU runaway above system threshold |
Wire MetricKit as a sink in your telemetry facade (per telemetry-facade-pattern) — a MetricKitSink that subscribes to MXMetricManager.shared and broadcasts payloads as TelemetryEvent instances. This keeps MetricKit wiring out of AppDelegate and testable via protocol injection. Note that MetricKit complements but does not replace the analytics tracking covered in apple-three-piece-analytics: MetricKit is system-generated aggregate performance data, not user behaviour events.
XCTMetric and `measure
For the testScrollPerformance sample wiring XCTOSSignpostMetric.scrollDecelerationMetric, XCTMemoryMetric, and XCTCPUMetric into measure {}, read references/samples.md.
measure {} runs the block iterationCount + 1 times (default 5 recorded + 1 discarded warm-up) and records the mean of the recorded runs. On first run, set the baseline via the inline editor in Xcode. Subsequent runs fail on either of two independent thresholds, both configurable per metric: Max % Relative Standard Deviation (default 10%) and Max % Deviation from the baseline average (default 10%) — exceeding either one fails the test; it is not a single product formula. Commit baselines in .xcbaseline files alongside the test file.
For server-side CI (where a physical display is unavailable), use XCTCPUMetric and XCTMemoryMetric in unit tests that exercise logic without UIKit rendering. UI performance metrics require a simulator or device with an active display session.
Verification checklist
- Profile with Instruments before claiming a fix; never tune by guessing.
- Time Profiler run completed; hot paths on the main thread identified and either offloaded or bounded.
- Allocations generation diff shows no unbounded growth across repeated user actions.
- SwiftUI instrument checked for unexpected body re-render counts on the primary screen.
os_signpostintervals added around any operation expected to take > 16 ms.MXMetricManagerSubscriberregistered in the composition root; payloads forwarded to the telemetry sink.XCTMetricbaseline committed for the primary performance-sensitive test; CI fails on regression.- No synchronous network or file I/O on the main thread (audited via the Hangs instrument, Time Profiler, and
os_signpostaround suspect call sites — Thread Sanitizer only detects data races and will not flag this). - Image assets decoded at display resolution, not source resolution.
- Binary size measured with
-Osizebefore each major release; asset catalog slices verified.
Related skills
telemetry-facade-pattern: wireMetricKitSinkas one sink in the fan-out facade; keepMXMetricManagerSubscriberregistration out ofAppDelegate.apple-three-piece-analytics: decides which Apple-only sources (ASC Analytics / MetricKit / Game Center) to rely on and whether a third-party SDK is justified; this skill owns reading and acting on MetricKit payloads for performance diagnosis.swift6-concurrency: moving work off@MainActorcorrectly requires understanding actor isolation,Task.detached, andSendableconstraints — the primary tool for eliminating main-thread hangs.swiftui-expert:swiftui-expert-skill(aggregated external): for the SwiftUI body-re-render slice specifically, it ships an Instruments.traceanalysis toolchain — prefer it for that profiling. This skill owns the broader surface (Time Profiler / Allocations / hangs / launch / memory / binary size / MetricKit / XCTMetric).apple-skills:guide-swiftui-performance-audit(aggregated external): code-first SwiftUI review (view-update causes, layout thrash) with user-run Instruments; this skill owns measurement (Instruments/xctrace/MetricKit/XCTMetric) and the non-SwiftUI surface (launch, memory, binary size).- Official sources: when verifying or updating a factual or version-sensitive claim, read
references/official-docs.md.
Files (apple-dev-skills)
-
references
-
crash-triage.md 18.7 KB
# Crash triage and symbolication How a crash reaches you, how to make its backtrace readable, how to read it, and how to decide what to fix first. Every falsifiable claim carries its source in §Sources. This is the crash half of field diagnostics; hangs and slow launches stay in `SKILL.md` (§Hangs and hitches, §Launch time) and cross-reference back here. ## Contents - [Four intake channels](#four-intake-channels) - [Symbolication](#symbolication) - [Reading a crash report](#reading-a-crash-report) - [Exception-type cheat sheet](#exception-type-cheat-sheet) - [Triage rules](#triage-rules) - [Checklist](#checklist) - [Sources](#sources) ## Four intake channels | Channel | What arrives | Notes | |---|---|---| | **Xcode Organizer → Crashes** | Crash reports from App Store and TestFlight users, already symbolicated if you uploaded symbols with the build. "The Crashes organizer presents crash reports from customers who share diagnostic and usage information … TestFlight users of your app automatically share crash reports with you, regardless of the device settings". [S1] | Not available here: "Watchdog events, such as those from slow app launch times", "Invalid code-signature crashes", "Thermal events", "Jetsam events" — those come only from the device. [S1] Select a report → Inspector → **Generate Recommendations** pastes the stack into the coding assistant. [S1] Reports delivered through the Organizer omit thread names for privacy. [S5] | | **App Store Connect / TestFlight** | ASC Analytics "Crashes" = "The total number of crashes on devices running a minimum of iOS 8, macOS 11, tvOS 9, or visionOS 1. Get detailed crash logs and crash reports in Xcode, such as unique totals for each type of crash and how many users experienced it." Usage totals "are based on App Store users who opt-in to share their data with you." [S2] TestFlight tab → Feedback → **Crashes**: tester-submitted crash feedback with comments; "Crash reports are available for download for 120 days." [S3] | ASC gives counts (dashboard widget "Crashes by App Version"); the per-crash detail and affected-user counts are in the Organizer. [S2] | | **MetricKit** (in-app) | iOS 26 floor: `MXDiagnosticPayload.crashDiagnostics` → `MXCrashDiagnostic` with `callStackTree`, `exceptionType`, `exceptionCode`, `signal`, `exceptionReason`, `terminationReason`, `virtualMemoryRegionInfo`. Diagnostic payloads "arrive immediately in iOS 15 and later". [S4][S6] iOS / macOS 27: `MXCrashDiagnostic` is deprecated ("Use CrashDiagnostic instead") together with `MXMetricManager` / `MXMetricManagerSubscriber` ("Use MetricManager instead"); iterate `diagnosticReports` on the app's single held `MetricManager` (the `MetricKitSink` in `telemetry-facade-pattern`) and switch on `report.result` — `.crash(CrashDiagnostic)` exposes the same fields plus `terminationCategory` (`.watchdog`, `.badAccess`, …). `DiagnosticReport` is `Codable`, so `JSONEncoder` serializes it for your own sink. [S6][S7][S8] "MetricKit does not generate a `DiagnosticReport` for every occurrence of a diagnostic event." [S8] | The `callStackTree` is unsymbolicated addresses plus binary UUIDs — you symbolicate it yourself with the dSYMs (below). Ship it through the `MetricKitSink` described in `SKILL.md`, not straight from `AppDelegate`. | | **A user's `.ips` file** | On device: Settings → Privacy & Security → Analytics & Improvements → **Analytics Data**; the log is named `<AppBinaryName>_<DateTime>` (crash) or `JetsamEvent_<DateTime>` (memory); Share → Mail. On macOS: Console → Crash Reports → Reveal in Finder. [S1] With the device attached: "click the Reports tab in the Device Hub app, then choose Crashes from the Inspector menu". [S9] | "Crash reports must have the `.crash` or `.ips` file extension. If the file has a different extension or no extension, rename the file before symbolicating." [S9] When debugging in Xcode the debugger swallows the crash — Debug > Detach (or `detach` in the console) to let the OS write the report. [S1] | ## Symbolication **What a dSYM is.** "Debug builds of an app place the debug symbols inside the compiled binary file by default, while release builds of an app place the debug symbols in a companion debug symbol (`dSYM`) file". Each binary (app, framework, extension) has its own dSYM; "A binary and a `dSYM` file are only compatible with each other when they have identical build UUIDs." [S10] **Build setting.** `DEBUG_INFORMATION_FORMAT` = "DWARF with dSYM File" (`dwarf-with-dsym`) for the configuration you ship. [S10] **Where the dSYMs go.** - Local archive: "When archiving your app for distribution, Xcode gathers all binaries and `dSYM` files for your app and stores them inside the Xcode archive." Upload symbols with the build so the Crashes organizer symbolicates for you; without them "you still receive the crash reports through the Crashes organizer, but without the symbol names" and Xcode fills them in only if the dSYMs are on your Mac. "You must retain the Xcode archive for each build of your app you distribute." [S10] - Xcode Cloud: the archive action "makes the exported app archive or framework bundle and the build logs available as artifacts" [S11] — download that archive artifact and keep it in a Spotlight-indexed location; it is the archive that holds the dSYMs (the archive rule above applies unchanged). Keep it under a path without `.noindex`. [S12] - MetricKit `callStackTree`: nothing is symbolicated for you; you need the matching dSYMs on the machine that post-processes the payloads. **Match the UUID.** Read the binary's UUID from the crash report's Binary Images section (or open the report in Xcode → Debug navigator → Control-click → Show Library Info), then: ``` % mdfind "com_apple_xcode_dsym_uuids == E3EA8743-C9E6-3C68-BF04-8D51363B689D" % dwarfdump --uuid /path/to/TouchCanvas.app.dSYM UUID: E3EA8743-C9E6-3C68-BF04-8D51363B689D (arm64) …/Contents/Resources/DWARF/TouchCanvas ``` `mdfind` printing nothing means Spotlight can't see the dSYM (indexing off, `.noindex` in the path) or the build didn't produce one. [S12] **Tools, from least to most manual.** [S9] 1. Open the `.ips` in Xcode (choose the project when prompted); Xcode symbolicates every thread it can. Missing OS frames → download that OS version's device symbols; missing app frames → find the dSYM as above. 2. `xcrun crashlog <path-to-crashReport>` — an LLDB Python module that resolves function names, source files and line numbers for every frame (`--help` for options). 3. `CrashSymbolicator.py` — "supports JSON-format crash reports and inlined frames when run with its default options". It lives inside the Xcode bundle: ``` % xcode-select -p /Applications/Xcode.app/Contents/Developer % cd /Applications/Xcode.app/Contents/SharedFrameworks/CoreSymbolicationDT.framework/Resources % python3 CrashSymbolicator.py <path-to-crashReport> -d <path-to-dSYM> [-o out.ips] ``` Derive the directory from `xcode-select -p` by replacing `Contents/Developer` with `Contents/SharedFrameworks/CoreSymbolicationDT.framework/Resources` (verified present on Xcode 26.5 at that path). The current Apple page documents only Xcode-open, `xcrun crashlog`, this script and `atos`; the old `symbolicatecrash` script is not mentioned. 4. `atos` — one frame at a time. Take the architecture and load address from the Binary Images section, and point `-o` at the DWARF file *inside* the dSYM, not the bundle: ``` % atos -arch arm64 -o TouchCanvas.app.dSYM/Contents/Resources/DWARF/TouchCanvas -l 0x10459c000 -i 0x1045a4610 ``` `-i` expands inlined frames; `-dedup` reveals the functions behind a `<deduplicated_symbol>` (release builds merge identical machine code by default). [S9] A report is *fully* symbolicated when every frame shows a function name, *partially* when only some do (often enough), and *unsymbolicated* when it is all hex — "an unsymbolicated crash report is rarely useful". [S9] ## Reading a crash report Read in this order [S5]: 1. **Exception information** — `Exception Type` (Mach exception + BSD signal), `Exception Codes` / `Subtype` / `Message`, `Exception Note`, `Termination Reason`, `Triggered by Thread`. "This information is important, but is often overlooked." - `Exception Note: EXC_CORPSE_NOTIFY` → "the crash didn't originate from a hardware trap, either because the process was explicitly quit by the operating system or the process called `abort()`". - `SIMULATED (this is NOT a crash)` / `NON-FATAL CONDITION (this is NOT a crash)` → the process did not actually crash; treat as a diagnostic, not a crash. - `Termination Reason` carries OS-side reasons: invalid code signature, missing dependent library, "accessing privacy sensitive information without a purpose string", watchdog codes. 2. **Diagnostic messages** — `Application Specific Information` (e.g. `BUG IN CLIENT OF LIBDISPATCH: dispatch_sync called on queue already owned by current thread`), `Termination Description` for watchdog kills, `VM Region Info` for bad memory access. Also `Last Exception Backtrace` before Thread 0 when an Objective-C / C++ exception was thrown. 3. **The crashed thread** (`Thread N Crashed:`), then walk down from frame 0 to the **first frame in one of your binaries** — that is where to open the source. For a Swift runtime trap frame 0 is usually already yours, with `(File.swift:line)` appended once symbolicated. [S13] 4. **Binary Images** — only for the UUID / load address you need to symbolicate. ## Exception-type cheat sheet Only types Apple documents in the "Diagnosing issues using crash reports" series. | `Exception Type` | Typical cause | Where to look next | |---|---|---| | `EXC_BAD_ACCESS (SIGSEGV)` / `(SIGBUS)` | "accessing an invalid index in an array, dereferencing a pointer to an invalid memory location, or writing to read-only memory"; zombies, use-after-free. `Exception Subtype: KERN_INVALID_ADDRESS at 0x…`. [S14][S15] | Reproduce under Address Sanitizer / Undefined Behavior Sanitizer / Thread Sanitizer; run the static analyzer for ObjC/C/C++; read `VM Region Info`. [S15] | | `EXC_BREAKPOINT (SIGTRAP)` (ARM) / `EXC_BAD_INSTRUCTION (SIGILL)` (x86_64) | A trace trap: "The Swift runtime uses trace traps for specific types of unrecoverable errors" — force-unwrapping `nil`, failed `as!`, out-of-range index; also `fatalError`, `__builtin_trap()`, and libdispatch misuse (see `Additional Diagnostic Information`). [S16][S13] | Frame 0 of the crashed thread names the file and line once symbolicated; fix the precondition. `Termination Reason: Namespace SIGNAL, Code 0x5`. [S13] | | `EXC_CRASH (SIGABRT)` | `abort()` — "such as when an app encounters an uncaught Objective-C or C++ language exception"; for app extensions, `Exception Subtype: LAUNCH_HANG` when initialization took too long. [S17] | Read `Last Exception Backtrace` and `Application Specific Information` first; `LAUNCH_HANG` → treat as a launch-time hang (`SKILL.md` §Launch time). | | `EXC_CRASH (SIGKILL)` | The OS killed the process; `Termination Reason` code says why: `0x8badf00d` watchdog, `0xdead10cc` held a file / SQLite lock during suspension, `0xc00010ff` thermal, `0xbaddd15c` cache purge for disk space, `0x2182bad2/3/4` background task / URL session / fetch overran, `0xd00d2bad` excessive system resources, `0xbaadca11` CallKit/PushKit, `0xc51bad01–03` watchOS background CPU/time. [S18] | Watchdog → below. `0xdead10cc` → `beginBackgroundTask(withName:expirationHandler:)` around the write. [S18] | | Watchdog (`0x8badf00d`) | "The watchdog terminates apps that block the main thread for a significant time" — synchronous networking, big JSON, synchronous Core Data migration, Vision requests. `Termination Description` shows `scene-create` (first frame never rendered) or `scene-update` (main thread too busy) and "exhausted real (wall clock) time allowance of 19.97 seconds". [S19] | Not in the Organizer — get the `.ips` from the device. [S1] This is the crash form of a hang: `SKILL.md` §Launch time (pre-main / first frame) and §Hangs and hitches (Hangs instrument, `MXHangDiagnostic`). | | Jetsam (`JetsamEvent_*` log) | Memory pressure: "the system frees memory by terminating applications to reclaim their memory. This is a jetsam event". JSON, "they don't contain the backtraces of any threads"; header `pageSize` and `largestProcess`; only the jettisoned process has a `reason` key. "If the system jettisons your app due to memory pressure while the app is visible, it will look like your app crashed." [S20] | Not in the Organizer. [S1] Multiply pages × `pageSize`; if your app is `largestProcess`, go to `SKILL.md` §Memory (Allocations generations, downsampling, `MXMemoryMetric.peakMemoryUsage`). | | `EXC_RESOURCE` | Resource limit: `Exception Subtype` `CPU` / `CPU_FATAL`, `MEMORY`, `IO`, `WAKEUPS`; `NON-FATAL CONDITION` means the process was *not* terminated. [S21] | `MEMORY` "may be a precursor to termination for excess memory usage"; `WAKEUPS` → look for tight `dispatch_async` / `perform(_:on:…)` loops across similar background-thread backtraces. [S21] | ## Triage rules 1. **Confirm it is a crash.** `SIMULATED` / `NON-FATAL CONDITION` notes, `EXC_RESOURCE` without `_FATAL`, and MetricKit hang diagnostics are not crashes — route them to the hang / memory sections instead of the crash queue. [S5][S21] 2. **Rank by users affected on the shipping version.** Apple's own surfaces expose "unique totals for each type of crash and how many users experienced it" [S2]; a crash with many reports from one device (same `CrashReporter Key` / `Beta Identifier`) [S5] ranks below one touching many users. A crash that only appears on an old app version with a fix already shipped is closed, not fixed twice. 3. **Split by OS version and hardware before reading code.** `OS Version`, `Hardware Model`, `AppVariant` in the header tell you whether it is a new-OS regression, a thinning variant, or universal. [S5] 4. **Classify by exception type** (table above) — it decides the tool: sanitizers for `EXC_BAD_ACCESS`, source line for `EXC_BREAKPOINT`, exception backtrace for `SIGABRT`, termination code for `SIGKILL`. 5. **Hand off non-crashes.** Watchdog and `LAUNCH_HANG` → `SKILL.md` §Launch time and §Hangs; Jetsam and `EXC_RESOURCE MEMORY` → §Memory; MetricKit `hangDiagnostics` → §Hangs. Conversely, a hang that ends in `0x8badf00d` comes back here as a crash. 6. **Keep the archive.** If the dSYM for the crashing build is gone, the only recovery is shipping a new version and retaining its archive. [S12] ## Checklist - [ ] Release configuration builds with `DEBUG_INFORMATION_FORMAT = dwarf-with-dsym`; symbols are uploaded with every App Store / TestFlight build. [S10] - [ ] Every distributed archive (local or Xcode Cloud artifact) is retained in a Spotlight-indexed location without `.noindex`. [S10][S12] - [ ] `dwarfdump --uuid` of the retained dSYM equals the crash report's Binary Images UUID before you trust any symbolicated line. [S12] - [ ] Crash intake covers all four channels; watchdog / Jetsam / thermal / signature crashes are pulled from devices, not expected in the Organizer. [S1] - [ ] MetricKit crash diagnostics reach the telemetry sink; on the 27 toolchain the sink reads `diagnosticReports` on the app's single held `MetricManager` and switches on `.crash`. [S6][S8] - [ ] Each open crash has: exception type, termination reason, crashed thread's first app frame, affected-user count, and OS/hardware split recorded before a fix is planned. - [ ] Non-crash diagnostics (hang, `NON-FATAL`, `EXC_RESOURCE` non-fatal) are routed to the hang / memory workflow in `SKILL.md`. ## Sources - [S1] Acquiring crash reports and diagnostic logs — https://developer.apple.com/documentation/xcode/acquiring-crash-reports-and-diagnostic-logs - [S2] App Store Connect Analytics metric definitions ("Crashes") — https://developer.apple.com/help/app-store-connect-analytics/reference/metrics-definitions - [S3] App Store Connect Help: View tester feedback — https://developer.apple.com/help/app-store-connect/test-a-beta-version/view-tester-feedback - [S4] `MXCrashDiagnostic` (deprecated 27.0, "Use CrashDiagnostic instead") — https://developer.apple.com/documentation/metrickit/mxcrashdiagnostic - [S5] Examining the fields in a crash report — https://developer.apple.com/documentation/xcode/examining-the-fields-in-a-crash-report - [S6] MetricKit framework overview / `MXMetricManager` (deprecated 27.0, "Use MetricManager instead") — https://developer.apple.com/documentation/metrickit and https://developer.apple.com/documentation/metrickit/mxmetricmanager - [S7] `MetricManager` (iOS 27+) and `CrashDiagnostic` — https://developer.apple.com/documentation/metrickit/metricmanager and https://developer.apple.com/documentation/metrickit/crashdiagnostic - [S8] `DiagnosticReport` — https://developer.apple.com/documentation/metrickit/diagnosticreport - [S9] Adding identifiable symbol names to a crash report — https://developer.apple.com/documentation/xcode/adding-identifiable-symbol-names-to-a-crash-report - [S10] Building your app to include debugging information — https://developer.apple.com/documentation/xcode/building-your-app-to-include-debugging-information - [S11] Configuring your Xcode Cloud workflow's actions (archive action artifacts) — https://developer.apple.com/documentation/xcode/configuring-your-xcode-cloud-workflow-s-actions - [S12] Locating a missing debug symbol file — https://developer.apple.com/documentation/xcode/locating-a-missing-debug-symbol-file - [S13] Addressing crashes from Swift runtime errors — https://developer.apple.com/documentation/xcode/addressing-crashes-from-swift-runtime-errors - [S14] EXC_BAD_ACCESS (SIGSEGV) — https://developer.apple.com/documentation/xcode/sigsegv - [S15] Investigating memory access crashes — https://developer.apple.com/documentation/xcode/investigating-memory-access-crashes - [S16] EXC_BREAKPOINT (SIGTRAP) and EXC_BAD_INSTRUCTION (SIGILL) — https://developer.apple.com/documentation/xcode/sigtrap_sigill - [S17] EXC_CRASH (SIGABRT) — https://developer.apple.com/documentation/xcode/sigabrt - [S18] EXC_CRASH (SIGKILL) — https://developer.apple.com/documentation/xcode/sigkill - [S19] Addressing watchdog terminations — https://developer.apple.com/documentation/xcode/addressing-watchdog-terminations - [S20] Identifying high-memory use with jetsam event reports — https://developer.apple.com/documentation/xcode/identifying-high-memory-use-with-jetsam-event-reports - [S21] EXC_RESOURCE — https://developer.apple.com/documentation/xcode/exc_resource - Series index: Diagnosing issues using crash reports and device logs — https://developer.apple.com/documentation/xcode/diagnosing-issues-using-crash-reports-and-device-logs -
instruments-templates.md 2.3 KB
# Instruments templates in detail Full walkthrough of each Instruments template referenced from the `## Instruments` table in SKILL.md. Read this when you need the how-to-drive-the-UI detail, not just the metric to watch. **Time Profiler** — samples the call stack at ~1 kHz. Reveals which functions consume CPU time. After recording, invert the call tree and hide system libraries to surface your own hot paths. A function taking >5 ms on the main thread in an interactive path is a candidate for offloading. **Allocations** — tracks every heap allocation. Use the "Generation" feature: take a snapshot before an action, perform the action repeatedly, take another snapshot, and diff. Any allocation that grew unboundedly across generations is a leak or an accumulation bug. The "Leaks" instrument detects reference cycles automatically but misses logical leaks (objects kept alive longer than needed). **SwiftUI instrument** — records View body invocation counts, `@State` change propagation, and diffing cost. Xcode 26 introduced a next-generation SwiftUI instrument that tracks the causes of each update. A body that fires more than expected usually means a dependency is too coarse (e.g. observing the whole model when only one field is needed). The instrument shows which property change triggered each body re-render. **Hangs instrument** (Xcode 14+) — captures main-thread spins longer than a configurable threshold (default 250 ms). Apple's tooling reports hangs starting at 250 ms; on-device hang detection can be tuned from 250 ms up to several seconds depending on the diagnostic. Pairs with the **App Launch** template for pre-first-frame blocking. The system also generates `MXHangDiagnostic` on-device (see MetricKit below). **Hitches** — a hitch occurs when a frame takes longer than one vsync interval to deliver, causing a visual stutter. On 60 Hz displays the budget is ~16.67 ms; on ProMotion (120 Hz) it halves to ~8.33 ms. Use the **Animation Hitches** instrument template (Hitches, Display, and Core Animation Commits tracks — the standalone "Core Animation" template no longer exists) to see committed frames and dropped frames. The `hitch rate` (ms of hitch per second of scrolling) is the standard metric: <5 ms/s is good; 5–10 ms/s is concerning (user notices interruptions); >10 ms/s is critical (greatly impacts UX) — per WWDC 2020 session 10077. -
official-docs.md 1.4 KB
Official pages backing this skill's claims; read when verifying or updating a factual or version-sensitive claim. | Page | URL | Backs | |---|---|---| | Understanding hangs in your app | https://developer.apple.com/documentation/xcode/understanding-hangs-in-your-app | 250 ms hang threshold (hangs only; doesn't cover Time Profiler / Allocations / hitches) | | Eliminate animation hitches with XCTest (WWDC20) | https://developer.apple.com/videos/play/wwdc2020/10077/ | Hitch bands: <5 / 5-10 / >=10 ms/s | | MetricKit | https://developer.apple.com/documentation/metrickit | Delivery cadence; `MetricManager` starting in 27 | | MXMetricManager | https://developer.apple.com/documentation/metrickit/mxmetricmanager | Deprecated in 27.0 | | OSSignposter | https://developer.apple.com/documentation/os/ossignposter | iOS 15.0 / macOS 12.0 | | Diagnosing memory, thread, and crash issues early | https://developer.apple.com/documentation/xcode/diagnosing-memory-thread-and-crash-issues-early | Main Thread Checker "verifies that system APIs that must run on the main thread actually do run on that thread" | | Code Size Optimization Mode in Swift 4.1 (swift.org blog, 2018-02-08) | https://www.swift.org/blog/osize/ | `-Osize` savings "from 5% to even 30% for some projects", "below 5%" | | Reducing your app's size | https://developer.apple.com/documentation/xcode/reducing-your-app-s-size | Binary-size section (App Thinning Size Report, asset catalogs) | -
samples.md 2.2 KB
# Samples ## Image downsampling (ImageIO) ```swift func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage { let options: [CFString: Any] = [ kCGImageSourceShouldCacheImmediately: false, kCGImageSourceShouldCache: false ] let src = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary)! let maxDim = max(pointSize.width, pointSize.height) * scale let thumbOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: maxDim ] let cgImage = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary)! return UIImage(cgImage: cgImage) } ``` ## MetricKit receiver (MXMetricManagerSubscriber) `MXMetricManager` / `MXMetricManagerSubscriber` are deprecated from iOS / macOS 27 (replacement: `MetricManager().metricReports`, `for await`); this sample targets the catalog's iOS 26 floor. ```swift import MetricKit final class MetricKitReceiver: NSObject, MXMetricManagerSubscriber { func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { // CPU time, memory, disk, network, display — aggregated over 24 h let cpuTime = payload.cpuMetrics?.cumulativeCPUTime let avgMemory = payload.memoryMetrics?.averageSuspendedMemory // Forward to your telemetry sink } } func didReceive(_ payloads: [MXDiagnosticPayload]) { for payload in payloads { // MXHangDiagnostic, MXCrashDiagnostic, MXCPUExceptionDiagnostic let hangs = payload.hangDiagnostics // call trees for hang events // Persist or upload for analysis } } } // Register at app start — one call, lives for the app lifetime MXMetricManager.shared.add(receiver) ``` ## XCTMetric baseline test ```swift func testScrollPerformance() { let app = XCUIApplication() app.launch() measure(metrics: [XCTOSSignpostMetric.scrollDecelerationMetric, XCTMemoryMetric(application: app), XCTCPUMetric(application: app)]) { // simulate the action app.swipeUp() } } ```
-
-
SKILL.md 13.8 KB
--- name: ios-performance-engineering description: Measure and fix iOS/macOS performance with Instruments (Time Profiler, Allocations, Hangs, App Launch), `xctrace` in CI, `OSSignposter`, MetricKit field telemetry (`MXMetricManager`, `MXHangDiagnostic`), `XCTMetric` baselines, launch time, memory footprint, binary size, and crash triage / symbolication (`MXCrashDiagnostic`, dSYM, `atos`). Use when diagnosing hangs or hitches measured with Instruments or MetricKit, high memory, slow launch, or a large binary, reading or symbolicating a crash report, wiring MetricKit, or setting CI perf baselines. SwiftUI-specific hitch triage from code review → apple-skills:guide-swiftui-performance-audit or swiftui-expert's `.trace` toolchain; this skill owns measurement and the system-level surface. --- # iOS Performance Engineering ## When to invoke - Diagnosing UI slowness, scroll hitches, or app hangs. - Investigating high memory usage, leaks, or large binary size. - Wiring MetricKit to receive field performance data from real devices. - Setting up `XCTMetric` / `measure {}` baselines in CI. - Deciding whether to move work off `@MainActor` and how to do it safely. - Evaluating launch time before a release. ## Instruments — the primary measurement tool Never guess at a performance problem; profile first. Instruments ships with Xcode. | Symptom | Instrument / template | Metric to read | |---|---|---| | High CPU / slow interactive path | Time Profiler | inverted call tree, self time > 5 ms on main thread | | Unbounded memory growth | Allocations (Generation) | allocations that grow across repeated actions | | Scroll / animation stutter | Animation Hitches | `hitch rate` (ms of hitch per second) | | Main-thread freeze / spin | Hangs | block duration ≥ 250 ms | | Slow cold launch | App Launch | time to first committed frame | | Excessive SwiftUI re-renders | SwiftUI instrument | body invocation count, triggering property | Key templates, condensed (full walkthrough of each template's UI: `references/instruments-templates.md`): - **Time Profiler**: a function taking >5 ms on the main thread in an interactive path is a candidate for offloading. - **Allocations**: the "Leaks" instrument detects reference cycles automatically but misses logical leaks (objects kept alive longer than needed). - **Hangs instrument** (Xcode 14+): default threshold **250 ms**. - **Hitches**: use the Animation Hitches template — the standalone "Core Animation" template no longer exists. Hitch rate (ms of hitch per second of scrolling): <5 ms/s is good; 5–10 ms/s is concerning; >10 ms/s is critical. ### `os_signpost` — annotate your own intervals ```swift import os let log = OSLog(subsystem: "com.example.MyApp", category: .pointsOfInterest) let id = OSSignpostID(log: log) os_signpost(.begin, log: log, name: "ImageDecode", signpostID: id) let image = decodeImage(data) os_signpost(.end, log: log, name: "ImageDecode", signpostID: id) ``` Signpost intervals appear in the Instruments timeline as coloured spans. Use `.event` for instantaneous markers (user taps, cache misses). Prefer `OSSignposter` (iOS 15+/macOS 12+, introduced WWDC 2021; Swift-only wrapper over C `os_signpost`) from the `os` framework — it supports structured metadata: ```swift let signposter = OSSignposter(subsystem: "com.example.MyApp", category: "Render") let state = signposter.beginInterval("TileRender", id: signposter.makeSignpostID()) // ... work ... signposter.endInterval("TileRender", state) ``` ### `xctrace` — Instruments from CI ```bash xctrace record --template 'Time Profiler' --output trace.trace --time-limit 30s --launch -- /path/App.app ``` `--launch -- command` must come last: everything after `--` is passed through to the launched process, so `--output` / `--time-limit` have to precede it or they get swallowed as app launch arguments instead of being read by `xctrace` itself. `xctrace` can drive any built-in or custom Instruments template headlessly and export the trace as a `.trace` file. Post-process with `xctrace export` to pull out human-readable XML. Wire this into a CI step on a dedicated Mac runner to catch regressions before they reach users. ## Hangs and hitches The system classifies a main-thread block of **250 ms or more** as a hang and surfaces it in the Organizer → Hang Reports (Xcode 14+) and via MetricKit's `MXDiagnosticPayload.hangDiagnostics` (an array of `MXHangDiagnostic` — there is no `MXHangDiagnosticPayload` type). The scroll hitch budget depends on display refresh rate (see above). A hang that the watchdog ends (`EXC_CRASH (SIGKILL)`, code `0x8badf00d`) arrives as a crash, not a hang report — triage it via `references/crash-triage.md`. **Moving work off `@MainActor`:** ```swift // Wrong — blocks the main thread func loadData() { let json = try! Data(contentsOf: remoteURL) // network I/O on main thread items = try! JSONDecoder().decode([Item].self, from: json) } // Right — async, main actor only for the final UI update func loadData() async throws { let json = try await URLSession.shared.data(from: remoteURL).0 let decoded = try JSONDecoder().decode([Item].self, from: json) await MainActor.run { items = decoded } } ``` For CPU-heavy processing (image decoding, compression, sorting large arrays), use `Task.detached(priority: .userInitiated)` or dispatch to a background `Actor`. Never use `DispatchQueue.global().async` in new Swift 6 code — prefer structured concurrency. One-shot bootstrapping on first appearance belongs in `.task` — the correct Apple-recommended modifier for async work tied to view lifetime. See `swiftui-interaction-footguns` for `.task` re-fire semantics on view identity changes. ## Launch time Launch time splits into two phases: **Pre-main (dyld)** — loading and linking dylibs before `main()` runs. Minimise by: keeping the embedded dylib count low (prefer static libraries for non-system frameworks), avoiding `+load` methods, and not registering large numbers of `@objc` classes at startup. The **App Launch** Instruments template shows the pre-main timeline. Target: under 400 ms on a cold launch on the slowest supported device. **Post-main / first frame** — everything from `application(_:didFinishLaunchingWithOptions:)` through the first committed frame. Defer every initialisation that is not required to display the initial screen. CloudKit containers, network prefetches, and analytics SDKs should be lazy. Measure with the **App Launch** template and the `os_signpost` `.begin`/`.end` around your own startup phases. Common traps: eager `CKContainer.default()` on the main thread (hangs until entitlement check completes), synchronous keychain reads at app start, and large SQLite `PRAGMA` operations before the first view renders. ## Memory **Footprint vs leaks**: Instruments Allocations shows the heap; use `vmmap` or the Memory Debugger in Xcode to see the full virtual memory map (dirty pages, compressed pages, mapped files). The OS terminates apps that exceed their footprint budget silently — a JetsamEvent log entry whose reason reads `per-process-limit` (or `highwater`). Reduce by: - **Image downsampling**: never decode a 4K image to display it at 100 pt. Use `ImageIO` with `kCGImageSourceThumbnailMaxPixelSize` or `UIGraphicsImageRenderer` to decode at display resolution. For the `downsample(imageAt:to:scale:)` sample, read `references/samples.md`. - **`autoreleasepool`** in tight loops that allocate many Objective-C objects (e.g. iterating `NSManagedObject` fetches, calling `UIImage(named:)` in a loop). The pool drains at the end of each `autoreleasepool { }` block rather than at the runloop turn boundary. - **Retain cycles**: `[weak self]` in closures stored on `self`; `weak var delegate` in delegation patterns. The Leaks instrument and the Memory Graph Debugger (product menu → Debug Memory Graph) visualise the reference graph and highlight cycles in red. ## Binary size Large binaries increase download time and App Store review scrutiny. Two primary levers: - **Dead code stripping** (`DEAD_CODE_STRIPPING = YES` in Xcode build settings, default on for Release). Removes unreachable functions and data sections. - **`-Osize`** (`SWIFT_OPTIMIZATION_LEVEL = -Osize`): optimises for binary size rather than speed. Typically 5–30% smaller than `-O`, with a runtime cost below 5% for most apps. For asset catalog / app thinning, Link Map analysis, and trimming unused SDK resource bundles, read `references/official-docs.md`. ## MetricKit — field performance telemetry MetricKit delivers on-device aggregated performance metrics to your app once per day (diagnostic payloads are delivered immediately, with no disconnect-from-Xcode condition, since iOS 15 / macOS 12). For the full `MXMetricManagerSubscriber` receiver sample, read `references/samples.md`. `MXMetricManager` / `MXMetricManagerSubscriber` are deprecated from iOS / macOS 27 in favour of `MetricManager().metricReports` (`for await`) — the catalog floor is 26, so the sample below still applies; see `apple-three-piece-analytics` and `telemetry-facade-pattern` for the 27+ shape. MetricKit data reflects **real user conditions** (actual device, network, battery state), making it the authoritative source for field performance signals. Key metric classes: | Class | What it measures | |---|---| | `MXCPUMetric` | Cumulative CPU time (user + system) | | `MXMemoryMetric` | Peak memory (`peakMemoryUsage`) and average suspended memory (`averageSuspendedMemory`) — there is no average-memory property | | `MXDisplayMetric` | Average pixel luminance (not the hitch signal) | | `MXAnimationMetric` | `scrollHitchTimeRatio` — field-measured ratio of hitch time while scrolling (the hitch signal) | | `MXDiskIOMetric` | Cumulative logical write bytes | | `MXHangDiagnostic` | Call tree for a main-thread hang > 250 ms | | `MXCrashDiagnostic` | Crash reason + call tree — for intake channels, symbolication and the exception-type cheat sheet, read `references/crash-triage.md` | | `MXCPUExceptionDiagnostic` | CPU runaway above system threshold | Wire MetricKit as a **sink** in your telemetry facade (per `telemetry-facade-pattern`) — a `MetricKitSink` that subscribes to `MXMetricManager.shared` and broadcasts payloads as `TelemetryEvent` instances. This keeps MetricKit wiring out of `AppDelegate` and testable via protocol injection. Note that MetricKit complements but does not replace the analytics tracking covered in `apple-three-piece-analytics`: MetricKit is system-generated aggregate performance data, not user behaviour events. ## `XCTMetric` and `measure {}` baselines in CI For the `testScrollPerformance` sample wiring `XCTOSSignpostMetric.scrollDecelerationMetric`, `XCTMemoryMetric`, and `XCTCPUMetric` into `measure {}`, read `references/samples.md`. `measure {}` runs the block `iterationCount + 1` times (default 5 recorded + 1 discarded warm-up) and records the mean of the recorded runs. On first run, set the baseline via the inline editor in Xcode. Subsequent runs fail on either of two independent thresholds, both configurable per metric: **Max % Relative Standard Deviation** (default 10%) and **Max % Deviation** from the baseline average (default 10%) — exceeding either one fails the test; it is not a single product formula. Commit baselines in `.xcbaseline` files alongside the test file. For server-side CI (where a physical display is unavailable), use `XCTCPUMetric` and `XCTMemoryMetric` in unit tests that exercise logic without UIKit rendering. UI performance metrics require a simulator or device with an active display session. ## Verification checklist - Profile with Instruments before claiming a fix; never tune by guessing. - Time Profiler run completed; hot paths on the main thread identified and either offloaded or bounded. - Allocations generation diff shows no unbounded growth across repeated user actions. - SwiftUI instrument checked for unexpected body re-render counts on the primary screen. - `os_signpost` intervals added around any operation expected to take > 16 ms. - `MXMetricManagerSubscriber` registered in the composition root; payloads forwarded to the telemetry sink. - `XCTMetric` baseline committed for the primary performance-sensitive test; CI fails on regression. - No synchronous network or file I/O on the main thread (audited via the Hangs instrument, Time Profiler, and `os_signpost` around suspect call sites — Thread Sanitizer only detects data races and will not flag this). - Image assets decoded at display resolution, not source resolution. - Binary size measured with `-Osize` before each major release; asset catalog slices verified. ## Related skills - `telemetry-facade-pattern`: wire `MetricKitSink` as one sink in the fan-out facade; keep `MXMetricManagerSubscriber` registration out of `AppDelegate`. - `apple-three-piece-analytics`: decides *which* Apple-only sources (ASC Analytics / MetricKit / Game Center) to rely on and whether a third-party SDK is justified; this skill owns *reading and acting on* MetricKit payloads for performance diagnosis. - `swift6-concurrency`: moving work off `@MainActor` correctly requires understanding actor isolation, `Task.detached`, and `Sendable` constraints — the primary tool for eliminating main-thread hangs. - `swiftui-expert:swiftui-expert-skill` (aggregated external): for the **SwiftUI body-re-render** slice specifically, it ships an Instruments `.trace` analysis toolchain — prefer it for that profiling. This skill owns the broader surface (Time Profiler / Allocations / hangs / launch / memory / binary size / MetricKit / XCTMetric). - `apple-skills:guide-swiftui-performance-audit` (aggregated external): code-first SwiftUI review (view-update causes, layout thrash) with user-run Instruments; this skill owns measurement (Instruments/xctrace/MetricKit/XCTMetric) and the non-SwiftUI surface (launch, memory, binary size). - Official sources: when verifying or updating a factual or version-sensitive claim, read `references/official-docs.md`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.