Android实现高德地图POI搜索(附带源码)

一、项目介绍

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 详情信息


二、相关知识

  1. 高德地图 Android SDK

    • 提供 MapView/MapFragment 地图组件,用于渲染瓦片地图

    • AMap 对象是地图控制器,负责各种地图操作

    • 需在高德开放平台官网申请 Android Key 并在 Manifest 中配置

  2. POI 搜索 API

    • PoiSearch.Query:封装搜索参数(关键字、城市、区域)

    • PoiSearch:发起异步或同步搜索

    • PoiSearch.OnPoiSearchListener:回调搜索结果,包括分页信息

  3. Android MapView 生命周期

    • 必须在 Activity/Fragment 的 onCreate/onResume/onPause/onDestroy/onSaveInstanceState/onLowMemory 中调用对应方法

  4. RecyclerView 列表展示

    • 使用 RecyclerView 与自定义 Adapter 展示 POI 列表

    • 下拉刷新可使用 SwipeRefreshLayout

  5. 运行时权限

    • 6.0+ 需动态申请定位权限:ACCESS_COARSE_LOCATIONACCESS_FINE_LOCATION

    • 请求成功后才能调用定位或周边检索


三、实现思路

  1. 准备工作

    • 在高德开放平台申请 Android SDK Key

    • 在项目中添加高德地图依赖

  2. UI 布局

    • MapView 用于地图显示

    • EditText + “搜索”按钮 用于关键字检索

    • RecyclerView 用于结果列表,外层包裹 SwipeRefreshLayout 支持下拉刷新

  3. 初始化地图

    • Application 中初始化高德 SDK

    • MainActivity 的相应生命周期方法中管理 MapView

  4. 权限申请

    • 启动时申请定位权限,获取经纬度用于周边检索

  5. POI 搜索

    • 关键词+城市检索:用户输入关键字、城市名后调用 PoiSearch 查询

    • 周边检索:获取当前位置后,调用 PoiSearch 设置中心点与半径并查询

  6. 结果展示

    • 列表:UI 更新,刷新适配器

    • 地图:在地图上添加 Marker,并设置点击弹窗

  7. 分页与加载更多

    • 通过 PoiResult.getPageCount()getPageNum() 实现分页加载

  8. 错误处理与提示

    • 在网络或无权限的情况下给出提示;


四、环境与依赖

// 文件: 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) }
}

六、代码解读

  1. AndroidManifest.xml

    • 声明必要的网络和定位权限;

    • <meta-data> 中填入高德地图申请的 API Key;

  2. MyApplication

    • 在 Application 启动时调用 MapsInitializer.updatePrivacyShow/Agree,满足隐私合规;

  3. 布局文件

    • MapView:用于渲染地图瓦片;

    • EditText + Button:关键字搜;

    • SwipeRefreshLayout + RecyclerView:下拉刷新与分页加载列表;

  4. 数据模型

    • SimplePoi:对高德 PoiItem 的简化包装,方便 UI 层使用;

  5. PoiAdapter

    • 标准 RecyclerView 适配器,绑定 POI 名称、地址,并处理点击;

  6. 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. 拓展方向

  1. 更多搜索模式

    • 矩形区域检索:使用 SearchBound(LatLonPoint, LatLonPoint)

    • 多类型过滤:在 Query 中传入 POI 类型参数进行筛选

  2. 地图交互增强

    • 自定义 Marker:使用 BitmapDescriptorFactory.fromResource 加载自定义图标

    • InfoWindow:实现自定义 InfoWindow 布局,展示更丰富信息

  3. UI 优化

    • 分页加载更多:在 RecyclerView 滑动到底部时自动加载 currentPage++ 并发起下一页请求

    • 定位到当前位置:增加“我的位置”按钮,中心对准用户当前位置,并触发周边检索

  4. 性能与离线

    • 离线地图:下载地图切片包,实现断网情况下的地图浏览

    • 缓存搜索结果:本地存储最近搜索结果,减少网络请求

  5. 与其他 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)) 即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值