☰
校园摆渡车微信小程序:地图定位与到站时间估算实践
2026/10/26 19:22:23 网站建设 项目流程

简介:这套原创校园摆渡车微信小程序源码面向小程序初学者、课程设计学生及校园交通应用开发者,围绕摆渡车实时位置查询、上下车点与运行时间表展示、座位预订等场景展开,可用于练习小程序页面布局、按钮事件、地图组件接入以及后端接口协作。压缩包共七十九个文件,约一点零一兆,含十个 Java 文件、二十三个 class、十三个 xml、三个 wxml、三个 wxss、四个 js,以及若干 png、json、jsp、jar 等,覆盖前端页面、后端 Servlet、DAO、Bean、配置和依赖资源。已有四百一十六人学习,说明该案例具备一定参考热度。读者可据此了解小程序目录组织、WXML/WXSS/JS 分工、前后端数据交换与 JSON 接口调用流程,并结合现成源码理解车辆信息、时刻表、用户数据的组织方式,适合作为入门实战或课程设计的对照案例。

1. 校园摆渡车的痛点是“不知道车在哪”,不是“有没有车”

校区之间那趟摆渡车,最难受的从来不是等,而是不知道它来没来、下一班几点。这份「校园摆渡车的微信小程序.zip」把这件事拆成了一个能跑的原生微信小程序:首页地图看车与最近站点、线路页逐站列表、时刻表页按学期改班次,数据层既支持静态 JSON 也支持云开发数据库。适合三类人:赶课程设计的学生、想给自家校区临时搭一套的运维人员、以及想拿真实小场景练地图与定位 API 的开发者。下面按拆包、跑通、改数据、避坑的顺序,把它拆开揉碎讲一遍。

2. 拆包与跑通:四步把 zip 变成能预览的第一屏

拿到一个陌生小程序包,很多人第一反应是直接点“编译”,然后对着控制台的红色报错发愣。这套资源的正确打开方式是把动作拆成四段:看目录定职责、配 appid 与地图权限、把站点数据落到本地 JSON、最后在 onLoad 里一次性 setData 出地图。四段各自独立,哪一段炸了都能单独回滚,不至于把整个工程搅成一团。

2.1 先看目录:每个文件到底负责什么

解压之后别急着改代码,先把目录职责摸清楚。常见的小程序工程组织方式是按页面切分,公共逻辑抽到 utils,静态数据放 data 目录。这套摆渡车资源的职责大致如下表,实际文件名可能有出入,但分层逻辑基本一致。

路径职责上线后改动频率
pages/index首页地图、最近站点、车辆位置刷新低
pages/route线路详情、逐站顺序与到达预估低
pages/timetable班次时刻表、工作日与周末切换高,每学期至少一次
pages/driver司机端位置上报(可裁剪)中
utils/geo.js球面距离、坐标格式校验低
data/stations.js站点、线路、班次的静态数据中
app.json、project.config.json全局页面注册与项目配置低

判断一个包能不能直接用的最快方式,是看 data 目录里的数据结构是不是“线路-站点”两层。如果站点是平铺的一维数组、线路信息直接写死在页面里,说明作者的场景非常单一,你要接第二条线路就需要自己重构。反过来,如果已经有 routeId 字段做归属,扩展成本就很低。

2.2 appid、编译模式与那两行不能省的权限声明

导入开发者工具时,如果没有正式 appid,选“测试号”即可,但地图和定位能力在测试号下会受限,真机调试建议换成自己的小程序 appid。工程配置文件里的 pages 数组第一项就是启动页,顺序别随手调,否则用户从小程序入口进来看到的第一个画面会变。

{ "pages": [ "pages/index/index", "pages/route/route", "pages/timetable/timetable" ], "window": { "navigationBarTitleText": "校园摆渡车", "navigationBarBackgroundColor": "#1f6feb", "backgroundColor": "#f5f6f8" }, "permission": { "scope.userLocation": { "desc": "用于在地图上显示你与最近站点的距离" } }, "requiredPrivateInfos": ["getLocation"], "sitemapLocation": "sitemap.json" }

这里有几个参数值得逐条说清楚。permission.scope.userLocation.desc不是装饰,它就是你第一次弹授权框时用户看到的那句话,写得太含糊会被直接点“拒绝”,后面再想引导回来就要走设置页,转化率掉一大截。requiredPrivateInfos是近年新增的必填项,只声明 permission 不声明它,开发者工具里可能一切正常,但真机上wx.getLocation会直接走 fail 回调,这是最容易翻车的一处。sitemapLocation指向的文件别删,删了不影响本地预览,但上传体验版时会报配置缺失。

2.3 站点数据:先静态 JSON 跑通,别一上来就上云

新手最常见的误操作是第一天就接云开发,结果数据没渲染出来,分不清是网络问题、权限问题还是字段问题。合理的顺序是先用本地模块把链路跑通,确认地图能打点、列表能出数,再把数据源换成云端。站点数据的最小结构只需要五个字段。

// data/stations.js // 站点坐标统一用 gcj02,与小程序 map 组件保持一致 const stations = [ { id: 'S01', routeId: 'R1', name: '东区宿舍', lat: 30.2741, lng: 120.1551, seq: 1 }, { id: 'S02', routeId: 'R1', name: '图书馆北门', lat: 30.2756, lng: 120.1572, seq: 2 }, { id: 'S03', routeId: 'R1', name: '西门站', lat: 30.2733, lng: 120.1524, seq: 3 } ]; // 按线路取站点并按 seq 排序,避免手写顺序错乱 function getStationsByRoute(routeId) { return stations .filter(s => s.routeId === routeId) .sort((a, b) => a.seq - b.seq); } module.exports = { stations, getStationsByRoute };

lat、lng必须是 Number 类型,从 JSON 文件或 Excel 导入时极容易变成字符串,"30.2741"传给 map 组件不会报错,只是 marker 静默不显示,这类“无报错不生效”的问题最耗时间。seq用整数而不是依赖数组下标,是因为运营改线路时常常在中间插站,靠下标排序会在插入后全乱。routeId一开始就加上,哪怕当前只有一条线路,后面加第二条时你会感谢自己。

2.4 第一屏跑通:一次 setData,别在循环里刷

地图页的核心就两件事:把站点转成 marker,把视野中心定在站点集合的几何中心。marker 的id字段必须是数字,这是 map 组件的硬约束,字符串 id 会导致点击事件拿不到对应 marker。批量数据一次性 setData,循环里反复 setData 会让渲染层频繁重建,低端安卓机上直接卡成幻灯片。

// pages/index/index.js const { getStationsByRoute } = require('../../data/stations'); Page({ data: { routeId: 'R1', markers: [], center: { latitude: 30.2741, longitude: 120.1551 }, scale: 16 }, onLoad() { this.renderRoute(this.data.routeId); }, renderRoute(routeId) { const list = getStationsByRoute(routeId); const markers = list.map((s, i) => ({ // id 必须是数字,用序号即可,不要用 'S01' id: i + 1, latitude: s.lat, longitude: s.lng, width: 26, height: 26, callout: { content: s.name, display: 'BYCLICK', padding: 6, borderRadius: 4, fontSize: 12 } })); // 视野中心取首站,也可以用 include-points 让地图自动缩放 this.setData({ markers, center: { latitude: list[0].lat, longitude: list[0].lng } }); } });

width和height建议显式写,不写会用默认图标尺寸,在高分屏上偏小;callout.display设成BYCLICK而不是ALWAYS,是因为摆渡车站点密度高,全部常驻标签会糊成一片。视野这块,如果你的站点跨度大,用 map 的include-points属性传全部坐标点比手算中心更省事,组件会自动把缩放级别调到刚好装下所有点。

3. 核心页面:地图打点、线路切换与到站时间估算

跑通第一屏只是及格线,真正决定这套资源能不能用起来的,是“用户站在某个站点,想知道下一班还有几分钟”这条主链路。这条链路涉及三个技术点:地图组件的视野控制、线路与班次的数据模型、以及一个足够简单又不会离谱的到站估算。三者缺一个,页面就只是个花架子。

3.1 地图组件参数:include-points 与 scale 的取舍

很多人习惯手算中心点加固定 scale,站点一多就出现“有几个站飘在屏幕外”的情况。map 组件自带的include-points属性专门解决这个问题,传入一组{latitude, longitude},组件自动算边界并缩放到刚好容纳全部点位。代价是你失去了对缩放级别的精确控制,用户双指缩放后如果数据刷新,视野可能被重置。

<!-- pages/index/index.wxml --> <map id="shuttleMap" class="map" latitude="{{center.latitude}}" longitude="{{center.longitude}}" scale="{{scale}}" markers="{{markers}}" include-points="{{includePoints}}" show-location="{{true}}" enable-rotate="{{false}}" bindmarkertap="onMarkerTap"> </map>

show-location打开后会显示用户当前位置的蓝点,但它依赖定位授权,用户拒绝时不会报错、只是不显示,别把它当成定位是否可用的判断依据。enable-rotate建议关掉,校内场景用不到旋转,开着反而容易在用户手里被误触成倾斜视角,回不来。bindmarkertap是点击站点弹详情或跳线路页的入口,事件对象里e.detail.markerId就是前面设置的数字 id,记得用 id 反查站点,而不是用数组下标硬对。

3.2 三层数据模型:线路、站点、班次不要揉在一起

资源包里的数据如果只有站点数组,扩展到多线路时必然要重构。我一般会把它拆成三层:线路表存线路名称、首末班时间、运营日;站点表存坐标和归属线路;班次表存具体发车时刻。三层用 id 关联,改一层不影响另外两层。班次表尤其要独立,因为它是唯一会被运营人员高频修改的部分,把它和坐标数据放在同一个文件里,改一次时刻表就要冒一次改错坐标的风险。

时刻表这类数据有个现实约束:工作日、周末、寒暑假的班次完全不同。常见做法是用一个calendarType字段区分,页面上做三个 tab 切换,数据侧按routeId + calendarType建索引查询。别指望用一个数组加if判断搞定,班次一多,条件分支会长到你不敢改。

3.3 到站时间估算:距离除以速度,再加一个停站惩罚

这是整套资源里最容易被用户质疑的部分。精确的到站时间需要 GPS 轨迹加路况,校内摆渡车既没有稳定轨迹也没有路况数据,所以我一般用一个可解释的经验公式:剩余站间距离之和除以平均速度,加上剩余站数乘以单站停靠时间。公式本身不复杂,关键是参数可调、结果有兜底。

// utils/geo.js const EARTH_R = 6371000; const toRad = d => (d * Math.PI) / 180; // 两点球面距离,单位米 function distance(a, b) { const dLat = toRad(b.lat - a.lat); const dLng = toRad(b.lng - a.lng); const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2; return 2 * EARTH_R * Math.asin(Math.sqrt(h)); } // busPos 当前车辆坐标,stations 已按 seq 排序,targetSeq 目标站序号 function estimateArrival(busPos, targetSeq, stations, opts = {}) { const speedMs = ((opts.speedKmh || 18) * 1000) / 3600; // 校内均速,m/s const stopSec = opts.stopSec || 30; // 单站停靠加起步 const ahead = stations.filter(s => s.seq > busPos.seq && s.seq <= targetSeq); if (ahead.length === 0) return 0; let meters = distance(busPos, stations.find(s => s.seq === busPos.seq)); for (let i = 0; i < ahead.length; i++) { const from = i === 0 ? busPos : ahead[i - 1]; meters += distance(from, ahead[i]); } const seconds = meters / speedMs + ahead.length * stopSec; return Math.round(seconds / 60); // 返回分钟 } module.exports = { distance, estimateArrival };

三个参数值得单独解释。speedKmh默认给 18,是校内道路含减速带、行人避让后的保守估计,设置页里应允许运营改成 12 到 25 之间的值。stopSec默认 30 秒,是上下客加车辆重新起步的总耗时,中午高峰会明显偏高,所以它更适合按时间段配两套值。meters的计算里第一段用的是“当前车辆所在站到车辆位置”,而不是“车辆位置到下一站”,因为车辆已经驶出站点的部分应该算作已完成的里程,否则每次刚出站都会多算一整段。

结果展示必须做兜底。算出来小于 1 分钟统一显示“即将到站”,大于 30 分钟显示“暂无近班”,这两个边界处理掉,用户就不会盯着一个明显错误的数字追问你。估算值旁边加一个“约”字,成本极低,能省掉大量解释。

3.4 页面跳转与状态传递:globalData 只放小东西

首页点站点跳到线路页,需要把 routeId 和目标站序号带过去。常见写法是塞进 globalData,页面 onShow 里再读。这种方式在单页面跳转时没问题,但一旦用户从分享卡片直接进入详情页,globalData 就是空的,页面直接崩。稳妥做法是 query 参数加页面内兜底默认值。

// 跳转:只传 id 这类可序列化的短字符串 wx.navigateTo({ url: `/pages/route/route?routeId=${routeId}&focusSeq=${seq}` }); // 详情页:参数缺失时回落到默认线路,避免白屏 Page({ onLoad(query) { const routeId = query.routeId || 'R1'; const focusSeq = Number(query.focusSeq || 0); this.setData({ routeId, focusSeq }); this.loadRoute(routeId); } });

query 参数在页面栈里是持久的,用户从详情页返回再进,参数还在,不会出现“第二次进来数据空了”的怪现象。参数值记得Number()转一下,query 里拿到的全是字符串,直接拿去和数字比较会静默不相等。

4. 避坑与排查:白屏、定位失败与审核驳回的五个现场

这套资源在本地跑通和真正上线之间,隔着一堆只在真机上才会出现的坑。下面五条是我在类似项目里踩过、并且在资源包里能看到对应痕迹的问题,每条按现象、原因、解决三段写,遇到同款直接对照。

4.1 渲染与页面类问题

现象一:开发者工具里一切正常,真机打开是白屏。原因通常是用了 npm 第三方包但没执行“构建 npm”,或者是页面的 json 里usingComponents声明了组件但路径写错。前者在工具里会被模拟器兜底,真机没有这层兜底。解决办法是先点工具菜单里的构建 npm,再检查每个页面 json 的相对路径是否以/开头、是否指向真实存在的目录。

现象二:地图出来了,但一个 marker 都没有,控制台无报错。大部分情况是latitude、longitude被写成了字符串,或者 marker 的id用了字符串。map 组件对字段类型非常宽容地“不报错”,只是不渲染。排查方式是在renderRoute里加一行console.log(typeof list[0].lat),把类型确认一遍,比盯着真机屏幕猜快得多。

4.2 定位与地图类问题

现象三:wx.getLocation永远走 fail,错误码还各不相同。原因分两层:配置层是app.json里漏了requiredPrivateInfos,运行层是用户此前拒绝过授权。解决要分两步,先补配置并重新上传体验版,再在业务代码里做降级:定位失败时不要让整页卡死,回落到默认中心点,并在页面上给一个“去设置开启定位”的入口。

wx.getLocation({ type: 'gcj02', // 必须与 map 组件坐标系一致 success: res => this.setData({ center: { latitude: res.latitude, longitude: res.longitude } }), fail: err => { // 用户拒绝授权时也能看到地图,只是没有蓝点 console.warn('定位失败', err.errMsg); this.setData({ locateDenied: true }); } });

type: 'gcj02'这个参数不要省。默认值在部分机型上是 wgs84,与国内地图底图存在几百米偏移,表现是“蓝点飘在马路对面的楼里”,iOS 和安卓的表现还不完全一致,很难靠肉眼判断。

现象四:模拟器上定位正常,真机一直停在默认城市。开发者工具可以手动指定模拟位置,真机不行。真机定位需要 GPS 或网络定位可用,室内环境经常直接超时。测试时去空旷处,或者临时放宽超时判断,别在工位上和它较劲。

4.3 审核与数据类问题

现象五:体验版一切正常,提交审核被驳回,理由集中在服务类目或资质。涉及车辆实时位置、班次调度的服务,类目选择要和实际功能对齐,选了偏教育的类目却做着交通服务的事,大概率被打回。同时,页面里如果出现“实时车辆位置”这类表述,但实际只是定时轮询的估算值,措辞上也要收敛。这条没有技术上的后悔药,提交前先对照类目说明过一遍功能描述,比被打回两次再改要省一周。

5. 数据更新与运营:班次热更新、司机上报与统计口径

一个摆渡车小程序上线只是开始,真正决定它活多久的是运营侧好不好改。每学期班次表要变、临时加开班车要能立刻生效、车辆位置要有人维护。这一章解决三个问题:数据怎么改不用重新发版、司机端怎么用最低成本上报位置、以及怎么统计出“这条线路到底有没有人坐”。

5.1 班次表热更新:静态 JSON 的缓存陷阱与云数据库方案

如果时刻表打包在代码里,改一次就要走一遍审核,节假日临时调班根本来不及。把时刻表挪到云端是必然选择,但把静态 JSON 直接搬到云存储也有坑:小程序端请求 JSON 文件会走 HTTP 缓存,运营改完文件,用户端可能还是旧数据,表现是“后台明明改了,用户看到的还是老时间”。

稳妥做法是时刻表落云开发数据库,用一个版本号字段控制读取。页面启动时先查timetable_version集合拿到当前版本号,与本地缓存的版本号比对,不一致才去拉全量班次。这样既避免了每次进页面都发一次全量请求,也不会出现改了不生效的情况。

// pages/timetable/timetable.js const db = wx.cloud.database(); Page({ async loadTimetable(routeId, calendarType) { // 先取版本号,命中本地缓存就不拉全量 const verRes = await db.collection('timetable_version').doc(routeId).get(); const remoteVer = verRes.data.version; const cacheKey = `tt_${routeId}_${calendarType}`; const cached = wx.getStorageSync(cacheKey); if (cached && cached.version === remoteVer) { this.setData({ list: cached.list }); return; } const res = await db.collection('timetable') .where({ routeId, calendarType }) .orderBy('departAt', 'asc') .limit(100) // 单次上限 100 条,超过要分页 .get(); this.setData({ list: res.data }); wx.setStorageSync(cacheKey, { version: remoteVer, list: res.data }); } });

calendarType建议用workday、weekend、holiday三个枚举值,而不是直接存日期区间,日期区间会让查询条件变得很难写。limit(100)是云数据库单次查询的硬上限,班次多的线路要记得做分页,否则你会发现时刻表永远只显示到第 100 条,且没有任何报错提示。

5.2 司机端位置上报:定时轮询加一个别忘了的 onHide

实时位置在校园场景下不需要秒级精度,15 到 30 秒上报一次足够,再密就是白白耗电。司机端页面打开时启动定时器,页面隐藏时必须停掉,否则小程序被切到后台,定时器还在跑,手机发热、耗电,司机会直接卸载。

// pages/driver/driver.js Page({ data: { reporting: false }, timer: null, startReport() { this.timer = setInterval(() => { wx.getLocation({ type: 'gcj02', success: res => { wx.cloud.callFunction({ name: 'reportPos', data: { busId: 'B01', lat: res.latitude, lng: res.longitude } }); } }); }, 15000); this.setData({ reporting: true }); }, stopReport() { if (this.timer) clearInterval(this.timer); this.timer = null; this.setData({ reporting: false }); }, // 页面隐藏或卸载都要停,两个钩子都写更保险 onHide() { this.stopReport(); }, onUnload() { this.stopReport(); } });

云函数侧用doc().set做覆盖写,不需要自增主键,一台车固定一条记录,查询时直接按 busId 拿最新位置。时间戳一定用db.serverDate(),用客户端时间的话,司机手机时间不准就会导致“车辆最后更新时间”显示成未来。

// cloudfunctions/reportPos/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); exports.main = async event => { const { busId, lat, lng } = event; // 类型校验放在服务端,客户端传错时能立刻暴露 if (typeof lat !== 'number' || typeof lng !== 'number') { return { ok: false, msg: 'invalid coords' }; } await db.collection('bus_pos').doc(busId).set({ data: { lat, lng, updatedAt: db.serverDate() } }); return { ok: true }; };

服务端的类型校验别嫌啰嗦。客户端wx.getLocation在异常情况下可能返回空对象,如果不校验就写库,后面地图上会出现一个坐标为空、指针飘到海里的 ghost marker,排查起来非常费劲。

5.3 统计口径:用聚合查询回答“这条线要不要保留”

运营最关心的问题往往不是技术指标,而是某条线路一天有多少人用。埋点数据落库后,直接用聚合查询出三个口径:按线路统计的日订单量、按站点统计的上车人数、按小时统计的峰值分布。这三个数出来,砍线还是加班的决策就有依据了。

// 按线路聚合,取用量前十 const $ = db.command.aggregate; const res = await db.collection('ride_log').aggregate() .match({ date: '2024-03-11' }) // 先过滤日期,减少参与聚合的文档数 .group({ _id: '$routeId', cnt: $.sum(1) }) .sort({ cnt: -1 }) .limit(10) .end();

match放在group前面是性能关键,聚合是先过滤后分组的,顺序反了会把整个集合都扫一遍。另外统计类的查询建议放到云函数里跑,小程序端直接做聚合受权限规则限制,非创建者的数据读不到,你会得到一份看起来“数据莫名其妙变少”的结果。

6. 把到站时间从玄学变成可校准的数字

到站估算最容易被吐槽的地方,是它永远差那么一两分钟,而且没人说得清差在哪。问题不在公式,在参数。前面给的 18 km/h 和 30 秒都是拍的,真想让它准,就得拿真实行程回推。我的习惯是让 ride_log 里带上“车辆到站时间”和“乘客上车时间”两个字段,跑一周之后用一个离线脚本做最小二乘,把均速和停站时间解出来。

原理很简单:一段行程的耗时 t 与距离 d 近似满足 t = d/v + s,其中 1/v 是斜率、s 是单站停靠时间。把历史数据里的 (d, t) 点对丢进去做一元线性回归,斜率的倒数就是实测均速,截距就是实测停站时间。

// scripts/calibrate.js 用历史行程反推两个参数 const legs = [ { d: 420, t: 96 }, // 距离米,耗时秒 { d: 780, t: 168 }, { d: 310, t: 74 }, { d: 1150, t: 236 } ]; const n = legs.length; const sx = legs.reduce((a, l) => a + l.d, 0); const sy = legs.reduce((a, l) => a + l.t, 0); const sxy = legs.reduce((a, l) => a + l.d * l.t, 0); const sxx = legs.reduce((a, l) => a + l.d * l.d, 0); const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx); // 1/v const intercept = (sy - slope * sx) / n; // 停站时间 console.log('实测均速 km/h:', (3600 / slope / 1000).toFixed(1)); console.log('实测停站秒:', intercept.toFixed(0));

跑出来的数一般会落在一个让人有点意外的区间:

参数拍脑袋初值校准后典型值采集方式
校内均速18 km/h12 到 15 km/h距离除以耗时回归斜率
单站停靠30 秒45 到 60 秒回归截距
高峰修正无乘 1.3按小时分组后分别回归

均速比想象中低很多,是因为减速带和避让行人的时间没法从地图距离里体现;停站时间比想象中长,是因为午间排队上车。样本少于 30 条时回归结果会飘,所以第一周先按初值跑,别急着调。校准完之后还有一个必做动作:把新参数写进配置而不是写死在代码里,让运营能在后台微调,否则下次换校区你又得重新发版。

另外提醒一句,回归出来的截距如果算出负数,不用怀疑公式,八成是数据采集端把“乘客上车时间”和“车辆到站时间”记反了。我第一版就栽在这上面,两个字段顺序搞错,算出来的停站时间是负十七秒,盯着结果笑了半天。从那以后我每次改这套估算逻辑,都会先拿三组手算的样例数据跑一遍脚本,确认输出和预期对得上,再接真实数据。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询