120Hz Map Performance: Hooking Native SDKs
Solving micro-stutter on Apple Maps, Google Maps, and Mapbox via delegate swizzling.
Capturing high-fidelity session replays of native map views (Apple Maps, Google Maps, Mapbox) on 120Hz "ProMotion" screens is notoriously difficult. The standard approach of repeatedly snapshotting the view hierarchy often leads to micro-stutters and tearingbecause the capture loop fights with the map's own aggressive rendering loop.
At Rejourney, we discovered that simply scheduling captures on a timer wasn't enough. To achieve buttery-smooth 120Hz performance while recording, we had to get deeper:Hooking the native map SDK rendering delegates.
The 120Hz Conflict
Modern map SDKs drive the GPU hard. On an iPhone 15 Pro, a map sitting idle might be efficient, but the moment a user pans or zooms, the map engine locks the main thread's render server to maintain 120 FPS.
If a session replay SDK tries to force a `drawHierarchy` or `snapshotView` call in the middle of this gesture, two things happen:
- Dropped Frames (Stutter): The map renderer blocks waiting for the snapshot to complete, causing a visible hitch in the user's scroll.
- Visual Artifacts: The snapshot might capture a half-rendered buffer state.
Delegate Swizzling & Hooking
Instead of guessing when to capture, we ask the Map SDK itself. We reverse-engineered the delegate lifecycles of the major map providers to identify the exact moments when the map is Idle (safe to capture) vs. Moving (unsafe to capture).
We use method swizzling on the `delegate` property. When we detect a map, we transparently hook into the lifecycle methods to toggle our internal `mapIdle` state.
We use dynamic proxies to intercept the `OnCameraIdleListener`. This allows us to wake up our visual capture engine exactly when the map settles.
Code Analysis: SpecialCases.swift
Here is the actual logic we use to safely swizzle the Apple Maps delegate without crashing the host app. We verify response to selectors before hooking.
private func _hookAppleMapKit(_ mapView: UIView) {
guard let delegate = mapView.value(forKey: "delegate") as? NSObject else { return }
// 1. Hook regionWillChange (Movement Start)
let willChangeSel = NSSelectorFromString("mapView:regionWillChangeAnimated:")
if let original = class_getInstanceMethod(delegateClass, willChangeSel) {
let block: @convention(block) (AnyObject, AnyObject, Bool) -> Void = {
[weak self] _, _, _ in
self?.mapIdle = false // <--- PAUSE CAPTURE
// ... call original implementation ...
}
// ... swizzle implementation ...
}
// 2. Hook regionDidChange (Movement End)
let didChangeSel = NSSelectorFromString("mapView:regionDidChangeAnimated:")
if let original = class_getInstanceMethod(delegateClass, didChangeSel) {
let block: @convention(block) (AnyObject, AnyObject, Bool) -> Void = {
[weak self] _, _, _ in
self?.mapIdle = true // <--- RESUME CAPTURE
VisualCapture.shared.snapshotNow() // <--- CRITICAL: Capture immediately
// ... call original implementation ...
}
// ... swizzle implementation ...
}
}The Result: Zero Jitter
By synchronizing our capture loop with the Map SDK's own camera logic, we achieve: