小程序地图与位置服务(LBS)实战

系统讲解小程序地图与位置服务实战:map 组件能力、getLocation 与 chooseLocation 授权、WGS84 与 GCJ-02 坐标系转换、marker 与自定义气泡、路线规划与距离计算、地理围栏与签到打卡、轨迹绘制与回放,以及定位精度与隐私合规。

一、map 组件能力全景

1.1 基础用法

map 是小程序的原生组件,需要显式指定 id 以便通过 createMapContext 拿到控制器:

<map
  id="mainMap"
  class="map"
  latitude="{{latitude}}"
  longitude="{{longitude}}"
  scale="16"
  markers="{{markers}}"
  polyline="{{polyline}}"
  show-location
  enable-zoom
  enable-scroll
  enable-rotate="{{false}}"
  bindmarkertap="onMarkerTap"
  bindcallouttap="onCalloutTap"
  bindregionchange="onRegionChange"
/>

map 组件必须有明确高度(如 .map { width: 100%; height: 100vh; }),否则渲染为空,Skyline 渲染下也不能靠 flex 自动撑高。

1.2 组件属性分组

分组属性说明
视野latitude、longitude、scale、include-pointsscale 取 3 到 20,越大越近;include-points 自动缩放包含所有点
覆盖物markers、polyline、polygons、circles标记、线、面、圆
交互enable-zoom、enable-scroll、enable-rotate手势开关
显示show-location、show-compass、show-scale定位点、指南针、比例尺
事件bindmarkertap、bindcallouttap、bindregionchange交互回调

1.3 地图控制器

在 onReady 中调用 this.mapCtx = wx.createMapContext('mainMap', this) 即可拿到控制器,常用方法如下:

方法作用
getCenterLocation()获取地图中心点坐标
getRegion()获取当前视野的西南、东北角坐标
includePoints()缩放视野包含指定点
moveAlong()让 marker 沿路径移动
fromScreenLocation() / toScreenLocation()屏幕坐标与地图坐标互转
openMapApp()唤起第三方地图 App 导航
addMarkers() / removeMarkers()动态增删标记
setCenterOffset()设置中心点偏移

setCenterOffset 是做「底部弹层加地图标记居中」时最实用的方法:底部有 300px 面板时,this.mapCtx.setCenterOffset({ offset: [0, 150] }) 就能把中心点往上偏移 150px。

二、定位授权与 API 选型

2.1 定位 API 对比

API返回精度是否需声明适用场景
wx.getLocation精确(十米级)需 requiredPrivateInfos打卡、导航、轨迹
wx.getFuzzyLocation模糊(城市级)需单独申请天气、城市推荐
wx.chooseLocation用户选点需声明选收货地址
wx.openLocation展示地图无需查看位置详情
wx.onLocationChange连续定位需声明轨迹记录、跑步

2.2 getLocation 的参数取舍

wx.getLocation({
  type: 'gcj02',                 // wgs84 或 gcj02,国内地图必须用 gcj02
  isHighAccuracy: true,          // 开启高精度定位
  highAccuracyExpireTime: 4000,  // 超时后降级返回当前结果
  success: (res) => console.log(res.latitude, res.longitude, res.accuracy),
  fail: (err) => handleLocationFail(err)
});
参数取值影响
typegcj02可直接用于 map 组件与腾讯地图服务
typewgs84GPS 原始坐标,需转 gcj02 才能上图
isHighAccuracytrue精度提升到十米级,但更慢更耗电
highAccuracyExpireTime毫秒超时未拿到高精度结果就返回当前结果

国内业务一律用 type: 'gcj02',需要轨迹精度时再开 isHighAccuracy。用 wgs84 直接上图会出现「位置偏移几百米」的经典问题。

2.3 三层权限

层级内容检查方式
系统层手机定位服务是否开启wx.getSystemSetting().locationEnabled
小程序层用户是否授权小程序wx.getSetting().authSetting['scope.userLocation']
隐私层是否同意隐私协议wx.getPrivacySetting()

任何一层不通过都会导致定位失败,提示语必须能区分,否则用户永远不知道该去哪里开。小程序层被拒后只能引导去设置页:判断 res.authSetting['scope.userLocation'] === false 时弹 wx.showModal,用户确认后调用 wx.openSetting();未授权过则用 wx.authorize({ scope: 'scope.userLocation' }) 主动拉起。连续定位则用 wx.startLocationUpdate() 启动、wx.onLocationChange 接收回调、wx.stopLocationUpdate() 停止;需要在后台继续记录时改用 wx.startLocationUpdateBackground,并在 app.json 声明 requiredBackgroundModes: ["location"]。

三、坐标系与转换

3.1 三套坐标系

坐标系全称使用方特点
WGS84世界大地测量系统GPS 硬件、国际标准真实坐标
GCJ-02国测局坐标(火星坐标)腾讯地图、高德、微信加密偏移
BD-09百度坐标百度地图在 GCJ-02 上二次偏移

国内直接使用 WGS84 坐标在地图上打点,会出现几百米的偏移。这不是 bug,而是坐标加密的结果。

3.2 转换实现

// utils/coord.js
const PI = Math.PI;
const A = 6378245.0;                 // 长半轴
const EE = 0.00669342162296594323;   // 偏心率平方

// 经纬度偏移量(GCJ-02 加密核心)
function delta(lng, lat) {
  let dLat = -100 + 2 * lng + 3 * lat + 0.2 * lat * lat + 0.1 * lng * lat
    + 0.2 * Math.sqrt(Math.abs(lng));
  dLat += (20 * Math.sin(6 * lng * PI) + 20 * Math.sin(2 * lng * PI)) * 2 / 3;
  dLat += (20 * Math.sin(lat * PI) + 40 * Math.sin(lat / 3 * PI)) * 2 / 3;
  dLat += (160 * Math.sin(lat / 12 * PI) + 320 * Math.sin(lat * PI / 30)) * 2 / 3;

  let dLng = 300 + lng + 2 * lat + 0.1 * lng * lng + 0.1 * lng * lat
    + 0.1 * Math.sqrt(Math.abs(lng));
  dLng += (20 * Math.sin(6 * lng * PI) + 20 * Math.sin(2 * lng * PI)) * 2 / 3;
  dLng += (20 * Math.sin(lng * PI) + 40 * Math.sin(lng / 3 * PI)) * 2 / 3;
  dLng += (150 * Math.sin(lng / 12 * PI) + 300 * Math.sin(lng / 30 * PI)) * 2 / 3;

  const radLat = lat / 180 * PI;
  let magic = Math.sin(radLat);
  magic = 1 - EE * magic * magic;
  const sqrtMagic = Math.sqrt(magic);
  return [
    (dLng * 180) / (A / sqrtMagic * Math.cos(radLat) * PI),
    (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI)
  ];
}

// WGS84 -> GCJ-02
function wgs84ToGcj02(lng, lat) {
  // 境外不偏移,直接返回原始坐标
  if (lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271) return [lng, lat];
  const [dLng, dLat] = delta(lng - 105, lat - 35);
  return [lng + dLng, lat + dLat];
}

// GCJ-02 -> WGS84(反向近似,精度足够业务使用)
function gcj02ToWgs84(lng, lat) {
  const [gLng, gLat] = wgs84ToGcj02(lng, lat);
  return [lng * 2 - gLng, lat * 2 - gLat];
}

百度坐标系 BD-09 是在 GCJ-02 基础上再做一次极坐标偏移,需要与百度地图对接时由服务端统一转换输出即可,端上不必实现。

3.3 坐标转换的工程约定

场景处理方式
wx.getLocation({ type: 'gcj02' })直接上图,无需转换
设备 GPS 原始数据(wgs84)先转 GCJ-02 再上图
服务端存储统一存 GCJ-02,避免前端反复转换
与百度地图对接由服务端统一转 BD-09 输出

最容易被忽略的一条:数据库里存的到底是什么坐标系,必须写在接口文档里。坐标系混乱导致的「位置对不上」问题,排查成本远高于提前约定。

四、marker 与自定义气泡

4.1 marker 配置

const markers = [{
  id: 1,
  latitude: 39.908823,
  longitude: 116.397470,
  width: 32,
  height: 32,
  iconPath: '/assets/marker-shop.png',
  anchor: { x: 0.5, y: 1 },   // 锚点在图标底部中心
  callout: {
    content: '门店 A\n营业中',
    color: '#323233',
    fontSize: 13,
    borderRadius: 8,
    bgColor: '#ffffff',
    display: 'BYCLICK'        // ALWAYS | BYCLICK
  },
  label: { content: '门店 A', color: '#1989fa', fontSize: 12, anchorX: -24, anchorY: -48 }
}];

callout 与 label 的区别:

字段位置交互适用
callout图标上方气泡可点击(bindcallouttap)展示详情、可跳转
label可自由定位的文本不可点击常驻名称标注

4.2 自定义气泡与 customCallout

callout 样式能力有限(只有纯文本)。需要图文混排、圆角阴影、按钮时用 customCallout:

<map id="mainMap" markers="{{markers}}" bindmarkertap="onMarkerTap">
  <cover-view slot="callout">
    <cover-view
      wx:for="{{markers}}"
      wx:key="id"
      marker-id="{{item.id}}"
      class="custom-callout"
    >
      <cover-view class="title">{{item.name}}</cover-view>
      <cover-view class="desc">距离 {{item.distance}} 米</cover-view>
    </cover-view>
  </cover-view>
</map>

要点:customCallout 内的节点必须用 cover-view,普通 view 在原生组件上不可见;每个气泡通过 marker-id 与 marker 关联;气泡内容变化时更新 markers 数组即可,但要避免高频更新,因为每次更新都会重建原生层。marker 数量超过 200 时应关闭 callout 默认展示、改为点击展示,并按 getRegion() 拿到的视野范围筛选,在 bindregionchange 的 type 为 end 时触发加载(拖动过程中不请求)再配 200ms 防抖;超过 1000 个点则改用聚合或点图层。

五、路线规划与距离计算

5.1 两点距离

// 球面距离(Haversine 公式),返回米
function distance(lat1, lng1, lat2, lng2) {
  const R = 6371008.8;   // 地球平均半径,米
  const rad = Math.PI / 180;
  const dLat = (lat2 - lat1) * rad;
  const dLng = (lng2 - lng1) * rad;

  const a = Math.sin(dLat / 2) ** 2
    + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(a));
}

Haversine 适合「直线距离」展示,不能用于导航距离:实际路径距离通常是直线距离的 1.2 到 1.5 倍。

5.2 调用腾讯位置服务

小程序不能直接使用腾讯地图的 WebService API 而不配置域名,需要把 https://apis.map.qq.com 加入 request 合法域名,并在腾讯位置服务控制台创建应用拿到 key。

async function planDriving(from, to) {
  const url = 'https://apis.map.qq.com/ws/direction/v1/driving/'
    + `?from=${from.latitude},${from.longitude}&to=${to.latitude},${to.longitude}`
    + `&key=${MAP_KEY}&output=json`;

  const res = await promisify(wx.request)({ url, method: 'GET' });
  if (res.data.status !== 0) throw new Error(`路线规划失败: ${res.data.message}`);

  const route = res.data.result.routes[0];
  // route.distance 单位米,route.duration 单位秒
  // route.polyline 是压缩坐标串,需按每 8 个字符表示一个坐标增量的规则解压后再上图
  return { distance: route.distance, duration: route.duration, polyline: route.polyline };
}

5.3 唤起外部导航

wx.openLocation({
  latitude: lat, longitude: lng, name: '门店 A',
  address: '北京市东城区某路 1 号', scale: 18,
  fail: () => {
    // 兜底:唤起腾讯地图小程序
    wx.openEmbeddedMiniProgram({ appId: 'wx76a9a06e5b4e693e', path: `pages/index/index?lat=${lat}&lng=${lng}` });
  }
});

距离计算在业务中的用法与注意事项:

场景计算方式注意事项
附近门店排序直线距离加前端排序数据量小于 500 时前端算即可
配送费计算服务端按路径距离必须服务端算,防篡改
电子围栏判定直线距离与半径比较边界要考虑定位精度

所有涉及权益的计算都必须在服务端复算:前端距离只用于展示与交互反馈。

六、地理围栏与签到打卡

6.1 客户端围栏判定

function checkGeofence(current, fence) {
  const d = distance(current.latitude, current.longitude, fence.latitude, fence.longitude);
  // 定位精度会带来误差,判定半径要留出余量
  return { inside: d <= fence.radius + 50, distance: d };
}

实际工程中围栏半径要大于定位误差。城市峡谷环境下 accuracy 可能达到 100 米以上,此时半径 50 米的围栏判定完全不可靠。

6.2 签到打卡流程

async function punchIn(fenceId) {
  const loc = await promisify(wx.getLocation)({
    type: 'gcj02', isHighAccuracy: true, highAccuracyExpireTime: 5000
  });

  // 精度太差时先提示,避免无意义的服务端请求
  if (loc.accuracy > 200) {
    wx.showToast({ title: '定位精度不足,请到开阔处重试', icon: 'none' });
    return;
  }

  const res = await request({
    url: '/api/checkin',
    method: 'POST',
    // accuracy 供服务端判断是否为模拟定位
    data: { fenceId, latitude: loc.latitude, longitude: loc.longitude, accuracy: loc.accuracy, timestamp: Date.now() }
  });
  wx.showToast({ title: res.code === 0 ? '打卡成功' : res.message, icon: res.code === 0 ? 'success' : 'none' });
}

防作弊手段:

手段原理局限
精度阈值过滤模拟定位的 accuracy 常为异常值高级模拟工具可伪造
时间窗口校验服务端比对请求时间与打卡时间需防重放
速度合理性两次打卡间位移速度超阈值则拒绝需历史数据
服务端复算所有规则在服务端执行必须做

前端判定只用于体验,服务端判定才用于权益。这条原则在签到、配送、考勤类业务里是底线。

七、轨迹绘制与回放

7.1 轨迹采集与抽稀

采集阶段就要做过滤:accuracy 差于 50 米的点直接丢弃,与前一个点距离小于 5 米的不记录,这样能挡掉大量抖动点。原始轨迹点动辄上千,直接绘制会明显卡顿,还需要用 Douglas-Peucker 算法抽稀:取首尾两点连线,找出距离该线最远的点,若最远距离大于容差就以此为界把轨迹切成两段递归处理,否则用首尾直线替代整段。容差取 10 米时,1000 个点通常能压到 100 到 200 个,视觉上几乎无差异。

7.2 绘制与回放

function buildPolyline(points) {
  return [{
    points: points.map((p) => ({ latitude: p.latitude, longitude: p.longitude })),
    color: '#1989faCC',     // 支持 8 位十六进制带透明度
    width: 6,
    arrowLine: true,
    borderColor: '#ffffff'
  }];
}

// 用 moveAlong 让标记沿路径移动,比定时 setData 平滑得多
const ctx = wx.createMapContext('mainMap', this);
ctx.moveAlong({
  markerId: 1,
  path: points.map((p) => ({ latitude: p.latitude, longitude: p.longitude })),
  duration: 10000,          // 整条路径的总时长,单位毫秒
  fail: (err) => console.error('回放失败', err)
});

如果需要按时间轴回放(还原真实速度),把路径按时间间隔切成多段,逐段 await 调用 moveAlong,每段 duration 取两点的真实时间差并限制在 100 到 3000 毫秒之间。回放时同时展示速度曲线、海拔曲线是运动类小程序的常见做法:用 polyline 画轨迹,用 canvas 2D 画曲线图,两者通过共享时间轴联动,具体绘制要点可对照小程序动画与 Canvas 可视化 。

八、定位精度与隐私合规

8.1 精度影响因素

因素影响缓解
室内无 GPS 信号,退化为基站或 WiFi 定位提示到室外,放宽精度阈值
城市峡谷高楼反射导致漂移开启 isHighAccuracy,多次采样取中位数
首次定位冷启动需下载星历,耗时 5 到 30 秒先展示上次位置,再逐步修正
设备差异低端机定位芯片较差用 accuracy 字段动态调整策略

多次采样提升精度的做法是连续取 3 次定位,每次间隔 200 毫秒,然后按纬度、经度分别取中位数,accuracy 取最小值,这样能有效抵抗离群点。单次失败可以忽略,只要有一次成功即可返回。

8.2 隐私合规要求

// app.json
{
  "requiredPrivateInfos": ["getLocation", "chooseLocation", "startLocationUpdate", "onLocationChange"],
  "requiredBackgroundModes": ["location"]
}
合规项要求
接口声明用到哪个位置接口就在 requiredPrivateInfos 里声明
隐私协议在微信公众平台配置用户隐私保护指引,说明采集位置的目的与用途;首次调用定位前用弹窗做前置告知
拒绝可降级用户拒绝定位后,功能应有可用降级路径,不能直接卡死
最小必要只需城市级信息时用 wx.getFuzzyLocation,不要申请精确定位
后台定位非必要不申请,申请时必须在隐私指引里说明
数据存储轨迹等敏感数据要加密存储,明确保留期限

处理隐私授权的入口是 wx.getPrivacySetting():返回 needAuthorization 为 true 时调 wx.requirePrivacyAuthorize() 让系统弹出协议内容,用户同意后继续。拒绝定位后的降级路径是:wx.getLocation 失败时捕获异常,改为按 wx.getStorageSync('last_city') 或用户手选城市拉取热门门店,而不是白屏或反复弹窗。

降级路径不是可选项:审核会重点检查「拒绝授权后是否仍可使用」,直接白屏或死循环弹窗都会被判定为不合规。

九、总结

小程序 LBS 开发的难点集中在三处:坐标系、精度、合规。坐标系上,国内业务统一用 type: 'gcj02',服务端统一存 GCJ-02,需要 WGS84 或 BD-09 时在边界处一次性转换,并把「接口返回什么坐标系」写进文档,这是避免「位置差几百米」的唯一可靠办法。

精度上,isHighAccuracy 与多次采样取中位数能显著改善城市环境表现,但必须承认室内与低端机的能力上限:用 accuracy 字段动态调整判定阈值,围栏半径永远大于定位误差,精度过差时宁可提示重试也不要提交无效数据。合规上,requiredPrivateInfos 声明、隐私保护指引配置、拒绝授权后的降级路径三者缺一不可,且能用模糊定位就别申请精确定位。

功能层面,map 组件的 customCallout 能做出完整的图文气泡,getRegion 配合视野筛选是 marker 数量上千时的必备优化,moveAlong 让轨迹回放比定时 setData 平滑得多,而 setCenterOffset 是解决「底部面板挡住标记」的现成答案。最后记住一条底线:前端算出的距离只用于展示,一切涉及权益的判定都必须在服务端复算。地图上的大量 POI 与门店数据如果要被搜索到,还需要配合小程序搜索与 SEO 做服务直达与内容索引,让位置数据真正产生流量价值。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniprogram」更多文章

  1. 小程序第三方 SDK 集成与治理
  2. 小程序架构演进与遗留重构
  3. 小程序无障碍与适老化改造