All posts

Engineering · UIKit · Debugging

17 ways to debug UIKit

No theory lecture. Every technique below is: what it's for, the exact code or command, and when to reach for it. Bookmark it, search it, ship your fix.

Muhammad Afham · July 2026 · Tested against Xcode 26 · 12 min read

Ordered by frequency, not difficulty. The techniques you'll actually reach for daily are at the top. The rare, staff-level ones that save an incident once a year are at the bottom. Ctrl/Cmd+F your symptom if you already know what you're chasing.

Reach for these daily

01

Structured logging with OSLog

Replace print() the moment a bug survives one rebuild. Logs persist on-device and are filterable by category, even without Xcode attached.

import OSLog
let log = Logger(subsystem: "dev.mafham.app", category: "checkout")
log.error("Payment failed: \(error.localizedDescription)")
02

Read state without recompiling

v reads straight from debug info. Faster and more reliable than p when you just need a value.

(lldb) v myArray.count
(lldb) po myViewController.view
03

Log-and-continue action (no rebuild)

Turn any breakpoint into a live print statement you can edit while the app keeps running. The single most underused feature in Xcode.

// Breakpoint → Add Action → Log Message, then check "Automatically continue":
count is @count@, user is @user.id@
04

Exception breakpoint

Add this once per project, on day one. Stops at the line that threw, not the useless main.swift crash you get by default.

// Breakpoint navigator → + → Exception Breakpoint → All, Objective-C
05

Conditional breakpoint

Stop only on the one row, the one user ID, the one edge case, out of thousands of calls.

// Right-click any breakpoint → Edit Breakpoint → Condition:
indexPath.row == 42 && items[indexPath.row].isExpired
06

Debug View Hierarchy

Debug bar's three-layer icon. Pauses the app and gives you an exploded 3D model of every view on screen. The fastest answer to "is my view actually there?"

Less obvious bugs

07

Mutate the running app live

Test a visual fix before writing a line of code. CATransaction.flush() forces the redraw while still paused.

(lldb) e label.textColor = .systemRed
(lldb) e CATransaction.flush()
08

Deinit tripwire

Fastest way to confirm a memory leak before reaching for heavier tools.

deinit { print("💀 ProfileVC gone") }
// push, pop. No skull printed = confirmed leak.
09

Break a retain cycle

Applies to escaping closures, delegates, and timers, in that order of frequency.

self.onUpdate = { [weak self] in self?.refresh() }
weak var delegate: SomeDelegate?
// Timers retain their target through the run loop. Invalidate in viewDidDisappear:
timer?.invalidate()

The untappable button

A button is visible, looks correct, does nothing when tapped. This is always a hit-testing failure, and hit-testing is one algorithm with exactly four gates:

Touch arrives at UIWindow.hitTest Skipped if hidden, alpha under 0.01, or user interaction disabled point(inside:) must pass for the view AND every ancestor Deepest passing subview wins the touch
Fig 1. Every "dead button" fails one of these four gates.
10

Interrogate the button directly

Select it in Debug View Hierarchy, grab its address, ask it the three questions that matter.

(lldb) e let $b = unsafeBitCast(0x7fa2c8d09e30, to: UIButton.self)
(lldb) p $b.isUserInteractionEnabled
(lldb) p $b.alpha
(lldb) p $b.superview!.bounds.contains($b.frame.origin)
11

Check for a gesture recognizer stealing the touch

The sneakiest cause: everything above checks out, but a parent's tap recognizer swallows the touch first.

(lldb) po $b.window?.gestureRecognizers
// then walk each ancestor's .gestureRecognizers the same way
ViewController holds: onUpdate closure Closure context captures: self (strong) Fix: [weak self]. The cycle can't close.
Fig 2. Neither object deallocates. Each keeps the other alive.

Rarely needed

12

Print the full autolayout trace

Finds ambiguous layout, views that could be in more than one valid position, without touching any UI.

(lldb) expr -l objc -O -- [[UIWindow keyWindow] _autolayoutTrace]
// flags every view: "AMBIGUOUS LAYOUT" if it applies
13

Recursive view description

Dump the entire view tree as text when the visual debugger is overkill for a quick check.

(lldb) expr -l objc -O -- [[UIWindow keyWindow] recursiveDescription]
14

Symbolic breakpoint on framework internals

Break inside Apple's own code, without the source. The dedicated tool for constraint conflicts.

// Breakpoint navigator → + → Symbolic Breakpoint → Symbol:
UIViewAlertForUnsatisfiableConstraints
15

Memory Graph Debugger

When the deinit tripwire confirms a leak but you don't know what's holding it. The three-circles icon in the debug bar draws every strong reference as a graph. Cycles render as a closed ring.

16

Signposts for custom timing

See your own code's cost inside an Instruments trace, named and labeled, not guessed from a stack sample.

let signposter = OSSignposter(subsystem: "dev.mafham.app", category: "feed")
let state = signposter.beginInterval("decode-page")
decodePage()
signposter.endInterval("decode-page", state)
17

Symbolicate a crash by hand

When the automated pipeline is down mid-incident and you have a raw address and a dSYM. Staff-level, but it ends incidents fast when nothing else can.

atos -arch arm64 \
  -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
  -l 0x100e34000 0x100e5b3c4
// → CheckoutViewModel.applyCoupon(_:) (CheckoutViewModel.swift:214)

Need help with a bug?

I mentor iOS engineers on exactly this: gnarly bugs, performance work, and the road from mid to senior. First session is free.

Book a free 1:1 Connect on LinkedIn