From 3c0e56aeafdb2f97fa2bea1cd1e6a9074983d661 Mon Sep 17 00:00:00 2001 From: Austin Payne Date: Wed, 14 Feb 2024 23:10:30 -0700 Subject: [PATCH 1/2] improvement: avoid duplicate map tile loading Previously a map tile cache miss would cause 2x loading of the tile: once from the remote tile server (which is then written to disk) and once from disk during the default MKTileOverlay.loadTile function. Instead we now directly implement loadTile so that we can avoid the duplicate loading and simply return the fetched remote tile after it is cached, which leads to a noticeable improvement in offline map performance. --- .../Helpers/Map/OfflineTileManager.swift | 30 +++++++++++-------- Meshtastic/Helpers/Map/TileOverlay.swift | 4 ++- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Meshtastic/Helpers/Map/OfflineTileManager.swift b/Meshtastic/Helpers/Map/OfflineTileManager.swift index 709517c2..269cf9f4 100644 --- a/Meshtastic/Helpers/Map/OfflineTileManager.swift +++ b/Meshtastic/Helpers/Map/OfflineTileManager.swift @@ -103,20 +103,26 @@ class OfflineTileManager: ObservableObject { } } } - func getTileOverlay(for path: MKTileOverlayPath) -> URL { - let file = "\(UserDefaults.mapTileServer.id)-z\(path.z)x\(path.x)y\(path.y).png" - // Check is tile is already available - let tilesUrl = documentsDirectory.appendingPathComponent("tiles").appendingPathComponent(file) - if fileManager.fileExists(atPath: tilesUrl.path) { - return tilesUrl - } else { - if UserDefaults.enableOfflineMaps, UserDefaults.mapTileServer.zoomRange.contains(path.z) { // Get and persist newTile - return persistLocally(path: path) - } else { // Else display empty tile (transparent over Maps tiles) - return Bundle.main.url(forResource: "alpha", withExtension: "png")! - } + + func loadAndCacheTileOverlay(for path: MKTileOverlayPath) throws -> Data { + guard UserDefaults.enableOfflineMaps, UserDefaults.mapTileServer.zoomRange.contains(path.z) else { + return try Data(contentsOf: Bundle.main.url(forResource: "alpha", withExtension: "png")!) + } + + let tilesUrl = documentsDirectory + .appendingPathComponent("tiles") + .appendingPathComponent("\(UserDefaults.mapTileServer.id)-z\(path.z)x\(path.x)y\(path.y)") + .appendingPathExtension("png") + + do { + return try Data(contentsOf: tilesUrl) + } catch let error as NSError where error.code == NSFileReadNoSuchFileError { + let data = try Data(contentsOf: overlay.url(forTilePath: path)) + try data.write(to: tilesUrl) + return data } } + // MARK: Private methods private func computeTileOverlayPaths(boundingBox box: MKMapRect, maxZ: Int = 17) -> [MKTileOverlayPath] { var paths = [MKTileOverlayPath]() diff --git a/Meshtastic/Helpers/Map/TileOverlay.swift b/Meshtastic/Helpers/Map/TileOverlay.swift index 020e30a9..f70befb8 100644 --- a/Meshtastic/Helpers/Map/TileOverlay.swift +++ b/Meshtastic/Helpers/Map/TileOverlay.swift @@ -11,5 +11,7 @@ import MapKit typealias TileCoordinates = (x: Int, y: Int, z: Int) class TileOverlay: MKTileOverlay { - override func url(forTilePath path: MKTileOverlayPath) -> URL { OfflineTileManager.shared.getTileOverlay(for: path) } + override func loadTile(at path: MKTileOverlayPath) async throws -> Data { + return try OfflineTileManager.shared.loadAndCacheTileOverlay(for: path) + } } From 950aa4a51462fc9f9eccbdceb5d4ec48c7e09361 Mon Sep 17 00:00:00 2001 From: Austin Payne Date: Wed, 14 Feb 2024 23:12:27 -0700 Subject: [PATCH 2/2] chore: remove dead OfflineTileManager code --- .../Helpers/Map/OfflineTileManager.swift | 125 +----------------- Meshtastic/Helpers/Map/TileOverlay.swift | 2 - 2 files changed, 7 insertions(+), 120 deletions(-) diff --git a/Meshtastic/Helpers/Map/OfflineTileManager.swift b/Meshtastic/Helpers/Map/OfflineTileManager.swift index 269cf9f4..ab973c1e 100644 --- a/Meshtastic/Helpers/Map/OfflineTileManager.swift +++ b/Meshtastic/Helpers/Map/OfflineTileManager.swift @@ -9,100 +9,29 @@ import Foundation import MapKit class OfflineTileManager: ObservableObject { - enum DownloadStatus { - case download, downloading, downloaded - } static let shared = OfflineTileManager() + init() { print("Documents Directory = \(documentsDirectory)") createDirectoriesIfNecessary() } + // MARK: - Private properties + private var overlay: MKTileOverlay { MKTileOverlay(urlTemplate: UserDefaults.mapTileServer.tileUrl.count > 1 ? UserDefaults.mapTileServer.tileUrl : MapTileServer.openStreetMap.tileUrl) } private var documentsDirectory: URL { fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! } private let fileManager = FileManager.default - // MARK: - Public property - var progress: Float = 0 - var status: DownloadStatus = .download + // MARK: - Public methods + func getAllDownloadedSize() -> String { fileManager.allocatedSizeOfDirectory(at: documentsDirectory.appendingPathComponent("tiles")) } - func hasBeenDownloaded(for boundingBox: MKMapRect) -> Bool { - getEstimatedDownloadSize(for: boundingBox) == 0 - } - func getEstimatedDownloadSize(for boundingBox: MKMapRect) -> Double { - let paths = self.computeTileOverlayPaths(boundingBox: boundingBox) - let count = self.filterTilesAlreadyExisting(paths: paths).count - let size: Double = 30000 // Bytes (average size) - return Double(count) * size - } - func getDownloadedSize(for mapTileLink: MapTileServer) -> Double { - var accumulatedSize: UInt64 = 0 - let mapTiles = try! fileManager.contentsOfDirectory(at: documentsDirectory.appendingPathComponent("tiles"), includingPropertiesForKeys: []) - let matchingTiles = mapTiles.filter { fileName in - let fileNameLower = fileName.absoluteString - return fileNameLower.contains(mapTileLink.id) - } - print("Deleting \(matchingTiles.count) tiles for \(mapTileLink.id)") - for tile in matchingTiles { - let url = documentsDirectory.appendingPathComponent(tile.absoluteString) - accumulatedSize += (try? url.regularFileAllocatedSize()) ?? 0 - } - return Double(accumulatedSize) - } - func getDownloadedSize(for boundingBox: MKMapRect) -> Double { - let paths = self.computeTileOverlayPaths(boundingBox: boundingBox) - var accumulatedSize: UInt64 = 0 - for path in paths { - let file = "tiles/\(UserDefaults.mapTileServer.id)-z\(path.z)x\(path.x)y\(path.y).png" - let url = documentsDirectory.appendingPathComponent(file) - accumulatedSize += (try? url.regularFileAllocatedSize()) ?? 0 - } - return Double(accumulatedSize) - } + func removeAll() { try? fileManager.removeItem(at: documentsDirectory.appendingPathComponent("tiles")) createDirectoriesIfNecessary() } - func remove(for mapTileLink: MapTileServer) { - let mapTiles = try! fileManager.contentsOfDirectory(at: documentsDirectory.appendingPathComponent("tiles"), includingPropertiesForKeys: []) - let matchingTiles = mapTiles.filter { fileName in - let fileNameLower = fileName.absoluteString - return fileNameLower.contains(mapTileLink.id) - } - print("Deleting \(matchingTiles.count) tiles for \(mapTileLink.id)") - for tile in matchingTiles { - try? fileManager.removeItem(at: tile.absoluteURL) - } - } - func remove(for boundingBox: MKMapRect) { - let paths = self.computeTileOverlayPaths(boundingBox: boundingBox) - for path in paths { - let file = "tiles/\(UserDefaults.mapTileServer.id)-z\(path.z)x\(path.x)y\(path.y).png" - let url = documentsDirectory.appendingPathComponent(file) - try? fileManager.removeItem(at: url) - } - self.status = .download - } - /// Download and persist all tiles within the boundingBox - func download(boundingBox: MKMapRect, name: String) { - NetworkManager.shared.runIfNetwork { - self.status = .downloading - self.progress = 0.01 - let paths = self.computeTileOverlayPaths(boundingBox: boundingBox) - let filteredPaths = self.filterTilesAlreadyExisting(paths: paths) - for i in 0.. Data { guard UserDefaults.enableOfflineMaps, UserDefaults.mapTileServer.zoomRange.contains(path.z) else { @@ -124,47 +53,7 @@ class OfflineTileManager: ObservableObject { } // MARK: Private methods - private func computeTileOverlayPaths(boundingBox box: MKMapRect, maxZ: Int = 17) -> [MKTileOverlayPath] { - var paths = [MKTileOverlayPath]() - for z in 1...maxZ { - let topLeft = tranformCoordinate(coordinates: MKMapPoint(x: box.minX, y: box.minY).coordinate, zoom: z) - let topRight = tranformCoordinate(coordinates: MKMapPoint(x: box.maxX, y: box.minY).coordinate, zoom: z) - let bottomLeft = tranformCoordinate(coordinates: MKMapPoint(x: box.minX, y: box.maxY).coordinate, zoom: z) - for x in topLeft.x...topRight.x { - for y in topLeft.y...bottomLeft.y { - paths.append(MKTileOverlayPath(x: x, y: y, z: z, contentScaleFactor: 2)) - } - } - } - return paths - } - private func tranformCoordinate(coordinates: CLLocationCoordinate2D, zoom: Int) -> TileCoordinates { - let lng = coordinates.longitude - let lat = coordinates.latitude - let tileX = Int(floor((lng + 180) / 360.0 * pow(2.0, Double(zoom)))) - let tileY = Int(floor((1 - log( tan( lat * Double.pi / 180.0 ) + 1 / cos( lat * Double.pi / 180.0 )) / Double.pi ) / 2 * pow(2.0, Double(zoom)))) - return (tileX, tileY, zoom) - } - @discardableResult private func persistLocally(path: MKTileOverlayPath) -> URL { - let url = overlay.url(forTilePath: path) - let file = "tiles/\(UserDefaults.mapTileServer.id)-z\(path.z)x\(path.x)y\(path.y).png" - let filename = documentsDirectory.appendingPathComponent(file) - do { - let data = try Data(contentsOf: url) - try data.write(to: filename) - } catch { - print("💀 Save Tile Error = \(error)") - return url - } - return filename - } - private func filterTilesAlreadyExisting(paths: [MKTileOverlayPath]) -> [MKTileOverlayPath] { - paths.filter { - let file = "\(UserDefaults.mapTileServer.id)-z\($0.z)x\($0.x)y\($0.y).png" - let tilesPath = documentsDirectory.appendingPathComponent("tiles").appendingPathComponent(file).path - return !fileManager.fileExists(atPath: tilesPath) - } - } + private func createDirectoriesIfNecessary() { let tiles = documentsDirectory.appendingPathComponent("tiles") try? fileManager.createDirectory(at: tiles, withIntermediateDirectories: true, attributes: [:]) diff --git a/Meshtastic/Helpers/Map/TileOverlay.swift b/Meshtastic/Helpers/Map/TileOverlay.swift index f70befb8..754771df 100644 --- a/Meshtastic/Helpers/Map/TileOverlay.swift +++ b/Meshtastic/Helpers/Map/TileOverlay.swift @@ -8,8 +8,6 @@ import Foundation import MapKit -typealias TileCoordinates = (x: Int, y: Int, z: Int) - class TileOverlay: MKTileOverlay { override func loadTile(at path: MKTileOverlayPath) async throws -> Data { return try OfflineTileManager.shared.loadAndCacheTileOverlay(for: path)