首例!原生小程序内部实现实时导航(不跳转APP)

警告:干货来袭,请准备好哇哈哈矿泉水

扫码加入诺克萨斯帝国,入住不朽堡垒。

前言

众所周知,在各大图商的 JS API 中都没有提供所谓的实时导航功能,只提供了 "路线规划" 这种看起来没什么用的api。

简单搜索一下现有的微信小程序,也都没有提供实时导航的能力,甚至诸如【腾讯地图】、【高德地图】官方的小程序,也是跳转外部应用实现的导航,那么大家为什么都不用小程序做实时导航呢?

调研

经过调研,为小程序提供了导航(准确的说是路径规划)相关能力的东西,一共有以下几种

腾讯地图小程序插件

地址:mp.weixin.qq.com/wxopen/plug...

优点:一键导入,一键生成路线

缺点:无法获取到格式化信息,只能跳转固定页面,并且只能看。

是否可用:否

腾讯地图小程序 JS SDK

地址:lbs.qq.com/miniProgram...

优点:有格式化的导航信息,api功能十分全面

缺点:无实时导航功能,只提供路径规划

是否可用:是

另外有一个 腾讯位置服务 webServer API,提供能力大致相同,地址:lbs.qq.com/service/web...

高德地图 小程序插件

地址:lbs.amap.com/api/wx/summ...

优点:有格式化的导航信息,api功能十分全面,且专门为小程序 map 组件数据格式做了优化

缺点:无实时导航功能,只提供路径规划

是否可用:是

百度地图 小程序 JS API

地址:lbsyun.baidu.com/index.php?t...

优点:优点....呃

缺点:甚至没有提供路径规划能力,且百度使用BD-09坐标系,需要二次解密

是否可用:否

可以看到,提供了路线规划的只有腾讯位置服务高德小程序插件。为了技术栈的统一性,最终选择使用腾讯位置服务的小程序JS SDK 结合 小程序后台持续定位能力,手动实现一个实时导航功能。

准备

首先,罗列一下需要使用的 API 和 小程序内置能力 以及 第三方库:

  • qqmap.direction 路径规划接口,详见 腾讯位置服务 文档:lbs.qq.com/miniProgram...
  • qqmap.search 查询周边点位,用于测试路径规划能力。
  • wx.getLocation 获取用户起始位置,用于导航起点
  • wx.startLocationUpdateBackground 实时获取小程序前后台 用户当前位置,用于导航中刷新位置、计算偏航、重新规划等。
  • wx.onLocationChange 接收 wx.startLocationUpdateBackground 返回的位置,用于处理上述逻辑。
  • wx.vibrateLong 震动能力,当偏航、到达目的地时将使用震动提醒用户
  • wx.openSetting(现已改为open-type按钮启动) 引导用户打开后台定位权限。
  • @turf/point-to-line-distance 计算点到线的距离
  • @turf/nearest-point-on-line 计算点到线的垂足
  • @turf/distance 计算点到点的距离

注意:

  • 使用 腾讯位置服务需要注册开发者账号并获取一个key。
  • 使用小程序相关接口需要在小程序后台 申请相关接口权限、并更新隐私协议(需审核)
  • 使用小程序内置隐私协议引导,需要小程序基础调试库大于 3.0.1,推荐使用目前最新的:3.2.x
  • 使用腾讯位置服务,需要在小程序后台配置域名白名单:在小程序管理后台 -> 开发 -> 开发管理 -> 开发设置 -> "服务器域名" 中设置request合法域名,添加 apis.map.qq.com

导航基本流程图

注意:该流程图考虑了,用户距离起点较远的情况、偏航不再提示后再次偏航的情况,为了快速完成简易的导航流程,在下面的实现中,并没有考虑这两点。

开始实现

注1:以下代码经过脱敏处理,可能无法直接运行

注2:使用了 vant 组件库

创建基本框架

首先新建一个 名为 map 的小程序页面

在 map.wxml 中,写入 基本的 map 组件、导航文本提示悬浮窗

xml 复制代码
<view class="map_container">
  <map class="map" id="map" longitude="{{longitude}}" latitude="{{latitude}}" include-points="{{viewPoints}}" scale="14" polyline="{{polyline}}" show-location="true" markers="{{markers}}" bindmarkertap="makertap"></map>
</view>
<view class="map_text">
  <text class="h1">{{textData.name}}</text>
  <text>{{textData.desc}}</text>
  <button class="btn" bindtap="goTo">去这里</button>
  <van-dialog use-slot title="定位授权设置不正确,无法使用巡查导航功能" show="{{ isShowGoAuth }}" show-cancel-button confirm-button-open-type="openSetting">
    <text>点击确认按钮跳转设置页面,你需要设置 位置信息 > 使用小程序时和离开后</text>
  </van-dialog>
</view>
<view class="navTipPanel" wx:if="{{currStep.instruction}}">
  <view class="intro">
    <text class="instruction">{{currStep.instruction}}</text>
    <text class="iconfont icon-{{icons[currStep.act_desc]}}"></text>
  </view>
  <view class="tips" wx:if="{{isYawed && !noTip}}">
    <view class="tip">
      <text class="iconfont icon-jinggao"></text>
      <text>你已偏离路线</text>
    </view>
    <view class="btns">
      <button class="btn" bindtap="noTipForThisTime">本次不再提醒</button>
      <button class="btn" bindtap="reStart">重新规划</button>
    </view>
  </view>
</view>

以上代码中 map 组件的传值解释:

  • longitude、latitude 经度和纬度,用于确定中心点
  • include-points 用于自适应视角
  • scale 其实就是常规意义的 zoom,小程序总爱整点不一样的
  • polyline 多段线
  • show-location 是否显示用户位置
  • markers 标记点
  • bindmarkertap 点位点击事件
php 复制代码
// map.ts
//qqmap 实例的创建详见腾讯位置服务文档
    qqmap.search({
      keyword: '超市',
      success: function (res: any) {
        that.setData({
          markers: res.data.map((item: any, index: number) => {
            return {
              address: item.address,
              height: 30,
              iconPath: '/img/marker.png',
              latitude: item.location.lat,
              longitude: item.location.lng,
              title: item.title,
              width: 18,
              id: index,
              callout: {
                content: item.title,
                color: "#000",
                borderRadius: 10,
                padding: 10,
                display: index == 0 ? "ALWAYS" : "BYCLICK", // 气泡窗口只有在用户点击标记时才会显示
              },
            };
          }),
        });
        if (
          that.data.markers[0] &&
          that.data.markers[0].latitude &&
          that.data.markers[0].longitude
        ) {
          that.setData({
            latitude: that.data.markers[0].latitude.toString(),
          });
          that.setData({
            longitude: that.data.markers[0].longitude.toString(),
          });
        }
      }
    });

我们通过 qqmap 搜索附近的超市,并将点位信息按照 map 组件要求的格式赋值给 this.data.markers。如果你使用了高德的小程序插件,则不用这么麻烦,高德将返回的 marker 信息封装好了,下面是高德的代码:

php 复制代码
    amap.getPoiAround({
      iconPathSelected: "/img/marker_checked.png",
      iconPath: "/img/marker.png",
      success: function (data: { markers: markerItem[] }) {
        markersData = data.markers;
        that.setData({
          markers: markersData.map((item: markerItem, index: number) => {
            return {
              ...item, //直接将对象放进去就好了
              callout: {
                content: item.name,
                color: "#000",
                borderRadius: 10,
                padding: 10,
                display: index == 0 ? "ALWAYS" : "BYCLICK", // 气泡窗口只有在用户点击标记时才会显示
              },
            };
          }),
        });
        if (
          markersData[0] &&
          markersData[0].latitude &&
          markersData[0].longitude
        ) {
          that.setData({
            latitude: markersData[0].latitude.toString(),
          });
          that.setData({
            longitude: markersData[0].longitude.toString(),
          });
        }
        that.showMarkerInfo(markersData, 0);
      }
    });
  

完成上面的一步之后,你应该会看到如下画面(模拟器)

当给 markers 使用 setData 赋值之后,地图上会同步刷新出这些点位。编写上面提到的makertap方法,即可捕获marker的点击事件。

这里我们给marker绑定一个获取路线的事件:

css 复制代码
  goTo() {
    wx.getLocation({
      type: "gcj02",
      success: (res) => {
          // 获取到当前位置,模拟器上固定在北京朝阳门外附近区域
        let currentLocation = {
          latitude: res.latitude,
          longitude: res.longitude
        }
        qqmap.direction({
          mode: 'walking',
          from: currentLocation,
          to: {
            latitude: this.data.textData.lnglat.split(',')[1],
            longitude: this.data.textData.lnglat.split(',')[0]
          },
          success: function (res: any) {
            for (var i = 2; i < res.result.routes[0].polyline.length; i++) { res.result.routes[0].polyline[i] = res.result.routes[0].polyline[i - 2] + res.result.routes[0].polyline[i] / 1000000 }
            let points = []
            for (let i = 0; i < res.result.routes[0].polyline.length; i++) {
              if (i % 2 == 0) {
                points.push({
                  longitude: res.result.routes[0].polyline[i + 1],
                  latitude: res.result.routes[0].polyline[i]
                })
              }
            }
              // 以上是解压缩小程序返回的路线点位
            that.setData({
              polyline: [
                {
                  points: points,
                  color: "#0091ff",
                  width: 6,
                },
              ],
              viewPoints: points,
              steps: res.result.routes[0].steps //显示第一步提示信息
            });
​
            mapCtx.includePoints({
              padding: [10],
              points: points,
            })
            // 使地图显示在最合适的缩放级别,不会因为点的多少而导致显示不全
          }
        })
      }
    })
  },

此时,我们可以看到,在地图上,勾画出了蓝色的线,标记出了线路。并且自动适应了视角

注意关键来了,这也我们基本已经把路径规划的接口利用完了,接下来,我们将尝试用实时定位接口,来实现真正意义上的实时导航。

使用实时定位

后台实时定位

javascript 复制代码
    wx.startLocationUpdateBackground({
      type: "gcj02",
      success: (res) => {
        wx.onLocationChange((res) => {
          console.log("onLocationChange", res);
        });
      },
    });

console.log("onLocationChange", res); 处,我们大概可以每秒钟获取到用户的最新位置。经过测试,这个位置虽然比较精确(米级),但是有时会瞬移乱跳,好在不太频发,基本可用。

接下来,我们将在wx.onLocationChange的回调中,处理所有的导航相关逻辑。

我这里预制了几条用于测试的用户位置

ini 复制代码
          // 真实位置
          let userLocation: [number, number] = [res.longitude, res.latitude]
          // 模拟走过一段距离后的位置
          // let userLocation: [number, number] = [this.data.viewPoints[+(this.data.viewPoints.length / 2)].longitude, this.data.viewPoints[+(this.data.viewPoints.length / 2)].latitude]
          // 模拟距离起点有一定距离的位置
          // let userLocation: [number, number] = [this.data.viewPoints[0].longitude + 0.0001, this.data.viewPoints[0].latitude + 0.0001]
          // 模拟用户偏离路线的位置
          // let userLocation: [number, number] = [this.data.viewPoints[+(this.data.viewPoints.length / 2)].longitude + 0.001, this.data.viewPoints[+(this.data.viewPoints.length / 2)].latitude + 0.0001]

上面的路径规划完成后,我们存在了this.data.viewPoints 中,这可以同时方便我们适应视角,所以在这里,我们可以直接用这个值,来算各种参数。

使用turf

turf.js 是目前应用最广泛的geo计算库,包含了几乎全方向的geo计算方法。

首先,我们需要计算出用户距离路线的最短距离,以及以这个最短距离向路线画垂直线的垂足位置

typescript 复制代码
import pointToLineDistance from '@turf/point-to-line-distance'
import nearestPointOnLine from '@turf/nearest-point-on-line';
import pointToPointDistance from '@turf/distance';
          let line: LineString = {
            type: "LineString",
            coordinates: this.data.viewPoints.map((item: any) => {
              return [item.longitude, item.latitude]
            })
          } // turf 的标准线数据格式
          console.log('用户位置::: ', userLocation);
          console.log('路线::: ', line);
          var distance = pointToLineDistance(userLocation, line, { units: 'kilometers' }) * 1000;
          console.log('最短距离为::: ', distance);
          let nearestPoint = nearestPointOnLine(line, userLocation, { units: 'kilometers' });
          console.log('最近的点(垂足)是::: ', nearestPoint);

通过计算用户位置和路线的最短距离,我们可以得知用户是否偏航

kotlin 复制代码
         // yawLimit == 20
        if (distance >= this.data.yawLimit) {
            console.log('超出偏离阈值::: ', this.data.yawLimit);
            this.setData({
              isYawed: true //设置此值后,wxml的偏航提示被显示
            })
            wx.vibrateLong()
            setTimeout(() => {
              wx.vibrateLong()
            }, 500); //两次震动提示
          }

偏航的逻辑处理完了,那么剩下的就是不偏航的,如下:

kotlin 复制代码
else {
            // 导航结束逻辑
            let endPoints = this.data.viewPoints[this.data.viewPoints.length - 1]
            if (distance <= 10 && pointToPointDistance(userLocation, [endPoints.longitude, endPoints.latitude], { units: 'kilometers' }) * 1000 <= 10) {
              wx.stopLocationUpdate({
                success: (res) => {
                  console.log('stopLocationUpdate', res);
                },
              })
              wx.showModal({
                title: '提示',
                content: '您已到达目的地附近,导航结束',
                showCancel: false,
              })
              this.setData({
                polyline: [],
                navigationJustStart: true,
                isYawed: false,
                lastTextData: {} as textData,
                currStep: {} as step,
              })
                // 以上判断用户位置是否距离终点小于10米,清空所有数据,并告知用户导航结束
              return
            }
            if (distance <= this.data.yawLimit && this.data.navigationJustStart) {
              this.setData({
                navigationJustStart: false,
              })
            }
            this.setData({
              isYawed: false
            })
            
            let pointIndex = nearestPoint.properties.index!
            let currStep = this.data.steps.filter((item: any, index: number) => {
              if (item.polyline_idx[0] <= pointIndex * 2 && item.polyline_idx[1] >= pointIndex * 2) {
                return item
              }
            })
            this.setData({
              currStep: currStep[0] //更新本段路的文本提示
            })
    	// 以下代码是:用户没有走过的线,以及垂足点,画蓝色的线
            this.data.polyline[0] = {
              points: [{
                longitude: nearestPoint.geometry.coordinates[0],
                latitude: nearestPoint.geometry.coordinates[1]
              }].concat(this.data.viewPoints.filter((item: any, index: number) => {
                if (index == this.data.viewPoints.length - 1) return true
                return index > pointIndex
              })),
              color: '#1989fa',
              width: 6,
            }
        // 以下代码是:在此之前的点都是已经走过的点,以及垂足点,衔接上没有走过的线,画一层灰色的线
            this.data.polyline[1] = {
              points: this.data.viewPoints.filter((item: any, index: number) => {
                if (index == 0) return true
                return index <= pointIndex
              }).concat({
                longitude: nearestPoint.geometry.coordinates[0],
                latitude: nearestPoint.geometry.coordinates[1]
              }),
              color: '#999',
              width: 6,
            }
        // 以下代码是:以下代码是用户真实位置和垂足点的连线,这里使用虚线,可以告知用户在路的附近,以及线路位于用户的方向
            this.data.polyline[2] = {
              points: [
                {
                  longitude: nearestPoint.geometry.coordinates[0],
                  latitude: nearestPoint.geometry.coordinates[1]
                },
                {
                  longitude: userLocation[0],
                  latitude: userLocation[1]
                }
              ],
              color: '#1989fa',
              dottedLine: true,
              width: 6,
            }
            this.setData({
              polyline: this.data.polyline //更新线
            });
          }

注意:小程序更新polyline时,会重新渲染整条线,而不是增量更新的,所以在线路较大,或者渲染性能低的时候,会看到线路会闪一下,这属于小程序map组件本身的Bug,我也无能为力。

完成

至此,这个小程序已经基本实现了导航功能,流程为:搜索附近点位 > 点击点选中并导航 > 规划路线 > 使用turf以及后台实时定位计算用户与路径的位置关系,从而实现各种事件。

来看一下最终效果:

真机上 用户位置会有一个带箭头的绿色点位

导航开始

导航开始

模拟起始位置距离起点有一定距离

模拟已经走过一段距离

模拟偏航

到达终点

注:上述"模拟"只是手动改变了用户位置,与真机上的实际显示情况是一致的。

结语

加入诺克萨斯,我德莱厄斯批准你做大将军

扫码加入诺克萨斯帝国,入住不朽堡垒。

相关推荐
jump_jump21 小时前
妙用 localeCompare 获取汉字拼音首字母
前端·javascript·浏览器
U.2 SSD21 小时前
Echarts单轴坐标系散点图
前端·javascript·echarts
德育处主任Pro21 小时前
前端玩转大模型,DeepSeek-R1 蒸馏 Llama 模型的 Bedrock 部署
前端·llama
Jedi Hongbin1 天前
Three.js NodeMaterial 节点材质系统文档
前端·javascript·three.js·nodematerial
前端小马1 天前
前后端Long类型ID精度丢失问题
java·前端·javascript·后端
用户1456775610371 天前
干净的图片批量处理,处理速度飞快
前端
用户1456775610371 天前
亲测好用!简单实用的图片尺寸调整工具
前端
索西引擎1 天前
npm、yarn、pnpm
前端·npm·node.js
aiguangyuan1 天前
微信小程序中的双线程模型及数据传输优化
微信小程序·前端开发
天生我材必有用_吴用1 天前
Vue3 + VitePress 搭建组件库文档平台(结合 Element Plus 与 Arco Design Vue)—— 超详细图文教程
前端