TL;DR: How to capture and analyze Xcode Instruments Time Profiler traces for the iOS simulator build: attach withxctrace record --template 'Time Profiler' --attach <pid>, export thetime-profileschema, and extract main-thread call stacks with the included Python script. Traces live intest-logs/(git-ignored), named<feature>-<scenario>-<duration>.trace. See Interpreting results for common hotspot patterns and fixes already applied to this codebase.
Use this guide to capture, analyze, and act on Xcode Instruments Time Profiler traces for the ALPA Mobile iOS simulator target.
| Tool | Install / verify |
|---|---|
| Xcode Command Line Tools | xcode-select --install |
xctrace | Ships with Xcode — confirm with xctrace version |
| .NET MAUI simulator build | dotnet build … -f net10.0-ios -r iossimulator-arm64 |
# Build Debug for simulator
dotnet build ALPAMobile/ALPAMobile.csproj \
-f net10.0-ios -r iossimulator-arm64 -c Debug
# Launch the simulator app and note the PID printed to the console
# (or read it with: xcrun simctl launch <device-uuid> org.alpa.alpaMobile)
Traces are written to test-logs/ (git-ignored).
# List available simulators
xcrun simctl list devices booted
# Record 120 s — attach to a running PID
xctrace record \
--template 'Time Profiler' \
--device <simulator-uuid> \
--attach <pid> \
--time-limit 120s \
--output 'test-logs/<name>.trace' \
--no-prompt
Naming convention:test-logs/<feature>-<scenario>-<duration>.trace
e.g.test-logs/login-to-home-120s.trace
xcrun simctl list | grep 'iPhone 17 Pro Max'
# Table of contents: see all schemas in the trace
xctrace export \
--input test-logs/<name>.trace \
--toc 2>/dev/null | less
# Export potential hangs
xctrace export \
--input test-logs/<name>.trace \
--xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]' \
2>/dev/null | grep -o 'start-usecs="[^"]*"\|duration-usecs="[^"]*"' | head -20
The time-profile schema contains pre-symbolicated frames — no dSYM wrangling required for simulator builds.
# Export to file first (5–6 MB typical)
xctrace export \
--input test-logs/<name>.trace \
--xpath '/trace-toc/run[@number="1"]/data/table[@schema="time-profile"]' \
2>/dev/null > /tmp/time_profile.xml
Then use this analysis script. The key lookup chain is:
frame id="N" name="SYMBOL" → build {id: name} dictbacktrace id="N" → list of frame id/ref elementssample-time + thread ref="<main-thread-id>" in each <row> → samplebacktrace ref="N" against the dict from step 2import re
with open('/tmp/time_profile.xml') as f:
data = f.read()
# 1. Symbol table
frame_names = {m.group(1): m.group(2)
for m in re.finditer(r'<frame id="(\d+)" name="([^"]+)"', data)}
# 2. Backtrace table
backtrace_frames = {}
for m in re.finditer(r'<backtrace id="(\d+)">(.*?)</backtrace>', data, re.DOTALL):
fids = re.findall(r'<frame[^>]+(?:id|ref)="(\d+)"', m.group(2))
backtrace_frames[m.group(1)] = fids
# 3. Find main thread ID
thread_fmts = {m.group(1): m.group(2)
for m in re.finditer(r'<thread id="(\d+)"[^>]*fmt="([^"]+)"', data)}
main_ids = {tid for tid, fmt in thread_fmts.items() if 'Main Thread' in fmt}
# 4. Collect samples in a time window (nanoseconds since trace start)
hang_start = 49_210_615_958
hang_end = 49_471_613_375
rows = data.split('</row>\n<row>')
samples = []
for row in rows:
ts_m = re.search(r'<sample-time[^>]*>(\d+)</sample-time>', row)
if not ts_m or not (hang_start <= int(ts_m.group(1)) <= hang_end):
continue
if not any(f'thread ref="{tid}"/>' in row for tid in main_ids):
continue
bt_m = re.search(r'<backtrace[^>]+(?:id|ref)="(\d+)"', row)
if bt_m:
samples.append(bt_m.group(1))
# 5. Print top stacks
seen = {}
for bt_id in samples:
seen[bt_id] = seen.get(bt_id, 0) + 1
for bt_id, count in sorted(seen.items(), key=lambda x: -x[1])[:5]:
print(f'\n{count}x:')
for fid in backtrace_frames.get(bt_id, [])[:15]:
print(' ', frame_names.get(fid, f'ref:{fid}'))
xctrace export \
--input test-logs/<name>.trace \
--xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]' \
2>/dev/null > /tmp/hangs.xml
grep -o 'start-usecs="[^"]*"\|duration-usecs="[^"]*"\|Main Thread' /tmp/hangs.xml
Timestamps in the time-profile XML use nanoseconds from trace start. Convert: hang_start_ns = start_usecs * 1000.
| Pattern | Likely cause |
|---|---|
mono_interp_exec_method at top of all stacks | Mono interpreter executing managed code — find the UIKit entry point below it |
UICollectionView _updateVisibleCellsNow → Mono | Cell creation or ItemsSource swap on main thread |
UITextField / UIKeyboardStateManager on nav | Keyboard state churn during page transition |
_UINavigationBarVisualProviderModernIOS … | Navigation bar layout recalculation |
reloadData → Mono | ItemsSource replaced with a new collection instance unnecessarily |
| Fix | File | Hotspot |
|---|---|---|
HideSoftInputOnTapped="True" | Pages/LoginPage.xaml | UITextField/Keyboard −36–40% |
ItemSizingStrategy="MeasureFirstItem" | Pages/HomePage.xaml | CollectionView layout −50% |
ID-equality guard before MenuItems = | ViewModels/HomePageViewModel.cs | DocumentsReadyMessage 261 ms hang eliminated |
When validating a fix, capture a before trace without the fix and an after trace with the fix under identical test conditions, then compare sample counts for the same hotspot symbol.
before = 300 # raw count in baseline trace
after = 258 # raw count in fixed trace
total_before = 13000
total_after = 13200
norm_before = before / total_before * 10000
norm_after = after / total_after * 10000
print(f'Before: {norm_before:.2f} After: {norm_after:.2f} Delta: {(norm_after - norm_before)/norm_before*100:+.1f}%')
test-logs/ (root of the repo)test-logs/ is git-ignored — traces are large binary bundles not suitable for VCS<feature>-<scenario>-<duration>.trace