Skip to content

地图显示兴趣点(POI)

本指南:通过 DynamicPOISearchController 注入搜索引擎,随相机移动在地图上展示并刷新 POI。

功能介绍

DynamicPOISearchController 提供 动态 POI 搜索 能力:业务方实现 DynamicSearchEngine 提供数据,SDK 监听相机移动触发搜索,支持 ID 追踪增量更新 与可配置的 定时刷新(如 EV 充电桩实时状态)。

效果示意

地图 POI 搜索

能力 说明
相机联动 相机移动后按可视区域自动搜索
增量更新 通过稳定的 POIAnnotationData.id 增删改标注
刷新策略 fixedInterval 定时刷新或 DISABLED 仅随相机搜索
自定义调度 可注入 CoroutineDispatcher 控制搜索线程

获取入口(单块 MapView):

1
val dynamicCtrl = mapView.getDynamicPOISearchController() ?: return

核心接口一览

方法 说明
injectSearchEngine(searchEngine, dispatcher?) 注入 DynamicSearchEngine
setRefreshStrategy(strategy?) 设置 POI 刷新策略
displayPOI(displayContent) 按类别/关键词展示 POI
clear() 清除当前屏 POI 标注;并清空 SDK 共享搜索缓存(多屏下任意一屏调用即可)

接口详细说明

1. injectSearchEngine — 注入动态搜索引擎

1
2
3
4
fun injectSearchEngine(
    searchEngine: DynamicSearchEngine,
    dispatcher: CoroutineDispatcher? = null
)
参数 类型 说明
searchEngine DynamicSearchEngine 业务实现的搜索接口;多次注入仅最后一次生效
dispatcher CoroutineDispatcher? 搜索任务协程调度器;默认 Dispatchers.IO

DynamicSearchEngine(业务方实现):

1
2
3
4
fun search(
    searchUnitParams: SearchUnitParams,
    originalResult: List<POIAnnotationData>? = null
): List<POIAnnotationData>?
参数 类型 说明
searchUnitParams SearchUnitParams SDK 根据相机位置生成的搜索单元(可视区域、类别、语言等)
originalResult List<POIAnnotationData>? 已有结果;null 表示首次搜索,非 null 表示刷新
返回值 List<POIAnnotationData>? 更新后的 POI 列表;null 视为空列表

SearchUnitParams 主要字段

字段 类型 说明
poiLayer PoiLayer POI 图层,与缩放级别相关
searchBox List<GeoPoint> 当前可视区域多边形
displayContent List<String> POI 类别或关键词(与 displayPOI 传入一致)
languageString String 结果语言
maxResultCount Int 最大返回数量
lat / lon Double 搜索中心经纬度

抛出 TimeoutException 时 SDK 会重试;抛出其他 Exception 不会 以相同参数重试。

POIAnnotationData(搜索结果项):

字段 类型 说明
id String 必须稳定且唯一,用于增量更新与移除
styleKey String TSS 样式 key(isUserGraphic = false 时)
location Location POI 坐标
text String 标注文字
isUserGraphic / userGraphic 自定义图标时使用
addStringValue / addFloatValue 刷新时更新标注属性,无需重建整条 POI

示例代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
dynamicCtrl.injectSearchEngine(object : DynamicSearchEngine {
    override fun search(
        searchUnitParams: SearchUnitParams,
        originalResult: List<POIAnnotationData>?
    ): List<POIAnnotationData>? {
        return if (originalResult == null) {
            mySearchService.searchInBox(searchUnitParams)
        } else {
            mySearchService.refresh(searchUnitParams, originalResult)
        }
    }
})

2. setRefreshStrategy — 设置刷新策略

1
fun setRefreshStrategy(strategy: PoiRefreshStrategy? = null)
参数 类型 说明
strategy PoiRefreshStrategy? 刷新策略;null 等同 DISABLED
策略 说明
PoiRefreshStrategies.fixedInterval(ms) 固定间隔刷新已有 POI;最小 30 秒30 * 1000L
PoiRefreshStrategies.DISABLED 不自动刷新,仅在相机移动时触发新搜索

示例代码

1
2
3
4
5
// EV 充电桩等需周期性更新状态
dynamicCtrl.setRefreshStrategy(PoiRefreshStrategies.fixedInterval(30 * 1000L))

// 仅随地图拖动搜索,不后台刷新
dynamicCtrl.setRefreshStrategy(PoiRefreshStrategies.DISABLED)

3. displayPOI — 展示 POI

1
fun displayPOI(displayContent: List<String>)
参数 类型 说明
displayContent List<String> POI 类别或关键词列表;须先 injectSearchEngine

调用后 SDK 监听相机移动,在可视区域内自动触发 DynamicSearchEngine.search 并渲染标注。

示例代码

1
dynamicCtrl.displayPOI(listOf("ev_charging", "restaurant"))

4. clear — 清除 POI

1
fun clear()

清除当前地图上由动态 POI 搜索展示的标注,并清空 SDK 内部的 共享搜索缓存

主要使用场景:切换 POI 类别——在调用 displayPOI(新类别) 之前先 clear(),避免旧类别的缓存结果干扰新类别展示。退出 POI 浏览页、页面销毁时也可调用。

示例代码

1
2
3
// 从「餐厅」切换到「充电桩」:先清缓存与当前标注,再展示新类别
dynamicCtrl.clear()
dynamicCtrl.displayPOI(listOf("ev_charging"))

单屏集成示例

以下示例面向最常见的 单块 MapView 场景,展示完整接入流程:MapView 就绪后注入搜索引擎配置刷新策略按类别展示 POI处理点击与生命周期清理。数据层以 Entity SDK 为例,亦可替换为自有 HTTP 服务。

多屏(如主屏 + 仪表盘)同时展示 POI 的接入方式见下文 多屏同时显示 POI

1. 初始化与注入

MapView.onReady 之后获取 DynamicPOISearchController,注入 DynamicSearchEngine 并设置刷新策略(须在 displayPOI 之前完成)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class PoiSearchHostFragment : Fragment() {

    private lateinit var mapView: MapView
    private var dynamicPoiCtrl: DynamicPOISearchController? = null
    private val searchEngine = EvPoiSearchEngine()  // 见下文实现

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        mapView.setOnReadyListener {
            dynamicPoiCtrl = mapView.getDynamicPOISearchController() ?: return@setOnReadyListener

            // 可选:自定义协程调度器,避免与主线程或其他 IO 任务争抢
            val dispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
            dynamicPoiCtrl?.injectSearchEngine(searchEngine, dispatcher)

            // EV 等需周期性更新状态的 POI:最小间隔 30 秒
            dynamicPoiCtrl?.setRefreshStrategy(
                PoiRefreshStrategies.fixedInterval(30 * 1000L)
            )

            registerPoiTouchListener()
        }
    }
}

2. 实现 DynamicSearchEngine

originalResult == null 时做首次搜索;非 null 时做定时刷新(在 fixedInterval 策略下由 SDK 回调)。POIAnnotationData.id 须使用业务侧稳定 ID(如 Entity entity.id)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class EvPoiSearchEngine : DynamicSearchEngine {

    override fun search(
        searchUnitParams: SearchUnitParams,
        originalResult: List<POIAnnotationData>?
    ): List<POIAnnotationData>? {
        return if (originalResult == null) {
            searchVisibleArea(searchUnitParams)
        } else {
            refreshExistingPois(searchUnitParams, originalResult)
        }
    }

    private fun searchVisibleArea(params: SearchUnitParams): List<POIAnnotationData> {
        val polygon = Polygon.builder()
            .setPoints(params.searchBox)
            .build()
        val filters = SearchFilters.builder()
            .setCategoryFilter(CategoryFilter.builder().setCategories(params.displayContent).build())
            .setGeoFilter(PolygonGeoFilter.builder(polygon).build())
            .build()

        val response = EntityService.getClient().searchRequest()
            .setFilters(filters)
            .setLimit(params.maxResultCount)
            .setLocation(params.lat, params.lon)
            .execute()

        return response.results?.mapNotNull { entity -> entity.toPoiAnnotationData() } ?: emptyList()
    }

    /** 刷新:在 originalResult 上更新图标/状态,保持 id 不变 */
    private fun refreshExistingPois(
        params: SearchUnitParams,
        original: List<POIAnnotationData>
    ): List<POIAnnotationData> {
        return original.map { poi ->
            poi.copy().apply {
                // 示例:根据后端最新状态更新 TSS 动态属性
                val available = fetchChargerStatus(poi.id)
                addFloatValue("available", if (available) 1f else 0f)
            }
        }
    }

    private fun Entity.toPoiAnnotationData(): POIAnnotationData? {
        if (type != EntityType.PLACE) return null
        val loc = Location("poi").apply {
            latitude = place.address.geoCoordinates.latitude
            longitude = place.address.geoCoordinates.longitude
        }
        return POIAnnotationData(
            id = id,                    // 稳定 ID,勿用随机值
            isUserGraphic = false,
            userGraphic = null,
            styleKey = "poi_annotations.ev_charging",  // 须与当前 TSS 一致
            location = loc,
            text = place.name
        )
    }
}

3. 开启、关闭与切换 POI 类别

用户进入 POI 浏览、切换搜索类别,或关闭 POI 图层时调用 displayPOI切换类别时建议先 clear()displayPOI,确保旧类别缓存被清空。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 展示 EV 充电桩 + 餐厅
fun showEvAndRestaurantPoi() {
    dynamicPoiCtrl?.displayPOI(listOf("ev_charging", "restaurant"))
}

// 切换到仅展示充电桩
fun switchToEvChargingOnly() {
    dynamicPoiCtrl?.clear()
    dynamicPoiCtrl?.displayPOI(listOf("ev_charging"))
}

// UI 开关:关闭 POI 图层
fun hidePoiByCategory() {
    dynamicPoiCtrl?.displayPOI(emptyList())
}

4. 注册 POI 点击

POI 标注的触摸事件通过 MapView.setOnPOITouchListener 接收,与动态搜索控制器独立注册。

1
2
3
4
5
6
7
8
private fun registerPoiTouchListener() {
    mapView.setOnPOITouchListener { touchType, position, poiDescription ->
        if (touchType == TouchType.Click) {
            // poiDescription 为被点击 POI 的描述信息,可与 POIAnnotationData.extrasInfo 对应
            poiDescription?.let { openPoiDetail(it) }
        }
    }
}

5. 生命周期清理

页面销毁或彻底退出 POI 功能时清除标注并释放缓存。

1
2
3
4
5
override fun onDestroyView() {
    dynamicPoiCtrl?.clear()
    mapView.setOnPOITouchListener(null)
    super.onDestroyView()
}

调用顺序小结

1
2
3
4
5
6
7
8
MapView.onReady
    → injectSearchEngine(engine)
    → setRefreshStrategy(...)        // 可选
    → displayPOI(categories)         // 开始展示
         ↳ 相机移动 → engine.search(params, null)
         ↳ 定时刷新 → engine.search(params, originalResult)
    → clear() + displayPOI(新类别)   // 切换类别
    → clear()                        // 退出 POI 功能 / 页面销毁

多屏同时显示 POI

除单屏外,HMI 也可在 多块地图(如中控 TnMapView + 仪表盘 TnClusterMapView,或两个 TnMapView)上同时展示相同类别的 POI。

多屏地图实例、Surface 生命周期与数据同步总览见 多屏显示。本节说明 POI 在多屏下的共享与接入逻辑

设计原则

组件 是否共享 说明
DynamicSearchEngine 共享(推荐单例) 多屏注入同一实例,避免重复发起相同搜索
搜索缓存与进行中请求 共享(SDK 内部) 全局 LRU 缓存(默认 500 个 search unit);相同 search unit 的并发请求会合并
DynamicPOISearchController 不共享 每块地图各有一个,分别 injectSearchEngine / displayPOI
POI Annotation 不共享 各屏在自己的 AnnotationsController 上独立 add / update / remove
相机 / 可视区域 不共享 各屏按自身视野触发搜索;两屏 POI 数量可能不同
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
HMI App
  |
  +-- MapView(主屏)
  |     +-- DynamicPOISearchController
  |     +-- Annotations(本屏渲染)
  |
  +-- ClusterMapView(仪表盘)
  |     +-- DynamicPOISearchController
  |     +-- Annotations(本屏渲染)
  |
  +-- DynamicSearchEngine(单例 / 长生命周期)  ← 多屏共享
        |
        v
  SDK 内部 DynamicPOISearchRepository
    - 共享 LRU cache
    - 相同 search unit 的 in-flight 请求合并(JOINED_IN_FLIGHT)

接入步骤

1. 创建共享 DynamicSearchEngine(App 层单例)

1
2
3
4
5
6
7
private var sharedDynamicSearchEngine: DynamicSearchEngine? = null

private fun getOrCreateSharedSearchEngine(context: Context): DynamicSearchEngine {
    return sharedDynamicSearchEngine ?: MyDynamicSearchEngine(
        context.applicationContext
    ).also { sharedDynamicSearchEngine = it }
}

2. 各屏 ready 后分别获取 Controller,注入同一 Engine

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
private var mainDynamicPoiCtrl: DynamicPOISearchController? = null
private var clusterDynamicPoiCtrl: DynamicPOISearchController? = null
private val poiCategories = listOf("ev_charging", "restaurant")

private val dynamicPoiDispatcher =
    Executors.newFixedThreadPool(6).asCoroutineDispatcher()

private fun configureDynamicPoiSearch(controller: DynamicPOISearchController?) {
    controller ?: return
    val engine = getOrCreateSharedSearchEngine(requireContext())
    controller.injectSearchEngine(engine, dynamicPoiDispatcher)
    controller.setRefreshStrategy(PoiRefreshStrategies.fixedInterval(30 * 1000L))
}

// 主屏 MapView onReady
mainDynamicPoiCtrl = mapView.getDynamicPOISearchController()
configureDynamicPoiSearch(mainDynamicPoiCtrl)

// Cluster 屏 onReady
clusterDynamicPoiCtrl = clusterMapView.getDynamicPOISearchController()
configureDynamicPoiSearch(clusterDynamicPoiCtrl)

3. 各屏分别调用 displayPOI(相同类别列表)

1
2
3
4
5
fun updateShowPoiOnAllMaps(enabled: Boolean) {
    val categories = if (enabled) poiCategories else emptyList()
    mainDynamicPoiCtrl?.displayPOI(categories)
    clusterDynamicPoiCtrl?.displayPOI(categories)
}

注意:仅 injectSearchEngine 不会自动显示 POI;每一块地图都必须各自调用 displayPOI()。共享的是搜索数据,不是某一屏的显示状态。

4. 车辆位置写入共享 Engine(一次更新,多屏受益)

DynamicSearchEngine 实现依赖自车位置(如沿路线或距离排序),在定位回调中更新 Engine 即可,无需分别写入各 Controller:

1
2
3
fun onVehicleLocationUpdated(location: Location) {
    (sharedDynamicSearchEngine as? MyDynamicSearchEngine)?.updateVehicleLocation(location)
}

多屏样式建议

多屏场景推荐 POIAnnotationData 使用 styleKeyisUserGraphic = false),由各屏自己的 TSS 控制图标样式:

1
2
3
4
5
6
7
POIAnnotationData(
    id = entity.id,           // 须全局唯一且稳定
    isUserGraphic = false,
    styleKey = "poi_annotations.ev_charging",
    location = loc,
    text = entity.name
)

主屏与 Cluster 屏可能加载不同样式文件(如 styles/default/styles/cluster/),但 styleKey 须在各自 TSS 中存在,否则该屏 POI 不可见。详见 地图配色方案

共享缓存与 clear()

多屏共用 DynamicSearchEngine 时,SDK 内部维护全局共享搜索缓存。clear() 行为须特别注意:

行为 说明
清空共享缓存 任意一块地图的 Controller 调用一次 clear() 即可清空全局缓存,无需各屏都调
清除 POI 标注 仅清除调用方所在屏已展示的 POI 标注;其他屏上的标注不会因此自动移除
典型用途 切换 POI 类别:先由任意一屏 clear() 清缓存,再在各屏 displayPOI(新类别)
1
2
3
4
5
6
// 多屏切换 POI 类别:任意一屏 clear() 清共享缓存即可
fun switchPoiCategoryOnAllMaps(newCategories: List<String>) {
    mainDynamicPoiCtrl?.clear()   // 清全局缓存;也可由 cluster 屏调用,效果相同
    mainDynamicPoiCtrl?.displayPOI(newCategories)
    clusterDynamicPoiCtrl?.displayPOI(newCategories)
}

注意:若切换类别后某屏仍短暂显示旧 POI 标注,可在该屏补调一次 clear() 后再 displayPOI,或依赖 displayPOI 触发的搜索刷新覆盖。

其他缓存相关行为:

场景 行为
屏 A 先搜到某 search unit 结果写入全局共享缓存
屏 B 视野命中同一 search unit 直接读缓存,不再重复调用 DynamicSearchEngine.search
两屏同时请求同一 search unit 进行中的请求合并
最后一个 Controller dispose() 活跃 Controller 计数归零时,SDK 清空共享缓存

功能模块整体退出时,各屏按需 clear() 移除标注,并释放 sharedDynamicSearchEngine 引用:

1
2
3
4
5
mainDynamicPoiCtrl?.clear()
clusterDynamicPoiCtrl?.clear()
mainDynamicPoiCtrl = null
clusterDynamicPoiCtrl = null
sharedDynamicSearchEngine = null

多屏集成检查清单

检查项 说明
多屏共用同一个 DynamicSearchEngine 核心要求,避免重复搜索
每屏各自 getDynamicPOISearchController() Controller 不可跨屏复用
每屏各自 displayPOI(sameCategories) inject 不等于显示
Cluster 屏已 initializeonReady 与地图其他能力相同
各屏 TSS 含对应 poi_annotations.* 样式按屏独立配置
POIAnnotationData.id 全局唯一 跨屏增量更新依赖稳定 ID
切换类别时任意一屏 clear() 即可 清空共享缓存,无需每屏都调

常见问题(多屏)

Q1:主屏有 POI,Cluster 屏没有?

  1. Cluster 屏是否调用了 displayPOI()(仅 inject 不够)
  2. Cluster Controller 是否注入了同一个 DynamicSearchEngine 实例
  3. Cluster 屏当前 zoom 是否满足搜索触发条件(过低 zoom 时不搜索)
  4. Cluster 屏 TSS 是否包含对应 styleKey

Q2:两屏 POI 数量不一致?

属正常现象。各屏相机视野不同,命中的 search unit 不同;共享缓存只避免对同一 search unit 重复搜索,不保证两屏 POI 列表完全相同。

Q3:是否需要每屏各实现一个 Search Engine?

不需要。一个 DynamicSearchEngine 实例即可,由 HMI 在 App 层维护单例或长生命周期对象。

Q4:多屏切换 POI 类别时,是否每屏都要 clear()

不需要。clear() 会清空全局共享搜索缓存,任意一屏调用一次即可;主要目的是切换类别前丢弃旧缓存。之后在各屏分别 displayPOI(新类别)clear() 只移除调用方所在屏的标注,若需立刻去掉其他屏旧标注,可在对应屏补调 clear()

注意事项

  • 须先 injectSearchEngine,再调用 displayPOI,否则不生效。
  • POIAnnotationData.id 必须在多次搜索/刷新间保持稳定,否则无法正确增量更新。
  • 搜索数据由 Entity SDK 或自有后端提供,地图模块负责 展示与相机/刷新联动
  • POI 触摸使用 POITouchListener,见 触摸与手势
  • 使用 styleKey 时须与当前 TSS 中 POI 样式一致。
  • 多屏场景另有接入要求,见 多屏同时显示 POI

相关指南