MKMapview overlay renderding performance issue on iOS16

Hi ,

I have following scenario where I feel performance issue.

Use-case:

I have multiple Overlays(MKOverlay) rendered on MapView, and overlay needs to refresh on point Drag(MKPinAnnotation). I have custom logic to handle drag behaviour of annotation, on annotation drag I do update the overlay. As point update, I create new overlay with updated coordinate and re-render it. iT slow down the performance after few overlay added.

Additional Notes: Performance was quite good on iOS16 but on iOS17, it lags the perforce on point drag. When I say it the performance, it point drag lags so it slow the overlay rendering.

I am using MKMapView inside SwiftUI.

I am sharing code-snippet where it re-render the overlay. Please help with issue in my code implementation.

func renderSegments(mapView: MKMapView, segmentPoint: FencePointAnnotation, renderNeeded: Bool = true) {
    mapViewModel.updateFencePointOrder()
    
    guard let activeLayer = mapViewModel.activeLayer else {
        debugPrint("Invalid active layer.")
        return
    }
    let segments = mapViewModel.activeFence.connectedSegmentsOf(vertex: segmentPoint)
    
    // Remove existing overlay.
    for overlay in mapView.overlays {
        if let overlay = overlay as? FenceOverlay {
            if overlay.layerId == activeLayer.layerId {
                mapView.removeOverlay(overlay)
            }
        } else if let overlay = overlay as? FenceSegmentPolyline {
            if overlay.layerId == activeLayer.layerId {
                for segment in segments.values where segment.identifier == overlay.identifier {
                    mapView.removeOverlay(overlay)
                }
            }
        }
    }
    
    // When vertex removed the no need to add segment
    if renderNeeded {
        if let segments = mapViewModel.updatedSegements(segment: segments.map({$0.key})) {
            let updatedSegments = mapView.updatedSegmentsWithOffset(segments: segments, layer: activeLayer)
            mapView.addOverlays(updatedSegments)
        }
    }
}

Attaching Timer Profiler Screenshot.

There's a lot going on here, so let's unpack a few different concepts for you to consider.

The first one relates to your type FenceSegmentPolyline. This sounds like it could be a good use for MKMultiPolyline and MKMultiPolylineRenderer as your rendering object, as I'm assuming there will be many fence segments that often use the same styling to render. This can produce a significant reduction in the number of renderer objects required to stroke the style for your polyline, and may give you a performance boost when there's a lot of identical rendering for the system to draw.

The second point is with the pattern of removing all overlays, and then putting them all back if that rendering flag is true. This can be pretty expensive, as you could be asking MapKit to re-evaluate a lot of overlays. Since overlays can have transparency, plus there is an order to them, this type of bulk removal just to then re-add them means that MapKit needs to effectively reevaluate all of the overlays multiple times, since it needs to redisplay the map with the overlays removed (and if there are overlays that remain, draw then again because the appearance may be different due to other overlays over or under considering transparency levels), and then do that evaluation again when things are re-added.

Depending on how common this operation is, sometimes doing a customized renderer is better, allowing you to more finely target regions of the overlay to redraw, which can shrink the amount of work required of the system to update the map. We have examples of this approach in sample code.

The third thing to watch out for is how you trigger these operations via your UIViewRepresentable with respect to integrating into SwiftUI. There's not anything in the code or trace screenshot for me to know if this is a problem for you or not. One thing that can be a surprise performance bottleneck is if you have significantly sized data objects with a lot of unrelated properties binding to SwiftUI views with ObservableObject. In such a scenario, one data change to any property may mean that the view needs to be reevaluated by SwiftUI, and if such data is changing with routine frequency, then you may wind up re-rendering the view with the MKMapView an unexpected number of times. The more recent Observable paradigm helps you avoid this, and there are also dedicated profiling tools for SwiftUI inside of Instruments that help you understand how many times various Views in your app are evaluated.

— Ed Ford,  DTS Engineer

struct UIMapView: UIViewRepresentable { typealias UIViewType = MKMapView

// MARK: Properties
@ObservedObject var mapViewModel: MapViewModel

@State private(set) var mapViewEvents: PassthroughSubject<MapViewActionType, Never>? = PassthroughSubject()
@State private(set) var magnifierEvents: PassthroughSubject<MagnifierHotPoint, Never>? = PassthroughSubject()

private(set) var mapView = MKMapView()
private let kAutoSnapDelta = 1.0
    
// MARK: - UIViewRepresentable
func makeCoordinator() -> UIMapCoordinator {
    return UIMapCoordinator(for: self)
}

func makeUIView(context: Context) -> UIViewType {
    configureMapView(context: context)
    
    DispatchQueue.main.async {
        setupSubscribers(context: context)
        context.coordinator.setupUserDefaultsObserver()
    }
    
    return mapView
}

func updateUIView(_ mapView: MKMapView, context: Context) {
    configureMapView(context: context, renderingMapView: mapView)
    handleMapLayerRendering(context: context, on: mapView)
}

// MARK: - Private Methods
private func configureMapView(context: Context, renderingMapView: MKMapView? = nil) {
    let mapView = renderingMapView ?? mapView
    mapView.delegate = context.coordinator
    mapView.userTrackingMode = .none
    mapView.showsCompass = true
    if mapView.mapType != mapViewModel.mapType.mkType {
        mapView.mapType = mapViewModel.mapType.mkType
    }
    mapView.tag = 10001 // tag to identity view
    let cameraZoomRange = MKMapView.CameraZoomRange(minCenterCoordinateDistance: 5, maxCenterCoordinateDistance: 2000)
    mapView.setCameraZoomRange(cameraZoomRange, animated: false)
}

private func setupSubscribers(context: Context) {
    context.coordinator.setupSubscribers(
        mapViewEvents: mapViewEvents,
        magnifierEvents: magnifierEvents
    )
}

private func handleMapLayerRendering(context: Context, on mapView: MKMapView) {
    switch mapViewModel.mapLayerRendering {
    case .none:
        break
        
    case .all:
        renderGeometries(mapView: mapView)
        zoomToActiveFence(mapView: mapView, viewMode: mapViewModel.mapViewMode)
        addSitePinAnnotation(siteModel: mapViewModel.parentSite)
        mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
        
    case .refreshLayers:
        reRenderAllGeometries(mapView: mapView)
        // No need to set 'mapLayerRendering' to none, as it is done in reRenderAllGeometries
        
    case .renderLayer(let layerId):
        if let layer = mapViewModel.layers.first(where: { $0.layerId == layerId }), !layer.hidden {
            renderGeometriesForLayer(layerId: layerId, mapView: mapView)
            zoomToActiveFence(mapView: mapView, viewMode: mapViewModel.mapViewMode)
        }
        mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
        
    case .renderForDeletedLayer(let layerId):
        removeGeometry(with: layerId, mapView: mapView)
        mapViewModel.setMapLayerRenderingMode(to: .none)
        
    case .toggleLayer(let layerId, let hidden):
        if hidden {
            removeGeometry(with: layerId, mapView: mapView)
        } else {
            renderGeometriesForLayer(layerId: layerId, mapView: mapView)
        }
        mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
        
    case .vertexDrawing(let layerId, let hidden):
        if hidden {
            lockLayer(with: layerId, mapView: mapView)
        } else {
            unlockLayer(with: layerId, mapView: mapView)
            renderGeometriesForLayer(layerId: layerId, mapView: mapView)
        }
        mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
        
    case .selectAnnotation(let gateModel):
        setGateSelection(mapView: mapView, gateModel: gateModel)
        mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
        
}

// MARK: - Configuring view state

private func configureMapView(_ mapView: UIViewType, context: UIViewRepresentableContext<UIMapView>) {
    if mapView.mapType != mapViewModel.mapType.mkType {
        mapView.mapType = mapViewModel.mapType.mkType

        // Since the map type has been changed, change magnifier preview
        context.coordinator.startMagnifierFromCurrentContext()
    }
    mapView.showsUserLocation = mapViewModel.showsUserlocation
    
    // Update Magnifier POI(hot point) if it is active.
    // It will updated on active layer change as well
    context.coordinator.updateMagnifierPOI()
}

Hi,

I have made changes but I am not sure how 2 overlay rendering lags. I can understand when we have array of overlays. I am attaching video link here which depict the behaviour. It has only TWO overlays (FenceSegmentPolyline: MKPolyline) and performance is lagging.

Let me know if it help. I am also sharing UIViewRepresentable Mapview.

DropBox Link : https://www.dropbox.com/scl/fi/o91nz6gd8gts4f5v0fiqk/Overlay_behaviour_recording.mov?rlkey=bb9oq4i01meli17onhwonk83i&dl=0


struct UIMapView: UIViewRepresentable {
    typealias UIViewType = MKMapView
    
    // MARK: Properties
    @ObservedObject var mapViewModel: MapViewModel
    
    @State private(set) var mapViewEvents: PassthroughSubject<MapViewActionType, Never>? = PassthroughSubject()
    @State private(set) var magnifierEvents: PassthroughSubject<MagnifierHotPoint, Never>? = PassthroughSubject()

    private(set) var mapView = MKMapView()
    private let kAutoSnapDelta = 1.0
        
    // MARK: - UIViewRepresentable
    func makeCoordinator() -> UIMapCoordinator {
        return UIMapCoordinator(for: self)
    }
    
    func makeUIView(context: Context) -> UIViewType {
        configureMapView(context: context)
        
        DispatchQueue.main.async {
            setupSubscribers(context: context)
            context.coordinator.setupUserDefaultsObserver()
        }
        
        return mapView
    }
    
    func updateUIView(_ mapView: MKMapView, context: Context) {
        configureMapView(context: context, renderingMapView: mapView)
        handleMapLayerRendering(context: context, on: mapView)
    }
    
    // MARK: - Private Methods
    private func configureMapView(context: Context, renderingMapView: MKMapView? = nil) {
        let mapView = renderingMapView ?? mapView
        mapView.delegate = context.coordinator
        mapView.userTrackingMode = .none
        mapView.showsCompass = true
        if mapView.mapType != mapViewModel.mapType.mkType {
            mapView.mapType = mapViewModel.mapType.mkType
        }
        mapView.tag = 10001 // tag to identity view
        let cameraZoomRange = MKMapView.CameraZoomRange(minCenterCoordinateDistance: 5, maxCenterCoordinateDistance: 2000)
        mapView.setCameraZoomRange(cameraZoomRange, animated: false)
    }
    
    private func setupSubscribers(context: Context) {
        context.coordinator.setupSubscribers(
            mapViewEvents: mapViewEvents,
            magnifierEvents: magnifierEvents
        )
    }
    
    private func handleMapLayerRendering(context: Context, on mapView: MKMapView) {
        switch mapViewModel.mapLayerRendering {
        case .none:
            break
            
        case .all:
            renderGeometries(mapView: mapView)
            zoomToActiveFence(mapView: mapView, viewMode: mapViewModel.mapViewMode)
            addSitePinAnnotation(siteModel: mapViewModel.parentSite)
            mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
            
        case .refreshLayers:
            reRenderAllGeometries(mapView: mapView)
            // No need to set 'mapLayerRendering' to none, as it is done in reRenderAllGeometries
            
        case .renderLayer(let layerId):
            if let layer = mapViewModel.layers.first(where: { $0.layerId == layerId }), !layer.hidden {
                renderGeometriesForLayer(layerId: layerId, mapView: mapView)
                zoomToActiveFence(mapView: mapView, viewMode: mapViewModel.mapViewMode)
            }
            mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
            
        case .renderForDeletedLayer(let layerId):
            removeGeometry(with: layerId, mapView: mapView)
            mapViewModel.setMapLayerRenderingMode(to: .none)
            
        case .toggleLayer(let layerId, let hidden):
            if hidden {
                removeGeometry(with: layerId, mapView: mapView)
            } else {
                renderGeometriesForLayer(layerId: layerId, mapView: mapView)
            }
            mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
            
        case .vertexDrawing(let layerId, let hidden):
            if hidden {
                lockLayer(with: layerId, mapView: mapView)
            } else {
                unlockLayer(with: layerId, mapView: mapView)
                renderGeometriesForLayer(layerId: layerId, mapView: mapView)
            }
            mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
            
        case .selectAnnotation(let gateModel):
            setGateSelection(mapView: mapView, gateModel: gateModel)
            mapViewModel.setMapLayerRenderingModeonAsyncThread(to: .none)
            
    }
    
    // MARK: - Configuring view state

    private func configureMapView(_ mapView: UIViewType, context: UIViewRepresentableContext<UIMapView>) {
        if mapView.mapType != mapViewModel.mapType.mkType {
            mapView.mapType = mapViewModel.mapType.mkType

            // Since the map type has been changed, change magnifier preview
            context.coordinator.startMagnifierFromCurrentContext()
        }
        mapView.showsUserLocation = mapViewModel.showsUserlocation
        
        // Update Magnifier POI(hot point) if it is active.
        // It will updated on active layer change as well
        context.coordinator.updateMagnifierPOI()
    }

Could you post a link to a small, buildable test project with the code you shared above? There seems to be other parts missing, like the original code that adds and removes overlays, and the implementation here of reRenderAllGeometries, so I'm not sure I have the complete picture yet. A buildable project would let me run and also profile this to understand it better.

If you're not familiar with preparing a test project, take a look at Creating a test project.

— Ed Ford,  DTS Engineer

MKMapview overlay renderding performance issue on iOS16
 
 
Q