HarmonyOS APP开发:周边搜索与POI检索

举报
Jack20 发表于 2026/06/22 14:06:57 2026/06/22
【摘要】 HarmonyOS APP开发:周边搜索与POI检索核心要点:本文深入讲解HarmonyOS平台基于位置服务(LBS)的周边搜索与POI(Point of Interest)检索技术实现,涵盖地理编码/逆地理编码、关键词搜索、周边区域搜索、分类筛选等核心能力,结合Map Kit与Location Kit构建完整的周边发现应用。项目说明核心KitMap Kit、Location Kit、Ne...

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 本文目标

构建一个完整的「周边探索」应用,实现以下核心功能:

  1. 获取用户实时位置
  2. 支持关键词与分类两种POI搜索模式
  3. 在地图上展示搜索结果并支持交互
  4. 列表视图与地图视图联动切换

二、核心原理

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

关键收获

  1. 分层架构:LocationManager负责定位、PoiSearchService负责搜索、UI层负责展示,职责清晰
  2. 三种搜索模式各有适用场景,关键词搜索精准、分类搜索全面、周边搜索以距离优先
  3. 逆地理编码是LBS应用的基础能力,将坐标转为可读地址是用户体验的关键
  4. 性能优化贯穿始终:定位缓存、搜索结果缓存、Marker数量控制、分页加载
  5. HarmonyOS 6适配重点关注权限模型变更和Map Kit增强能力

下一步

  • 第347篇将深入轨迹记录与运动轨迹回放,讲解如何持续追踪位置并绘制运动路径
  • 结合本篇的POI搜索能力,可构建更丰富的LBS应用场景
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。