ClusterMap

Swift Platform Framework Package Manager GitHub

ClusterMap 是一个用于高性能地图聚类的开源库。

原因

当在地图上显示多个点时,创建聚类是提高用户体验和性能的非常有效的方法。原生聚类机制 提供了一个直接、无需代码的解决方案,并具有视觉上吸引人的动画效果。然而,值得注意的是,这种实现方式确实有一个主要的缺点:所有点都在主线程中添加到地图上,这在处理大量点时可能会导致问题。不幸的是,这个瓶颈是无法避免的。另一个问题是 SwiftUI 仍然缺乏此功能。

ClusterMap 有什么?

ClusterMap 提供了基于 四叉树 算法的高性能计算。这个库是 UI 框架独立的,并在任何线程中执行计算。

ClusterMap 与原生实现在 20,000 个注释上的比较。有关详细比较,请查看 Example-UIKit

Demo Cluster Demo MKMapKit

功能特性

接下来的版本有什么?

演示项目

该仓库包含三个主要示例,这是探索库基本功能的良好起点。

安装

Xcode

将 ClusterMap 依赖项作为包依赖项添加到 Xcode 项目。

  1. File 菜单中,选择 Add Packages...
  2. 在包仓库 URL 文本字段中输入 “https://github.com/vospennikov/ClusterMap.git”。

SPM 清单

将 ClusterMap 依赖项添加到您的 Package.swift 清单中。

  1. 将以下依赖项添加到您的 dependencies 参数中
    .package(url: "https://github.com/vospennikov/ClusterMap.git", from: "2.1.0")
  2. 将依赖项添加到您在清单中声明的任何目标
    .target(
      name: "MyTarget", 
      dependencies: [
        .product(name: "ClusterMap", package: "ClusterMap"),
      ]
    )

用法

基础知识

ClusterManager 存储和聚类地图点。它是一个 actor,并且是线程安全的。

  1. 您需要让您的地图点遵循协议 CoordinateIdentifiable, Identifiable, Hashable, Sendable

    // SwiftUI annotation
    struct ExampleAnnotation: CoordinateIdentifiable, Identifiable, Hashable {
      let id = UUID()
      var coordinate: CLLocationCoordinate2D
    }
    
    // MapKit (MKMapItem) integration
    extension MKMapItem: CoordinateIdentifiable, Identifiable, Hashable {
      let id = UUID()
      var coordinate: CLLocationCoordinate2D {
        get { placemark.coordinate }
        set(newValue) { }
      }
    }
    
    // MapKit (MKPointAnnotation) integration
    class ExampleAnnotation: MKPointAnnotation, CoordinateIdentifiable, Identifiable, Hashable {
      let id = UUID()
    }
  2. 您需要创建 ClusterManager 的实例,并将您的注释类型设置为替代 ExampleAnnotation

    let clusterManager = ClusterManager<ExampleAnnotation>()
  3. 接下来,您可以添加和删除您的点。

    let reykjavik = ExampleAnnotation(coordinates: CLLocationCoordinate2D(latitude: 64.1466, longitude: -21.9426))
    let akureyri = ExampleAnnotation(coordinates: CLLocationCoordinate2D(latitude: 65.6835, longitude: -18.1002))
    let husavik = ExampleAnnotation(coordinates: CLLocationCoordinate2D(latitude: 66.0449, longitude: -17.3389))
    let cities = [reykjavik, akureyri, husavik]
     
    await clusterManager.add(cities)
    await clusterManager.remove(husavik)
    await clusterManager.removeAll()

重新加载注释

SwiftUI 集成

  1. 为了正确计算聚类,ClusterMap 需要知道地图的大小。不幸的是,Apple 没有提供此信息。为了读取地图大小,您可以使用 GeometryReader 并将大小传递给您的模型对象。

    @State private var mapSize: CGSize = .zero
    
    var body: some View {
      GeometryReader(content: { geometryProxy in
        Map()
        .onAppear(perform: {
          mapSize = geometryProxy.size
        })
        .onChange(of: geometryProxy.size) { oldValue, newValue in
          mapSize = newValue
        }
      })
    }

    或者您可以使用来自 ClusterMapSwiftUI 包的预构建方法 .readSize

    import ClusterMapSwiftUI
    
    @State private var mapSize: CGSize = .zero
    
    var body: some View {
      Map()
        .readSize(onChange: { newValue in
          mapSize = newValue
      })
    }
  2. 接下来,您需要每次都将此大小传递给 ClusterMap 的 reload 方法。让我们在每次相机位置改变时重新加载我们的地图。在示例中,我们使用了现代 (iOS 17) 方法 onMapCameraChange。与 iOS 17 之前的 Map 集成有点棘手,有关更多信息,请查看 Example-SwiftUI/App/LegacyMap

    Map()
    .onMapCameraChange(frequency: .onEnd) { context in
      Task.detached { await clusterManager.reload(mapViewSize: mapSize, coordinateRegion: context.region) }
    }

    作为此方法的结果,ClusterMap 返回结构体 ClusterManager<ExampleAnnotation>.Difference。您需要将此差异应用于您的视图。

    private var annotations: [ExampleAnnotation] = []
    private var clusters: [ExampleClusterAnnotation] = []
    
    func applyChanges(_ difference: ClusterManager<ExampleAnnotation>.Difference) {
      for removal in difference.removals {
        switch removal {
        case .annotation(let annotation):
          annotations.removeAll { $0 == annotation }
        case .cluster(let clusterAnnotation):
          clusters.removeAll { $0.id == clusterAnnotation.id }
        }
      }
      for insertion in difference.insertions {
        switch insertion {
        case .annotation(let newItem):
          annotations.append(newItem)
        case .cluster(let newItem):
          clusters.append(ExampleClusterAnnotation(
            id: newItem.id,
            coordinate: newItem.coordinate,
            count: newItem.memberAnnotations.count
          ))
        }
      }
    }

有关集成的更多信息,请查看 Example-SwiftUI

UIKit 集成

  1. 为了正确计算聚类,ClusterMap 需要知道地图的大小。我们可以直接从 MKMapView 读取大小。

    var mapView = MKMapView(frame: .zero)
    
    func reloadMap() async {
      await clusterManager.reload(mapViewSize: mapView.bounds.size, coordinateRegion: mapView.region)
    }
  2. 作为此方法的结果,ClusterMap 返回结构体 ClusterManager<ExampleAnnotation>.Difference。您需要将此差异应用于您的视图。这很棘手,因为您的 MKMapView 保留了 MKAnnotation 并擦除了类型。您可以将注释转换为类型检查,或者将它们保存在额外的变量中。为了清晰的示例,我将它们保存在变量中。

    var annotations: [ExampleAnnotation] = []
    
    private func applyChanges(_ difference: ClusterManager<ExampleAnnotation>.Difference) {
      for annotationType in difference.removals {
        switch annotationType {
        case .annotation(let annotation):
          annotations.removeAll(where: { $0 == annotation })
          mapView.removeAnnotation(annotation)
        case .cluster(let clusterAnnotation):
          if let result = annotations.enumerated().first(where: { $0.element.id == clusterAnnotation.id }) {
            annotations.remove(at: result.offset)
            mapView.removeAnnotation(result.element)
          }
        }
      }
      for annotationType in difference.insertions {
        switch annotationType {
        case .annotation(let annotation):
          annotations.append(annotation)
          mapView.addAnnotation(annotation)
        case .cluster(let clusterAnnotation):
          let cluster = ClusterAnnotation()
          cluster.id = clusterAnnotation.id
          cluster.coordinate = clusterAnnotation.coordinate
          cluster.memberAnnotations = clusterAnnotation.memberAnnotations
          annotations.append(cluster)
          mapView.addAnnotation(cluster)
        }
      }
    }

有关集成的更多信息,请查看 Example-UIKit

TCA 集成

ClusterMap 是一个 UI 框架独立的库。您可以将此库集成到任何应用层。让我们看看如何将此库作为 TCA 依赖项集成。最后,您可以将结果保存在数据库中,并在任何时候重用项目,而无需额外的重量级计算。

struct ClusterMapClient {
  struct ClusterClientResult {
    let objects: [ExampleAnnotation]
    let clusters: [ExampleClusterAnnotation]
  }
  var clusterObjects: @Sendable ([ExampleAnnotation], MKCoordinateRegion, CGSize) async -> ClusterClientResult
}
extension DependencyValues {
  var clusterMapClient: ClusterMapClient {
    get { self[ClusterMapClient.self] }
    set { self[ClusterMapClient.self] = newValue }
  }
}

extension ClusterMapClient: DependencyKey {
  static var liveValue = ClusterMapClient(
    clusterObjects: { inputObjects, mapRegion, mapSize in
      let clusterManager = ClusterManager<ExampleAnnotations>()

      await clusterManager.add(inputObjects)
      await clusterManager.reload(mapViewSize: mapSize, coordinateRegion: mapRegion)
      
      var objects: [ExampleAnnotation] = []
      var clusters: [ExampleClusterAnnotation] = []
      await clusterManager.visibleAnnotations.forEach { annotationType in
        switch annotationType {
        case .annotation(let annotation):
          objects.append(annotation)
        case .cluster(let cluster):
          clusters.append(.init(coordinate: cluster.coordinate))
        }
      }
      
      return .init(objects: objects, clusters: clusters)
    }
  )
}

高级配置

ClusterManager 具有配置,可帮助您提高性能和控制聚类逻辑,有关更多信息,请查看 ClusterManager.Configuration

文档

有关发布版本和 main 的文档可在此处找到

鸣谢和感谢

这个项目基于 Lasha Efremidze 的工作,他创建了 Cluster

许可证

此库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE。