Load the map framework on demand instead of at launch

MapLibre ships as a dynamic framework, so being named in the app's load
commands means dyld loads it before main runs on every launch: 7.3 MB paged in,
133 C++ static initialisers run and its classes registered, all for a map that
only appears once a location screen is opened.

Measured on an iPhone 17 Pro with two otherwise identical probe apps launched
alternately, linking MapLibre takes pre-main from 11.1 ms to 23.2 ms, and on a
cold launch from 32.9 ms to 60.7 ms. Loading it on demand brings pre-main back
to the 11.1 ms baseline and moves the roughly 11 ms it costs to dlopen to the
moment the map first appears.

MapLibreShim is now embedded but not linked, and the app resolves the shim
through the ObjC runtime after dlopen-ing it. MapLibre stays in the bundle as
an embed-only framework reference so the shim's load command can resolve
against it, expressed as an implicit framework because xcodegen can't describe
embed-without-link for a package product.

Timeline location messages already use the static tile view, which never
touched MapLibre, so the shim only loads for interactive maps.
This commit is contained in:
Stefan Ceriu
2026-08-12 11:23:52 +03:00
parent 8753fe3980
commit 4de42be688
4 changed files with 97 additions and 61 deletions
@@ -9,33 +9,14 @@
import Compound
import CoreLocation
import MapLibreInterface
import MapLibreShim
import SwiftUI
/// The interactive map, rendered by the `MapLibreShim` framework.
/// The interactive map, rendered by the `MapLibreShim` framework which is loaded on first use.
///
/// The app deliberately doesn't link MapLibre: dyld would otherwise load it, run its static
/// initialisers and register its classes on every single launch, for a map that is only shown
/// on the location screens.
struct MapLibreMapView: View {
/// Built once, so that MapLibre's logging is only configured once.
private static let shim: MapLibreShimProtocol = {
let shim = MapLibreShim()
shim.configureLogging { severity, message in
switch severity {
case .error:
MXLog.error(message)
case .warning:
MXLog.warning(message)
case .info:
MXLog.info(message)
case .debug:
MXLog.debug(message)
case .verbose:
MXLog.verbose(message)
}
}
return shim
}()
struct Options {
/// the final zoom level used when the first user location emit
let zoomLevel: Double
@@ -77,7 +58,13 @@ struct MapLibreMapView: View {
var userDidPan: (() -> Void)?
var body: some View {
Self.shim.makeMapView(configuration: configuration)
if let shim = MapLibreLoader.shim {
shim.makeMapView(configuration: configuration)
} else {
// The shim failed to load, surface it the same way as a map loading failure.
Color.clear
.onAppear { error = .failedLoadingMap }
}
}
/// The configuration for the shim, with the app-only pieces (marker views, tint, styles)
@@ -106,3 +93,41 @@ struct MapLibreMapView: View {
userDidPan: userDidPan)
}
}
/// Loads the `MapLibreShim` framework on first use.
enum MapLibreLoader {
static let shim: MapLibreShimProtocol? = {
guard let frameworksPath = Bundle.main.privateFrameworksPath else {
MXLog.error("Missing frameworks path.")
return nil
}
guard dlopen("\(frameworksPath)/MapLibreShim.framework/MapLibreShim", RTLD_NOW) != nil else {
MXLog.error("Failed loading MapLibreShim: \(dlerror().map { String(cString: $0) } ?? "unknown error")")
return nil
}
guard let shimClass = NSClassFromString("MapLibreShim") as? NSObject.Type,
let shim = shimClass.init() as? MapLibreShimProtocol else {
MXLog.error("Failed resolving MapLibreShim.")
return nil
}
shim.configureLogging { severity, message in
switch severity {
case .error:
MXLog.error(message)
case .warning:
MXLog.warning(message)
case .info:
MXLog.info(message)
case .debug:
MXLog.debug(message)
case .verbose:
MXLog.verbose(message)
}
}
return shim
}()
}