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个月内不可修改。