From 536051dbf10142aa262e754589078b613cf57542 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 12:53:45 -0800 Subject: [PATCH] Build no marker icons before the MarkerListIdentity gate on MapKit MapKitMapView.Coordinator.updateContent rebuilt two dictionaries over every marker on each call, before syncMarkers reached its MarkerListIdentity gate: one with each marker's state, and one with each marker's BitmapIcon, which read the @Published icon and, for markers with no icon, made a fresh DefaultMarkerIcon() and drew it again. SwiftUI calls updateContent on every body evaluation, camera moves included, so a resent identical list paid for all of it although the gate then threw the work away. Cost of one updateContent with the same content, 144,183 markers, simulator, best of 15: Debug Release 1 in 4 with no icon 2970 ms -> 12.6 ms 2608 ms -> 0.04 ms every marker an icon 111 ms -> 27.0 ms 61 ms -> 0.14 ms Neither dictionary is needed. The state cache duplicated what MapKitMarkerController already keeps from its last processed list (markerStatesById, filled synchronously when the gate lets a list through, before add() runs), now exposed as syncedMarkerState(for:). The icon cache is replaced by reading the icon off the state in mapView(_:viewFor:); toBitmapIcon() keeps the drawn image per icon, and the no-icon marker shares one default image. Reading the current state also means an annotation view recreated after an in-place icon change shows the new icon, rather than whatever the cache held at the last body evaluation. The other iOS providers already put the gate first. Checked on the iPad (Debug): the marker basic and street tree pages look the same as before, and on a check page a new marker state under the same id and an in-place icon change both show up as before. --- .../MapConductorForMapKit/MapKitMapView.swift | 44 +++++++++---------- .../marker/MapKitMarkerController.swift | 6 +++ 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/Sources/MapConductorForMapKit/MapKitMapView.swift b/Sources/MapConductorForMapKit/MapKitMapView.swift index 763dedd..a208aed 100644 --- a/Sources/MapConductorForMapKit/MapKitMapView.swift +++ b/Sources/MapConductorForMapKit/MapKitMapView.swift @@ -186,11 +186,9 @@ private struct MapKitMapViewRepresentable: UIViewRepresentable { private weak var draggingAnnotationView: MKAnnotationView? private var dragDisplayLink: CADisplayLink? - // Store icon for each marker state to provide to delegate (best-effort cache). - // `mapView(_:viewFor:)` should not depend on this cache because it can be called - // before `updateContent` finishes populating it. - private var markerIcons: [String: BitmapIcon] = [:] - private var markerStates: [String: MarkerState] = [:] + /// アイコンを持たないマーカーの絵。1 枚を使い回す(`DefaultMarkerIcon()` を毎回作ると + /// そのたびに描き直しになる)。 + private let defaultBitmapIcon = DefaultMarkerIcon().toBitmapIcon() private lazy var strategyManager = StrategyMarkerManager( makeRenderer: { [weak self] strategy in @@ -362,8 +360,6 @@ private struct MapKitMapViewRepresentable: UIViewRepresentable { groundImageController = nil overlayScope?.clear() overlayScope = nil - markerIcons.removeAll() - markerStates.removeAll() strategyMarkerIcons.removeAll() strategyManager.clear() @@ -376,15 +372,11 @@ private struct MapKitMapViewRepresentable: UIViewRepresentable { } infoBubbleCoordinator?.syncInfoBubbles(content.infoBubbles) - // Prime marker caches before triggering MKMapView annotation creation. - // (MapKit can ask for annotation views immediately after addAnnotation.) - markerIcons = content.markers.reduce(into: [:]) { dict, marker in - dict[marker.id] = (marker.state.icon ?? DefaultMarkerIcon()).toBitmapIcon() - } - markerStates = content.markers.reduce(into: [:]) { dict, marker in - dict[marker.id] = marker.state - } - + // マーカーは門番(MarkerListIdentity、syncMarkers の入口)を通るものだけを触る。 + // ここで全マーカーのアイコンや辞書を作ると、同じ一覧の再送でも body の評価ごとに + // 14.4 万本ぶん(@Published の icon の読み出しと、アイコンを持たないマーカーの + // 描き直し)が走り、門番が意味を失う。アイコンは `mapView(_:viewFor:)` が + // その場で state から引き、追加直後の state はコントローラが同期的に持っている。 markerController?.tilingOptions = content.markerTilingOptions markerController?.syncMarkers(content.markers) overlayScope?.circleCollector.sync(content.circles.map { $0.state }) @@ -513,17 +505,20 @@ private struct MapKitMapViewRepresentable: UIViewRepresentable { let mapConductorAnnotation = pointAnnotation as? MapConductorPointAnnotation let markerIdString = mapConductorAnnotation?.markerId ?? pointAnnotation.title ?? "" let markerState = markerController?.getMarkerState(for: markerIdString) - ?? markerStates[markerIdString] + ?? markerController?.syncedMarkerState(for: markerIdString) ?? strategyManager.controller?.markerManager.getEntity(markerIdString)?.state ?? mapConductorAnnotation?.markerState - let cachedIcon = markerIcons[markerIdString] ?? mapConductorAnnotation?.initialBitmapIcon - ?? strategyMarkerIcons[markerIdString] + // アイコンは state の今の値から引く(`toBitmapIcon()` はアイコンごとに描いた絵を + // 覚えているので軽い)。in-place で差し替えたアイコンも、ビューが作り直されたときに + // 古い絵へ戻らない。 + let stateIcon = markerState.map { $0.icon?.toBitmapIcon() ?? defaultBitmapIcon } + let fallbackIcon = mapConductorAnnotation?.initialBitmapIcon ?? strategyMarkerIcons[markerIdString] // If this isn't one of our markers, allow MapKit's default behavior. - if markerState == nil, cachedIcon == nil { return nil } + if markerState == nil, fallbackIcon == nil { return nil } // Avoid returning nil here; returning nil makes MapKit fall back to the default pin annotation. - let resolvedIcon = cachedIcon ?? (markerState?.icon ?? DefaultMarkerIcon()).toBitmapIcon() + let resolvedIcon = stateIcon ?? fallbackIcon ?? defaultBitmapIcon let identifier = "MapKitMarker" let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) @@ -531,7 +526,8 @@ private struct MapKitMapViewRepresentable: UIViewRepresentable { annotationView.annotation = annotation if let markerState { - if markerController?.getMarkerState(for: markerIdString) != nil || markerStates[markerIdString] != nil { + if markerController?.getMarkerState(for: markerIdString) != nil + || markerController?.syncedMarkerState(for: markerIdString) != nil { markerController?.renderer.configureAnnotationView(annotationView, for: markerState, bitmapIcon: resolvedIcon) } else { strategyManager.renderer?.configureAnnotationView(annotationView, for: markerState, bitmapIcon: resolvedIcon) @@ -559,7 +555,7 @@ private struct MapKitMapViewRepresentable: UIViewRepresentable { guard let pointAnnotation = view.annotation as? MKPointAnnotation, let markerId = pointAnnotation.title, let markerState = markerController?.getMarkerState(for: markerId) - ?? markerStates[markerId] + ?? markerController?.syncedMarkerState(for: markerId) ?? strategyManager.controller?.markerManager.getEntity(markerId)?.state else { return } @@ -580,7 +576,7 @@ private struct MapKitMapViewRepresentable: UIViewRepresentable { guard let pointAnnotation = view.annotation as? MKPointAnnotation, let markerId = pointAnnotation.title, let markerState = markerController?.getMarkerState(for: markerId) - ?? markerStates[markerId] else { + ?? markerController?.syncedMarkerState(for: markerId) else { return } diff --git a/Sources/MapConductorForMapKit/marker/MapKitMarkerController.swift b/Sources/MapConductorForMapKit/marker/MapKitMarkerController.swift index 5833a1f..ba3fc19 100644 --- a/Sources/MapConductorForMapKit/marker/MapKitMarkerController.swift +++ b/Sources/MapConductorForMapKit/marker/MapKitMarkerController.swift @@ -105,6 +105,12 @@ final class MapKitMarkerController: AbstractMarkerController MarkerState? { + markerStatesById[id] + } + func getIcon(for state: MarkerState) -> BitmapIcon { let resolvedIcon = state.icon ?? DefaultMarkerIcon() return resolvedIcon.toBitmapIcon()