Cache map data better, fix delete all dm message crash

This commit is contained in:
Garth Vander Houwen 2026-04-18 09:28:33 -07:00
parent 4a83444c13
commit 6d14a23998
5 changed files with 75 additions and 49 deletions

View file

@ -174,10 +174,20 @@ struct GeoJSONStyledFeature: Identifiable {
let id = UUID()
let feature: GeoJSONFeature
let overlayId: String
/// MKOverlay pre-computed once at init avoids repeated JSONSerialization + MKGeoJSONDecoder
/// calls on every map render pass.
let precomputedOverlay: MKOverlay?
/// Create MKOverlay from this styled feature
func createOverlay() -> MKOverlay? {
// Convert feature to standard GeoJSON format for MKGeoJSONDecoder
init(feature: GeoJSONFeature, overlayId: String) {
self.feature = feature
self.overlayId = overlayId
// Call the static helper after all stored properties are assigned so `self` is available
// for the instance but we don't actually need self here, so this is safe.
self.precomputedOverlay = GeoJSONStyledFeature.makeOverlay(for: feature)
}
/// Builds an MKOverlay from a GeoJSON feature. Static so it can be called from init.
private static func makeOverlay(for feature: GeoJSONFeature) -> MKOverlay? {
let featureDict: [String: Any] = [
"type": feature.type,
"geometry": [
@ -188,31 +198,23 @@ struct GeoJSONStyledFeature: Identifiable {
]
do {
// Serialize feature dictionary to JSON data
let geojsonData = try JSONSerialization.data(withJSONObject: featureDict)
do {
// Decode GeoJSON data into MKGeoJSONFeature objects
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature {
// Extract geometry and create overlay
if let geometry = mkFeature.geometry.first as? MKOverlay {
// Successfully created overlay
return geometry
} else {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - Geometry is not an MKOverlay.")
}
} else {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON - No valid MKGeoJSONFeature found.")
}
} catch {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON data: \(error.localizedDescription)")
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature,
let geometry = mkFeature.geometry.first as? MKOverlay {
return geometry
} else {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - no valid MKOverlay geometry.")
}
} catch {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to serialize feature dictionary to JSON: \(error.localizedDescription)")
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to build overlay: \(error.localizedDescription)")
}
return nil
}
/// Returns the pre-computed overlay. Retained for API compatibility.
func createOverlay() -> MKOverlay? { precomputedOverlay }
/// Get stroke style for this feature
var strokeStyle: StrokeStyle {
let dashArray = feature.lineDashArray

View file

@ -8,6 +8,10 @@ class GeoJSONOverlayManager {
private init() {}
private var featureCollection: GeoJSONFeatureCollection?
// Cache the last styled-features result keyed by the enabled-configs set.
// GeoJSONStyledFeature instances have stable UUIDs once created, so SwiftUI's
// ForEach diffing correctly skips unchanged overlays between renders.
private var styledFeaturesCache: (configs: Set<UUID>, features: [GeoJSONStyledFeature])?
/// Load raw GeoJSON feature collection from user uploads
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
@ -24,36 +28,35 @@ class GeoJSONOverlayManager {
return nil
}
/// Load styled features for specific enabled configs
/// Load styled features for specific enabled configs.
/// Results are cached per unique `enabledConfigs` set file I/O and JSON decoding
/// only happen when the set changes, not on every map render.
func loadStyledFeaturesForConfigs(_ enabledConfigs: Set<UUID>) -> [GeoJSONStyledFeature] {
// Get files that match the enabled configs
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
if let cache = styledFeaturesCache, cache.configs == enabledConfigs {
return cache.features
}
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
guard !enabledFiles.isEmpty else {
styledFeaturesCache = (configs: enabledConfigs, features: [])
return []
}
// Load feature collection from enabled files only
guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else {
styledFeaturesCache = (configs: enabledConfigs, features: [])
return []
}
var styledFeatures: [GeoJSONStyledFeature] = []
for feature in collection.features {
// Skip invisible features
guard feature.isVisible else {
continue
}
let layerId = feature.layerId ?? "default"
let styledFeature = GeoJSONStyledFeature(
guard feature.isVisible else { continue }
styledFeatures.append(GeoJSONStyledFeature(
feature: feature,
overlayId: layerId
)
styledFeatures.append(styledFeature)
overlayId: feature.layerId ?? "default"
))
}
styledFeaturesCache = (configs: enabledConfigs, features: styledFeatures)
return styledFeatures
}
@ -106,9 +109,10 @@ class GeoJSONOverlayManager {
return Array(layerIds).sorted()
}
/// Clear cached data (useful for testing or memory management)
/// Clear cached data (called when files are added, deleted, or toggled).
func clearCache() {
featureCollection = nil
styledFeaturesCache = nil
}
/// Check if user-uploaded data is available (regardless of active state)

View file

@ -184,7 +184,12 @@ extension MeshPackets {
nonisolated public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) {
do {
let objects = user.messageList
// Fetch messages using the same context that will perform the deletes.
// user.messageList fetches from viewContext, which would cause a context-mismatch
// crash when this method is called with a background context.
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.predicate = user.messageFetchRequest.predicate
let objects = (try? context.fetch(fetchRequest)) ?? []
for object in objects {
context.delete(object)
}

View file

@ -40,7 +40,7 @@ struct MeshMapContent: MapContent {
@AppStorage("mapOverlaysEnabled") private var showMapOverlays = false
@Binding var enabledOverlayConfigs: Set<UUID>
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn)
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .none)
var positions: FetchedResults<PositionEntity>
@FetchRequest(fetchRequest: WaypointEntity.allWaypointssFetchRequest(), animation: .none)
@ -184,10 +184,13 @@ struct MeshMapContent: MapContent {
@MapContentBuilder
var meshMap: some MapContent {
let loraNodes = positions.filter { $0.nodePosition?.viaMqtt ?? true == false }
let loraCoords = Array(loraNodes).compactMap({(position) -> CLLocationCoordinate2D in
return position.nodeCoordinate ?? LocationsHandler.DefaultLocation
})
// Only compute LoRa node coordinates when the convex hull is actually displayed.
// The filter scans the entire positions array on every render, so guard it.
let loraCoords: [CLLocationCoordinate2D] = showConvexHull
? positions
.filter { !($0.nodePosition?.viaMqtt ?? true) }
.compactMap { $0.nodeCoordinate ?? LocationsHandler.DefaultLocation }
: []
/// Convex Hull
if showConvexHull {
if loraCoords.count > 0 {
@ -214,8 +217,10 @@ struct MeshMapContent: MapContent {
let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs)
return Group {
ForEach(0..<allStyledFeatures.count, id: \.self) { index in
let styledFeature = allStyledFeatures[index]
// GeoJSONStyledFeature is Identifiable with a stable UUID assigned at creation.
// Using ForEach with Identifiable gives SwiftUI stable identity for diffing,
// avoiding full teardown/rebuild of overlay views on each render.
ForEach(allStyledFeatures) { styledFeature in
let feature = styledFeature.feature
let geometryType = feature.geometry.type

View file

@ -20,6 +20,12 @@ struct NodeMapContent: MapContent {
@Namespace var mapScope
@State var selectedPosition: PositionEntity?
// Static UIImage caches keyed by node.num.
// Node colors are deterministic from node.num (via UIColor(hex:)), so caching by num is correct.
// nonisolated(unsafe) is required for static mutable state in Swift 6.
private nonisolated(unsafe) static var circleImageCache: [Int64: UIImage] = [:]
private nonisolated(unsafe) static var arrowImageCache: [Int64: UIImage] = [:]
@MapContentBuilder
var nodeMap: some MapContent {
let positionArray = node.positions?.array as? [PositionEntity] ?? []
@ -160,18 +166,20 @@ struct NodeMapContent: MapContent {
}
private func prerenderHistoryPointCircle(fill: Color, stroke: Color) -> UIImage {
// Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points.
if let cached = NodeMapContent.circleImageCache[node.num] { return cached }
let content = Circle()
.fill(fill)
.strokeBorder(stroke, lineWidth: 2)
.frame(width: 12, height: 12)
let renderer = ImageRenderer(content: content)
renderer.scale = UIScreen.main.scale
return renderer.uiImage!
let image = renderer.uiImage!
NodeMapContent.circleImageCache[node.num] = image
return image
}
private func prerenderHistoryPointArrow(fill: Color, stroke: Color) -> UIImage {
// Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points.
if let cached = NodeMapContent.arrowImageCache[node.num] { return cached }
let content = Image(systemName: "location.north.circle")
.resizable()
.scaledToFit()
@ -181,6 +189,8 @@ struct NodeMapContent: MapContent {
.frame(width: 16, height: 16)
let renderer = ImageRenderer(content: content)
renderer.scale = UIScreen.main.scale
return renderer.uiImage!
let image = renderer.uiImage!
NodeMapContent.arrowImageCache[node.num] = image
return image
}
}