Back to Engineering Log
Feb 17, 20264 min read

120Hz Map Performance: Hooking Native SDKs

Solving micro-stutter on Apple Maps, Google Maps, and Mapbox via delegate swizzling.

M

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 CHALLENGE

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.
THE SOLUTION

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).

iOS (Swift)

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.

MKMapViewDelegate
regionWillChangeAnimated -> PAUSE
regionDidChangeAnimated -> CAPTURE
Android (Kotlin)

We use dynamic proxies to intercept the `OnCameraIdleListener`. This allows us to wake up our visual capture engine exactly when the map settles.

GoogleMap.OnCameraIdleListener
onCameraIdle() -> SNAPSHOT NOW

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:

0ms
Main Thread Block
During active map gestures
High
Frame Integrity
No tearing or half-rendered tiles
Auto
Detection
Works for Mapbox, Google, Apple