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.
Reach for these daily
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)")
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
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@
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
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
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
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()
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.
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:
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)
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
Rarely needed
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
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]
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
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.
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)
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