From 1a5fd6f4d3de30b80d0a35560b12dd056ba1e120 Mon Sep 17 00:00:00 2001 From: Aleksei Sapitskii <45671572+aleksproger@users.noreply.github.com> Date: Mon, 29 Jan 2024 11:43:29 +0200 Subject: [PATCH] MAPSIOS-1170: Expose performance statistics API (#1946) * MAPSIOS-1170: Expose performance statistics API * Add tests and changelog * Add `isOn` parameter to `collectPerformanceStatistics` in SwiftUI Sync UIKit example to what Android have * Bump versions and sync example with Android * Add tests, adjust the example * Adopt fixes from gl-native * Bump CoreMaps version to build on CI * Update Package.resolved with snapshot * Fix doc and rname handler to callback to sync with gl-native naming * Use snapshot with proper Equatable PerformanceStatisticsOptions and fix comments --- .../xcshareddata/swiftpm/Package.resolved | 9 - .../All Examples/DebugMapExample.swift | 154 ++++++++++++------ .../Testing Examples/MapSettingsExample.swift | 103 +++++++++--- CHANGELOG.md | 1 + .../API Catalogs/CoreMaps.md | 2 +- .../MapboxMaps/Foundation/CoreAliases.swift | 1 + Sources/MapboxMaps/Foundation/MapboxMap.swift | 29 ++++ .../PerformanceStatisticsOptions.swift | 61 +++++++ Sources/MapboxMaps/SwiftUI/Deps.swift | 13 +- Sources/MapboxMaps/SwiftUI/Map.swift | 25 +++ .../SwiftUI/MapBasicCoordinator.swift | 28 ++++ .../Foundation/Mocks/MockMapboxMap.swift | 11 +- .../SwiftUI/MapBasicCoordinatorTests.swift | 68 ++++++++ 13 files changed, 418 insertions(+), 87 deletions(-) create mode 100644 Sources/MapboxMaps/Foundation/PerformanceStatisticsOptions.swift diff --git a/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3acab2391b8e..9d586afa7334 100644 --- a/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,15 +18,6 @@ "version" : "24.1.0" } }, - { - "identity" : "mapbox-core-maps-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mapbox/mapbox-core-maps-ios.git", - "state" : { - "revision" : "d9cdf51cf4b897811a5c5d5135d3c772830e2b7c", - "version" : "11.1.0" - } - }, { "identity" : "turf-swift", "kind" : "remoteSourceControl", diff --git a/Apps/Examples/Examples/All Examples/DebugMapExample.swift b/Apps/Examples/Examples/All Examples/DebugMapExample.swift index 4c4ced5a20c8..b33d0ac00109 100644 --- a/Apps/Examples/Examples/All Examples/DebugMapExample.swift +++ b/Apps/Examples/Examples/All Examples/DebugMapExample.swift @@ -1,17 +1,24 @@ import UIKit @_spi(Experimental) import MapboxMaps -private protocol DebugOptionSettingsDelegate: AnyObject { - func debugOptionSettingsDidChange(_ controller: SettingsViewController) -} - -private struct MapDebugOptionSetting { - let debugOption: MapViewDebugOptions - let displayTitle: String -} - -final class DebugMapExample: UIViewController, ExampleProtocol, DebugOptionSettingsDelegate { +final class DebugMapExample: UIViewController, ExampleProtocol { + private var collectStatisticsButton = UIButton(type: .system) private var mapView: MapView! + private var performanceStatisticsCancelable: AnyCancelable? + private let settings: [Setting] = [ + Setting(option: .debug(.collision), title: "Debug collision"), + Setting(option: .debug(.depthBuffer), title: "Show depth buffer"), + Setting(option: .debug(.overdraw), title: "Debug overdraw"), + Setting(option: .debug(.parseStatus), title: "Show tile coordinate"), + Setting(option: .debug(.stencilClip), title: "Show stencil buffer"), + Setting(option: .debug(.tileBorders), title: "Debug tile clipping"), + Setting(option: .debug(.timestamps), title: "Show tile loaded time"), + Setting(option: .debug(.modelBounds), title: "Show 3D model bounding boxes"), + Setting(option: .debug(.light), title: "Show light conditions"), + Setting(option: .debug(.camera), title: "Show camera debug view"), + Setting(option: .debug(.padding), title: "Camera padding"), + Setting(option: .performance(.init([.perFrame, .cumulative], samplingDurationMillis: 5000)), title: "Performance statistics"), + ] override func viewDidLoad() { super.viewDidLoad() @@ -52,7 +59,7 @@ final class DebugMapExample: UIViewController, ExampleProtocol, DebugOptionSetti } @objc private func openDebugOptionsMenu(_ sender: UIBarButtonItem) { - let settingsViewController = SettingsViewController(debugOptions: mapView.debugOptions) + let settingsViewController = SettingsViewController(settings: settings) settingsViewController.delegate = self let navigationController = UINavigationController(rootViewController: settingsViewController) @@ -68,34 +75,28 @@ final class DebugMapExample: UIViewController, ExampleProtocol, DebugOptionSetti showAlert(withTitle: "Displayed tiles", and: message) } - fileprivate func debugOptionSettingsDidChange(_ controller: SettingsViewController) { - controller.dismiss(animated: true, completion: nil) - mapView.debugOptions = controller.enabledDebugOptions + private func handle(statistics: PerformanceStatistics) { + showAlert(with: "\(statistics.topRenderedGroupDescription)\n\(statistics.renderingDurationStatisticsDescription)") } } -private final class SettingsViewController: UIViewController, UITableViewDataSource { +extension DebugMapExample: DebugOptionSettingsDelegate { + func settingsDidChange(debugOptions: MapViewDebugOptions, performanceOptions: PerformanceStatisticsOptions?) { + mapView.debugOptions = debugOptions + + guard let performanceOptions else { return performanceStatisticsCancelable = nil } + performanceStatisticsCancelable?.cancel() + performanceStatisticsCancelable = mapView.mapboxMap.collectPerformanceStatistics(performanceOptions, callback: handle(statistics:)) + } +} +final class SettingsViewController: UIViewController, UITableViewDataSource { weak var delegate: DebugOptionSettingsDelegate? private var listView: UITableView! + private let settings: [Setting] - private(set) var enabledDebugOptions: MapViewDebugOptions - private let allSettings: [MapDebugOptionSetting] = [ - MapDebugOptionSetting(debugOption: .collision, displayTitle: "Debug collision"), - MapDebugOptionSetting(debugOption: .depthBuffer, displayTitle: "Show depth buffer"), - MapDebugOptionSetting(debugOption: .overdraw, displayTitle: "Debug overdraw"), - MapDebugOptionSetting(debugOption: .parseStatus, displayTitle: "Show tile coordinate"), - MapDebugOptionSetting(debugOption: .stencilClip, displayTitle: "Show stencil buffer"), - MapDebugOptionSetting(debugOption: .tileBorders, displayTitle: "Debug tile clipping"), - MapDebugOptionSetting(debugOption: .timestamps, displayTitle: "Show tile loaded time"), - MapDebugOptionSetting(debugOption: .modelBounds, displayTitle: "Show 3D model bounding boxes"), - MapDebugOptionSetting(debugOption: .light, displayTitle: "Show light conditions"), - MapDebugOptionSetting(debugOption: .camera, displayTitle: "Show camera debug view"), - MapDebugOptionSetting(debugOption: .padding, displayTitle: "Camera padding") - ] - - init(debugOptions: MapViewDebugOptions) { - enabledDebugOptions = debugOptions + fileprivate init(settings: [Setting]) { + self.settings = settings super.init(nibName: nil, bundle: nil) } @@ -134,11 +135,21 @@ private final class SettingsViewController: UIViewController, UITableViewDataSou } @objc private func saveSettings(_ sender: UIBarButtonItem) { - delegate?.debugOptionSettingsDidChange(self) + let debugOptions = settings + .filter(\.isEnabled) + .compactMap(\.option.debugOption) + .reduce(MapViewDebugOptions()) { result, next in result.union(next) } + + let performanceOptions = settings + .filter(\.isEnabled) + .compactMap(\.option.performanceOption) + + delegate?.settingsDidChange(debugOptions: debugOptions, performanceOptions: performanceOptions.first) + dismiss(animated: true, completion: nil) } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - allSettings.count + settings.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -146,15 +157,9 @@ private final class SettingsViewController: UIViewController, UITableViewDataSou // swiftlint:disable:next force_cast let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) as! DebugOptionCell - let setting = allSettings[indexPath.row] - cell.configure(with: setting, isOptionEnabled: enabledDebugOptions.contains(setting.debugOption)) - cell.onToggled { [unowned self] isEnabled in - if isEnabled { - self.enabledDebugOptions.insert(setting.debugOption) - } else { - self.enabledDebugOptions.remove(setting.debugOption) - } - } + let setting = settings[indexPath.row] + cell.configure(with: setting.title, isOptionEnabled: setting.isEnabled) + cell.onToggled(setting.toggle) return cell } @@ -163,9 +168,9 @@ private final class SettingsViewController: UIViewController, UITableViewDataSou // MARK: Cell private class DebugOptionCell: UITableViewCell { - private let titleLabel = UILabel() private let toggle = UISwitch() + private var onToggleHandler: (() -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -193,18 +198,69 @@ private class DebugOptionCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func configure(with setting: MapDebugOptionSetting, isOptionEnabled: Bool) { - titleLabel.text = setting.displayTitle + func configure(with title: String, isOptionEnabled: Bool) { + titleLabel.text = title toggle.isOn = isOptionEnabled } - private var onToggleHandler: ((Bool) -> Void)? - func onToggled(_ handler: @escaping (Bool) -> Void) { + func onToggled(_ handler: @escaping () -> Void) { onToggleHandler = handler } @objc private func didToggle(_ sender: UISwitch) { - onToggleHandler?(sender.isOn) + onToggleHandler?() + } +} + +protocol DebugOptionSettingsDelegate: AnyObject { + func settingsDidChange(debugOptions: MapViewDebugOptions, performanceOptions: PerformanceStatisticsOptions?) +} + +private final class Setting { + enum Option { + case debug(MapViewDebugOptions) + case performance(PerformanceStatisticsOptions) + } + + let option: Option + let title: String + private(set) var isEnabled: Bool + + init(option: Option, title: String, isEnabled: Bool = false) { + self.option = option + self.title = title + self.isEnabled = isEnabled + } + + func toggle() { isEnabled.toggle() } +} + +extension Setting.Option { + var debugOption: MapViewDebugOptions? { + if case let .debug(option) = self { return option } + else { return nil } + } + + var performanceOption: PerformanceStatisticsOptions? { + if case let .performance(option) = self { return option } + else { return nil } + } +} + +extension PerformanceStatistics { + fileprivate var topRenderedGroupDescription: String { + if let topRenderedGroup = perFrameStatistics?.topRenderGroups.first { + return "Top rendered group: `\(topRenderedGroup.name)` took \(topRenderedGroup.durationMillis)ms." + } else { + return "No information about topRenderedLayer." + } + } + + fileprivate var renderingDurationStatisticsDescription: String { + guard let drawCalls = cumulativeStatistics?.drawCalls else { return "Cumulative statistics haven't been collected." } + return """ + Number of draw calls: \(drawCalls). + """ } } diff --git a/Apps/Examples/Examples/SwiftUI Examples/Testing Examples/MapSettingsExample.swift b/Apps/Examples/Examples/SwiftUI Examples/Testing Examples/MapSettingsExample.swift index bd82c5b08a01..8e62436ec294 100644 --- a/Apps/Examples/Examples/SwiftUI Examples/Testing Examples/MapSettingsExample.swift +++ b/Apps/Examples/Examples/SwiftUI Examples/Testing Examples/MapSettingsExample.swift @@ -9,44 +9,69 @@ struct Settings { var constrainMode: ConstrainMode = .heightOnly var ornamentSettings = OrnamentSettings() var debugOptions: MapViewDebugOptions = [.camera] - + var performance = PerformanceSettings() + struct OrnamentSettings { var isScaleBarVisible = true var isCompassVisible = true } + + struct PerformanceSettings { + var samplerOptions = PerformanceStatisticsOptions.SamplerOptions([.perFrame, .cumulative]) + var samplingDurationMillis: UInt32 = 5000 + var isStatisticsEnabled = false + + var statisticsOptions: PerformanceStatisticsOptions { + PerformanceStatisticsOptions(samplerOptions, samplingDurationMillis: Double(samplingDurationMillis)) + } + } } @available(iOS 14.0, *) struct MapSettingsExample : View { @State private var settingsOpened = false @State private var settings = Settings() + @State private var performanceStatistics: PerformanceStatistics? = nil var body: some View { - Map(initialViewport: .camera(center: .berlin, zoom: 12)) - .cameraBounds(settings.cameraBounds) - .mapStyle(settings.mapStyle) - .gestureOptions(settings.gestureOptions) - .gestureHandlers(gestureHandlers) - .northOrientation(settings.orientation) - .constrainMode(settings.constrainMode) - .ornamentOptions(OrnamentOptions( - scaleBar: ScaleBarViewOptions(visibility: settings.ornamentSettings.isScaleBarVisible ? .visible : .hidden), - compass: CompassViewOptions(visibility: settings.ornamentSettings.isCompassVisible ? .visible : .hidden) - )) - .debugOptions(settings.debugOptions) - .ignoresSafeArea() - .sheet(isPresented: $settingsOpened) { - SettingsView(settings: $settings) - .defaultDetents() - } - .safeOverlay(alignment: .trailing, content: { - MapStyleSelectorButton(mapStyle: $settings.mapStyle) - }) - .toolbar { - Button("Settings") { - settingsOpened.toggle() + ZStack(alignment: .bottom) { + Map(initialViewport: .camera(center: .berlin, zoom: 12)) + .cameraBounds(settings.cameraBounds) + .mapStyle(settings.mapStyle) + .gestureOptions(settings.gestureOptions) + .gestureHandlers(gestureHandlers) + .northOrientation(settings.orientation) + .constrainMode(settings.constrainMode) + .collectPerformanceStatistics(settings.performance.isStatisticsEnabled ? settings.performance.statisticsOptions : nil) { stats in + performanceStatistics = stats + } + .ornamentOptions(OrnamentOptions( + scaleBar: ScaleBarViewOptions(visibility: settings.ornamentSettings.isScaleBarVisible ? .visible : .hidden), + compass: CompassViewOptions(visibility: settings.ornamentSettings.isCompassVisible ? .visible : .hidden) + )) + .debugOptions(settings.debugOptions) + .ignoresSafeArea() + .sheet(isPresented: $settingsOpened) { + SettingsView(settings: $settings) + .defaultDetents() + } + .safeOverlay(alignment: .trailing) { + MapStyleSelectorButton(mapStyle: $settings.mapStyle) } + .toolbar { + Button("Settings") { + settingsOpened.toggle() + } + } + + if settings.performance.isStatisticsEnabled, let stats = performanceStatistics { + VStack(alignment: .leading) { + Text(stats.topRenderedLayerDescription).font(.safeMonospaced) + Text(stats.renderingDurationStatisticsDescription).font(.safeMonospaced) + } + .floating() } + } } var gestureHandlers: MapGestureHandlers { @@ -121,6 +146,22 @@ struct SettingsView : View { } header: { Text("Debug Options") } + Section { + let samplerOptions = [ + ("Per Frame", PerformanceStatisticsOptions.SamplerOptions.perFrame), + ("Cumulative", .cumulative) + ] + Toggle("Collect Statistics", isOn: $settings.performance.isStatisticsEnabled) + + if settings.performance.isStatisticsEnabled { + Stepper("Sampling Duration, \(settings.performance.samplingDurationMillis) ms", value: $settings.performance.samplingDurationMillis, step: 1000) + ForEach(samplerOptions, id: \.0) { option in + Toggle(option.0, isOn: $settings.performance.samplerOptions.contains(option: option.1)) + } + } + } header: { + Text("Performance Statistics") + } } } } @@ -149,3 +190,17 @@ struct MapSettingsExample_Preveiw: PreviewProvider { } } } + +extension PerformanceStatistics { + fileprivate var topRenderedLayerDescription: String { + if let topRenderedLayer = perFrameStatistics?.topRenderLayers.first { + return "Top rendered layer: `\(topRenderedLayer.name)` for \(topRenderedLayer.durationMillis)ms." + } else { + return "No information about topRenderedLayer." + } + } + + fileprivate var renderingDurationStatisticsDescription: String { + "Max rendering call duration: \(mapRenderDurationStatistics.maxMillis)ms.\nMedian rendering call duration: \(mapRenderDurationStatistics.medianMillis)ms" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f1227d1617..ea762c678291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Use them to configure respective map options after creating a map view. * Expose experimental `CustomRasterSource` and non-experimental `CustomGeometrySource` as regular `Source`'s providing a better way to work with them and also allow for using them in Style DSL. * Introduce `SymbolLayer.iconColorSaturation` API. * Introduce experimental `RasterLayer.rasterElevation` API. +* Introduce experimental `MapboxMap.collectPerformanceStatistics` allowing to collect map rendering performance statistics, both for UIKit and SwiftUI. ## 11.1.0 - 17 January, 2024 diff --git a/Sources/MapboxMaps/Documentation.docc/API Catalogs/CoreMaps.md b/Sources/MapboxMaps/Documentation.docc/API Catalogs/CoreMaps.md index f1cb701f4767..ea4531e35695 100644 --- a/Sources/MapboxMaps/Documentation.docc/API Catalogs/CoreMaps.md +++ b/Sources/MapboxMaps/Documentation.docc/API Catalogs/CoreMaps.md @@ -8,4 +8,4 @@ Maps rendering SDK also known as GL-Native - ### Free Camera -- +- \ No newline at end of file diff --git a/Sources/MapboxMaps/Foundation/CoreAliases.swift b/Sources/MapboxMaps/Foundation/CoreAliases.swift index ec046b9bb542..e5e4400b4ce5 100644 --- a/Sources/MapboxMaps/Foundation/CoreAliases.swift +++ b/Sources/MapboxMaps/Foundation/CoreAliases.swift @@ -30,3 +30,4 @@ typealias CoreRuntimeStylingOptions = MapboxCoreMaps_Private.RuntimeStylingOptio typealias CoreObservable = MapboxCoreMaps_Private.Observable typealias CoreViewAnnotationPositionsUpdateListener = MapboxCoreMaps_Private.ViewAnnotationPositionsUpdateListener typealias CoreMapSnapshotter = MapboxCoreMaps_Private.MapSnapshotter +typealias CorePerformanceSamplerOptions = MapboxCoreMaps_Private.PerformanceSamplerOptions diff --git a/Sources/MapboxMaps/Foundation/MapboxMap.swift b/Sources/MapboxMaps/Foundation/MapboxMap.swift index d8e1b2ee1f4d..a99789476dd3 100644 --- a/Sources/MapboxMaps/Foundation/MapboxMap.swift +++ b/Sources/MapboxMaps/Foundation/MapboxMap.swift @@ -21,6 +21,8 @@ protocol MapboxMapProtocol: AnyObject { func endGesture() @discardableResult func queryRenderedFeatures(with point: CGPoint, options: RenderedQueryOptions?, completion: @escaping (Result<[QueriedRenderedFeature], Error>) -> Void) -> Cancelable + func collectPerformanceStatistics(_ options: PerformanceStatisticsOptions, callback: @escaping (PerformanceStatistics) -> Void) -> AnyCancelable + // View annotation management func setViewAnnotationPositionsUpdateCallback(_ callback: ViewAnnotationPositionsUpdateCallback?) func addViewAnnotation(withId id: String, options: ViewAnnotationOptions) throws @@ -427,6 +429,33 @@ public final class MapboxMap: StyleManager { __map.setViewportModeFor(viewportMode) } + /// Collects CPU and GPU resource usage, as well as timings of layers and rendering groups, over a user-configurable sampling duration. + /// Use the collected information to identify layers or rendering groups that may be performing poorly. + /// + /// Use ``PerformanceStatisticsOptions`` to configure the following collection behaviours: + /// - Which types of sampling to perform, whether cumulative, per-frame, or both. + /// - Duration of sampling in milliseconds. A value of 0 forces the collection of performance statistics every frame. + /// + /// The statistics collection can be canceled using the ``AnyCancelable`` object returned by this function, note that if the token goes out of the scope it's deinitialized and thus canceled. Canceling collection will prevent the + /// callback from being called. Collection can be restarted by calling ``MapboxMap/collectPerformanceStatistics(_:callback:)`` again to obtain a new ``AnyCancelable`` object. + /// + /// The callback function will be called every time the configured sampling duration ``PerformanceStatisticsOptions/samplingDurationMillis`` has elapsed. + /// + /// - Parameters: + /// - options The statistics collection options to collect. + /// - callback The callback to be invoked when performance statistics are available. + /// - Returns: The ``AnyCancelable`` object that can be used to cancel performance statistics collection. + #if swift(>=5.8) + @_documentation(visibility: public) + #endif + @_spi(Experimental) + public func collectPerformanceStatistics(_ options: PerformanceStatisticsOptions, callback: @escaping (PerformanceStatistics) -> Void) -> AnyCancelable { + __map.startPerformanceStatisticsCollection(for: options, callback: callback) + return BlockCancelable { [weak self] in + self?.__map.stopPerformanceStatisticsCollection() + }.erased + } + /// Calculates a `CameraOptions` to fit a `CoordinateBounds` /// /// This API isn't supported by Globe projection. diff --git a/Sources/MapboxMaps/Foundation/PerformanceStatisticsOptions.swift b/Sources/MapboxMaps/Foundation/PerformanceStatisticsOptions.swift new file mode 100644 index 000000000000..0827e7001a80 --- /dev/null +++ b/Sources/MapboxMaps/Foundation/PerformanceStatisticsOptions.swift @@ -0,0 +1,61 @@ +import Foundation +import MapboxCoreMaps + +/// Options for the following statistics collection behaviors: +/// - Specify the types of sampling: cumulative, per-frame, or both. +/// - Define the minimum elapsed time for collecting performance samples. +@_spi(Experimental) +extension PerformanceStatisticsOptions { + @_spi(Experimental) + public struct SamplerOptions: OptionSet, Hashable { + /// Enables the collection of `cumulativeValues`, which are GPU resource statistics. + public static let cumulative = SamplerOptions(rawValue: 1 << 0) + /// Enables the collection of `perFrameValues`, which are CPU timeline duration statistics. + public static let perFrame = SamplerOptions(rawValue: 1 << 1) + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + } + + /// Set of samplers to which define the following types of sampling: cumulative, per-frame, or boths. + public var samplerOptions: SamplerOptions { + SamplerOptions(rawValue: __samplerOptions.reduce(0) { $0 + $1.intValue }) + } + + /// Specify the types of sampling: cumulative, per-frame, or both and define the minimum elapsed time for collecting performance samples. + /// - Note: Setting ``samplingDurationMillis`` to 0 forces the collection of performance statistics every frame., negative sampling duration is an error and results in no operation. + public convenience init(_ samplerOptions: SamplerOptions, samplingDurationMillis: Double) { + self.init(__samplerOptions: samplerOptions.core, samplingDurationMillis: samplingDurationMillis) + } + + /// Specify the types of sampling: cumulative, per-frame, or both. + /// - Note: Default minimum elapsed time for collecting performance samples will be used, default is 1000 milliseconds. + public convenience init(_ samplerOptions: SamplerOptions) { + self.init(__samplerOptions: samplerOptions.core) + } +} + +extension PerformanceStatisticsOptions.SamplerOptions { + var core: [NSNumber] { + var nativeDebugOptions = [CorePerformanceSamplerOptions]() + if contains(.cumulative) { nativeDebugOptions.append(.cumulativeRenderingStats) } + if contains(.perFrame) { nativeDebugOptions.append(.perFrameRenderingStats) } + return nativeDebugOptions.map(\.NSNumber) + } +} + +extension CumulativeRenderingStatistics { + /// The number of draw calls at the end of the collection window. + public var drawCalls: UInt? { __drawCalls?.uintValue } + + /// The amount of texture memory in use at the end of the collection window. + /// - Note: This value is nil for Metal renderer. + public var textureBytes: UInt? { __textureBytes?.uintValue } + + /// The amount of vertex memory (array and index buffer memory) in use at the end of the collection window. + /// - Note: This value is nil for Metal renderer. + public var vertexBytes: UInt? { __vertexBytes?.uintValue } +} diff --git a/Sources/MapboxMaps/SwiftUI/Deps.swift b/Sources/MapboxMaps/SwiftUI/Deps.swift index f65df0d11d8f..ba418211c4fc 100644 --- a/Sources/MapboxMaps/SwiftUI/Deps.swift +++ b/Sources/MapboxMaps/SwiftUI/Deps.swift @@ -16,9 +16,8 @@ struct MapDependencies { var debugOptions = MapViewDebugOptions() var presentsWithTransaction = false var additionalSafeArea = SwiftUI.EdgeInsets() - var viewportOptions = ViewportOptions( - transitionsToIdleUponUserInteraction: true, - usesSafeAreaInsetsAsPadding: true) + var viewportOptions = ViewportOptions(transitionsToIdleUponUserInteraction: true, usesSafeAreaInsetsAsPadding: true) + var performanceStatisticsParameters: Map.PerformanceStatisticsParameters? var onMapTap: ((MapContentGestureContext) -> Void)? var onMapLongPress: ((MapContentGestureContext) -> Void)? @@ -40,3 +39,11 @@ struct AnyEventSubscription { } } } + +@available(iOS 13.0, *) +extension Map { + struct PerformanceStatisticsParameters { + var options: PerformanceStatisticsOptions + var callback: (PerformanceStatistics) -> Void + } +} diff --git a/Sources/MapboxMaps/SwiftUI/Map.swift b/Sources/MapboxMaps/SwiftUI/Map.swift index 8047819e3782..a4e19152202c 100644 --- a/Sources/MapboxMaps/SwiftUI/Map.swift +++ b/Sources/MapboxMaps/SwiftUI/Map.swift @@ -365,6 +365,31 @@ public extension Map { copyAssigned(self, \.mapDependencies.additionalSafeArea, insets) } + /// Collects CPU, GPU resource usage and timings of layers and rendering groups over a user-configurable sampling duration. + /// Use the collected information to find which layers or rendering groups might be performing poorly. + /// + /// Use ``PerformanceStatisticsOptions`` to configure the following collection behaviours: + /// - Which types of sampling to perform, whether cumulative, per-frame, or both. + /// - Duration of sampling in milliseconds. Value 0 forces the collection of performance statistics every frame. + /// + /// Utilize ``PerformanceStatisticsCallback`` to observe the collected performance statistics. The callback function is invoked + /// after the configured sampling duration has elapsed. The callback is invoked on the main thread. The collection process is + /// continuous; without user-input, it restarts after each callback invocation. + /// - Note: Specifying a negative sampling duration or omitting the callback function will result in no operation, which will be logged for visibility. + /// - Note: The statistics collection can be canceled by setting `nil` to the options parameter. + /// The callback function will be called every time the configured sampling duration ``PerformanceStatisticsOptions/sasamplingDurationMillis has elapsed. + /// + /// - Parameters: + /// - options The statistics collection options to collect. + /// - callback The callback to be invoked when performance statistics are available. + /// - Returns: An ``AnyCancelable`` object that can be used to cancel performance statistics collection. +#if swift(>=5.8) + @_documentation(visibility: public) +#endif + func collectPerformanceStatistics(_ options: PerformanceStatisticsOptions?, callback: @escaping (PerformanceStatistics) -> Void) -> Self { + copyAssigned(self, \.mapDependencies.performanceStatisticsParameters, options.map { PerformanceStatisticsParameters(options: $0, callback: callback) }) + } + /// Sets the amount of additional safe area insets for the given edges. /// /// If called multiple times, the last call wins. This property behaves identically to the diff --git a/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift b/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift index 2f34142a5efe..6eab3a91f07d 100644 --- a/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift +++ b/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift @@ -14,6 +14,7 @@ final class MapBasicCoordinator { // Update params private var cameraChangeHandlers = [(CameraChanged) -> Void]() private var cameraBoundsOptions = CameraBoundsOptions() + private let performanceStatisticsState: PerformanceStatisticsState // Runtime variables private var currentViewport: Viewport? @@ -31,6 +32,7 @@ final class MapBasicCoordinator { ) { self.mapView = mapView self.mainQueue = mainQueue + self.performanceStatisticsState = PerformanceStatisticsState(mapboxMap: mapView.mapboxMap) mapView.mapboxMap.onCameraChanged .blockUpdates(while: onCameraUpdateInProgress.signal) @@ -122,6 +124,8 @@ final class MapBasicCoordinator { .observe(onMapLongPress) .store(in: &shortLivedSubscriptions) } + + performanceStatisticsState.update(with: deps.performanceStatisticsParameters) } private func groupCameraUpdates(_ map: MapboxMapProtocol, _ updates: () -> Void) { @@ -186,3 +190,27 @@ private final class IdleViewportObserver: ViewportStatusObserver { } } } + +@available(iOS 13.0, *) +extension MapBasicCoordinator { + final class PerformanceStatisticsState { + private var token: AnyCancelable? + private var parameters: Map.PerformanceStatisticsParameters? + private let mapboxMap: MapboxMapProtocol + + init(mapboxMap: MapboxMapProtocol) { + self.mapboxMap = mapboxMap + } + + func update(with newParameters: Map.PerformanceStatisticsParameters?) { + let oldParameters = parameters + parameters = newParameters + guard let parameters else { return token = nil } + + if oldParameters?.options != parameters.options { + token?.cancel() + token = mapboxMap.collectPerformanceStatistics(parameters.options) { [weak self] statistics in self?.parameters?.callback(statistics) } + } + } + } +} diff --git a/Tests/MapboxMapsTests/Foundation/Mocks/MockMapboxMap.swift b/Tests/MapboxMapsTests/Foundation/Mocks/MockMapboxMap.swift index 1fc2581eaf6f..a19fdfbc3823 100644 --- a/Tests/MapboxMapsTests/Foundation/Mocks/MockMapboxMap.swift +++ b/Tests/MapboxMapsTests/Foundation/Mocks/MockMapboxMap.swift @@ -1,4 +1,4 @@ -@testable import MapboxMaps +@testable @_spi(Experimental) import MapboxMaps import CoreLocation import UIKit @@ -191,4 +191,13 @@ final class MockMapboxMap: MapboxMapProtocol { func queryRenderedFeatures(with point: CGPoint, options: RenderedQueryOptions?, completion: @escaping (Result<[QueriedRenderedFeature], Error>) -> Void) -> Cancelable { qrfStub.call(with: QRFParameters(point: point, options: options, completion: completion)) } + + struct PerformanceStatisticsParameters { + let options: PerformanceStatisticsOptions + let callback: (PerformanceStatistics) -> Void + } + let collectPerformanceStatisticsStub = Stub(defaultReturnValue: MockCancelable().erased) + func collectPerformanceStatistics(_ options: PerformanceStatisticsOptions, callback: @escaping (PerformanceStatistics) -> Void) -> AnyCancelable { + collectPerformanceStatisticsStub.call(with: PerformanceStatisticsParameters(options: options, callback: callback)) + } } diff --git a/Tests/MapboxMapsTests/SwiftUI/MapBasicCoordinatorTests.swift b/Tests/MapboxMapsTests/SwiftUI/MapBasicCoordinatorTests.swift index cd0945c99c99..e22a89c0b79d 100644 --- a/Tests/MapboxMapsTests/SwiftUI/MapBasicCoordinatorTests.swift +++ b/Tests/MapboxMapsTests/SwiftUI/MapBasicCoordinatorTests.swift @@ -190,4 +190,72 @@ final class MapBasicCoordinatorTests: XCTestCase { XCTAssertEqual(setViewportStub.invocations.count, 3) XCTAssertEqual(setViewportStub.invocations.last?.parameters, .idle) } + + func testUpdateWithPerformanceStatisticsParametersCallsCoreMap() { + let deps = MapDependencies(performanceStatisticsParameters: .fixture()) + + update(with: deps) + + XCTAssertEqual(mapView.mapboxMap.collectPerformanceStatisticsStub.invocations.map(\.parameters.options), [deps.performanceStatisticsParameters?.options]) + } + + func testUpdateWithNullPerformanceStatisticsParametersDoesNotCallCoreMap() { + let deps = MapDependencies(performanceStatisticsParameters: .fixture()) + + update(with: deps) + update(with: MapDependencies(performanceStatisticsParameters: nil)) + + XCTAssertEqual(mapView.mapboxMap.collectPerformanceStatisticsStub.invocations.map(\.parameters.options), [deps.performanceStatisticsParameters?.options]) + } + + func testUpdateWithSameOptionsDoesNotCallCoreMap() { + let deps1 = MapDependencies(performanceStatisticsParameters: .fixture()) + let deps2 = MapDependencies(performanceStatisticsParameters: .fixture()) + + update(with: deps1) + update(with: deps2) + + XCTAssertEqual(mapView.mapboxMap.collectPerformanceStatisticsStub.invocations.map(\.parameters.options), [deps1.performanceStatisticsParameters?.options]) + } + + func testUpdateWithSameOptionsAndNewCallbackInvokesNewCallback() { + let firstCallback = Stub() + let secondCallback = Stub() + + update(with: MapDependencies(performanceStatisticsParameters: .fixture(callback: firstCallback.call))) + mapView.mapboxMap.collectPerformanceStatisticsStub.invocations.last?.parameters.callback(.fixture()) + XCTAssertEqual(firstCallback.invocations.count, 1) + XCTAssertEqual(secondCallback.invocations.count, 0) + + update(with: MapDependencies(performanceStatisticsParameters: .fixture(callback: secondCallback.call))) + mapView.mapboxMap.collectPerformanceStatisticsStub.invocations.last?.parameters.callback(.fixture()) + XCTAssertEqual(firstCallback.invocations.count, 1) + XCTAssertEqual(secondCallback.invocations.count, 1) + } +} + +@available(iOS 13.0, *) +extension Map.PerformanceStatisticsParameters { + static func fixture( + options: PerformanceStatisticsOptions = PerformanceStatisticsOptions([.cumulative]), + callback: @escaping (PerformanceStatistics) -> Void = Stub().call + ) -> Map.PerformanceStatisticsParameters { + Map.PerformanceStatisticsParameters(options: options, callback: callback) + } +} + +extension PerformanceStatistics { + static func fixture() -> PerformanceStatistics { + PerformanceStatistics( + collectionDurationMillis: 1000, + mapRenderDurationStatistics: DurationStatistics(maxMillis: 0, medianMillis: 0), + cumulativeStatistics: CumulativeRenderingStatistics(drawCalls: nil, textureBytes: nil, vertexBytes: nil), + perFrameStatistics: PerFrameRenderingStatistics( + topRenderGroups: [], + topRenderLayers: [], + shadowMapDurationStatistics: DurationStatistics(maxMillis: 0, medianMillis: 0), + uploadDurationStatistics: DurationStatistics(maxMillis: 0, medianMillis: 0) + ) + ) + } }