基于鸿蒙os开发跑步管理系统(八)-路线服务与地图渲染
路线服务与地图渲染
Canvas 地图渲染
采用 Canvas 动态绘制方案 (非 MapComponent/Shape/Polyline),解决以下问题:
- MapComponent 需要 HMS 服务,模拟器不可用
- Shape/Polyline 在状态变化时不重绘
- Canvas + Image 方案存在 660x360 和 320vp 的尺寸不匹配问题
渲染架构
Canvas(this.mapCanvas)
.width('100%')
.height(320)
.backgroundColor('#F5F2EB')
.onReady(() => {
this.currentMapRoute = RouteService.getInstance().getRouteByType(routeType);
this.drawRouteMap();
})
CanvasRenderingContext2D 为 plain private 属性 (非 @State):
private mapCanvas: CanvasRenderingContext2D =
new CanvasRenderingContext2D(new RenderingContextSettings(true));
geoToPixel 坐标转换
const geoToPixel = (lat: number, lng: number): number[] => {
const px = PAD + drawW * (lng - (viewCenterLng - viewLngSpan / 2)) / viewLngSpan;
const py = PAD + drawH * ((viewCenterLat + viewLatSpan / 2) - lat) / viewLatSpan;
return [px, py];
};
关键:geoToPixel 在 drawRouteMap() 内部定义,读取 canvasContext.width/height,而非缓存在 aboutToAppear 中。
视图中心与范围
- 视图中心 = 路线起点 (route.points[0])
- 视图范围 = maxDistFromStart * 2 * 1.35 (1.35 为边距系数)
- drawW/drawH = canvasSize - PAD * 2 (PAD = 50)
绘制层次 (从底到顶)
- 坐标网格: 0.005 度间隔,#E0DDD5 线条
- 坐标标签: 经纬度文本标注
- 地标: 9 个北京地标 (自动判断是否在视图内)
- 路线折线: 路线颜色 (#4CAF50/#2196F3/#FF9800) + 白色中心线
- 起点/终点标记: 绿色 S / 红色 E 圆形标记 (7px)
- 途经点: 3px 彩色圆点
Canvas 时序修复
问题: aboutToAppear 在 Canvas onReady 之前执行,此时 canvasContext.width/height 为 0。
解决: 不在 aboutToAppear 中缓存像素坐标,而是在 onReady 回调中调用 drawRouteMap(),drawRouteMap() 内部读取 canvasContext.width/height。
aboutToAppear() → canvasContext.width = 0 (未就绪)
Canvas.onReady() → canvasContext.width = 实际值 (就绪)
└── drawRouteMap() → 使用实际 width/height 绘制
标准跑路线

地标数据
9 个北京核心地标,在地图视图内自动显示:
| 地标 | 纬度 | 经度 |
|---|---|---|
| 天安门 | 39.9087 | 116.3975 |
| 故宫 | 39.9163 | 116.3972 |
| 景山 | 39.9210 | 116.3935 |
| 北海 | 39.9260 | 116.3830 |
| 天坛 | 39.8820 | 116.3930 |
| 王府井 | 39.9200 | 116.4000 |
| 前门 | 39.9050 | 116.3970 |
| 鼓楼 | 39.9280 | 116.3860 |
| 什刹海 | 39.9250 | 116.3850 |
RouteService 服务架构
单例模式
RouteService 采用单例模式,确保全局只有一个路线数据实例:
class RouteService {
private static instance: RouteService | null = null;
private routes: RouteInfo[] = [];
static getInstance(): RouteService {
if (!RouteService.instance) {
RouteService.instance = new RouteService();
}
return RouteService.instance;
}
}
单例模式的优势在于路线数据只需初始化一次,避免重复创建带来的内存开销和数据不一致问题。所有页面通过 RouteService.getInstance() 获取同一个实例,保证了数据源的统一性。
数据模型
路线数据由 RouteInfo 和 Waypoint 两个模型组成:
class Waypoint {
name: string;
lat: number;
lng: number;
constructor(name: string, lat: number, lng: number) {
this.name = name;
this.lat = lat;
this.lng = lng;
}
}
class RouteInfo {
type: string;
name: string;
distance: number;
duration: string;
difficulty: string;
color: string;
points: Waypoint[];
getStartPoint(): Waypoint {
return this.points[0];
}
getEndPoint(): Waypoint {
return this.points[this.points.length - 1];
}
}
Waypoint 模型包含地标名称和经纬度坐标。RouteInfo 模型包含路线的基本属性(类型、名称、距离、时长、难度、颜色)和途经点列表。getStartPoint 和 getEndPoint 方法分别返回起点和终点,用于地图渲染时绘制标记。
路线查询接口
getRouteByType(type: string): RouteInfo | undefined {
return this.routes.find((r: RouteInfo) => r.type === type);
}
getAllRoutes(): RouteInfo[] {
return this.routes;
}
getRouteByType 根据路线类型字符串(‘easy’/‘standard’/‘endurance’)查找对应路线,用于地图详情页渲染指定路线。getAllRoutes 返回全部路线列表,用于路线列表页展示。
路线初始化
三条路线在 RouteService 构造函数中硬编码初始化,每条路线包含详细的途经点坐标:
private constructor() {
this.routes = [
this.createEasyRoute(),
this.createStandardRoute(),
this.createEnduranceRoute()
];
}
路线坐标基于北京城区真实地理坐标,经由地图工具精确标定。每个途经点对应一个可识别的地标或路口,方便跑步者定位导航。
地图渲染详细流程
drawRouteMap() 完整流程
drawRouteMap() 是地图渲染的核心方法,按照以下步骤执行:
1. 获取 Canvas 上下文和尺寸
├── ctx = this.mapCanvas
├── canvasW = ctx.width
└── canvasH = ctx.height
2. 计算视图范围
├── 遍历所有途经点,计算 maxDistFromStart
├── viewLatSpan = maxDist * 2 * 1.35
├── viewLngSpan = maxDist * 2 * 1.35
├── viewCenterLat = route.points[0].lat
└── viewCenterLng = route.points[0].lng
3. 计算绘图区域
├── drawW = canvasW - PAD * 2 (PAD = 50)
└── drawH = canvasH - PAD * 2
4. 定义 geoToPixel 转换函数
5. 分层绘制
├── 清除画布
├── 绘制坐标网格
├── 绘制坐标标签
├── 绘制地标标注
├── 绘制路线折线
├── 绘制起点/终点标记
└── 绘制途经点标记
视图范围计算详解
视图范围的计算目标是确保所有途经点都能在 Canvas 中显示,同时留有适当边距:
let maxDist: number = 0;
const startLat: number = route.points[0].lat;
const startLng: number = route.points[0].lng;
for (const point of route.points) {
const dLat: number = Math.abs(point.lat - startLat);
const dLng: number = Math.abs(point.lng - startLng);
if (dLat > maxDist) maxDist = dLat;
if (dLng > maxDist) maxDist = dLng;
}
const viewLatSpan: number = maxDist * 2 * 1.35;
const viewLngSpan: number = maxDist * 2 * 1.35;
以起点为中心,maxDist 为所有途经点距起点的最大经纬度差值。乘以 2 得到覆盖全部途经点所需的最小跨度,再乘以 1.35 增加约 35% 的边距,确保路线不会紧贴画布边缘。三个路线使用统一的 1.35 系数,在不同路线跨度下均能获得良好的视觉效果。
起点居中策略
const viewCenterLat: number = startLat;
const viewCenterLng: number = startLng;
视图中心固定为路线起点,而非所有途经点的几何中心。这一设计决策基于以下考量:
- 跑步者从起点出发,起点是用户最关注的参考位置
- 耐力跑路线为环线(终点回到起点),起点居中可以同时展示起终点
- 几何中心可能导致起点偏移到画布角落,不够直观
坐标网格绘制
const gridInterval: number = 0.005;
const latStart: number = viewCenterLat - viewLatSpan / 2;
const latEnd: number = viewCenterLat + viewLatSpan / 2;
for (let lat: number = Math.ceil(latStart / gridInterval) * gridInterval;
lat <= latEnd; lat += gridInterval) {
const [px1, py1] = geoToPixel(lat, viewCenterLng - viewLngSpan / 2);
const [px2, py2] = geoToPixel(lat, viewCenterLng + viewLngSpan / 2);
ctx.strokeStyle = '#E0DDD5';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(px1, py1);
ctx.lineTo(px2, py2);
ctx.stroke();
ctx.fillStyle = '#999999';
ctx.font = '10px sans-serif';
ctx.fillText(lat.toFixed(3), px1 + 2, py1 - 2);
}
0.005 度间隔约对应 550 米距离,在北京城区范围内可以提供足够的网格密度。网格线使用低饱和度颜色 #E0DDD5,不干扰路线和地标信息的阅读。坐标标签使用 10px 字体,显示在网格线起始处。
地标过滤与绘制
9 个北京地标在绘制时需要判断是否落在当前视图范围内:
for (const lm of landmarks) {
if (lm.lat >= latStart && lm.lat <= latEnd &&
lm.lng >= lngStart && lm.lng <= lngEnd) {
const [px, py] = geoToPixel(lm.lat, lm.lng);
ctx.fillStyle = '#666666';
ctx.font = '12px sans-serif';
ctx.fillText(lm.name, px + 8, py - 4);
ctx.beginPath();
ctx.arc(px, py, 3, 0, 2 * Math.PI);
ctx.fillStyle = '#999999';
ctx.fill();
}
}
地标过滤避免了视图范围外的地标占据绘制计算资源,同时防止标注出现在画布可视区域之外。每条路线由于视图范围不同,显示的地标数量也不同:轻松跑显示约 5-6 个地标,耐力跑可能显示全部 9 个。
路线折线绘制
路线折线采用双层绘制方案:外层为路线颜色粗线,内层为白色细线,形成道路效果:
ctx.strokeStyle = route.color;
ctx.lineWidth = 4;
ctx.beginPath();
const [sx, sy] = geoToPixel(route.points[0].lat, route.points[0].lng);
ctx.moveTo(sx, sy);
for (let i: number = 1; i < route.points.length; i++) {
const [px, py] = geoToPixel(route.points[i].lat, route.points[i].lng);
ctx.lineTo(px, py);
}
ctx.stroke();
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx, sy);
for (let i: number = 1; i < route.points.length; i++) {
const [px, py] = geoToPixel(route.points[i].lat, route.points[i].lng);
ctx.lineTo(px, py);
}
ctx.stroke();
这种双层绘制在地图可视化中常见,粗色线提供视觉辨识度,白色中心线增加道路质感。线宽 4px 和 2px 的比例在各种屏幕密度下均可清晰辨识。
起终点标记绘制
起点和终点使用带字母的圆形标记,半径 7px:
const [startPx, startPy] = geoToPixel(route.points[0].lat, route.points[0].lng);
ctx.beginPath();
ctx.arc(startPx, startPy, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#4CAF50';
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 10px sans-serif';
ctx.fillText('S', startPx - 3, startPy + 3);
const lastIdx: number = route.points.length - 1;
const [endPx, endPy] = geoToPixel(
route.points[lastIdx].lat, route.points[lastIdx].lng);
ctx.beginPath();
ctx.arc(endPx, endPy, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#F44336';
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.fillText('E', endPx - 3, endPy + 3);
绿色 S 标记起点(Start),红色 E 标记终点(End),颜色选择遵循通用地图标记惯例。7px 半径在 320px 高度的画布中大小适中,不会遮挡路线细节。
Canvas 方案设计决策
方案对比
| 方案 | 优势 | 劣势 | 结论 |
|---|---|---|---|
| MapComponent | 原生地图、交互丰富 | 依赖 HMS 服务、模拟器不可用 | 排除 |
| Shape + Polyline | 声明式 UI、代码简洁 | 状态变化时不重绘、坐标绑定困难 | 排除 |
| Canvas + Image | 利用离屏渲染 | 660x360 像素与 320vp 不匹配 | 排除 |
| Canvas 直接绘制 | 完全控制、模拟器可用 | 无交互手势、需手动计算坐标 | 采用 |
排除 MapComponent 的原因
MapComponent 是 HarmonyOS 提供的地图组件,但在 RunAdvisor 的开发环境中存在关键限制:
- MapComponent 需要 HMS (Huawei Mobile Services) 地图服务支持
- 模拟器未安装 HMS 服务框架,MapComponent 初始化即崩溃
- 开发阶段无法在模拟器上验证地图功能
- 地图服务的 API Key 配置需要开发者账号认证
排除 Shape + Polyline 的原因
ArkUI 的 Shape 容器和 Polyline 子组件理论上可以绘制折线,但实际测试发现:
- Polyline 的 points 属性在 @State 变化时不触发重绘
- 嵌套在 Scroll/Tabs 中的 Shape 组件布局异常
- 无法实现分层绘制(网格、路线、标记需要不同样式)
Canvas 直接绘制的权衡
Canvas 方案的最大优势是完全的绘制控制权,可以在 onReady 回调中按需重绘。缺点是放弃了声明式 UI 的便利性,所有视觉元素需要手动计算坐标和样式。在路线地图场景下,由于地图内容在页面打开后不再变化,Canvas 的"一次绘制"模式恰好适合。
路线列表页实现
页面结构
路线列表页作为 Route Tab 的主页面,展示三条路线的卡片列表:
ForEach(RouteService.getInstance().getAllRoutes(), (route: RouteInfo) => {
Column() {
Row() {
Circle({ width: 12, height: 12 }).fill(route.color)
Text(route.name).fontSize(16).fontWeight(FontWeight.Bold)
}
Row() {
Text(route.distance + 'km')
Text(route.duration)
Text(route.difficulty)
}
Button('查看路线')
.onClick(() => {
this.navStack.pushPath({ name: 'MapRoute', param: route.type });
})
}
})
每张路线卡片包含路线颜色标识、名称、关键指标(距离/时长/难度)和查看按钮。点击"查看路线"通过 NavPathStack 导航到地图详情页,传递路线类型字符串作为参数。
地图详情页导航
地图详情页通过 NavDestination 实现,从 NavDestBuilder 接收路线类型参数:
@Builder
NavDestBuilder(name: string, param: Object) {
if (name === 'MapRoute') {
this.MapRoutePageNav(param as string)
}
}
MapRoutePageNav Builder 根据路线类型加载对应数据并渲染 Canvas 地图。路线类型字符串(‘easy’/‘standard’/‘endurance’)作为唯一标识贯穿路由跳转和数据查询。
路线详情页截图
路线详情

距离与时长计算说明
距离计算
路线距离基于途经点之间的 Haversine 公式近似计算:
d = 2R * arcsin(sqrt(sin2(dLat/2) + cos(lat1)*cos(lat2)*sin2(dLng/2)))
其中 R 为地球半径 6371km。由于城市跑步路线的途经点间距较短(通常小于 1km),Haversine 公式的精度足以满足展示需求。
时长估算
跑步时长根据平均配速估算:
| 难度 | 配速范围 | 平均配速 |
|---|---|---|
| 入门(轻松跑) | 6:00-8:00 min/km | 7:00 min/km |
| 中等(标准跑) | 5:30-7:00 min/km | 6:00 min/km |
| 挑战(耐力跑) | 5:00-6:30 min/km | 5:30 min/km |
时长 = 距离 x 平均配速,取整为 5 分钟的倍数。
使用场景
场景一:初跑者选择路线
初跑者打开应用,进入路线 Tab,浏览三条路线卡片。通过距离和难度信息判断轻松跑路线适合自己,点击"查看路线"进入地图详情页,查看途经地标和路线走向,确认路线可行后开始跑步。
场景二:有经验跑者规划长距离
有经验的跑者查看耐力跑路线地图,确认天坛、前门等地标的位置关系,评估路线距离是否匹配当日训练计划。地图上的坐标网格帮助判断各段路程的实际距离。
场景三:游客跑步游览
来北京旅游的用户选择标准跑路线,地图上标注的故宫、雍和宫、王府井等地标既是途经点也是旅游景点,跑步同时完成城市观光。
更多推荐


所有评论(0)