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)
            )

            registerAnnotationTouchListener()
        }
    }
}

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 属于 Annotation,其触摸事件通过 MapView.setOnAnnotationTouchListener 接收,与动态搜索控制器独立注册。

1
2
3
4
5
6
7
private fun registerAnnotationTouchListener() {
    mapView.setOnAnnotationTouchListener { touchType, position, touchedAnnotations ->
        if (touchType == TouchType.Click) {
            touchedAnnotations.firstOrNull()?.let { openPoiDetail(it) }
        }
    }
}

5. 生命周期清理

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

1
2
3
4
5
override fun onDestroyView() {
    dynamicPoiCtrl?.clear()
    mapView.setOnAnnotationTouchListener(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 使用 styleKey(isUserGraphic = 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 屏已 initialize 且 onReady 与地图其他能力相同
各屏 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 触摸使用 AnnotationTouchListener,见 触摸与手势。
  • 使用 styleKey 时须与当前 TSS 中 POI 样式一致。
  • 多屏场景另有接入要求,见 多屏同时显示 POI。

相关指南