HarmonyOS APP开发:周边搜索与POI检索
HarmonyOS APP开发:周边搜索与POI检索
核心要点:本文深入讲解HarmonyOS平台基于位置服务(LBS)的周边搜索与POI(Point of Interest)检索技术实现,涵盖地理编码/逆地理编码、关键词搜索、周边区域搜索、分类筛选等核心能力,结合Map Kit与Location Kit构建完整的周边发现应用。
| 项目 | 说明 |
|---|---|
| 核心Kit | Map Kit、Location Kit、Network Kit |
| 难度等级 | ⭐⭐⭐☆☆ |
一、背景与动机
1.1 LBS服务的行业价值
基于位置的服务(Location-Based Service,LBS)已成为移动互联网的基础设施级能力。从外卖配送、出行导航到本地生活服务,LBS技术渗透到了用户日常生活的方方面面。根据行业数据显示,超过70%的移动应用场景与位置信息直接或间接相关。
在HarmonyOS生态中,Map Kit与Location Kit为开发者提供了完整的LBS能力矩阵。其中,POI检索是最基础也最高频的LBS功能——用户通过搜索周边的兴趣点(餐厅、加油站、药店等),获取位置、距离、评分等信息,进而完成决策与导航。
1.2 为什么需要深入理解POI检索
POI检索看似简单——“搜索附近”——但其背后涉及多个技术环节的协同:
- 定位获取:如何高效、省电地获取用户当前位置
- 搜索策略:关键词搜索 vs 分类搜索 vs 周边搜索的差异
- 结果排序:距离优先、评分优先、热度优先的排序逻辑
- 地图渲染:POI标记的聚合、点击交互与信息窗展示
- 性能优化:大数据量下的分页加载与缓存策略
1.3 本文目标
构建一个完整的「周边探索」应用,实现以下核心功能:
- 获取用户实时位置
- 支持关键词与分类两种POI搜索模式
- 在地图上展示搜索结果并支持交互
- 列表视图与地图视图联动切换
二、核心原理
2.1 POI检索技术架构
flowchart TB
classDef kit fill:#4ECDC4,stroke:#2C3E50,color:#fff,font-weight:bold
classDef service fill:#FF6B6B,stroke:#2C3E50,color:#fff,font-weight:bold
classDef data fill:#45B7D1,stroke:#2C3E50,color:#fff,font-weight:bold
classDef ui fill:#96CEB4,stroke:#2C3E50,color:#fff,font-weight:bold
A[用户触发搜索]:::ui --> B{搜索类型判断}
B -->|关键词| C[关键词POI搜索]:::service
B -->|分类| D[分类POI搜索]:::service
B -->|周边| E[周边区域搜索]:::service
C --> F[Map Kit POI Service]:::kit
D --> F
E --> F
F --> G[华为地图云服务]:::data
G --> H[返回POI结果集]:::data
H --> I[结果解析与排序]:::service
I --> J[地图标记渲染]:::ui
I --> K[列表视图渲染]:::ui
L[Location Kit]:::kit --> M[获取用户位置]:::service
M --> C
M --> D
M --> E
style A fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
style B fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
2.2 关键概念解析
POI(Point of Interest)
POI即兴趣点,是地图上的一个具体位置标注,包含以下核心属性:
| 属性 | 类型 | 说明 |
|---|---|---|
id |
string | POI唯一标识 |
name |
string | POI名称 |
location |
LatLng | 经纬度坐标 |
address |
string | 详细地址 |
distance |
number | 与搜索中心点的距离(米) |
phoneNum |
string | 联系电话 |
poiType |
string | POI分类编码 |
搜索模式对比
| 搜索模式 | API | 适用场景 | 优势 |
|---|---|---|---|
| 关键词搜索 | poiKeywordSearch |
精确查找特定商家/地点 | 结果精准,支持模糊匹配 |
| 分类搜索 | poiCategorySearch |
浏览某类场所(如所有餐厅) | 覆盖全面,适合探索 |
| 周边搜索 | searchNearby |
查找附近所有POI | 距离优先排序 |
2.3 地理编码与逆地理编码
flowchart LR
classDef encode fill:#FF6B6B,stroke:#2C3E50,color:#fff,font-weight:bold
classDef decode fill:#4ECDC4,stroke:#2C3E50,color:#fff,font-weight:bold
classDef result fill:#45B7D1,stroke:#2C3E50,color:#fff,font-weight:bold
A[地址文本]:::encode -->|地理编码| B[经纬度坐标]:::result
C[经纬度坐标]:::decode -->|逆地理编码| D[地址描述]:::result
B --> E[地图定位标记]:::result
D --> F[位置信息展示]:::result
style A fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
style C fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
- 地理编码(Geocoding):将地址文本(如"北京市海淀区中关村")转换为经纬度坐标
- 逆地理编码(Reverse Geocoding):将经纬度坐标转换为可读的地址描述
三、代码实战
3.1 权限配置与初始化
首先在module.json5中声明必要的权限:
// module.json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:location_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:location_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.INTERNET",
"reason": "$string:internet_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
3.2 位置服务管理器
构建统一的位置服务管理类,封装定位获取与权限请求逻辑:
// LocationManager.ets
import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 位置信息数据模型
*/
export interface LocationInfo {
latitude: number; // 纬度
longitude: number; // 经度
accuracy: number; // 精度(米)
timestamp: number; // 时间戳
}
/**
* 位置服务管理器
* 负责权限申请、定位获取、位置监听等核心能力
*/
export class LocationManager {
private static instance: LocationManager;
private currentLocation: LocationInfo | null = null;
private locationChangeCallback: ((location: LocationInfo) => void) | null = null;
// 需要申请的权限列表
private readonly LOCATION_PERMISSIONS: Permissions[] = [
'ohos.permission.APPROXIMATELY_LOCATION',
'ohos.permission.LOCATION'
];
private constructor() {}
/**
* 单例模式获取实例
*/
static getInstance(): LocationManager {
if (!LocationManager.instance) {
LocationManager.instance = new LocationManager();
}
return LocationManager.instance;
}
/**
* 检查并申请位置权限
* @returns 是否已获取权限
*/
async requestLocationPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const bundleInfo = await bundleManager.getBundleInfoForSelf(
bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT
);
// 逐个检查权限状态
for (const permission of this.LOCATION_PERMISSIONS) {
const grantStatus = await atManager.checkAccessToken(
bundleInfo.appInfo.accessTokenId,
permission
);
if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
// 申请权限
const result = await atManager.requestPermissionsFromUser(
getContext(),
this.LOCATION_PERMISSIONS
);
// 检查用户是否授权
return result.authResults.every(
(status) => status === 0
);
}
}
return true;
} catch (error) {
const err = error as BusinessError;
console.error(`[LocationManager] 权限申请失败: ${err.code} - ${err.message}`);
return false;
}
}
/**
* 获取当前位置
* 优先使用缓存位置,超时后重新定位
* @param timeout 缓存超时时间(毫秒),默认30秒
*/
async getCurrentLocation(timeout: number = 30000): Promise<LocationInfo> {
// 检查缓存是否有效
if (this.currentLocation &&
Date.now() - this.currentLocation.timestamp < timeout) {
return this.currentLocation;
}
// 先确保有权限
const hasPermission = await this.requestLocationPermission();
if (!hasPermission) {
throw new Error('位置权限未授予');
}
try {
// 配置定位请求参数
const requestInfo: geoLocationManager.SingleLocationRequest = {
latitude: this.currentLocation?.latitude ?? 39.9042, // 默认北京
longitude: this.currentLocation?.longitude ?? 116.4074,
timeout: 10000 // 定位超时10秒
};
// 发起单次定位请求
const location = await geoLocationManager.getCurrentLocation(requestInfo);
this.currentLocation = {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.accuracy,
timestamp: Date.now()
};
// 通知位置变更回调
this.locationChangeCallback?.(this.currentLocation);
return this.currentLocation;
} catch (error) {
const err = error as BusinessError;
console.error(`[LocationManager] 定位失败: ${err.code} - ${err.message}`);
throw new Error(`定位失败: ${err.message}`);
}
}
/**
* 注册位置变更监听
* @param callback 位置变更回调
*/
onLocationChange(callback: (location: LocationInfo) => void): void {
this.locationChangeCallback = callback;
}
/**
* 获取缓存的当前位置(不触发新定位)
*/
getCachedLocation(): LocationInfo | null {
return this.currentLocation;
}
}
3.3 POI搜索服务
封装Map Kit的POI搜索能力,支持关键词搜索、分类搜索和周边搜索三种模式:
// PoiSearchService.ets
import { mapCommon, map, poisearch } from '@kit.MapKit';
import { LocationInfo } from './LocationManager';
/**
* POI数据模型
*/
export interface PoiItem {
id: string; // POI唯一标识
name: string; // POI名称
address: string; // 地址
latitude: number; // 纬度
longitude: number; // 经度
distance: number; // 距离(米)
phoneNum: string; // 联系电话
poiType: string; // 分类编码
rating: number; // 评分
businessArea: string; // 商圈
}
/**
* 搜索结果模型
*/
export interface SearchResult {
poiList: PoiItem[]; // POI列表
totalCount: number; // 总数
pageIndex: number; // 当前页码
hasMore: boolean; // 是否有更多
}
/**
* 搜索类型枚举
*/
export enum SearchType {
KEYWORD = 'keyword', // 关键词搜索
CATEGORY = 'category', // 分类搜索
NEARBY = 'nearby' // 周边搜索
}
/**
* POI分类常量
*/
export class PoiCategory {
static readonly CATERING = '050000'; // 餐饮
static readonly HOTEL = '100000'; // 酒店
static readonly SCENIC = '110000'; // 景点
static readonly GAS_STATION = '010100'; // 加油站
static readonly HOSPITAL = '090000'; // 医院
static readonly PARKING = '150700'; // 停车场
static readonly SHOPPING = '060000'; // 购物
static readonly BANK = '160000'; // 银行
}
/**
* POI搜索服务
* 封装Map Kit POI检索核心能力
*/
export class PoiSearchService {
private static instance: PoiSearchService;
private poiSearch: poisearch.PoiSearch | null = null;
private searchResultCache: Map<string, SearchResult> = new Map();
private constructor() {}
static getInstance(): PoiSearchService {
if (!PoiSearchService.instance) {
PoiSearchService.instance = new PoiSearchService();
}
return PoiSearchService.instance;
}
/**
* 初始化POI搜索服务
* 必须在Map组件加载后调用
*/
initSearch(mapController: map.MapComponentController): void {
try {
this.poiSearch = new poisearch.PoiSearch(mapController);
console.info('[PoiSearchService] POI搜索服务初始化成功');
} catch (error) {
console.error('[PoiSearchService] 初始化失败:', JSON.stringify(error));
}
}
/**
* 关键词搜索POI
* @param keyword 搜索关键词
* @param location 中心点位置
* @param radius 搜索半径(米),默认3000米
* @param pageIndex 页码,从0开始
* @param pageSize 每页数量,默认20
*/
async searchByKeyword(
keyword: string,
location: LocationInfo,
radius: number = 3000,
pageIndex: number = 0,
pageSize: number = 20
): Promise<SearchResult> {
if (!this.poiSearch) {
throw new Error('POI搜索服务未初始化');
}
// 检查缓存
const cacheKey = `keyword_${keyword}_${location.latitude}_${location.longitude}_${radius}_${pageIndex}`;
const cached = this.searchResultCache.get(cacheKey);
if (cached) {
return cached;
}
try {
const request: poisearch.PoiKeywordSearchRequest = {
keyword: keyword,
location: {
latitude: location.latitude,
longitude: location.longitude
},
radius: radius,
pageIndex: pageIndex,
pageSize: pageSize,
language: 'zh'
};
const result = await this.poiSearch.poiKeywordSearch(request);
const searchResult = this.parseSearchResult(result, pageIndex, pageSize);
// 缓存结果
this.searchResultCache.set(cacheKey, searchResult);
return searchResult;
} catch (error) {
console.error('[PoiSearchService] 关键词搜索失败:', JSON.stringify(error));
throw error;
}
}
/**
* 分类搜索POI
* @param category 分类编码
* @param location 中心点位置
* @param radius 搜索半径(米)
* @param pageIndex 页码
* @param pageSize 每页数量
*/
async searchByCategory(
category: string,
location: LocationInfo,
radius: number = 3000,
pageIndex: number = 0,
pageSize: number = 20
): Promise<SearchResult> {
if (!this.poiSearch) {
throw new Error('POI搜索服务未初始化');
}
try {
const request: poisearch.PoiCategorySearchRequest = {
category: category,
location: {
latitude: location.latitude,
longitude: location.longitude
},
radius: radius,
pageIndex: pageIndex,
pageSize: pageSize,
language: 'zh'
};
const result = await this.poiSearch.poiCategorySearch(request);
return this.parseSearchResult(result, pageIndex, pageSize);
} catch (error) {
console.error('[PoiSearchService] 分类搜索失败:', JSON.stringify(error));
throw error;
}
}
/**
* 周边搜索
* 搜索指定中心点周围的所有类型POI
*/
async searchNearby(
location: LocationInfo,
radius: number = 5000,
pageIndex: number = 0,
pageSize: number = 20
): Promise<SearchResult> {
if (!this.poiSearch) {
throw new Error('POI搜索服务未初始化');
}
try {
const request: poisearch.SearchNearbyRequest = {
location: {
latitude: location.latitude,
longitude: location.longitude
},
radius: radius,
pageIndex: pageIndex,
pageSize: pageSize,
language: 'zh'
};
const result = await this.poiSearch.searchNearby(request);
return this.parseSearchResult(result, pageIndex, pageSize);
} catch (error) {
console.error('[PoiSearchService] 周边搜索失败:', JSON.stringify(error));
throw error;
}
}
/**
* 解析搜索结果,统一数据格式
*/
private parseSearchResult(
result: poisearch.PoiSearchResult,
pageIndex: number,
pageSize: number
): SearchResult {
const poiList: PoiItem[] = (result.pois ?? []).map((poi) => ({
id: poi.id ?? '',
name: poi.name ?? '未知',
address: poi.formatAddress ?? '',
latitude: poi.latLng?.latitude ?? 0,
longitude: poi.latLng?.longitude ?? 0,
distance: poi.distance ?? 0,
phoneNum: poi.phoneNum ?? '',
poiType: poi.poiType ?? '',
rating: poi.rating ?? 0,
businessArea: poi.businessArea ?? ''
}));
return {
poiList,
totalCount: result.totalCount ?? 0,
pageIndex,
hasMore: (pageIndex + 1) * pageSize < (result.totalCount ?? 0)
};
}
/**
* 清除搜索缓存
*/
clearCache(): void {
this.searchResultCache.clear();
}
}
3.4 周边搜索主页面
构建完整的UI界面,集成地图展示、搜索交互和列表视图:
// NearbySearchPage.ets
import { map, mapCommon } from '@kit.MapKit';
import { LocationManager, LocationInfo } from '../service/LocationManager';
import { PoiSearchService, PoiItem, SearchResult, SearchType, PoiCategory } from '../service/PoiSearchService';
import { promptAction } from '@kit.ArkUI';
/**
* 搜索分类标签数据
*/
interface CategoryTag {
label: string;
category: string;
icon: ResourceStr;
}
@Entry
@Component
struct NearbySearchPage {
// 状态管理
@State currentLocation: LocationInfo | null = null;
@State searchResults: PoiItem[] = [];
@State isLoading: boolean = false;
@State searchKeyword: string = '';
@State selectedCategory: string = '';
@State showListView: boolean = false;
@State mapController: map.MapComponentController | null = null;
@State markers: map.Marker[] = [];
@State totalResults: number = 0;
@State currentPage: number = 0;
@State hasMore: boolean = false;
@State searchRadius: number = 3000;
// 分类标签
private readonly categoryTags: CategoryTag[] = [
{ label: '餐饮', category: PoiCategory.CATERING, icon: $r('app.media.ic_food') },
{ label: '酒店', category: PoiCategory.HOTEL, icon: $r('app.media.ic_hotel') },
{ label: '景点', category: PoiCategory.SCENIC, icon: $r('app.media.ic_scenic') },
{ label: '加油站', category: PoiCategory.GAS_STATION, icon: $r('app.media.ic_gas') },
{ label: '医院', category: PoiCategory.HOSPITAL, icon: $r('app.media.ic_hospital') },
{ label: '停车场', category: PoiCategory.PARKING, icon: $r('app.media.ic_parking') },
{ label: '购物', category: PoiCategory.SHOPPING, icon: $r('app.media.ic_shopping') },
{ label: '银行', category: PoiCategory.BANK, icon: $r('app.media.ic_bank') }
];
// 服务实例
private locationManager = LocationManager.getInstance();
private poiSearchService = PoiSearchService.getInstance();
// 地图回调
private mapCallback = async () => {
console.info('[NearbySearchPage] 地图组件加载完成');
};
aboutToAppear(): void {
this.initLocation();
}
/**
* 初始化定位
*/
async initLocation(): Promise<void> {
try {
this.isLoading = true;
this.currentLocation = await this.locationManager.getCurrentLocation();
console.info(`[NearbySearchPage] 定位成功: ${this.currentLocation.latitude}, ${this.currentLocation.longitude}`);
// 定位成功后自动搜索周边
await this.searchNearby();
} catch (error) {
console.error('[NearbySearchPage] 定位失败:', JSON.stringify(error));
promptAction.showToast({ message: '定位失败,请检查位置权限' });
} finally {
this.isLoading = false;
}
}
/**
* 搜索周边POI
*/
async searchNearby(): Promise<void> {
if (!this.currentLocation) return;
this.isLoading = true;
this.currentPage = 0;
try {
const result = await this.poiSearchService.searchNearby(
this.currentLocation,
this.searchRadius,
this.currentPage
);
this.updateSearchResults(result);
} catch (error) {
console.error('[NearbySearchPage] 搜索失败:', JSON.stringify(error));
promptAction.showToast({ message: '搜索失败,请重试' });
} finally {
this.isLoading = false;
}
}
/**
* 按关键词搜索
*/
async searchByKeyword(keyword: string): Promise<void> {
if (!this.currentLocation || !keyword.trim()) return;
this.isLoading = true;
this.currentPage = 0;
this.selectedCategory = '';
try {
const result = await this.poiSearchService.searchByKeyword(
keyword,
this.currentLocation,
this.searchRadius,
this.currentPage
);
this.updateSearchResults(result);
} catch (error) {
console.error('[NearbySearchPage] 关键词搜索失败:', JSON.stringify(error));
promptAction.showToast({ message: '搜索失败,请重试' });
} finally {
this.isLoading = false;
}
}
/**
* 按分类搜索
*/
async searchByCategory(category: string): Promise<void> {
if (!this.currentLocation) return;
this.isLoading = true;
this.currentPage = 0;
this.selectedCategory = category;
this.searchKeyword = '';
try {
const result = await this.poiSearchService.searchByCategory(
category,
this.currentLocation,
this.searchRadius,
this.currentPage
);
this.updateSearchResults(result);
} catch (error) {
console.error('[NearbySearchPage] 分类搜索失败:', JSON.stringify(error));
promptAction.showToast({ message: '搜索失败,请重试' });
} finally {
this.isLoading = false;
}
}
/**
* 加载更多
*/
async loadMore(): Promise<void> {
if (!this.hasMore || !this.currentLocation) return;
this.currentPage++;
try {
let result: SearchResult;
if (this.searchKeyword) {
result = await this.poiSearchService.searchByKeyword(
this.searchKeyword, this.currentLocation, this.searchRadius, this.currentPage
);
} else if (this.selectedCategory) {
result = await this.poiSearchService.searchByCategory(
this.selectedCategory, this.currentLocation, this.searchRadius, this.currentPage
);
} else {
result = await this.poiSearchService.searchNearby(
this.currentLocation, this.searchRadius, this.currentPage
);
}
// 追加结果
this.searchResults = [...this.searchResults, ...result.poiList];
this.hasMore = result.hasMore;
this.addMarkers(result.poiList);
} catch (error) {
console.error('[NearbySearchPage] 加载更多失败:', JSON.stringify(error));
}
}
/**
* 更新搜索结果
*/
updateSearchResults(result: SearchResult): void {
this.searchResults = result.poiList;
this.totalResults = result.totalCount;
this.hasMore = result.hasMore;
this.clearMarkers();
this.addMarkers(result.poiList);
}
/**
* 清除地图标记
*/
clearMarkers(): void {
this.markers.forEach(marker => marker.remove());
this.markers = [];
}
/**
* 添加地图标记
*/
addMarkers(poiList: PoiItem[]): void {
if (!this.mapController) return;
poiList.forEach((poi, index) => {
const markerOptions: map.MarkerOptions = {
position: { latitude: poi.latitude, longitude: poi.longitude },
title: poi.name,
snippet: `${poi.distance}米 · ${poi.address}`,
anchor: { x: 0.5, y: 1.0 }
};
try {
const marker = this.mapController.addMarker(markerOptions);
this.markers.push(marker);
} catch (error) {
console.error(`[NearbySearchPage] 添加标记失败: ${poi.name}`);
}
});
}
build() {
Column() {
// 顶部搜索栏
this.SearchBar()
// 分类标签栏
this.CategoryTabs()
// 内容区域:地图/列表切换
Stack() {
// 地图视图
MapComponent({
mapOptions: {
position: {
target: {
latitude: this.currentLocation?.latitude ?? 39.9042,
longitude: this.currentLocation?.longitude ?? 116.4074
},
zoom: 14
}
},
mapCallback: this.mapCallback
})
.width('100%')
.height('100%')
.onLoad(() => {
// 地图加载完成后初始化搜索服务
if (this.mapController) {
this.poiSearchService.initSearch(this.mapController);
}
})
.onControllerReady((controller: map.MapComponentController) => {
this.mapController = controller;
this.poiSearchService.initSearch(controller);
})
.visibility(this.showListView ? Visibility.None : Visibility.Visible)
// 列表视图
this.ListView()
.visibility(this.showListView ? Visibility.Visible : Visibility.None)
// 加载指示器
if (this.isLoading) {
LoadingProgress()
.width(48)
.height(48)
.color(Color.White)
}
}
.layoutWeight(1)
// 底部视图切换
this.ViewToggle()
}
.width('100%')
.height('100%')
.backgroundColor('#1A1A2E')
}
/**
* 搜索栏组件
*/
@Builder
SearchBar() {
Row() {
Image($r('app.media.ic_search'))
.width(20)
.height(20)
.fillColor('#8B8B9E')
.margin({ left: 12 })
TextInput({ placeholder: '搜索周边...', text: this.searchKeyword })
.layoutWeight(1)
.height(40)
.backgroundColor(Color.Transparent)
.fontColor('#FFFFFF')
.placeholderColor('#8B8B9E')
.onChange((value: string) => {
this.searchKeyword = value;
})
.onSubmit(() => {
if (this.searchKeyword.trim()) {
this.searchByKeyword(this.searchKeyword);
}
})
// 定位按钮
Image($r('app.media.ic_locate'))
.width(24)
.height(24)
.fillColor('#4ECDC4')
.margin({ right: 12 })
.onClick(() => this.initLocation())
}
.width('100%')
.height(52)
.backgroundColor('rgba(30, 30, 60, 0.9)')
.borderRadius(12)
.margin({ left: 16, right: 16, top: 8 })
}
/**
* 分类标签组件
*/
@Builder
CategoryTabs() {
Scroll() {
Row({ space: 8 }) {
ForEach(this.categoryTags, (tag: CategoryTag) => {
Column() {
Image(tag.icon)
.width(24)
.height(24)
.fillColor(this.selectedCategory === tag.category ? '#4ECDC4' : '#8B8B9E')
Text(tag.label)
.fontSize(11)
.fontColor(this.selectedCategory === tag.category ? '#4ECDC4' : '#8B8B9E')
.margin({ top: 4 })
}
.width(56)
.height(56)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(this.selectedCategory === tag.category ? 'rgba(78, 205, 196, 0.15)' : 'rgba(30, 30, 60, 0.8)')
.borderRadius(12)
.border({
width: 1,
color: this.selectedCategory === tag.category ? '#4ECDC4' : 'transparent'
})
.onClick(() => this.searchByCategory(tag.category))
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(72)
.margin({ top: 8 })
}
/**
* 列表视图组件
*/
@Builder
ListView() {
Column() {
// 结果统计
Text(`找到 ${this.totalResults} 个结果`)
.fontSize(13)
.fontColor('#8B8B9E')
.margin({ left: 16, top: 12, bottom: 8 })
// POI列表
List() {
ForEach(this.searchResults, (poi: PoiItem, index: number) => {
ListItem() {
this.PoiCard(poi, index + 1)
}
})
// 加载更多
if (this.hasMore) {
ListItem() {
Row() {
Text('加载更多')
.fontSize(14)
.fontColor('#4ECDC4')
}
.width('100%')
.height(48)
.justifyContent(FlexAlign.Center)
.onClick(() => this.loadMore())
}
}
}
.width('100%')
.layoutWeight(1)
.divider({ strokeWidth: 0.5, color: 'rgba(139, 139, 158, 0.2)' })
}
.width('100%')
.height('100%')
.backgroundColor('#1A1A2E')
}
/**
* POI卡片组件
*/
@Builder
PoiCard(poi: PoiItem, index: number) {
Row() {
// 序号标记
Text(`${index}`)
.width(28)
.height(28)
.fontSize(13)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
.textAlign(TextAlign.Center)
.backgroundColor('#4ECDC4')
.borderRadius(14)
.margin({ right: 12 })
// POI信息
Column() {
Text(poi.name)
.fontSize(15)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(poi.address)
.fontSize(12)
.fontColor('#8B8B9E')
.margin({ top: 4 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
// 距离信息
Column() {
Text(this.formatDistance(poi.distance))
.fontSize(14)
.fontColor('#4ECDC4')
.fontWeight(FontWeight.Medium)
if (poi.rating > 0) {
Text(`⭐ ${poi.rating}`)
.fontSize(11)
.fontColor('#FFD700')
.margin({ top: 2 })
}
}
.alignItems(HorizontalAlign.End)
.margin({ left: 8 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.onClick(() => {
// 点击POI,地图移动到对应位置
this.mapController?.moveTo({
latitude: poi.latitude,
longitude: poi.longitude
});
this.showListView = false;
})
}
/**
* 视图切换按钮
*/
@Builder
ViewToggle() {
Row() {
// 列表视图按钮
Button(this.showListView ? '地图' : '列表')
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('rgba(78, 205, 196, 0.8)')
.borderRadius(20)
.width(80)
.height(36)
.onClick(() => {
this.showListView = !this.showListView;
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
}
/**
* 格式化距离显示
*/
private formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)}m`;
}
return `${(meters / 1000).toFixed(1)}km`;
}
}
3.5 逆地理编码服务
补充逆地理编码能力,将用户当前位置转换为可读地址:
// GeoCodingService.ets
import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 地址信息模型
*/
export interface AddressInfo {
country: string; // 国家
province: string; // 省份
city: string; // 城市
district: string; // 区县
street: string; // 街道
streetNumber: string; // 门牌号
formattedAddress: string; // 完整地址
}
/**
* 逆地理编码服务
*/
export class GeoCodingService {
private static instance: GeoCodingService;
private constructor() {}
static getInstance(): GeoCodingService {
if (!GeoCodingService.instance) {
GeoCodingService.instance = new GeoCodingService();
}
return GeoCodingService.instance;
}
/**
* 逆地理编码:经纬度 → 地址
*/
async reverseGeocode(latitude: number, longitude: number): Promise<AddressInfo> {
try {
const request: geoLocationManager.ReverseGeoCodeRequest = {
latitude: latitude,
longitude: longitude,
maxItems: 1,
locale: 'zh'
};
const addresses = await geoLocationManager.getAddressesFromLocation(request);
if (addresses && addresses.length > 0) {
const addr = addresses[0];
return {
country: addr.countryName ?? '',
province: addr.administrativeArea ?? '',
city: addr.locality ?? '',
district: addr.subLocality ?? '',
street: addr.roadName ?? '',
streetNumber: addr.roadNumber ?? '',
formattedAddress: addr.placeName ?? ''
};
}
throw new Error('未找到对应地址信息');
} catch (error) {
const err = error as BusinessError;
console.error(`[GeoCodingService] 逆地理编码失败: ${err.code} - ${err.message}`);
throw error;
}
}
/**
* 地理编码:地址 → 经纬度
*/
async geocode(address: string, city?: string): Promise<{ latitude: number; longitude: number }> {
try {
const request: geoLocationManager.GeoCodeRequest = {
description: address,
maxItems: 1,
locale: 'zh'
};
if (city) {
request.locality = city;
}
const addresses = await geoLocationManager.getAddressesFromLocationName(request);
if (addresses && addresses.length > 0) {
const addr = addresses[0];
return {
latitude: addr.latitude ?? 0,
longitude: addr.longitude ?? 0
};
}
throw new Error('未找到对应位置');
} catch (error) {
const err = error as BusinessError;
console.error(`[GeoCodingService] 地理编码失败: ${err.code} - ${err.message}`);
throw error;
}
}
}
四、踩坑与注意事项
4.1 权限申请时序问题
问题:在aboutToAppear中直接调用定位API,此时权限弹窗尚未完成用户交互,导致定位失败。
解决方案:权限申请必须在UI渲染完成后执行,建议使用onPageShow或在异步回调中处理:
// ❌ 错误:在构造阶段请求权限
aboutToAppear(): void {
this.locationManager.getCurrentLocation(); // 可能因权限未授予而失败
}
// ✅ 正确:确保UI就绪后再请求
onPageShow(): void {
this.initLocation(); // UI已渲染,权限弹窗可正常展示
}
4.2 Map组件初始化时序
问题:PoiSearchService依赖MapComponentController,如果在地图组件onLoad之前调用搜索API,会抛出空指针异常。
解决方案:在onControllerReady回调中初始化搜索服务,该回调确保控制器已就绪:
// ✅ 在控制器就绪回调中初始化
.onControllerReady((controller: map.MapComponentController) => {
this.mapController = controller;
this.poiSearchService.initSearch(controller);
// 初始化完成后再执行搜索
this.searchNearby();
})
4.3 搜索半径与结果数量的平衡
问题:搜索半径设置过大(如50km),返回结果过多导致渲染卡顿;半径过小(如500m),可能搜不到结果。
建议策略:
| 场景 | 推荐半径 | 说明 |
|---|---|---|
| 城市核心区 | 1000-2000m | POI密度高,小半径即可 |
| 城市普通区 | 3000-5000m | 中等密度,默认值 |
| 郊区/乡村 | 5000-10000m | POI稀疏,需扩大范围 |
4.4 Marker数量限制
问题:地图上同时展示过多Marker会导致渲染性能下降,建议单次展示不超过100个。
优化方案:
- 使用聚合Marker(Cluster)替代独立Marker
- 只展示当前可视区域内的Marker
- 滑动地图时动态加载/卸载Marker
4.5 缓存策略
搜索结果缓存可显著提升用户体验,但需注意:
- 缓存Key应包含位置信息,不同位置的搜索结果不应复用
- 缓存应设置过期时间(建议5分钟)
- 用户主动刷新时应清除缓存
五、HarmonyOS 6适配
5.1 Location Kit API变更
HarmonyOS 6对Location Kit进行了以下关键更新:
| 变更项 | HarmonyOS 5 | HarmonyOS 6 |
|---|---|---|
| 定位API | getCurrentLocation(request) |
getCurrentLocation(request)(参数结构微调) |
| 权限模型 | 运行时权限 | 增强型运行时权限(支持单次授权) |
| 后台定位 | LOCATION_IN_BACKGROUND |
合并为LOCATION权限+后台使用声明 |
5.2 Map Kit增强
- 3D地图渲染:HarmonyOS 6支持3D建筑模型渲染,POI标记可附着于3D建筑
- 离线地图:新增离线地图下载能力,支持无网环境下的POI检索
- 室内地图:支持商场、机场等室内场景的POI搜索
5.3 适配代码示例
// HarmonyOS 6适配:增强型权限请求
async requestLocationPermissionV6(): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager();
try {
// HarmonyOS 6支持单次授权模式
const result = await atManager.requestPermissionsFromUser(
getContext(),
['ohos.permission.LOCATION'],
{
// 新增:权限弹窗配置
dialogShown: true,
permissionType: abilityAccessCtrl.PermissionType.IN_USE
}
);
return result.authResults[0] === 0;
} catch (error) {
console.error('权限请求失败:', JSON.stringify(error));
return false;
}
}
六、总结
本文系统讲解了HarmonyOS平台POI检索的完整实现方案,核心要点如下:
flowchart LR
classDef core fill:#FF6B6B,stroke:#2C3E50,color:#fff,font-weight:bold
classDef key fill:#4ECDC4,stroke:#2C3E50,color:#fff,font-weight:bold
classDef tip fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
A[POI检索核心能力]:::core --> B[三种搜索模式]:::key
A --> C[权限与定位]:::key
A --> D[地图渲染]:::key
A --> E[性能优化]:::key
B --> B1[关键词搜索]:::tip
B --> B2[分类搜索]:::tip
B --> B3[周边搜索]:::tip
C --> C1[运行时权限]:::tip
C --> C2[定位缓存]:::tip
C --> C3[逆地理编码]:::tip
D --> D1[Marker管理]:::tip
D --> D2[信息窗交互]:::tip
D --> D3[地图/列表联动]:::tip
E --> E1[结果缓存]:::tip
E --> E2[分页加载]:::tip
E --> E3[聚合渲染]:::tip
关键收获
- 分层架构:LocationManager负责定位、PoiSearchService负责搜索、UI层负责展示,职责清晰
- 三种搜索模式各有适用场景,关键词搜索精准、分类搜索全面、周边搜索以距离优先
- 逆地理编码是LBS应用的基础能力,将坐标转为可读地址是用户体验的关键
- 性能优化贯穿始终:定位缓存、搜索结果缓存、Marker数量控制、分页加载
- HarmonyOS 6适配重点关注权限模型变更和Map Kit增强能力
下一步
- 第347篇将深入轨迹记录与运动轨迹回放,讲解如何持续追踪位置并绘制运动路径
- 结合本篇的POI搜索能力,可构建更丰富的LBS应用场景
- 点赞
- 收藏
- 关注作者
评论(0)