HarmonyOS APP开发:离线地图与数据预加载

举报
Jack20 发表于 2026/06/21 16:45:48 2026/06/21
【摘要】 HarmonyOS APP开发:离线地图与数据预加载核心要点:掌握HarmonyOS离线地图瓦片管理、数据预加载策略、离线搜索与路线规划的完整实现方案 一、背景与动机离线地图能力是出行类应用的刚需。在地铁隧道、偏远山区、海外漫游等网络受限场景下,在线地图会变成一片空白,而离线地图则能确保用户始终可以查看地图、搜索地点、规划路线。对于外卖骑手、快递员、户外探险者等用户群体,离线地图甚至是决定...

HarmonyOS APP开发:离线地图与数据预加载

核心要点:掌握HarmonyOS离线地图瓦片管理、数据预加载策略、离线搜索与路线规划的完整实现方案


一、背景与动机

离线地图能力是出行类应用的刚需。在地铁隧道、偏远山区、海外漫游等网络受限场景下,在线地图会变成一片空白,而离线地图则能确保用户始终可以查看地图、搜索地点、规划路线。对于外卖骑手、快递员、户外探险者等用户群体,离线地图甚至是决定应用可用性的核心功能。

然而,离线地图开发面临以下核心挑战:

  • 瓦片数据量巨大:一个城市的离线地图包可能达到数百MB,如何高效下载与存储?
  • 版本更新管理:道路数据频繁变化,如何检测更新并增量下载?
  • 存储空间优化:设备存储有限,如何智能清理过期数据?
  • 离线搜索:无网络时如何实现地点搜索与POI查询?
  • 离线路线规划:无网络时如何提供基础路线规划能力?
  • 数据预加载:如何在用户到达某区域前预加载地图数据?

本文将从离线地图的底层存储结构出发,结合完整代码实战,系统解决上述难题。


二、核心原理

2.1 离线地图存储架构

HarmonyOS的离线地图采用分层存储架构:瓦片文件存储 → 索引数据库 → 缓存管理器 → Map组件。理解每一层的职责是优化离线体验的基础。

flowchart TB
    subgraph 数据源层
        A[华为离线地图服务器] --> B[城市离线包]
        A --> C[更新增量包]
        A --> D[POI离线数据]
    end
    
    subgraph 下载管理层
        E[OfflineMapManager] --> F[下载调度器]
        F --> G[断点续传]
        F --> H[并发控制]
        F --> I[进度回调]
    end
    
    subgraph 存储层
        J[瓦片文件存储] --> K[SQLite索引数据库]
        K --> L[瓦片坐标索引]
        K --> M[版本号索引]
        K --> N[过期时间索引]
    end
    
    subgraph 缓存管理层
        O[CacheManager] --> P[LRU淘汰策略]
        O --> Q[容量限制检查]
        O --> R[过期数据清理]
    end
    
    subgraph 渲染层
        S[Map组件] --> T[瓦片请求]
        T --> U{本地缓存命中?}
        U -- 是 --> V[直接渲染]
        U -- 否 --> W[请求网络瓦片]
    end
    
    B -.下载.-> F
    C -.下载.-> F
    D -.下载.-> F
    I -.写入.-> J
    J -.索引.-> K
    K -.查询.-> O
    O -.提供.-> T
    
    classDef srcStyle fill:#EF5350,stroke:#C62828,color:#FFF,font-weight:bold
    classDef dlStyle fill:#FF9800,stroke:#E65100,color:#FFF,font-weight:bold
    classDef storeStyle fill:#4CAF50,stroke:#2E7D32,color:#FFF,font-weight:bold
    classDef cacheStyle fill:#2196F3,stroke:#1565C0,color:#FFF,font-weight:bold
    classDef renderStyle fill:#9C27B0,stroke:#6A1B9A,color:#FFF,font-weight:bold
    
    class A,B,C,D srcStyle
    class E,F,G,H,I dlStyle
    class J,K,L,M,N storeStyle
    class O,P,Q,R cacheStyle
    class S,T,U,V,W renderStyle

2.2 瓦片坐标系与存储结构

地图瓦片采用**TMS(Tile Map Service)**坐标系,每个瓦片由(z, x, y)三个值唯一标识:

z = 缩放级别 (3-20)
x = 水平方向瓦片编号
y = 垂直方向瓦片编号

存储路径格式: /tiles/{z}/{x}/{y}.png

示例:
  z=15, x=13741, y=6543 → /tiles/15/13741/6543.png

每个瓦片覆盖的地理范围:
  z=15: 约1.1km × 1.1km
  z=18: 约140m × 140m

2.3 离线数据容量估算

缩放级别 瓦片尺寸 1个城市(约100km²) 全国(约960万km²)
10 ~11km ~1张 ~8000张 ≈ 200MB
12 ~2.7km ~16张 ~130000张 ≈ 3GB
15 ~1.1km ~100张 ~800000张 ≈ 20GB
17 ~140m ~6400张 ~51000000张 ≈ 1.2TB
18 ~70m ~25600张 ~2亿张 ≈ 5TB

实践建议:离线包通常只包含z=10~15的瓦片,更高缩放级别按需下载。

2.4 数据预加载策略

数据预加载的核心思想是预测用户行为,提前加载数据:

策略 触发条件 预加载范围 适用场景
位置预加载 GPS位置变化 当前位置周围5km 日常出行
路线预加载 导航开始 路线两侧2km 驾车导航
兴趣预加载 搜索/收藏 目标地点周围3km 出行规划
定时预加载 WiFi连接 常用城市完整包 定期更新
历史预加载 应用启动 历史访问区域 日常使用

三、代码实战

3.1 离线地图下载管理器

// OfflineMapManager.ets - 离线地图管理器
import { map, mapCommon } from '@kit.MapKit';
import { http } from '@kit.NetworkKit';
import { fileIo, fileUri } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';

// 离线城市数据模型
export interface OfflineCity {
  cityId: string;
  cityName: string;
  province: string;
  size: number;          // 离线包大小(字节)
  version: string;       // 数据版本号
  downloadStatus: DownloadStatus;
  localPath?: string;    // 本地存储路径
}

// 下载状态枚举
export enum DownloadStatus {
  NOT_DOWNLOADED = 'NOT_DOWNLOADED',
  DOWNLOADING = 'DOWNLOADING',
  PAUSED = 'PAUSED',
  COMPLETED = 'COMPLETED',
  FAILED = 'FAILED',
  UPDATING = 'UPDATING'
}

// 下载进度信息
export interface DownloadProgress {
  cityId: string;
  downloadedBytes: number;
  totalBytes: number;
  percentage: number;    // 0-100
  speed: number;         // 字节/秒
  remainingTime: number; // 预计剩余时间(秒)
}

export class OfflineMapManager {
  private context: Context;
  // 离线数据存储目录
  private offlineDir: string = '';
  // 下载任务映射表
  private downloadTasks: Map<string, http.HttpRequest> = new Map();
  // 下载进度映射表
  private downloadProgress: Map<string, DownloadProgress> = new Map();
  // 已下载城市列表
  private downloadedCities: Map<string, OfflineCity> = new Map();
  // 回调
  onProgressUpdate?: (progress: DownloadProgress) => void;
  onDownloadComplete?: (cityId: string) => void;
  onDownloadFailed?: (cityId: string, error: string) => void;

  constructor(context: Context) {
    this.context = context;
    this.initOfflineDir();
  }

  // 初始化离线数据目录
  private initOfflineDir(): void {
    // 使用应用专属文件目录
    this.offlineDir = `${this.context.filesDir}/offline_maps`;
    try {
      if (!fileIo.accessSync(this.offlineDir)) {
        fileIo.mkdirSync(this.offlineDir, true);
      }
      console.info(`[OfflineMap] 离线目录初始化: ${this.offlineDir}`);
    } catch (error) {
      console.error(`[OfflineMap] 离线目录创建失败: ${error}`);
    }
  }

  // 获取可下载的城市列表
  async getAvailableCities(): Promise<OfflineCity[]> {
    try {
      // 调用华为离线地图API获取城市列表
      const offlineMapManager = map.createOfflineMapManager(this.context);
      const cityList = await offlineMapManager.getOfflineCityList();

      return cityList.map(city => ({
        cityId: city.cityCode,
        cityName: city.cityName,
        province: city.province,
        size: city.size,
        version: city.version,
        downloadStatus: this.getDownloadStatus(city.cityCode),
        localPath: this.getCityLocalPath(city.cityCode)
      }));
    } catch (error) {
      console.error(`[OfflineMap] 获取城市列表失败: ${error}`);
      return [];
    }
  }

  // 下载城市离线包
  async downloadCity(cityId: string): Promise<void> {
    if (this.downloadTasks.has(cityId)) {
      console.warn(`[OfflineMap] 城市${cityId}正在下载中`);
      return;
    }

    try {
      const offlineMapManager = map.createOfflineMapManager(this.context);
      const cityDir = `${this.offlineDir}/${cityId}`;

      // 确保城市目录存在
      if (!fileIo.accessSync(cityDir)) {
        fileIo.mkdirSync(cityDir, true);
      }

      // 创建下载任务
      const downloadTask = await offlineMapManager.download(cityId, cityDir, {
        onProgress: (downloaded: number, total: number) => {
          const progress: DownloadProgress = {
            cityId,
            downloadedBytes: downloaded,
            totalBytes: total,
            percentage: total > 0 ? Math.round(downloaded / total * 100) : 0,
            speed: 0,
            remainingTime: 0
          };
          this.downloadProgress.set(cityId, progress);
          this.onProgressUpdate?.(progress);
        },
        onComplete: () => {
          console.info(`[OfflineMap] 城市${cityId}下载完成`);
          this.downloadTasks.delete(cityId);
          this.downloadProgress.delete(cityId);
          this.updateCityStatus(cityId, DownloadStatus.COMPLETED);
          this.onDownloadComplete?.(cityId);
        },
        onError: (error: BusinessError) => {
          console.error(`[OfflineMap] 城市${cityId}下载失败: ${error.code}`);
          this.downloadTasks.delete(cityId);
          this.updateCityStatus(cityId, DownloadStatus.FAILED);
          this.onDownloadFailed?.(cityId, error.message);
        }
      });

      this.downloadTasks.set(cityId, downloadTask);
      this.updateCityStatus(cityId, DownloadStatus.DOWNLOADING);
    } catch (error) {
      console.error(`[OfflineMap] 启动下载失败: ${error}`);
      this.updateCityStatus(cityId, DownloadStatus.FAILED);
    }
  }

  // 暂停下载
  pauseDownload(cityId: string): void {
    const task = this.downloadTasks.get(cityId);
    if (task) {
      try {
        // 取消当前下载任务(支持断点续传)
        task.destroy();
        this.downloadTasks.delete(cityId);
        this.updateCityStatus(cityId, DownloadStatus.PAUSED);
        console.info(`[OfflineMap] 城市${cityId}下载已暂停`);
      } catch (error) {
        console.error(`[OfflineMap] 暂停下载失败: ${error}`);
      }
    }
  }

  // 恢复下载(断点续传)
  resumeDownload(cityId: string): void {
    this.downloadCity(cityId);
  }

  // 删除已下载的离线包
  deleteCity(cityId: string): void {
    const cityDir = `${this.offlineDir}/${cityId}`;
    try {
      if (fileIo.accessSync(cityDir)) {
        fileIo.rmdirSync(cityDir);
      }
      this.downloadedCities.delete(cityId);
      console.info(`[OfflineMap] 城市${cityId}离线包已删除`);
    } catch (error) {
      console.error(`[OfflineMap] 删除离线包失败: ${error}`);
    }
  }

  // 检查更新
  async checkForUpdates(): Promise<OfflineCity[]> {
    const updateList: OfflineCity[] = [];

    try {
      const offlineMapManager = map.createOfflineMapManager(this.context);
      const serverCities = await offlineMapManager.getOfflineCityList();

      this.downloadedCities.forEach((localCity, cityId) => {
        const serverCity = serverCities.find(c => c.cityCode === cityId);
        if (serverCity && serverCity.version !== localCity.version) {
          updateList.push({
            ...localCity,
            version: serverCity.version,
            size: serverCity.size,
            downloadStatus: DownloadStatus.UPDATING
          });
        }
      });
    } catch (error) {
      console.error(`[OfflineMap] 检查更新失败: ${error}`);
    }

    return updateList;
  }

  // 获取下载状态
  private getDownloadStatus(cityId: string): DownloadStatus {
    const city = this.downloadedCities.get(cityId);
    return city?.downloadStatus ?? DownloadStatus.NOT_DOWNLOADED;
  }

  // 获取城市本地路径
  private getCityLocalPath(cityId: string): string {
    return `${this.offlineDir}/${cityId}`;
  }

  // 更新城市下载状态
  private updateCityStatus(cityId: string, status: DownloadStatus): void {
    const city = this.downloadedCities.get(cityId);
    if (city) {
      city.downloadStatus = status;
    } else {
      this.downloadedCities.set(cityId, {
        cityId,
        cityName: '',
        province: '',
        size: 0,
        version: '',
        downloadStatus: status,
        localPath: this.getCityLocalPath(cityId)
      });
    }
  }

  // 获取已下载城市列表
  getDownloadedCities(): OfflineCity[] {
    return Array.from(this.downloadedCities.values())
      .filter(c => c.downloadStatus === DownloadStatus.COMPLETED);
  }

  // 获取总占用空间
  getTotalStorageSize(): number {
    let totalSize = 0;
    this.downloadedCities.forEach(city => {
      totalSize += city.size;
    });
    return totalSize;
  }

  // 销毁
  destroy(): void {
    this.downloadTasks.forEach(task => {
      try {
        task.destroy();
      } catch (e) {
        // 忽略销毁错误
      }
    });
    this.downloadTasks.clear();
    this.downloadProgress.clear();
  }
}

3.2 数据预加载引擎

// DataPreloadEngine.ets - 数据预加载引擎
import { map, mapCommon } from '@kit.MapKit';
import { geoLocationManager } from '@kit.LocationKit';
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';

// 预加载策略枚举
export enum PreloadStrategy {
  LOCATION_BASED = 'LOCATION_BASED',     // 基于位置预加载
  ROUTE_BASED = 'ROUTE_BASED',           // 基于路线预加载
  FAVORITE_BASED = 'FAVORITE_BASED',     // 基于收藏预加载
  SCHEDULED = 'SCHEDULED'                // 定时预加载
}

// 预加载任务
export interface PreloadTask {
  id: string;
  strategy: PreloadStrategy;
  centerLat: number;
  centerLon: number;
  radiusKm: number;       // 预加载半径(公里)
  zoomLevels: number[];   // 需要预加载的缩放级别
  priority: number;       // 优先级 1-10
  status: 'pending' | 'running' | 'completed' | 'failed';
}

// 预加载配置
export interface PreloadConfig {
  // 位置预加载半径(公里)
  locationRadius: number;
  // 路线预加载宽度(公里)
  routeWidth: number;
  // 预加载缩放级别范围
  zoomRange: { min: number; max: number };
  // 最大并发下载数
  maxConcurrent: number;
  // 仅WiFi下预加载
  wifiOnly: boolean;
  // 存储空间上限(MB)
  storageLimit: number;
}

export class DataPreloadEngine {
  private context: Context;
  private controller?: map.MapComponentController;
  // 预加载配置
  private config: PreloadConfig = {
    locationRadius: 5,
    routeWidth: 2,
    zoomRange: { min: 10, max: 15 },
    maxConcurrent: 3,
    wifiOnly: true,
    storageLimit: 500
  };
  // 预加载任务队列
  private taskQueue: PreloadTask[] = [];
  // 正在执行的任务数
  private activeTaskCount: number = 0;
  // 位置监听ID
  private locationListenerId: number = -1;
  // 上次预加载位置
  private lastPreloadLat: number = 0;
  private lastPreloadLon: number = 0;
  // 预加载距离阈值(公里)
  private preloadDistanceThreshold: number = 2;
  // 回调
  onPreloadProgress?: (taskId: string, progress: number) => void;
  onPreloadComplete?: (taskId: string) => void;

  constructor(context: Context) {
    this.context = context;
  }

  init(controller: map.MapComponentController, config?: Partial<PreloadConfig>): void {
    this.controller = controller;
    if (config) {
      this.config = { ...this.config, ...config };
    }
  }

  // 启动位置预加载
  startLocationBasedPreload(): void {
    const locationRequest: geoLocationManager.LocationRequest = {
      priority: geoLocationManager.LocationRequestPriority.ACCURACY,
      timeInterval: 30,    // 30秒更新一次
      distanceInterval: 100 // 100米更新一次
    };

    try {
      this.locationListenerId = geoLocationManager.on(
        'locationChange',
        locationRequest,
        (location) => {
          this.onLocationUpdate(location);
        }
      );
      console.info('[PreloadEngine] 位置预加载已启动');
    } catch (error) {
      console.error(`[PreloadEngine] 位置预加载启动失败: ${error}`);
    }
  }

  // 位置更新回调
  private onLocationUpdate(location: geoLocationManager.Location): void {
    const lat = location.latitude;
    const lon = location.longitude;

    // 计算与上次预加载位置的距离
    const distance = this.calculateDistance(
      this.lastPreloadLat, this.lastPreloadLon, lat, lon
    );

    // 距离超过阈值时触发预加载
    if (distance > this.preloadDistanceThreshold || this.lastPreloadLat === 0) {
      console.info(`[PreloadEngine] 触发位置预加载: 距离=${distance.toFixed(1)}km`);
      this.createPreloadTask(
        PreloadStrategy.LOCATION_BASED,
        lat, lon,
        this.config.locationRadius,
        5  // 高优先级
      );

      this.lastPreloadLat = lat;
      this.lastPreloadLon = lon;
    }
  }

  // 路线预加载
  preloadRoute(
    routePoints: Array<{ lat: number; lon: number }>
  ): void {
    if (routePoints.length === 0) return;

    // 沿路线每隔5km创建一个预加载任务
    let accumulatedDist = 0;
    let lastPoint = routePoints[0];

    routePoints.forEach((point, index) => {
      if (index === 0) return;
      const segDist = this.calculateDistance(
        lastPoint.lat, lastPoint.lon, point.lat, point.lon
      );
      accumulatedDist += segDist;

      if (accumulatedDist >= 5) {
        this.createPreloadTask(
          PreloadStrategy.ROUTE_BASED,
          point.lat, point.lon,
          this.config.routeWidth,
          8  // 路线预加载优先级更高
        );
        accumulatedDist = 0;
      }
      lastPoint = point;
    });

    // 处理路线终点
    const lastRoutePoint = routePoints[routePoints.length - 1];
    this.createPreloadTask(
      PreloadStrategy.ROUTE_BASED,
      lastRoutePoint.lat, lastRoutePoint.lon,
      this.config.routeWidth * 2,  // 终点预加载范围更大
      9  // 最高优先级
    );

    this.processTaskQueue();
  }

  // 收藏地点预加载
  preloadFavorites(
    favorites: Array<{ lat: number; lon: number; name: string }>
  ): void {
    favorites.forEach(fav => {
      this.createPreloadTask(
        PreloadStrategy.FAVORITE_BASED,
        fav.lat, fav.lon,
        3,  // 3km半径
        3   // 低优先级
      );
    });

    this.processTaskQueue();
  }

  // 创建预加载任务
  private createPreloadTask(
    strategy: PreloadStrategy,
    centerLat: number,
    centerLon: number,
    radiusKm: number,
    priority: number
  ): void {
    const taskId = `preload_${strategy}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

    // 生成需要预加载的缩放级别
    const zoomLevels: number[] = [];
    for (let z = this.config.zoomRange.min; z <= this.config.zoomRange.max; z++) {
      zoomLevels.push(z);
    }

    const task: PreloadTask = {
      id: taskId,
      strategy,
      centerLat,
      centerLon,
      radiusKm,
      zoomLevels,
      priority,
      status: 'pending'
    };

    // 检查是否与已有任务重叠
    const isOverlapping = this.taskQueue.some(existing =>
      this.calculateDistance(
        existing.centerLat, existing.centerLon,
        centerLat, centerLon
      ) < radiusKm * 0.5
    );

    if (!isOverlapping) {
      this.taskQueue.push(task);
      // 按优先级排序
      this.taskQueue.sort((a, b) => b.priority - a.priority);
    }
  }

  // 处理任务队列
  private processTaskQueue(): void {
    while (this.activeTaskCount < this.config.maxConcurrent && this.taskQueue.length > 0) {
      const task = this.taskQueue.find(t => t.status === 'pending');
      if (!task) break;

      task.status = 'running';
      this.activeTaskCount++;
      this.executePreloadTask(task);
    }
  }

  // 执行预加载任务
  private async executePreloadTask(task: PreloadTask): Promise<void> {
    console.info(`[PreloadEngine] 执行预加载: ${task.id}, ` +
      `中心=(${task.centerLat.toFixed(4)}, ${task.centerLon.toFixed(4)}), ` +
      `半径=${task.radiusKm}km`);

    try {
      // 计算需要预加载的瓦片范围
      for (const zoom of task.zoomLevels) {
        const tileRange = this.calculateTileRange(
          task.centerLat, task.centerLon,
          task.radiusKm, zoom
        );

        // 逐个下载瓦片
        for (let x = tileRange.minX; x <= tileRange.maxX; x++) {
          for (let y = tileRange.minY; y <= tileRange.maxY; y++) {
            await this.preloadTile(zoom, x, y);
          }
        }
      }

      task.status = 'completed';
      this.onPreloadComplete?.(task.id);
    } catch (error) {
      console.error(`[PreloadEngine] 预加载任务失败: ${task.id}, error: ${error}`);
      task.status = 'failed';
    } finally {
      this.activeTaskCount--;
      this.processTaskQueue();  // 继续处理队列
    }
  }

  // 计算瓦片范围
  private calculateTileRange(
    centerLat: number, centerLon: number,
    radiusKm: number, zoom: number
  ): { minX: number; maxX: number; minY: number; maxY: number } {
    // 经纬度转瓦片坐标
    const centerTileX = this.lonToTileX(centerLon, zoom);
    const centerTileY = this.latToTileY(centerLat, zoom);

    // 计算半径对应的瓦片数
    const metersPerTile = 40075016.686 * Math.cos(centerLat * Math.PI / 180) /
      Math.pow(2, zoom + 8) * 256;
    const tilesInRadius = Math.ceil(radiusKm * 1000 / metersPerTile);

    return {
      minX: centerTileX - tilesInRadius,
      maxX: centerTileX + tilesInRadius,
      minY: centerTileY - tilesInRadius,
      maxY: centerTileY + tilesInRadius
    };
  }

  // 经度转瓦片X坐标
  private lonToTileX(lon: number, zoom: number): number {
    return Math.floor((lon + 180) / 360 * Math.pow(2, zoom));
  }

  // 纬度转瓦片Y坐标
  private latToTileY(lat: number, zoom: number): number {
    const latRad = lat * Math.PI / 180;
    return Math.floor(
      (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
      2 * Math.pow(2, zoom)
    );
  }

  // 预加载单个瓦片
  private async preloadTile(zoom: number, x: number, y: number): Promise<void> {
    const tilePath = `${this.context.filesDir}/offline_maps/tiles/${zoom}/${x}/${y}.png`;

    // 检查瓦片是否已存在
    try {
      if (fileIo.accessSync(tilePath)) {
        return; // 已存在,跳过
      }
    } catch (e) {
      // 文件不存在,继续下载
    }

    // 下载瓦片
    const tileUrl = `https://maptile-dl.petals.cn/tiles/${zoom}/${x}/${y}.png`;
    try {
      const httpRequest = http.createHttp();
      const response = await httpRequest.request(tileUrl, {
        method: http.RequestMethod.GET,
        expectDataType: http.HttpDataType.BUFFER,
        connectTimeout: 10000,
        readTimeout: 10000
      });

      if (response.responseCode === 200 && response.result) {
        // 确保目录存在
        const dir = `${this.context.filesDir}/offline_maps/tiles/${zoom}/${x}`;
        if (!fileIo.accessSync(dir)) {
          fileIo.mkdirSync(dir, true);
        }

        // 写入瓦片文件
        const file = fileIo.openSync(tilePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
        fileIo.writeSync(file.fd, response.result as ArrayBuffer);
        fileIo.closeSync(file.fd);
      }

      httpRequest.destroy();
    } catch (error) {
      // 瓦片下载失败,静默跳过
    }
  }

  // Haversine距离计算
  private calculateDistance(
    lat1: number, lon1: number,
    lat2: number, lon2: number
  ): number {
    const R = 6371; // 地球半径(公里)
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLon = (lon2 - lon1) * Math.PI / 180;
    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
              Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
              Math.sin(dLon / 2) * Math.sin(dLon / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
  }

  // 停止预加载
  stopPreload(): void {
    if (this.locationListenerId !== -1) {
      try {
        geoLocationManager.off('locationChange', this.locationListenerId);
        this.locationListenerId = -1;
      } catch (e) {
        // 忽略
      }
    }
    this.taskQueue = [];
    this.activeTaskCount = 0;
  }

  destroy(): void {
    this.stopPreload();
  }
}

3.3 离线搜索与POI查询

// OfflineSearchEngine.ets - 离线搜索引擎
import { relationalStore } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';

// POI数据模型
export interface OfflinePOI {
  id: string;
  name: string;
  category: string;
  latitude: number;
  longitude: number;
  address: string;
  phone?: string;
  rating?: number;
}

// 搜索结果
export interface SearchResult {
  query: string;
  results: OfflinePOI[];
  totalCount: number;
  searchTime: number;  // 搜索耗时(毫秒)
}

export class OfflineSearchEngine {
  private store?: relationalStore.RelationalStore;
  private context: Context;
  // 搜索索引是否就绪
  private isIndexReady: boolean = false;

  constructor(context: Context) {
    this.context = context;
  }

  // 初始化离线搜索数据库
  async initSearchIndex(): Promise<void> {
    try {
      const storeConfig: relationalStore.StoreConfig = {
        name: 'offline_poi.db',
        securityLevel: relationalStore.SecurityLevel.S1
      };

      this.store = await relationalStore.getRdbStore(this.context, storeConfig);

      // 创建POI表
      const createTableSQL = `
        CREATE TABLE IF NOT EXISTS poi_index (
          id TEXT PRIMARY KEY,
          name TEXT NOT NULL,
          category TEXT,
          latitude REAL NOT NULL,
          longitude REAL NOT NULL,
          address TEXT,
          phone TEXT,
          rating REAL,
          name_pinyin TEXT,
          city_code TEXT
        )
      `;
      await this.store.executeSql(createTableSQL);

      // 创建索引
      const createIndexSQLs = [
        'CREATE INDEX IF NOT EXISTS idx_poi_name ON poi_index(name)',
        'CREATE INDEX IF NOT EXISTS idx_poi_pinyin ON poi_index(name_pinyin)',
        'CREATE INDEX IF NOT EXISTS idx_poi_category ON poi_index(category)',
        'CREATE INDEX IF NOT EXISTS idx_poi_location ON poi_index(latitude, longitude)',
        'CREATE INDEX IF NOT EXISTS idx_poi_city ON poi_index(city_code)'
      ];
      for (const sql of createIndexSQLs) {
        await this.store.executeSql(sql);
      }

      this.isIndexReady = true;
      console.info('[OfflineSearch] 搜索索引初始化完成');
    } catch (error) {
      console.error(`[OfflineSearch] 搜索索引初始化失败: ${error}`);
    }
  }

  // 导入POI数据到搜索索引
  async importPOIData(pois: OfflinePOI[], cityCode: string): Promise<number> {
    if (!this.store) return 0;

    let importedCount = 0;
    try {
      for (const poi of pois) {
        const valueBucket: relationalStore.ValuesBucket = {
          id: poi.id,
          name: poi.name,
          category: poi.category,
          latitude: poi.latitude,
          longitude: poi.longitude,
          address: poi.address,
          phone: poi.phone ?? '',
          rating: poi.rating ?? 0,
          name_pinyin: this.toPinyin(poi.name),
          city_code: cityCode
        };

        await this.store.insert('poi_index', valueBucket, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
        importedCount++;
      }
      console.info(`[OfflineSearch] 导入${importedCount}条POI数据`);
    } catch (error) {
      console.error(`[OfflineSearch] POI数据导入失败: ${error}`);
    }
    return importedCount;
  }

  // 离线搜索POI
  async searchPOI(
    query: string,
    centerLat?: number,
    centerLon?: number,
    radiusKm?: number,
    limit: number = 50
  ): Promise<SearchResult> {
    if (!this.store || !this.isIndexReady) {
      return { query, results: [], totalCount: 0, searchTime: 0 };
    }

    const startTime = Date.now();

    try {
      // 构建搜索条件
      let whereClause = `name LIKE '%${query}%' OR name_pinyin LIKE '%query}%'`;
      const predicates = new relationalStore.RdbPredicates('poi_index');

      // 文本搜索
      predicates.like('name', `%${query}%`)
        .or()
        .like('name_pinyin', `%${this.toPinyin(query)}%`);

      // 位置过滤
      if (centerLat !== undefined && centerLon !== undefined && radiusKm !== undefined) {
        const latRange = radiusKm / 111.32; // 1度纬度约111.32km
        const lonRange = radiusKm / (111.32 * Math.cos(centerLat * Math.PI / 180));
        predicates.between('latitude', centerLat - latRange, centerLat + latRange);
        predicates.between('longitude', centerLon - lonRange, centerLon + lonRange);
      }

      predicates.limitAs(limit);
      predicates.orderByDesc('rating');

      const resultSet = await this.store.query(predicates);
      const results: OfflinePOI[] = [];

      while (resultSet.goToNextRow()) {
        results.push({
          id: resultSet.getString(resultSet.getColumnIndex('id')),
          name: resultSet.getString(resultSet.getColumnIndex('name')),
          category: resultSet.getString(resultSet.getColumnIndex('category')),
          latitude: resultSet.getDouble(resultSet.getColumnIndex('latitude')),
          longitude: resultSet.getDouble(resultSet.getColumnIndex('longitude')),
          address: resultSet.getString(resultSet.getColumnIndex('address')),
          phone: resultSet.getString(resultSet.getColumnIndex('phone')),
          rating: resultSet.getDouble(resultSet.getColumnIndex('rating'))
        });
      }
      resultSet.close();

      const searchTime = Date.now() - startTime;
      return {
        query,
        results,
        totalCount: results.length,
        searchTime
      };
    } catch (error) {
      console.error(`[OfflineSearch] 搜索失败: ${error}`);
      return { query, results: [], totalCount: 0, searchTime: Date.now() - startTime };
    }
  }

  // 按分类搜索
  async searchByCategory(
    category: string,
    centerLat: number,
    centerLon: number,
    radiusKm: number = 5,
    limit: number = 50
  ): Promise<SearchResult> {
    if (!this.store) {
      return { query: category, results: [], totalCount: 0, searchTime: 0 };
    }

    const startTime = Date.now();
    const predicates = new relationalStore.RdbPredicates('poi_index');
    predicates.equalTo('category', category);

    const latRange = radiusKm / 111.32;
    const lonRange = radiusKm / (111.32 * Math.cos(centerLat * Math.PI / 180));
    predicates.between('latitude', centerLat - latRange, centerLat + latRange);
    predicates.between('longitude', centerLon - lonRange, centerLon + lonRange);
    predicates.limitAs(limit);
    predicates.orderByDesc('rating');

    try {
      const resultSet = await this.store.query(predicates);
      const results: OfflinePOI[] = [];

      while (resultSet.goToNextRow()) {
        results.push({
          id: resultSet.getString(resultSet.getColumnIndex('id')),
          name: resultSet.getString(resultSet.getColumnIndex('name')),
          category: resultSet.getString(resultSet.getColumnIndex('category')),
          latitude: resultSet.getDouble(resultSet.getColumnIndex('latitude')),
          longitude: resultSet.getDouble(resultSet.getColumnIndex('longitude')),
          address: resultSet.getString(resultSet.getColumnIndex('address')),
          rating: resultSet.getDouble(resultSet.getColumnIndex('rating'))
        });
      }
      resultSet.close();

      return {
        query: category,
        results,
        totalCount: results.length,
        searchTime: Date.now() - startTime
      };
    } catch (error) {
      return { query: category, results: [], totalCount: 0, searchTime: Date.now() - startTime };
    }
  }

  // 简易拼音转换(实际项目应使用专业拼音库)
  private toPinyin(text: string): string {
    // 简化版:仅转小写,实际项目需集成pinyin4j等库
    return text.toLowerCase().replace(/\s+/g, '');
  }

  // 获取索引状态
  isReady(): boolean {
    return this.isIndexReady;
  }

  // 销毁
  async destroy(): Promise<void> {
    if (this.store) {
      await relationalStore.deleteRdbStore(this.context, 'offline_poi.db');
      this.store = undefined;
      this.isIndexReady = false;
    }
  }
}

3.4 离线地图管理UI

// OfflineMapPage.ets - 离线地图管理页面
import { OfflineMapManager, OfflineCity, DownloadStatus, DownloadProgress } from './OfflineMapManager';
import { DataPreloadEngine, PreloadStrategy } from './DataPreloadEngine';

@Entry
@Component
struct OfflineMapPage {
  private offlineManager?: OfflineMapManager;
  private preloadEngine?: DataPreloadEngine;
  // UI状态
  @State availableCities: OfflineCity[] = [];
  @State downloadedCities: OfflineCity[] = [];
  @State totalStorageMB: number = 0;
  @State isDownloading: boolean = false;
  @State currentProgress: DownloadProgress | null = null;
  @State searchQuery: string = '';

  aboutToAppear(): void {
    this.offlineManager = new OfflineMapManager(getContext(this));
    this.loadCityList();
  }

  aboutToDisappear(): void {
    this.offlineManager?.destroy();
    this.preloadEngine?.destroy();
  }

  // 加载城市列表
  private async loadCityList(): Promise<void> {
    if (!this.offlineManager) return;
    this.availableCities = await this.offlineManager.getAvailableCities();
    this.downloadedCities = this.offlineManager.getDownloadedCities();
    this.totalStorageMB = Math.round(this.offlineManager.getTotalStorageSize() / 1024 / 1024);
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Text('离线地图管理')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
        Blank()
        Text(`${this.totalStorageMB} MB`)
          .fontSize(14)
          .fontColor('#666666')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })

      // 搜索栏
      Search({ placeholder: '搜索城市' })
        .width('92%')
        .height(40)
        .margin({ top: 8, bottom: 8 })
        .onChange((value: string) => {
          this.searchQuery = value;
        })

      // 已下载区域
      if (this.downloadedCities.length > 0) {
        this.DownloadedSection()
      }

      Divider().margin({ top: 8, bottom: 8 })

      // 可下载城市列表
      this.AvailableSection()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  DownloadedSection() {
    Column() {
      Text('已下载')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#1A1A1A')
        .padding({ left: 16, top: 8, bottom: 8 })

      ForEach(this.downloadedCities, (city: OfflineCity) => {
        Row() {
          Column() {
            Text(city.cityName)
              .fontSize(15)
              .fontColor('#1A1A1A')
            Text(`${city.province} · ${this.formatSize(city.size)}`)
              .fontSize(12)
              .fontColor('#999999')
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)

          Blank()

          Text('已下载')
            .fontSize(12)
            .fontColor('#4CAF50')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor('#E8F5E9')
            .borderRadius(10)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(Color.White)
      })
    }
  }

  @Builder
  AvailableSection() {
    Column() {
      Text('可下载')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#1A1A1A')
        .padding({ left: 16, top: 8, bottom: 8 })

      ForEach(
        this.availableCities.filter(c =>
          c.downloadStatus !== DownloadStatus.COMPLETED &&
          (this.searchQuery === '' || c.cityName.includes(this.searchQuery))
        ),
        (city: OfflineCity) => {
          Row() {
            Column() {
              Text(city.cityName)
                .fontSize(15)
                .fontColor('#1A1A1A')
              Text(`${city.province} · ${this.formatSize(city.size)}`)
                .fontSize(12)
                .fontColor('#999999')
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)

            Blank()

            // 下载按钮/进度
            if (city.downloadStatus === DownloadStatus.DOWNLOADING) {
              // 下载中 - 显示进度
              Column() {
                Progress({
                  value: this.currentProgress?.percentage ?? 0,
                  total: 100,
                  type: ProgressType.Ring
                })
                  .width(32)
                  .height(32)
                  .color('#4285F4')
                Text(`${this.currentProgress?.percentage ?? 0}%`)
                  .fontSize(10)
                  .fontColor('#4285F4')
                  .margin({ top: 2 })
              }
            } else if (city.downloadStatus === DownloadStatus.PAUSED) {
              // 已暂停
              Button('继续')
                .fontSize(12)
                .height(28)
                .fontColor('#FF9800')
                .backgroundColor('#FFF3E0')
                .borderRadius(14)
                .onClick(() => {
                  this.offlineManager?.resumeDownload(city.cityId);
                })
            } else {
              // 未下载
              Button('下载')
                .fontSize(12)
                .height(28)
                .fontColor('#4285F4')
                .backgroundColor('#E3F2FD')
                .borderRadius(14)
                .onClick(() => {
                  this.startDownload(city.cityId);
                })
            }
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          .backgroundColor(Color.White)
        }
      )
    }
  }

  // 开始下载
  private startDownload(cityId: string): void {
    if (!this.offlineManager) return;

    this.offlineManager.onProgressUpdate = (progress) => {
      this.currentProgress = progress;
    };

    this.offlineManager.onDownloadComplete = (id) => {
      this.currentProgress = null;
      this.loadCityList();
    };

    this.offlineManager.downloadCity(cityId);
  }

  // 格式化文件大小
  private formatSize(bytes: number): string {
    if (bytes >= 1024 * 1024 * 1024) {
      return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
    } else if (bytes >= 1024 * 1024) {
      return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
    } else if (bytes >= 1024) {
      return `${(bytes / 1024).toFixed(1)} KB`;
    }
    return `${bytes} B`;
  }
}

四、踩坑与注意事项

4.1 离线地图存储空间管理

设备存储 建议离线包上限 可下载城市数 清理策略
64GB 500MB 3-5个城市 LRU淘汰
128GB 1GB 8-10个城市 按过期时间清理
256GB 2GB 15-20个城市 手动管理
512GB+ 5GB 50+城市 自动管理

关键建议:

  1. 在WiFi环境下自动下载,移动网络下仅下载紧急包
  2. 超过30天未使用的离线包自动提示清理
  3. 提供存储空间仪表盘,让用户清楚了解占用情况

4.2 瓦片下载常见问题

问题 原因 解决方案
下载速度慢 服务器限速或网络不稳定 使用多线程下载,支持断点续传
瓦片损坏 下载中断导致文件不完整 下载后校验文件大小,异常则重试
磁盘空间不足 离线包过大 下载前检查剩余空间,提供清理建议
瓦片过期 道路数据更新 定期检查版本号,增量更新
权限不足 文件目录无写入权限 使用应用专属目录filesDir

4.3 离线搜索优化

  1. 索引预构建:离线包中应包含预构建的搜索索引,而非在客户端实时构建
  2. 模糊搜索:支持拼音首字母搜索、模糊匹配,提升搜索体验
  3. 搜索结果排序:按距离 + 评分综合排序,而非单一维度
  4. 搜索缓存:热门搜索结果缓存,减少数据库查询次数
  5. 增量更新:POI数据更新时使用增量包,而非全量替换

4.4 数据预加载注意事项

  1. WiFi优先:预加载默认仅在WiFi下执行,避免消耗用户流量
  2. 电量感知:低电量模式下暂停预加载,避免加速耗电
  3. 去重检查:预加载前检查瓦片是否已存在,避免重复下载
  4. 并发控制:同时下载数不超过3个,避免占用过多带宽
  5. 存储上限:预加载数据总量不超过配置的存储上限

五、HarmonyOS 6适配

5.1 智能预加载

HarmonyOS 6引入了AI驱动的智能预加载,基于用户行为模式自动预测需要预加载的区域:

// HarmonyOS 6 智能预加载(预览)
const smartPreload = map.createSmartPreloadEngine({
  learningEnabled: true,      // 启用行为学习
  predictionWindow: 3600,     // 预测未来1小时的行程
  autoPreload: true,          // 自动预加载预测区域
  maxDailyQuota: 100 * 1024 * 1024  // 每日预加载上限100MB
});

// 获取预测结果
const predictions = await smartPreload.getPredictions();
// predictions: [
//   { lat: 39.9, lon: 116.4, probability: 0.85, reason: '工作日通勤' },
//   { lat: 31.2, lon: 121.5, probability: 0.3, reason: '周末出行' }
// ]

5.2 离线地图增量更新

HarmonyOS 6支持离线地图的增量更新,只下载变化的瓦片:

// 增量更新(预览)
const updateResult = await offlineMapManager.incrementalUpdate(cityId, {
  onProgress: (downloaded, total) => {
    console.info(`增量更新进度: ${downloaded}/${total}`);
  }
});
// updateResult: { updatedTiles: 156, newTiles: 23, removedTiles: 8 }

5.3 分布式离线地图

HarmonyOS 6支持在多设备间共享离线地图数据:

// 分布式离线地图共享(预览)
import { distributedKVStore } from '@kit.ArkData';

// 在手机上下载离线包后,自动同步到平板
const syncConfig: distributedKVStore.SyncConfig = {
  mode: distributedKVStore.SyncMode.PUSH_ONLY,
  delay: 5000
};

六、总结

本文系统讲解了HarmonyOS离线地图与数据预加载的完整技术栈,从离线包管理到数据预加载引擎、离线搜索和增量更新。核心要点回顾:

flowchart TB
    A[离线地图开发] --> B[离线包管理]
    A --> C[数据预加载]
    A --> D[离线搜索]
    A --> E[存储优化]
    
    B --> B1[城市包下载]
    B --> B2[断点续传]
    B --> B3[版本更新检测]
    
    C --> C1[位置预加载]
    C --> C2[路线预加载]
    C --> C3[收藏预加载]
    
    D --> D1[SQLite索引]
    D --> D2[模糊搜索]
    D --> D3[分类查询]
    
    E --> E1[容量限制]
    E --> E2[LRU淘汰]
    E --> E3[增量更新]
    
    classDef coreStyle fill:#00897B,stroke:#004D40,color:#FFF,font-weight:bold
    classDef subStyle fill:#80CBC4,stroke:#00897B,color:#000
    classDef leafStyle fill:#E0F2F1,stroke:#80CBC4,color:#000
    
    class A coreStyle
    class B,C,D,E subStyle
    class B1,B2,B3,C1,C2,C3,D1,D2,D3,E1,E2,E3 leafStyle

关键实践原则:

  1. WiFi优先下载:离线包下载和预加载默认仅在WiFi下执行
  2. 断点续传:大文件下载必须支持断点续传,避免重复下载
  3. 增量更新:使用增量更新替代全量更新,大幅减少更新流量
  4. 存储感知:下载前检查剩余空间,提供清理建议
  5. 智能预加载:基于位置、路线、收藏多维度预加载,而非盲目全量下载
  6. 离线搜索:使用SQLite索引 + 拼音搜索,确保离线场景下的搜索体验

至此,本系列5篇文章已完整覆盖了HarmonyOS地图开发的核心技术栈:Map组件渲染、地图标注、路线规划、交互手势、离线地图。掌握这些技术,即可构建一个功能完备、性能优异的HarmonyOS地图应用。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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