做内部调度系统的时候,被安排了一个很实际的需求:管理员在页面上自己填起点和终点,选一种驾车偏好,系统马上算出一条路线来,并且按路口和路段的维度给出可读的驾驶指导。调研了一圈,我选择了百度地图API里的驾车路线规划能力,因为它同时覆盖了路线计算、策略切换和分段指引文本,前端直接调用就能把整个功能串起来。这篇文章就完整记录一下我实现“自定义起始点 + 多策略驾车路线搜索 + 驾驶指导”的过程,包括API选型、代码实现、策略对比和排查坑。适合正在做类似功能的开发者,也适合想快速上手地图路线规划的初学者。
1. 需求拆解:先搞清楚做的是哪一类功能
1.1 两种地图API怎么选
百度地图开放平台提供两类接口,刚接触的人容易混淆。
一类是JavaScript API,直接在浏览器页面里加载,地图渲染、覆盖物绘制、路线规划这些能力都封装在前端SDK里,几行代码就能跑起来。另一类是Web服务API,走HTTP请求,返回JSON数据,适合在服务端做聚合、存储和转发,不负责地图显示。
我这次的需求有一个明显特征:要在一个管理后台页面里完成“输入起终点—选择策略—查看路线—阅读驾驶指导”的完整交互闭环。如果走Web服务API,我还得自己写代理层、自己渲染地图、自己解析分段指引,工程复杂度会立刻上来。所以最终选了JavaScript API,理由很直接:交互页面需要前后端联动,而JS API把最重的地图渲染部分直接接管了,开发量小,调试也直观。
这里也提醒一句:如果后续有批量计算多条路线、需要高并发或者要把路线数据落库做分析的场景,那就要认真考虑Web服务API了,它不受页面生命周期限制,也更适合服务端调用。
1.2 整体功能模块拆解
把需求拆开后,大约分成了五个模块:
- 起终点输入模块:两个输入框,一个起点,一个终点,支持地址联想和坐标转换。
- 路线规划模块:调用驾车路线检索,拿到路线数据。
- 策略选择模块:提供不同驾车策略,切换后重新计算路线。
- 地图渲染模块:把路线折线、起终点标注画到地图上。
- 驾驶指导模块:把路线里的分段指引转换成列表展示,供用户逐条阅读。
这个拆法不是一次到位的。我刚开始只想做“能出路线”,结果发现“驾驶指导”这个要求如果没有在前端数据结构层面提前预留,后面再补会非常别扭。所以建议第一版就直接按五个模块来设计,后面所有工作都会顺畅很多。
2. 准备工作:注册应用、拿到AK、把地图引进来
2.1 AK申请与白名单配置
使用百度地图API的第一步是去开放平台创建“浏览器端”应用,拿到一串AK(访问密钥)。这一步看起来简单,但坑不少。
创建应用的时候会有“白名单”配置。本地调试阶段,Referer白名单可以填成*,意思是不校验来源页面。一上正式环境,就必须收紧成你自己的域名白名单,比如https://yourdomain.com/*。否则别人把你的AK拿去用,账算在你头上,而且随时可能因为流量异常被平台限流。
还有一点容易被忽略:AK和域名是绑定的,如果你本地用的是http://localhost:8080,线上是https://yourdomain.com,那白名单里两个都要配。不要问为什么线上突然加载不出地图,多半就是白名单漏了线上域名。
申请页面有时候还会让选“IP白名单”还是“SN校验”。浏览器端应用基本只用Referer白名单。SN校验是服务端API用的,前端页面用不了,这里别选错。
2.2 引入JS API并初始化地图
老版本JS API 2.0的引入方式是:
<script src="https://api.map.baidu.com/api?v=2.0&ak=你的AK"></script>新版GL版本则略有不同,需要在URL里加type=webgl:
<script src="https://api.map.baidu.com/api?type=webgl&v=1.0&ak=你的AK"></script>两个版本的核心用法接近,但存在一些兼容性差异,尤其是策略参数的写法,后面我会单独对比。我这篇文章以老版JS API 2.0的BMap命名空间为例讲解,最后会补充GL版的差异对照。
初始化地图的代码很简单:
var map = new BMap.Map("mapContainer"); // 页面里一个div容器 map.centerAndZoom(new BMap.Point(116.403, 39.915), 14); // 初始中心和缩放级别 map.enableScrollWheelZoom(true); // 开启滚轮缩放这里的centerAndZoom接收一个坐标和缩放级别。实际使用中,初始中心可以根据你的业务场景设置。我一般习惯定位到系统里最常出现的城市,方便第一次打开就有个可用的视野。
加一个提示:script标签引入的方式是同步阻塞的。如果你把地图相关脚本放在head里,页面其他内容会被阻塞。建议异步加载,或者把初始化逻辑放到window.onload里执行。当然,最简单的办法是把script标签放到body底部。
3. 自定义起终点的路线搜索实现
3.1 起终点输入与坐标转换
需求里说的是“自定义起始点”。这里的“自定义”有两个层次:一是用户自己填地址文本,二是用户在地图上点选坐标。两种我都实现了,最终感觉最稳的是:先用输入框让用户填地址,通过自动补全把地址转为坐标,然后用坐标去搜索路线。
百度地图JS API里有个BMap.Autocomplete控件,可以绑定到一个input输入框,用户在输入时自动弹出匹配的POI(兴趣点)。选中某个候选项后,可以通过回调拿到对应的坐标。
var ac = new BMap.Autocomplete({ "input": "startInput", // 起点输入框的id "location": map }); ac.addEventListener("onconfirm", function(e) { var item = e.item; var point = new BMap.Point(item.point.lng, item.point.lat); // 这里就拿到了选中的坐标 });如果用户直接手输入了一个地址而不是通过联想选中,那这个输入框里的字符串可能不是一个精确的POI。这时候就要借助BMap.Geocoder的getPoint方法,把地址文本解析成坐标。
var geocoder = new BMap.Geocoder(); geocoder.getPoint("某市软件园二期", function(point) { if (point) { // 解析成功,拿到坐标 } else { // 解析失败,提示用户检查地址 } }, "某市");我个人的实操心得是:路线规划一定要基于坐标,而不是地址字符串。DrivingRoute.search()虽然也接受字符串地址,但它内部也会做一步地理编码。问题在于这一步的解析规则不完全可控,可能出现“用户输入A地址,结果定位到同名B地址”的情况,而且你很难在界面上告诉用户“定位错在哪里”。先用自动补全或者地理编码把坐标定死,后续所有操作都基于坐标,逻辑会清晰得多。
3.2 发起驾车路线检索
拿到起终点坐标后,创建BMap.DrivingRoute对象并调用search方法:
var startPoint = new BMap.Point(起点经度, 起点纬度); var endPoint = new BMap.Point(终点经度, 终点纬度); var driving = new BMap.DrivingRoute(map, { policy: BMAP_DRIVING_POLICY_LEAST_TIME, // 默认策略:最少时间 onSearchComplete: function(result) { if (result.getStatus() === BMAP_STATUS_SUCCESS) { // 有路线结果 } else { // 没有结果,提示用户 } } }); driving.search(startPoint, endPoint);policy参数就是“驾车策略”,这里先不展开,后面专门讲。onSearchComplete是检索完成后的回调,成功状态下,result对象里就包含了完整的路线方案。
这里有个细节:search方法支持传入坐标点,也支持传入字符串。传入坐标点时,SDK会直接以该坐标为中心搜索路线,精确可控。传入字符串时,SDK内部会做地理编码,解析出坐标再搜。既然我们已经在输入阶段把坐标拿到手,就用坐标,别偷懒传字符串。
3.3 路线结果与轨迹绘制
搜索结果里通常有多个方案。getPlan(index)可以拿到其中一个方案,plan.getNumRoutes()返回方案内的路线数量。一般情况下,一个方案对应一条主路线。
我实际代码里做了这样几步:
driving.search(startPoint, endPoint); function handleResult(result) { var plan = result.getPlan(0); // 取第一个方案 var route = plan.getRoute(0); // 取该方案的第一条路线 // 路线总距离和总耗时 var distance = route.getDistance(true); // 带单位的文本,如 "15.3公里" var duration = route.getDuration(true); // 带单位的文本,如 "40分钟" // 画路线折线 var path = route.getPath(); // 路线坐标点数组 var polyline = new BMap.Polyline(path, { strokeColor: "#2b7de1", strokeWeight: 6, strokeOpacity: 0.8 }); map.addOverlay(polyline); // 起终点标注 var startMarker = new BMap.Marker(startPoint, {icon: 起点图标}); var endMarker = new BMap.Marker(endPoint, {icon: 终点图标}); map.addOverlay(startMarker); map.addOverlay(endMarker); // 调整视野,让整条路线完整显示 map.setViewport(path); }getDistance(true)和getDuration(true)返回的是格式化好的文本,比如“2.3公里”“15分钟”。如果不传参数,返回的是原始数值,距离是米,耗时是秒。做二次计算的时候一定用原始数值,展示的时候再用带单位的文本。
我第一版做的时候没有调用setViewport,结果路线算出来了,但地图视野还在初始位置,用户根本看不到路线全貌。这个函数会自动调整地图中心和缩放级别,让传入的坐标数组恰好全部显示在视野内,属于必加步骤。
4. 不同驾车策略的实现与选择逻辑
4.1 策略枚举与传参方式
驾车策略是这个需求里最有意思的部分。百度地图API的驾车路线规划支持多种策略,不同策略算出来的路线可能差异巨大。
下表是我整理的策略对照,覆盖老版JS API和GL版:
| 策略含义 | 2.0版常量 | GL版字符串 | 建议适用场景 |
|---|---|---|---|
| 最少时间 | BMAP_DRIVING_POLICY_LEAST_TIME | "least_time" | 默认选择,适用于大多数日常导航 |
| 最短距离 | BMAP_DRIVING_POLICY_LEAST_DISTANCE | "least_distance" | 路线短但可能经过小路,不熟的地区慎用 |
| 少走高速 | BMAP_DRIVING_POLICY_FEWER_HIGHWAY | "fewer_highway" | 想避开收费高速或长途驾驶时偏好普通道路 |
| 避开拥堵 | BMAP_DRIVING_POLICY_AVOID_CONGESTION | "avoid_congestion" | 实时路况数据可用时效果明显 |
老版本里还有BMAP_DRIVING_POLICY_AVOID_HIGHWAY这种“避开高速”的常量,但不同版本支持程度不一样,项目里用之前先去对应版本文档确认一下。我代码里以官方文档确认过的四个常规策略为准。
注意:如果你用的是GL版,策略传参方式跟2.0不一样,是字符串形式而不是全局常量。两种版本混着用,很容易出现“策略传了但不生效”的诡异问题。
4.2 策略切换的完整实现
需求要求支持“根据不同驾车策略给出驾驶指导”,所以页面上做了一个策略下拉框,用户切换后重新搜索路线。
切换的逻辑并不复杂,但有一个容易漏的细节:clearResults。
var policyMap = { "least_time": BMAP_DRIVING_POLICY_LEAST_TIME, "least_distance": BMAP_DRIVING_POLICY_LEAST_DISTANCE, "fewer_highway": BMAP_DRIVING_POLICY_FEWER_HIGHWAY, "avoid_congestion": BMAP_DRIVING_POLICY_AVOID_CONGESTION }; // 假设下拉框id是policySelect document.getElementById("policySelect").addEventListener("change", function(e) { var key = e.target.value; var policy = policyMap[key]; // 1. 更新策略 driving.setPolicy(policy); // 2. 清空地图上旧的路线覆盖物 driving.clearResults(); // 3. 重新搜索 driving.search(startPoint, endPoint); // 4. 如果有“驾驶指导列表”,同时清空旧列表 clearInstructionList(); });clearResults非常关键。如果漏了它,老路线的折线和标注都还留在map上,新路线一叠加上来,地图上会出现好几条纠缠在一起的线条,用户根本分不清哪条是当前策略的结果。我一开始就没调,切了三次策略后地图乱成一团,排查了半天。
另外,策略切换后,驾车指导列表也要同步清空,等新结果回来再重新生成。否则用户看到的指引和地图上的路线是对不上的,这属于交互一致性问题,别看小,实际使用中非常影响体验。
4.3 策略对比的实际观察
我在测试时把同一个起终点分别用四种策略跑了一遍,发现策略差异在不同路段下表现完全不同。
最短距离策略最容易出“看似短,实际难开”的路线。比如它会让你穿过一段很窄的街巷,虽然总里程短了,但车速一直上不来,综合耗时反而更长。最少时间策略通常会优先考虑主干道和快速路,路线更顺畅,总时长也更短。少走高速策略在跨城场景下特别有用,如果你不想交高速费,就选这个。避开拥堵策略依赖实时路况数据,在早晚高峰时段效果明显,但前提是平台对当前路段的拥堵信息更新及时,凌晨或者偏远路段就基本看不出来区别。
从这里得到一个经验:默认策略用“最少时间”,把其他策略作为可选项。因为对大多数驾驶场景来说,用户真正想要的并不是“距离最短”,而是“到达最早”。最短距离可以作为引导用户理解策略差异的对比项,但不应作为系统默认。
5. 驾驶指导:把路线翻译成“人话”
5.1 分段指引的数据结构
路线规划结果里最大的宝藏是route.steps。每一条step代表路线中的一个驾驶动作,比如“直行”“左转”“到达目的地”等。
step提供了几个关键方法:
getInstructions():返回人类可读的指引文本,比如“沿某路行驶300米后左转进入某路”。getDistance():这一步的距离。getPath():这一步终点位置的坐标点数组,可以用于定位。
我第一版做驾驶指导时,就是简单把getInstructions()的文本循环打印到页面上。看起来能用,但有个问题:文本里混了距离信息,看着很长很乱。后来我做了一些加工,把指引文本和距离分离展示,效果好了很多。
function buildInstructionList(route) { var steps = route.steps; var list = []; for (var i = 0; i < steps.length; i++) { var step = steps[i]; var instruction = step.getInstructions(); var stepDistance = step.getDistance(true); list.push({ index: i + 1, instruction: instruction, distance: stepDistance }); } return list; }拿到这个结构后,前端渲染就非常自由了。可以用有序列表展示成一个“步骤清单”,也可以做成类似导航App里的“路段引导卡片”。
5.2 路线指导列表的UI呈现
驾驶指导的展示我最终做成了左侧列表、右侧地图的布局。左侧从上往下排列每一步指引,右侧地图显示对应的路线和当前高亮的步骤。
这里有一个很加分的交互:点击列表里的某一步,地图视野定位到该步骤的位置,同时高亮这一步对应的路段。
实现思路也不复杂。每个step都持有自己的getPath(),选中某一步时,把地图视野调整到这段路径,再做视觉高亮。
function highlightStep(step) { var path = step.getPath(); map.setViewport(path); var highlightLine = new BMap.Polyline(path, { strokeColor: "#f76c6c", strokeWeight: 8, strokeOpacity: 1 }); map.addOverlay(highlightLine); }这里要注意的是,多次点击不同步骤时,上一次的高亮折线需要先移除。我是用一个全局变量保存当前高亮折线,在下一次高亮前移除它。
提示:
getInstructions()返回的文本是平台根据路网结构动态生成的,不是固定模板。它的可读性总体不错,但偶尔会出现“进入环岛”这种表达,对不熟悉区域的用户来说,最好同时在地图上用箭头或者路径颜色辅助理解。
驾驶指导的另一种用法是语音播报。代码里拿到getInstructions()之后,接一个speechSynthesis接口,就可以实现简单的语音导航。我在内部演示环境里试过,效果还行,至少能解决“一边开车一边看屏幕”的部分痛点。
6. 我踩过的坑与排查建议
6.1 鉴权与白名单问题
运行时报AK错误或者校验失败的情况,我至少遇到过五次。大部分原因不是AK本身写错,而是白名单配置不对。
本地调试时,页面域名是localhost,如果应用白名单只配了线上域名,请求必然失败。反过来,如果线上环境里白名单只配了localhost,线上也会挂。
排查方式很简单:打开浏览器的开发者工具,看网络请求里的返回信息。百度地图API的报错信息里有错误码,比如BMM_AK_404就是AK不存在,BMM_AK_ILLEGAL就是AK校验失败。按错误码去查官方文档,基本都能定位。
注意:不要在生产环境用
*通配白名单。一旦AK泄露,别人可以拿你的配额去跑大量地图请求,轻则产生费用,重则触发平台封禁。白名单限制虽然挡不住恶意构造Referer的请求,但至少能挡掉大部分误用和滥用。
6.2 策略不生效与数据缓存
“策略切换了,但路线没变化”也是一个高频问题。我排查后总结出三个原因:
第一个原因就是前面说的,没有调用clearResults,旧路线和新路线叠在一起,看起来像没变。第二个原因是GL版本和2.0版本的策略参数格式不一样,传了常量但SDK不认识,直接忽略了。第三个原因是最隐蔽的:如果search方法传的是字符串地址,而地址的解析结果命中了一个“缓存坐标”,那么即使你切换策略,起点终点都没变,路线计算结果自然一样。这其实是正常的,路线最优解没变,和策略是否生效无关。
正确的排查思路是:切换策略后,在onSearchComplete回调里打印route.getDistance()和route.getDuration(),对比数值是否有变化。如果连续多次数值一模一样,再判断是不是策略参数传递有问题。
6.3 起终点不准的坑
这是我做这个项目时体会最深的一个教训。早期版本里,我直接让用户在输入框填地址,然后把地址字符串传给driving.search()。测试时发现,填“某大厦”总是被解析到另一个区的大厦,地图上出来的路线起点离真实位置差了五六公里。
解决办法就是我前面反复强调的:先转坐标,再用坐标搜索。在输入阶段通过Autocomplete或Geocoder把地址解析为坐标,同时在下拉菜单里展示解析结果,让用户确认。用户没确认前,不给“搜索路线”按钮。这样一来,起终点不准的问题几乎消失了。
另外,起终点坐标最好在页面上展示出来。哪怕只是一个小字号的经纬度文本,也会让用户有种“这东西靠谱”的感觉,同时方便排查问题。我在测试阶段经常需要对照坐标来定位是不是路线引擎的问题,这个辅助信息省了不少事。
6.4 一个提升体验的小技巧
最后分享一个小技巧:在路线搜索之前,可以先调用一下地图的LocalSearch或者Geocoder做一个“可达性预检”。比如用户输入的终点是一个楼盘的内部名称,可能在地图上无法直接被路网识别。预检能提前发现这类问题,并给出更友好的提示,而不是等到路线计算返回“没有结果”才告诉用户。
做这类功能,我个人的体会是:先把坐标处理环节做扎实,策略和驾驶指导都是后面顺手的事。起点终点都不准,后面策略切得再花哨也没有意义。先把“输入地址—确认坐标—搜出路线—展示指引”的最小闭环跑通,再逐步加入策略切换、语音播报这些增强功能,这个顺序在任何一个地图API项目里都适用。