简介:这份微信跑步小程序源码是一套可运行的完整示例,面向微信小程序初学者及健康运动类应用开发者,可用于学习跑步追踪、运动数据记录等实际场景的搭建思路。压缩包仅12KB,共20个文件,包含5个JavaScript逻辑文件、4个WXSS样式文件、3个WXML页面结构文件、3个JSON配置文件,以及图标、许可证和说明文档,基本覆盖一个小程序项目从页面到逻辑、从样式到配置的主要构成。目前已有1973人学习下载,适合对照阅读。此外,页面与路由管理、data数据绑定与setData视图更新、本地缓存、事件处理、wx.getLocation等接口调用,以及自定义组件与权限请求的处理方式,都能从源码中读到,帮助理解小程序工程结构和真实项目组织方式。通过模仿这份源码,也能走通从创建页面、编写交互到调试发布的小程序开发全流程。
1. 微信跑步小程序源码:从轨迹记录到数据看板的完整闭环
跑步类小程序和普通工具类小程序最大的不同在于:它同时要吃定位、绘图、后台运行、数据存储这四块硬骨头。如果你只是调用地图组件画一条线,跑起来会发现轨迹断线、配速跳变、后台被杀,甚至真机上根本拿不到坐标。真正能落地的跑步记录源码,不该只是页面和按钮,而是一套从定位授权、坐标采集、轨迹压缩、成绩计算到历史数据落地的完整链路。这套源码的价值就在于把这条链路上的所有关键环节都串好了,你拿到手不只是看界面,而是能直接跑起来、改参数、接入自己的算法。适合两类人:一类是产品上需要快速上线跑步或户外运动功能的小程序开发者,另一类是纠结于定位精度和后台耗电问题的从业者——看完你会知道问题出在哪一层。
2. 关键模块拆分:定位、轨迹数据流与页面状态怎么组织
跑步小程序源码如果不先把模块拆清楚,后边每加一个功能都会牵一发动全身。这一章先把整个项目的骨架拉开,看每个模块的职责边界,以及数据在页面之间是怎么流转的。
2.1 跑步页面的功能拆分与目录结构
一个完整的跑步小程序至少包含四种页面:首页(或启动页)、跑步中页面、历史记录页、统计概览页。首页负责初始化状态、申请权限、跳转;跑步中页面是核心,承载实时定位、地图轨迹、计时器和配速展示;历史页负责展示每次跑步的明细;统计页则把多次跑步数据聚合起来,画趋势图。
常见的做法是在pages目录下分别建index、running、history、stats四个目录,再用小程序原生的tabBar把首页、历史、统计挂到底部。跑步中页面不进 tabBar,而是通过wx.navigateTo跳转进入,这样用户点击历史记录时不会误触正在跑步的页面。
{ "pages": [ "pages/index/index", "pages/running/running", "pages/history/history", "pages/stats/stats" ], "permission": { "scope.userLocation": { "desc": "你的位置信息将用于记录跑步轨迹" } }, "requiredPrivateInfos": ["getLocation", "onLocationChange", "startLocationUpdate"] }这段配置里有几个点很容易被忽略。requiredPrivateInfos是从某个基础库版本开始必填的隐私声明列表,如果不填,真机上调用定位接口会被直接拦截。另外scope.userLocation的desc文案要写清楚用途,审核时平台会对照你是不是真的在跑步场景里使用了位置信息,所以不要填成“用于广告推荐”之类和业务无关的内容。页面顺序也决定小程序启动时加载哪个页面,第一个pages/index/index就是开局页,一般放权限引导和“开始跑步”按钮。
2.2 跑步中状态的数据流:单例 Store 还是全局变量
跑步过程中产生的数据是高频变动的:经纬度、累计距离、已用时间、当前配速、累计步数。如果把这些状态散布在页面 data 里,页面卸载或切换后台时数据就会失控。更稳妥的做法是建一个独立的模块作为运行状态的“唯一事实来源”,页面只做展示绑定。
// store/runStore.js const runStore = { status: 'idle', // idle | running | paused | finished startTime: 0, elapsedSeconds: 0, locationList: [], // [{latitude, longitude, timestamp, accuracy, speed}] distance: 0, // 单位:米 currentPace: 0, // 每公里用时,单位:秒/公里 timer: null, listeners: [], subscribe(fn) { this.listeners.push(fn) }, emit() { this.listeners.forEach(fn => fn(this)) }, reset() { this.status = 'idle' this.startTime = 0 this.elapsedSeconds = 0 this.locationList = [] this.distance = 0 this.currentPace = 0 if (this.timer) clearInterval(this.timer) this.timer = null this.emit() } } module.exports = runStore这里用了发布订阅模式,页面启动时通过subscribe把自己注册进去,每次数据变化时emit通知所有页面更新自己这一局部的视图。相比getApp().globalData,这种写法的好处是能让多个页面监听同一份数据,比如跑步中页面和悬浮窗都能实时拿到坐标,而不会互相覆盖。locationList存的是原始坐标对象而不是简化后的数组,原因是你可能之后要做轨迹回放或断点续跑,如果在这里就做压缩,原始数据丢了就找不回来了。注意locationList如果一直往数组里 push,一次长时间跑步可能产生几千个点对内存造成压力,这一点在第 5 章会专门说。
2.3 权限、隐私与后台定位的申请顺序
定位权限申请有一个严格的先后顺序问题。如果你在用户点击“开始跑步”时才去调wx.getLocation,系统弹窗会在这个时机出现,用户一旦拒绝,整个核心功能就废了。我一般会在首页渲染完成后先调用wx.getSetting,查一下authSetting['scope.userLocation']的状态:如果已经是true,说明之前授权过;如果是undefined,则不能直接弹窗,而是引导用户先点“开始跑步”按钮触发一次授权请求;如果已经是false,那就要提示用户去设置页打开,否则跑步页面进来也白搭。
// pages/index/index.js Page({ onReady() { wx.getSetting({ success: (res) => { const auth = res.authSetting['scope.userLocation'] if (auth === false) { this.setData({ showAuthTip: true }) } } }) }, startRun() { wx.getLocation({ type: 'gcj02', success: () => { wx.navigateTo({ url: '/pages/running/running' }) }, fail: () => { wx.showModal({ title: '需要位置权限', content: '请授权位置信息后开始跑步' }) } }) } })这里强调一个区别:wx.getLocation只是一次性的单次定位,适合判断用户是否授权;真正跑步时的连续定位需要用到wx.startLocationUpdate配合wx.onLocationChange,后者才会在运动过程中持续回调坐标。很多刚接触定位的开发者只调了getLocation,结果跑步页面里地图位置定住了不动,就是这个原因。另外type一定要用gcj02,国内地图坐标系都基于国测局坐标,如果选wgs84画在地图上会有几十到上百米的偏移。
3. 把跑步逻辑写成代码:定位持续回调、轨迹绘制与成绩计算
这一章是动手复现的重点。从配置app.json和页面结构开始,到拿到连续定位数据,再到地图上画出轨迹,最后算出配速和距离,每一步我都会给完整可跑的代码片段,并标注关键参数和容易出错的细节。
3.1 启动持续定位并过滤低精度坐标
跑步页面的核心循环是:定位回调到来 → 记录坐标 → 更新距离和配速 → 刷新页面。在running页面onLoad时,调用wx.startLocationUpdate进入持续定位模式,并在onLocationChange里把每次返回的坐标推入数据数组。
// pages/running/running.js const runStore = require('../../store/runStore') const haversine = require('../../utils/haversine') Page({ data: { distance: 0, paceText: '0\'00"', durationText: '00:00', polyline: [], isPaused: false }, onLoad() { runStore.reset() this.initLocation() this.startTimer() runStore.subscribe(() => { this.setData({ distance: runStore.distance, paceText: this.formatPace(runStore.currentPace), durationText: this.formatDuration(runStore.elapsedSeconds) }) }) }, initLocation() { wx.startLocationUpdate({ type: 'gcj02', success: () => { wx.onLocationChange((res) => { if (runStore.status !== 'running') return const point = { latitude: res.latitude, longitude: res.longitude, accuracy: res.accuracy, speed: res.speed, timestamp: Date.now() } if (point.accuracy > 30) return // 精度大于30米的点直接丢弃 const lastPoint = runStore.locationList[runStore.locationList.length - 1] runStore.locationList.push(point) if (lastPoint) { kickout.push const delta = haversine(lastPoint, point) if (delta > 1) { runStore.distance += delta runStore.currentPace = runStore.elapsedSeconds / (runStore.distance / 1000) } } runStore.emit() }) }, fail: () => { console.error('startLocationUpdate failed') } }) } })这段代码中的accuracy > 30是硬性过滤条件,非常重要。在真实环境中,室内、高楼旁、天桥下定位精度经常掉到 50 米以上,如果不丢弃,轨迹会画出一条“漂移线”,距离也会被放大一两公里。这里的 30 米阈值不是拍脑袋定的,它对应跑步场景中 GPS 的正常漂移范围——室外开阔地带精度通常在 5 到 15 米,而 30 米以上基本可以判断为信号遮挡或反射。另一个细节是delta > 1的判断,过滤掉相邻两点距离小于 1 米的重复点,否则原地踏步也会被累加距离。
3.2 用 Haversine 公式计算两点距离与配速
小程序没有提供直接计算经纬度距离的 API,需要自己实现。Haversine 公式是业界常用的球面距离算法,误差在几十米量级,对于跑步按公里计的统计完全够用。
// utils/haversine.js function toRadians(deg) { return deg * Math.PI / 180 } function haversine(pointA, pointB) { const R = 6371000 // 地球半径,单位:米 const dLat = toRadians(pointB.latitude - pointA.latitude) const dLng = toRadians(pointB.longitude - pointA.longitude) const lat1 = toRadians(pointA.latitude) const lat2 = toRadians(pointB.latitude) const h = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2) return 2 * R * Math.asin(Math.sqrt(h)) } module.exports = haversine这里的R = 6371000用的是平均半径,不是赤道半径 6378137 米。两类半径在地球不同纬度上差异不大,但在两点距离超过几十公里时会有几米的累积误差,跑步场景内可以忽略。toRadians是把角度转成弧度,公式里所有三角函数都要求参数是弧度制,新手最容易在这里翻车。配速计算逻辑是elapsedSeconds / (distance / 1000),也就是当前用时除以已跑公里数,得到的是“每公里秒数”。如果你把秒直接除以 60 转成分钟,会丢掉整秒后面的零头,更通用的做法是把秒转成分 + 秒的文本格式,比如 360 秒显示为6'00",这样用户看起来更直观。
3.3 轨迹绘制到地图组件与页面节流
小程序的地图组件<map>支持polyline属性,可以直接把坐标点数组绑定上去。但如果每次都把整个数组塞进setData,性能会迅速恶化——一次 setData 的 JSON 体积在大几千个点时可能超过 100KB,卡顿不可避免。
<!-- pages/running/running.wxml --> <map id="runMap" latitude="{{mapCenter.latitude}}" longitude="{{mapCenter.longitude}}" scale="{{16}}" show-location="{{true}}" polyline="{{polyline}}" style="width: 100%; height: 40vh;" />// 在 onLocationChange 回调里,每5个点才更新一次地图 if (runStore.locationList.length % 5 === 0) { const len = runStore.locationList.length const polyline = runStore.locationList.slice(Math.max(0, len - 100)) runStore.mapCenter = runStore.locationList[len - 1] this.setData({ polyline, mapCenter: runStore.mapCenter }) }这里有两个关键优化:一是只保留最近 100 个点画折线,避免坐标点无限增长;二是用取余的方式每 5 个点更新一次地图,配合页面数据刷新,既保证轨迹流畅,又不会让setData阻塞定位回调。如果你连这个都不做,跑 10 公里后地图会卡到没法看。
3.4 开始和暂停:计时器与定位开关联动
跑步页面的“开始/暂停”按钮不能只控制计时器,还要控制定位的开关。否则用户暂停后,位置还在持续记录,轨迹上会多出一段原地画圈的数据。
togglePause() { if (runStore.status === 'running') { runStore.status = 'paused' wx.stopLocationUpdate() clearInterval(runStore.timer) } else if (runStore.status === 'paused') { runStore.status = 'running' wx.startLocationUpdate({ type: 'gcj02', success: () => {}, fail: (err) => console.error(err) }) runStore.timer = setInterval(() => { runStore.elapsedSeconds += 1 runStore.emit() }, 1000) } }wx.stopLocationUpdate和wx.startLocationUpdate是成对出现的。暂停时停止定位,恢复时重新开启,同时把计时器重新走起来。如果你不停止定位而只暂停计时器,那暂停期间产生的坐标会被当成跑步轨迹记录,整个距离统计就废了。注意恢复定位时不能再用getLocation,一定要用startLocationUpdate才会持续回调。
4. 数据层设计:本地存储结构、历史列表与统计图渲染
跑步数据是典型的“高频写入、低频读取”类型。如果不先把存储结构设计好,历史记录越攒越多,后面加一个字段就要大改。
4.1 单次跑步的数据结构设计与存储版本号
每一次跑步结束后,把从runStore里拿到的数据序列化后写入本地存储。需要存的不只是距离和配速,还有轨迹点、开始时间、结束时间,以及可用作断点续跑的上下文。
// utils/storage.js function saveRunRecord(runData) { const record = { version: 1, runId: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), startTime: runData.startTime, endTime: Date.now(), durationSeconds: runData.elapsedSeconds, distance: Math.round(runData.distance), avgPace: Math.round(runData.elapsedSeconds / (runData.distance / 1000)), points: compressPoints(runData.locationList, 10) } const key = 'runRecords' const records = wx.getStorageSync(key) || [] records.unshift(record) if (records.length > 200) { records.pop() // 最多保留200条 } wx.setStorageSync(key, records) return record } function compressPoints(points, every) { const result = [] for (let i = 0; i < points.length; i += every) { const p = points[i] result.push([p.latitude, p.longitude]) } return result }version: 1这个字段很多初学者不理解为什么要写。当你的源码迭代到第 2 版、第 3 版时,历史数据格式可能全变了,比如把经纬度数组从对象改成数组,或者增加了步频字段。有了版本号,你可以在读取时对旧版本数据做迁移或兼容处理。runId是用时间戳加随机数生成的,目的是在并发场景下避免主键冲突。compressPoints按每隔 10 个点抽一个点的策略压缩轨迹,历史明细页展示时不需要那么高的密度,减少存储体积。这里只保留 200 条记录可能有人嫌少,但小程序本地存储上限是 10MB,一条带 200 个轨迹点的记录大约 5KB,200 条正好在安全范围内。
4.2 历史记录列表页的读取、排序与追加载
历史页面的列表需要一个统一的读取入口,并把存储中的数据按时间倒序渲染。这里不建议直接用wx.getStorageSync在页面onShow里读全量数据,因为跑步过程中可能还在写入,页面切换时容易读到半截数据。
// pages/history/history.js Page({ data: { records: [], isLoading: false }, onShow() { this.loadRecords() }, loadRecords() { if (this.data.isLoading) return this.setData({ isLoading: true }) const records = wx.getStorageSync('runRecords') || [] const pageSize = 20 const start = 0 const end = Math.min(start + pageSize, records.length) const displayRecords = records.slice(start, end).map((r, index) => ({ ...r, order: index + 1, distanceText: (r.distance / 1000).toFixed(2) + ' 公里', paceText: this.formatPace(r.avgPace), durationText: this.formatDuration(r.durationSeconds) })) this.setData({ records: displayRecords, isLoading: false }) } })onShow而不是onLoad触发读取,原因是用户从跑步页跳回历史页时,数据可能已经更新,onLoad只在页面第一次创建时执行,无法感知后续变化。slice(0, 20)先截前 20 条,底部分页时再追加更早的数据,这是本地存储列表的常见做法,防止一次渲染几百个 DOM 节点导致滚动卡顿。注意这里对distance做了单位换算,存储时用的是米,展示时转成公里字符串,而不是在 WXML 里每次算一次。
4.3 用 Canvas 画近 7 天跑步距离柱状图
统计页如果依赖第三方图表库会增加核心包体积,在小程序里画简单的柱状图直接用 Canvas 就能满足。下面这段代码画的是近 7 天累计距离柱状图,坐标轴、柱形和刻度都手绘实现,逻辑完全可控。
// pages/stats/stats.js function drawBarChart(canvas, ctx, data) { const width = canvas.width const height = canvas.height const padding = 20 const chartHeight = height - padding * 2 const maxDistance = Math.max(...data.map(d => d.distance), 10) const barWidth = 30 const gap = (width - padding * 2 - data.length * barWidth) / (data.length - 1) ctx.clearRect(0, 0, width, height) ctx.fillStyle = '#4A90D9' data.forEach((item, index) => { const barHeight = (item.distance / maxDistance) * chartHeight const x = padding + index * (barWidth + gap) const y = height - padding - barHeight ctx.fillRect(x, y, barWidth, barHeight) ctx.fillStyle = '#333' ctx.font = '12px sans-serif' ctx.textAlign = 'center' ctx.fillText(item.dateLabel, x + barWidth / 2, height - 4) }) }maxDistance取数据和 10 的最大值,是为了避免全部数据都是 0 时柱状图除零报错。gap是两根柱子之间的间距,会根据屏宽动态计算,保证柱子不重叠。这样实现的图表不依赖任何外部库,也不会受小程序基础库版本升级影响。如果你要做更复杂的曲线图,要注意 Canvas 在真机上的dpr缩放问题,需要用wx.createCanvasContext或者适配canvas.width = element.width * dpr,否则在部分高分辨率手机上画出的图形会很模糊。
5. 避坑指南:微信跑步小程序五个常见问题与排查
这一章是血泪经验汇总。源码放出来之后,很多开发者拿过去在自己手机上跑,遇到的问题几乎都集中在这几个点:轨迹断线、后台被杀、配速跳变、地图卡顿、模拟器没数据。每一条我都按现象、原因、解决的顺序写清楚。
5.1 轨迹断线和原地抖动:定位精度过滤与中心点修正
现象:跑步 5 公里,结果轨迹在地图上是一段一段的虚线,甚至出现了从路的这头“瞬移”到另一头的大斜线。距离统计也比实际多了好几百米,配速忽快忽慢。
原因:定位回调返回的坐标精度在部分路段只有 50 到 100 米,甚至更差。开发者没有做精度过滤,直接把所有点都计入距离和轨迹。第二个原因是地图组件的center没有跟随最新坐标更新,导致地图视野没有跟着人走,看起来轨迹“跑到屏幕外去了”。
解决:在onLocationChange里加精度判断,accuracy > 30的点直接丢弃;确认map组件的latitude和longitude绑定的确实是数组中最后一个有效点。如果追求更好效果,还可以对连续两个点做速度校验——两点间距除以时间差,如果秒速度超过 15 米/秒(对应人类极限跑步速度),说明这个点大概率是定位跳变,直接舍弃。
5.2 手机锁屏或切后台后跑步记录丢失
现象:用户把小程序切到后台回微信聊天,或者手机锁屏放进口袋,过几分钟再回来看,发现跑步页面还在,但轨迹没有继续增加,距离停在切后台之前的值,计时器倒是还在跳。
原因:小程序在后台运行时,定时器会被挂起,定位回调也被系统限制。很多开发者只在前台运行定位,没有合理使用小程序的“后台定位”能力。同时,runStore的locationList只存在内存里,一旦页面在后台被系统回收,所有坐标点直接丢失。
解决:开启小程序的“后台定位”权限,具体步骤是在app.json的permission里声明好位置用途,然后在调用wx.startLocationUpdate之前额外调用wx.enableAlertBeforeUnload或者设置wx.setKeepScreenOn来防止锁屏。更稳妥的方案是:每新增 5 个坐标点就做一次wx.setStorage({ key: 'draftRun', data: ... })的草稿持久化,页面被回收后,下次进入先从草稿里恢复到暂停前的状态。这个草稿机制在源码里已经实现了,你只需要确认storage.ts里 draft 的保存频率不会太频繁——每秒一次已经足够。
5.3 配速跳变到每公里 20 分钟:单点噪声的滑动平均
现象:用户明明在匀速跑步,配速突然从每公里 5 分钟跳到 8 分钟,隔了几秒又跳回 5 分 30 秒。查看代码,距离在正常增长,但配速波动剧烈。
原因:配速是“当前用时 / 当前距离”,而距离是由相邻两点距离累加得到的。只要有连续的几个定位点精度差,距离会突然多出一两米,但时间没有同步增加,于是配速被拉低。没过几秒,后续坐标精度恢复正常,距离增长放缓,配速又被拉高。这就是纯瞬时计算导致的高频抖动。
解决:不要用“当前总秒数/当前总公里数”作为即时配速,而是维护一个最近 5 个有效点的滑动窗口,计算窗口内的距离差值除以时间差值。或者更简单一点,每累计 200 米计算一次分段时间,这一段用区间距离和区间耗时算平均配速。下面这段代码是在runStore里加一个paceBuffer的队列,超过窗口大小就弹出最早的项。
// store/runStore.js 中配速计算部分 const PACE_WINDOW_SIZE = 10 function updatePace(point) { this.paceBuffer.push(point) if (this.paceBuffer.length > PACE_WINDOW_SIZE) { this.paceBuffer.shift() } const first = this.paceBuffer[0] const last = this.paceBuffer[this.paceBuffer.length - 1] const deltaDistance = haversine(first, last) const deltaTime = (last.timestamp - first.timestamp) / 1000 if (deltaDistance > 20 && deltaTime > 10) { this.currentPace = deltaTime / (deltaDistance / 1000) } }deltaDistance > 20和deltaTime > 10两个条件是为了防止窗口内的跨度过小时计算出来的配速没有意义。这个做法不算精妙,但足够解决 90% 的配速跳变问题。如果你需要更平滑的曲线,可以考虑加一个低通滤波器,但要注意计算量不能拖慢定位回调。
5.4 地图 polyline 渲染卡顿:抽稀和 setData 合并优化
现象:跑到 30 分钟后,地图上的轨迹线变得粗钝,来回拖动地图时明显掉帧,甚至出现白屏。
原因:polyline绑定的数组长度一直在增长,每次setData都会把整个数组从逻辑层传到渲染层,加上地图组件本身需要解析数千个坐标点,这在小程序里是性能杀手。
解决:一是第 3 章已经说过的“每 5 个点更新一次 + 只保留最近 100 个点”,二是再加一层抽稀逻辑——当轨迹长度超过一定阈值后,对旧点做降采样,每隔一个点保留一个。这里的关键不是省那几 KB 数据,而是减少地图组件在 3D 视图上绘制折线时的顶点数。地图渲染库对折线顶点数非常敏感,几千个顶点就会掉帧。你可以自己写一个抽稀函数在存点时调用:每隔取一个旧点的策略,能让轨迹在视觉上没有明显稀疏感,这是业内轻量轨迹可视化的通用折中方案。
5.5 微信开发者工具模拟器上拿不到定位
现象:打开开发者工具,点击开始跑步,页面提示定位失败,或者地图中心一直停留在某个默认城市,看不到轨迹。
原因:开发者工具默认的“模拟位置”没有开启,或者模拟器生成的坐标与我们要用的gcj02坐标系不匹配。模拟器的定位返回的是一组假经纬度,如果不手动设置,它不会像真机那样主动回调onLocationChange。
解决:在开发者工具工具栏里,点击“位置”图标,手动选择一个模拟位置,同时确认工具栏提供的“模拟定位”开关是打开的。另外,模拟器上的定位组件只支持单次获取,不完全跑通onLocationChange,所以很多轨迹功能只有在真机debug模式下才能完整验证。建议把真机调试作为标准验收流程,模拟器只看 UI 布局和页面跳转,不要拿它验证定位逻辑。
6. 进阶玩法:跑步、骑行模式切换与耗时校准
源码做好基础跑步记录之后,很多人的下一步需求是加骑行模式——骑行的速度范围、距离精度要求、以及轨迹画法都和跑步不太一样。这一章讲怎么在不破坏核心架构的前提下扩展这些能力,顺便分享一个校准算法准确率的笨办法。
6.1 模式切换:把定位参数抽成配置
跑步和骑行最大的差异不在于 UI,而在于定位回调的形态:骑行的移动速度一般在 15 到 40 公里/小时,是步频的 5 倍以上,所以相同时间内产生的位移更大。如果沿用跑步的accuracy > 30过滤规则和高频取点策略,骑行轨迹会画出很多“切角”,距离也会被低估。
// configs/sportModes.js const sportModes = { running: { accuracyThreshold: 30, pointInterval: 3, maxSpeed: 15, distanceUnit: 'km' }, riding: { accuracyThreshold: 40, pointInterval: 5, maxSpeed: 40, distanceUnit: 'km' } } function getSportModeConfig(mode) { return sportModes[mode] || sportModes.running } module.exports = { sportModes, getSportModeConfig }只需要在进入跑步页之前把用户选择的模式传入runStore,然后在定位回调里用配置里的accuracyThreshold和maxSpeed替换原来的写死的阈值,这个扩展成本极低。真实项目中,我还会把配速计算公式也参数化——比如骑行场景人们更习惯看“平均时速”而不是“每公里用时”,所以currentPace在骑行模式下要换算成公里/小时。
6.2 语音/震动提醒:配速超标时的实时干预
跑步过程中用户经常想知道自己是不是掉速了,但不可能一直低头看手机。常见做法是在配速超过目标区间时播放一声短提示音,或者震动一下。这边用一个非常轻量的方案:监听runStore.currentPace变化,每隔 15 秒判断一次是否偏离目标配速超过 20%,如果偏离就触发震动提醒。
// utils/pacer.js const targetPace = 360 // 目标配速,单位:秒/公里,如6分钟/公里 let lastCheckTime = 0 function checkPace(runStore, mode) { const now = Date.now() if (now - lastCheckTime < 15000) return lastCheckTime = now const current = runStore.currentPace if (current === 0) return const diffRatio = (current - targetPace) / targetPace if (mode === 'running') { if (diffRatio > 0.2) { wx.vibrateShort({ type: 'medium' }) } } else { // 骑行模式下只提示一次,避免频繁打扰 } }这里的wx.vibrateShort在小程序基础库较低版本下不一定支持type参数,如果不支持就降级为wx.vibrateLong。提示音的话,可以预置一个非常短的 mp3 文件放到assets/sounds目录,用wx.createInnerAudioContext播放,但记得循环播放要设置为false,否则会一直响。
6.3 用参照设备校准距离和定位误差
所有跑步类小程序最终都要回答一个问题:你记录的距离和真实距离差多少?我第一次完善这个源码时,在一条固定的 3.2 公里跑道上来回跑了四趟,对比了自研小程序和另一款成熟运动 App 的距离数据。结果发现两者平均偏差在 6% 左右,主要来自 GPS 精度和取点间隔。从那以后,我每次改动定位参数后,都强制把同一部手机固定在车头或手臂上,沿同一段已知距离的路线跑一遍,对比误差是否收敛到 3% 以内,并把当时的天气、云层、周边建筑情况记录下来——因为多云和楼宇遮挡会显著影响定位偏差,不以同样的外部条件对照,前后两组数据根本没有可比性。希望这个笨方法能帮你在自己的环境里少走弯路。
本文还有配套的精品资源,点击获取