一、项目介绍
1. 背景与动机
在很多位置服务类 App(如出行、餐饮、住宿、电商、社交)中,用户常常需要搜索附近的兴趣点(Point of Interest,简称 POI),例如查找“附近的餐厅”、“地铁站”、“加油站”等。高德地图作为国产领先的地图与定位服务提供商,提供了功能强大的 Android SDK,能够在 App 内快速完成地图展示、定位、POI 搜索等功能。
通过集成高德地图 POI 搜索,您的 App 可实现:
-
高性能离线/在线搜索:支持关键字、城市、周边、矩形区域等多种搜索方式
-
丰富的 POI 信息:包含名称、地址、电话、类型、坐标等
-
灵活的分页加载:支持一次性返回多页结果,提升用户体验
-
地图与列表联动:在地图上高亮、在列表里选择,提升交互性
本项目将带您从零开始,一步步完成 Android 集成高德地图 SDK、申请 API Key、UI 界面搭建、POI 搜索调用与结果展示、点击跳转、地理编码等功能,并提供完整示例代码。
2. 项目目标
-
快速集成高德地图 Android SDK:完成 Maven 依赖、Manifest 配置、基础 MapView 管理
-
实现关键词+城市检索:根据用户输入的“关键字”和“城市名”进行 POI 搜索
-
实现周边检索:在定位到的当前位置附近进行 POI 搜索
-
列表与地图双视图展示:在 RecyclerView 列表里显示搜索结果,并在 MapView 上添加标记
-
分页加载与下拉刷新:支持分页加载更多结果
-
Marker 点击与详情展示:点击列表或地图标记,弹出 POI 详情信息
二、相关知识
-
高德地图 Android SDK
-
提供 MapView/MapFragment 地图组件,用于渲染瓦片地图
-
AMap
对象是地图控制器,负责各种地图操作 -
需在高德开放平台官网申请 Android Key 并在 Manifest 中配置
-
-
POI 搜索 API
-
PoiSearch.Query
:封装搜索参数(关键字、城市、区域) -
PoiSearch
:发起异步或同步搜索 -
PoiSearch.OnPoiSearchListener
:回调搜索结果,包括分页信息
-
-
Android MapView 生命周期
-
必须在 Activity/Fragment 的
onCreate/onResume/onPause/onDestroy/onSaveInstanceState/onLowMemory
中调用对应方法
-
-
RecyclerView 列表展示
-
使用
RecyclerView
与自定义Adapter
展示 POI 列表 -
下拉刷新可使用
SwipeRefreshLayout
-
-
运行时权限
-
6.0+ 需动态申请定位权限:
ACCESS_COARSE_LOCATION
或ACCESS_FINE_LOCATION
-
请求成功后才能调用定位或周边检索
-
三、实现思路
-
准备工作
-
在高德开放平台申请 Android SDK Key
-
在项目中添加高德地图依赖
-
-
UI 布局
-
MapView
用于地图显示 -
EditText
+ “搜索”按钮 用于关键字检索 -
RecyclerView
用于结果列表,外层包裹SwipeRefreshLayout
支持下拉刷新
-
-
初始化地图
-
在
Application
中初始化高德 SDK -
在
MainActivity
的相应生命周期方法中管理MapView
-
-
权限申请
-
启动时申请定位权限,获取经纬度用于周边检索
-
-
POI 搜索
-
关键词+城市检索:用户输入关键字、城市名后调用
PoiSearch
查询 -
周边检索:获取当前位置后,调用
PoiSearch
设置中心点与半径并查询
-
-
结果展示
-
列表:UI 更新,刷新适配器
-
地图:在地图上添加
Marker
,并设置点击弹窗
-
-
分页与加载更多
-
通过
PoiResult.getPageCount()
与getPageNum()
实现分页加载
-
-
错误处理与提示
-
在网络或无权限的情况下给出提示;
-
四、环境与依赖
// 文件: app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.amappoisearch"
minSdkVersion 21
targetSdkVersion 34
versionCode 1
versionName "1.0.0"
}
buildFeatures {
viewBinding true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
// Kotlin & AndroidX
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.7.20"
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
// RecyclerView & SwipeRefreshLayout
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
// 高德地图 Android SDK
implementation 'com.amap.api:3dmap:7.2.1'
implementation 'com.amap.api:search:7.2.1'
}
五、整合代码
<!-- =======================================================
文件: app/src/main/AndroidManifest.xml
======================================================= -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.amappoisearch">
<!-- 必要权限 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:name=".MyApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.AMapPOISearch">
<!-- 在 meta-data 中配置高德 Key -->
<meta-data
android:name="com.amap.api.v2.apikey"
android:value="在此填写你申请的Key"/>
<activity android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
// =======================================================
// 文件: app/src/main/java/com/example/amappoisearch/MyApplication.kt
// =======================================================
package com.example.amappoisearch
import android.app.Application
import com.amap.api.maps.MapsInitializer
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// 初始化高德地图 SDK,加载离线地图时用到
MapsInitializer.updatePrivacyShow(this, true, true)
MapsInitializer.updatePrivacyAgree(this, true)
// MapsInitializer.initialize(this) // 视 SDK 版本可选
}
}
<!-- =======================================================
文件: app/src/main/res/layout/activity_main.xml
======================================================= -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 地图容器 -->
<com.amap.api.maps.MapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="300dp"/>
<!-- 搜索输入与按钮 -->
<LinearLayout
android:id="@+id/searchBar"
android:orientation="horizontal"
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white"
app:layout_anchor="@id/mapView"
app:layout_anchorGravity="bottom">
<EditText
android:id="@+id/etKeyword"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="40dp"
android:hint="输入关键字,如餐厅"/>
<Button
android:id="@+id/btnSearch"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="搜索"/>
</LinearLayout>
<!-- 下拉刷新包裹列表 -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="300dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvPoiList"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<!-- =======================================================
文件: app/src/main/res/layout/item_poi.xml
======================================================= -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:padding="12dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground">
<TextView
android:id="@+id/tvPoiName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="POI 名称"
android:textSize="16sp"
android:textColor="@android:color/black"/>
<TextView
android:id="@+id/tvPoiAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="POI 地址"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"/>
</LinearLayout>
// =======================================================
// 文件: app/src/main/java/com/example/amappoisearch/model/PoiItem.kt
// =======================================================
package com.example.amappoisearch.model
import com.amap.api.services.core.PoiItem
/**
* 简化版 POI 数据模型,包装高德的 PoiItem
*/
data class SimplePoi(
val title: String,
val snippet: String,
val latitude: Double,
val longitude: Double
) {
companion object {
fun from(amapPoi: PoiItem): SimplePoi {
return SimplePoi(
title = amapPoi.title ?: "",
snippet = amapPoi.snippet ?: "",
latitude = amapPoi.latLonPoint.latitude,
longitude = amapPoi.latLonPoint.longitude
)
}
}
}
// =======================================================
// 文件: app/src/main/java/com/example/amappoisearch/adapter/PoiAdapter.kt
// =======================================================
package com.example.amappoisearch.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.amappoisearch.databinding.ItemPoiBinding
import com.example.amappoisearch.model.SimplePoi
class PoiAdapter(
private val data: MutableList<SimplePoi> = mutableListOf(),
private val onClick: (SimplePoi) -> Unit
) : RecyclerView.Adapter<PoiAdapter.PoiViewHolder>() {
inner class PoiViewHolder(val binding: ItemPoiBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: SimplePoi) {
binding.tvPoiName.text = item.title
binding.tvPoiAddress.text = item.snippet
binding.root.setOnClickListener { onClick(item) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PoiViewHolder {
val b = ItemPoiBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return PoiViewHolder(b)
}
override fun onBindViewHolder(holder: PoiViewHolder, position: Int) {
holder.bind(data[position])
}
override fun getItemCount(): Int = data.size
fun setData(list: List<SimplePoi>) {
data.clear()
data.addAll(list)
notifyDataSetChanged()
}
fun addData(list: List<SimplePoi>) {
val old = data.size
data.addAll(list)
notifyItemRangeInserted(old, list.size)
}
}
// =======================================================
// 文件: app/src/main/java/com/example/amappoisearch/MainActivity.kt
// =======================================================
package com.example.amappoisearch
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.amap.api.maps.AMap
import com.amap.api.maps.MapView
import com.amap.api.maps.model.*
import com.amap.api.services.core.PoiItem
import com.amap.api.services.geocoder.GeocodeQuery
import com.amap.api.services.poisearch.*
import com.example.amappoisearch.adapter.PoiAdapter
import com.example.amappoisearch.databinding.ActivityMainBinding
import com.example.amappoisearch.model.SimplePoi
class MainActivity : AppCompatActivity(), PoiSearch.OnPoiSearchListener {
private lateinit var binding: ActivityMainBinding
private lateinit var mapView: MapView
private var aMap: AMap? = null
private val poiAdapter = PoiAdapter(onClick = this::onPoiClicked)
private var poiSearch: PoiSearch? = null
private var currentPage = 0
// 运行时定位权限
private val requestPermission = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { perms ->
val granted = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (granted) initMap() else
Toast.makeText(this, "需要定位权限以支持周边搜索", Toast.LENGTH_SHORT).show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 初始化 MapView
mapView = binding.mapView
mapView.onCreate(savedInstanceState)
// RecyclerView 设置
binding.rvPoiList.layoutManager = LinearLayoutManager(this)
binding.rvPoiList.adapter = poiAdapter
// 下拉刷新重置分页
binding.swipeRefresh.setOnRefreshListener {
currentPage = 0
startKeywordSearch()
}
// 搜索按钮点击
binding.btnSearch.setOnClickListener {
currentPage = 0
startKeywordSearch()
}
// 申请权限后初始化地图
requestPermission.launch(arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
))
}
/** 地图与定位初始化 */
private fun initMap() {
aMap = mapView.map
aMap?.uiSettings?.isZoomControlsEnabled = true
aMap?.uiSettings?.isMyLocationButtonEnabled = true
aMap?.isMyLocationEnabled = true
}
/** 发起关键字+城市 POI 搜索 */
private fun startKeywordSearch() {
val keyword = binding.etKeyword.text.toString().trim()
if (keyword.isEmpty()) {
Toast.makeText(this, "请输入关键字", Toast.LENGTH_SHORT).show()
binding.swipeRefresh.isRefreshing = false
return
}
// 构造查询参数:keyword、城市限定“全国”、每页20条、页码
val query = PoiSearch.Query(keyword, "", "")
.apply {
pageSize = 20
pageNum = currentPage
}
poiSearch?.setOnPoiSearchListener(null)
poiSearch = PoiSearch(this, query).apply {
setOnPoiSearchListener(this@MainActivity)
searchPOIAsyn()
}
}
/** 发起周边搜索(以当前位置为中心,500米范围) */
private fun startAroundSearch(lat: Double, lon: Double) {
val query = PoiSearch.Query("", "", "")
.apply {
pageSize = 20
pageNum = currentPage
}
poiSearch = PoiSearch(this, query).apply {
bound = PoiSearch.SearchBound(
LatLonPoint(lat, lon),
500, // 半径500米
true // 是否按距离排序
)
setOnPoiSearchListener(this@MainActivity)
searchPOIAsyn()
}
}
/** POI 搜索结果回调 */
override fun onPoiSearched(result: PoiResult?, rCode: Int) {
binding.swipeRefresh.isRefreshing = false
if (rCode != 1000) {
Toast.makeText(this, "搜索失败:错误码 $rCode", Toast.LENGTH_SHORT).show()
return
}
val poiResult = result ?: return
val list = poiResult.pois.map(PoiItem::let).map(SimplePoi::from)
if (currentPage == 0) {
poiAdapter.setData(list)
} else {
poiAdapter.addData(list)
}
addMarkersToMap(list)
// 若还有更多页,可在列表底部触发 currentPage++ 再调用
}
override fun onPoiItemSearched(p0: PoiItem?, p1: Int) {
// 单个查询结果,可忽略
}
/** 在地图上添加标记 */
private fun addMarkersToMap(list: List<SimplePoi>) {
aMap?.clear()
list.forEach { poi ->
val marker = aMap?.addMarker(
MarkerOptions()
.position(LatLng(poi.latitude, poi.longitude))
.title(poi.title)
)
marker?.tag = poi
}
// 地图缩放至第一个 POI
list.firstOrNull()?.let {
aMap?.moveCamera(CameraUpdateFactory.newLatLngZoom(
LatLng(it.latitude, it.longitude), 14f
))
}
// 点击 Marker 弹窗
aMap?.setOnMarkerClickListener { marker ->
val poi = marker.tag as? SimplePoi
Toast.makeText(this, "${poi?.title}\n${poi?.snippet}", Toast.LENGTH_SHORT).show()
true
}
}
// MapView 生命周期方法
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onDestroy(){ super.onDestroy();mapView.onDestroy() }
override fun onLowMemory(){ super.onLowMemory();mapView.onLowMemory() }
override fun onSaveInstanceState(out: Bundle){ super.onSaveInstanceState(out); mapView.onSaveInstanceState(out) }
}
六、代码解读
-
AndroidManifest.xml
-
声明必要的网络和定位权限;
-
在
<meta-data>
中填入高德地图申请的 API Key;
-
-
MyApplication
-
在 Application 启动时调用
MapsInitializer.updatePrivacyShow/Agree
,满足隐私合规;
-
-
布局文件
-
MapView
:用于渲染地图瓦片; -
EditText
+Button
:关键字搜; -
SwipeRefreshLayout
+RecyclerView
:下拉刷新与分页加载列表;
-
-
数据模型
-
SimplePoi
:对高德PoiItem
的简化包装,方便 UI 层使用;
-
-
PoiAdapter
-
标准 RecyclerView 适配器,绑定 POI 名称、地址,并处理点击;
-
-
MainActivity
-
权限申请:使用
ActivityResultContracts.RequestMultiplePermissions
动态获取定位权限; -
地图初始化:在权限同意后开启
isMyLocationEnabled
; -
关键字检索:构造
PoiSearch.Query(keyword, cityCode, "")
,调用searchPOIAsyn()
; -
周边检索:使用
SearchBound
指定中心点与半径; -
结果回调:在
onPoiSearched()
中更新列表、添加地图标记,并支持分页; -
Marker 交互:点击标记弹出 Toast 展示 POI 详情;
-
MapView 生命周期管理:在 Activity 对应生命周期方法中调用;
-
七、项目总结与拓展
1. 总结
-
本文基于高德地图 Android SDK,实现了关键字检索与周边检索两种 POI 搜索场景;
-
UI 层采用 MapView + RecyclerView 双视图展示,并支持分页加载与点击交互;
-
完整演示了高德地图 SDK 的初始化、权限请求、生命周期管理、POI 搜索接口调用及结果处理;
2. 拓展方向
-
更多搜索模式
-
矩形区域检索:使用
SearchBound(LatLonPoint, LatLonPoint)
-
多类型过滤:在
Query
中传入 POI 类型参数进行筛选
-
-
地图交互增强
-
自定义 Marker:使用
BitmapDescriptorFactory.fromResource
加载自定义图标 -
InfoWindow:实现自定义 InfoWindow 布局,展示更丰富信息
-
-
UI 优化
-
分页加载更多:在 RecyclerView 滑动到底部时自动加载
currentPage++
并发起下一页请求 -
定位到当前位置:增加“我的位置”按钮,中心对准用户当前位置,并触发周边检索
-
-
性能与离线
-
离线地图:下载地图切片包,实现断网情况下的地图浏览
-
缓存搜索结果:本地存储最近搜索结果,减少网络请求
-
-
与其他 SDK 集成
-
导航:在点击 POI 后调用高德导航组件,提供驾车/步行导航
-
轨迹回放:配合定位组件记录轨迹并在地图上回放
-
八、FAQ
Q1:如何申请高德地图 SDK Key?
A1:访问高德开放平台官网(https://lbs.amap.com),注册账号、创建应用并获取 Key,将其填写到 Manifest 的 com.amap.api.v2.apikey
中。
Q2:为什么需要动态申请定位权限?
A2:Android 6.0+ 运行时权限机制要求用户授予敏感权限后才能使用,否则调用定位或周边检索会报错。
Q3:如何处理分页加载?
A3:通过 PoiResult.getPageCount()
获取总页数,每次搜索时设置 query.pageNum = currentPage
,当 currentPage < pageCount - 1
时可以继续加载更多。
Q4:MapView 与 Fragment 如何结合?
A4:在 Fragment 中同样需要在 onCreateView/onResume/onPause/onDestroy/onSaveInstanceState/onLowMemory
中调用 mapView
对应生命周期方法。
Q5:如何实现矩形区域检索?
A5:使用 poiSearch.searchBound = PoiSearch.SearchBound(LatLonPoint(southWest), LatLonPoint(northEast))
即可。