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.
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. In simiple terms, Rejourney only captures screenshots on maps when it is idle and not being panned or zoomed.
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.
SpecialCases.swift
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: