likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 3

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

前言

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

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

调研

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

腾讯地图小程序插件

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

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

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

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

是否可用:否

腾讯地图小程序 JS SDK

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

地址:lbs.qq.com/miniProgram…

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

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

是否可用:是

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

高德地图 小程序插件

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

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

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

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

是否可用:是

百度地图 小程序 JS API

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

地址: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

导航基本流程图

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

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

开始实现

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

注2:使用了 vant 组件库

创建基本框架

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

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

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

<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 点位点击事件
// 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 信息封装好了,下面是高德的代码:

    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);
      }
    });
  

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

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

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

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

  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,
            })
            // 使地图显示在最合适的缩放级别,不会因为点的多少而导致显示不全
          }
        })
      }
    })
  },

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

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

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

使用实时定位

后台实时定位

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

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

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

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

          // 真实位置
          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计算方法。

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

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);

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

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

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

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

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以及后台实时定位计算用户与路径的位置关系,从而实现各种事件。

来看一下最终效果:

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

导航开始

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

导航开始

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

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

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

模拟已经走过一段距离

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

模拟偏航

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

到达终点

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

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

结语

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