HarmonyOS APP开发:离线地图与数据预加载
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+城市 | 自动管理 |
关键建议:
- 在WiFi环境下自动下载,移动网络下仅下载紧急包
- 超过30天未使用的离线包自动提示清理
- 提供存储空间仪表盘,让用户清楚了解占用情况
4.2 瓦片下载常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 下载速度慢 | 服务器限速或网络不稳定 | 使用多线程下载,支持断点续传 |
| 瓦片损坏 | 下载中断导致文件不完整 | 下载后校验文件大小,异常则重试 |
| 磁盘空间不足 | 离线包过大 | 下载前检查剩余空间,提供清理建议 |
| 瓦片过期 | 道路数据更新 | 定期检查版本号,增量更新 |
| 权限不足 | 文件目录无写入权限 | 使用应用专属目录filesDir |
4.3 离线搜索优化
- 索引预构建:离线包中应包含预构建的搜索索引,而非在客户端实时构建
- 模糊搜索:支持拼音首字母搜索、模糊匹配,提升搜索体验
- 搜索结果排序:按距离 + 评分综合排序,而非单一维度
- 搜索缓存:热门搜索结果缓存,减少数据库查询次数
- 增量更新:POI数据更新时使用增量包,而非全量替换
4.4 数据预加载注意事项
- WiFi优先:预加载默认仅在WiFi下执行,避免消耗用户流量
- 电量感知:低电量模式下暂停预加载,避免加速耗电
- 去重检查:预加载前检查瓦片是否已存在,避免重复下载
- 并发控制:同时下载数不超过3个,避免占用过多带宽
- 存储上限:预加载数据总量不超过配置的存储上限
五、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
关键实践原则:
- WiFi优先下载:离线包下载和预加载默认仅在WiFi下执行
- 断点续传:大文件下载必须支持断点续传,避免重复下载
- 增量更新:使用增量更新替代全量更新,大幅减少更新流量
- 存储感知:下载前检查剩余空间,提供清理建议
- 智能预加载:基于位置、路线、收藏多维度预加载,而非盲目全量下载
- 离线搜索:使用SQLite索引 + 拼音搜索,确保离线场景下的搜索体验
至此,本系列5篇文章已完整覆盖了HarmonyOS地图开发的核心技术栈:Map组件渲染、地图标注、路线规划、交互手势、离线地图。掌握这些技术,即可构建一个功能完备、性能优异的HarmonyOS地图应用。
- 点赞
- 收藏
- 关注作者
评论(0)