简介这份资源是面向Java后端开发者与小程序入门者的实战项目包围绕「小程序地图定位」这一常见移动场景讲解如何用Java技术栈配合前端实现位置服务。内容涉及GPS与网络定位原理、地理编码与反地理编码、路径规划算法、定位数据实时更新、隐私安全处理以及前后端API接口设计等关键环节适合希望打通后端服务与地图SDK集成的开发者参考。压缩包共38个文件约314KB以15个png界面截图、6个js逻辑脚本、5个wxss样式、4个wxml结构、4个json配置为主另含说明文档与开源许可目录涵盖location、index、logs等页面模块及utils工具类结构清晰便于对照学习。目前已有156人学习下载。通过该资源读者可获取一套可运行的地图定位小程序源码理解Java后端与地图服务商SDK的协作方式并借鉴其接口设计与性能优化思路快速搭建自己的位置服务原型。1. 基于 Java 开发的小程序地图定位从后端算路到前端打点的完整链路微信小程序里做地图定位很多人第一反应是前端调wx.getLocation拿经纬度再往地图组件上一贴就完事。真到业务里你会发现光有坐标根本不够用门店按距离排序、配送范围判定、轨迹回放、逆地理编码出中文地址这些都得后端参与。而 Java 在这个链路里的角色恰恰是把「坐标」变成「业务能用的距离和地址」。这篇笔记拆的就是这条链路——小程序端负责采集与展示Java 后端负责算路、纠偏、缓存和权限校验。适合正在做小程序商城、校园订餐、旅行社门店导航这类带位置诉求的 Java 开发也适合想把java基础里那些集合、排序、HTTP 客户端知识真正用起来的人。下面按「坐标怎么来 → 后端怎么算 → 坑在哪 → 怎么验证」推一遍。2. 坐标系与定位链路为什么你的小程序定位总是偏几百米2.1 三种坐标系混用是偏移的根源国内做地图定位绕不开三套坐标系WGS84 是 GPS 原始坐标GCJ02 是国测局加密后的坐标腾讯地图、高德、微信小程序地图组件用的都是这套BD09 是百度在 GCJ02 上又加了一层偏移。小程序wx.getLocation默认返回的就是 GCJ02type参数写wgs84才会给你原始 GPS 坐标。问题出在后端如果你拿 WGS84 的坐标直接丢给腾讯地图的逆地址解析接口返回的地址能偏出几百米这就是很多人说的「玄学偏移」。我一般会在后端统一收口坐标系。约定小程序端只传 GCJ02后端所有存储、计算、调第三方接口都用 GCJ02只有对接硬件 GPS 设备时才在入库前做一次 WGS84→GCJ02 的转换。转换算法是公开的不用引第三方 SDK自己写个工具类就行public class CoordConverter { private static final double PI 3.1415926535897932384626; private static final double A 6378245.0; // 长半轴 private static final double EE 0.00669342162296594323; // 偏心率平方 // 判断是否在国内国外坐标不做偏移 public static boolean outOfChina(double lat, double lon) { return lon 72.004 || lon 137.8347 || lat 0.8293 || lat 55.8271; } // WGS84 - GCJ02 public static double[] wgs84ToGcj02(double lat, double lon) { if (outOfChina(lat, lon)) { return new double[]{lat, lon}; } double dLat transformLat(lon - 105.0, lat - 35.0); double dLon transformLon(lon - 105.0, lat - 35.0); double radLat lat / 180.0 * PI; double magic Math.sin(radLat); magic 1 - EE * magic * magic; double sqrtMagic Math.sqrt(magic); dLat (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); dLon (dLon * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); return new double[]{lat dLat, lon dLon}; } private static double transformLat(double x, double y) { double ret -100.0 2.0 * x 3.0 * y 0.2 * y * y 0.1 * x * y 0.2 * Math.sqrt(Math.abs(x)); ret (20.0 * Math.sin(6.0 * x * PI) 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(y * PI) 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0; ret (160.0 * Math.sin(y / 12.0 * PI) 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0; return ret; } private static double transformLon(double x, double y) { double ret 300.0 x 2.0 * y 0.1 * x * x 0.1 * x * y 0.1 * Math.sqrt(Math.abs(x)); ret (20.0 * Math.sin(6.0 * x * PI) 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(x * PI) 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0; ret (150.0 * Math.sin(x / 12.0 * PI) 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0; return ret; } }这段代码里A和EE是克拉索夫斯基椭球参数transformLat/transformLon是公开的偏移多项式不用改。outOfChina那个判断很关键——海外坐标如果也走这套偏移反而会算错。参数上唯一要注意的是入参顺序是「纬度在前、经度在后」跟很多地图 API 的lng,lat顺序相反传反了偏移会大到离谱。2.2 小程序端定位采集的最小闭环前端这块不用写太复杂核心就三步拿授权、取坐标、上报。wx.getLocation需要先在app.json里声明requiredPrivateInfos否则真机上直接失败。下面是最小可用的一段// pages/location/index.js Page({ data: { latitude: 0, longitude: 0, address: }, onLoad() { this.fetchLocation(); }, fetchLocation() { wx.getLocation({ type: gcj02, // 关键统一用 gcj02 isHighAccuracy: true, // 开启高精度室内定位更稳 highAccuracyExpireTime: 4000, success: (res) { this.setData({ latitude: res.latitude, longitude: res.longitude }); this.reportToServer(res.latitude, res.longitude); }, fail: (err) { // 用户拒绝授权或定位失败引导去设置页 wx.showModal({ title: 需要定位权限, content: 请在设置中开启位置信息后重试, success: (m) { if (m.confirm) wx.openSetting(); } }); } }); }, reportToServer(lat, lng) { wx.request({ url: https://your-domain.com/api/location/report, method: POST, data: { latitude: lat, longitude: lng }, success: (res) { this.setData({ address: res.data.address }); } }); } });type: gcj02是必须的不写默认也是 gcj02但显式写出来能避免团队里有人改成 wgs84 造成后端混乱。isHighAccuracy打开后会多耗一点电但室内场景定位成功率明显提升highAccuracyExpireTime设 4000 毫秒是经验值太短拿不到高精度结果太长用户等得烦。失败回调里引导wx.openSetting是标配不然用户拒过一次就再也弹不出授权框了。2.3 后端接收与逆地理编码的落点后端收到经纬度后第一件事是逆地理编码换中文地址第二件事是算距离。逆地理编码别自己造轮子调腾讯位置服务或高德开放平台的 WebService API 就行。Java 侧用RestTemplate或OkHttp都行我一般用RestTemplate配个超时Service public class GeoService { private final RestTemplate restTemplate; private final String key 你的腾讯地图key; public GeoService() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(2000); factory.setReadTimeout(3000); this.restTemplate new RestTemplate(factory); } public String reverseGeocode(double lat, double lng) { String url String.format( https://apis.map.qq.com/ws/geocoder/v1/?location%f,%fkey%sget_poi0, lat, lng, key); try { ResponseEntityMap resp restTemplate.getForEntity(url, Map.class); Map body resp.getBody(); if (body ! null Integer.valueOf(0).equals(body.get(status))) { Map result (Map) body.get(result); return (String) result.get(address); } } catch (Exception e) { // 降级返回空地址不阻塞主流程 return ; } return ; } }location参数的顺序是「纬度,经度」跟前面转换工具类的顺序一致。get_poi0表示不返回周边 POI能省流量也能快一点。超时设 2 秒连接、3 秒读取是因为逆地理编码是同步调用卡住会拖垮整个接口。catch 里返回空字符串而不是抛异常是因为地址解析失败不该让用户定位功能整个不可用——这是血泪经验线上第三方接口抖动是常态。3. Java 后端算距离与门店排序别再用勾股定理糊弄了3.1 Haversine 公式与它的参数边界算两个坐标之间的距离网上很多代码直接用平面直角坐标系的勾股定理纬度一高误差能到百分之几十。正确做法是 Haversine 公式把地球当球体算大圆距离public class DistanceUtil { private static final double EARTH_RADIUS 6371000.0; // 地球平均半径单位米 /** * param lat1 纬度1 * param lng1 经度1 * param lat2 纬度2 * param lng2 经度2 * return 距离单位米 */ public static double haversine(double lat1, double lng1, double lat2, double lng2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double dLat radLat2 - radLat1; double dLng Math.toRadians(lng2 - lng1); double a Math.sin(dLat / 2) * Math.sin(dLat / 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2); double c 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return EARTH_RADIUS * c; } }EARTH_RADIUS取 6371000 米是平均值赤道半径和极半径差 21 公里但换算到几公里内的门店距离误差在米级业务上完全够用。Math.atan2比Math.asin数值稳定性好a接近 1 时不会出 NaN。参数上唯一要盯的是单位入参是十进制度数不是弧度别自己先转一遍再传进来。3.2 门店列表按距离排序的完整接口有了距离工具门店排序就是「查库 → 算距离 → 排序 → 分页」。注意别在数据库里用 SQL 算距离除非你用空间索引否则全表扫描加三角函数数据量一上来就崩。我一般把门店坐标缓存到 Redis 或本地 Caffeine在 Java 内存里算public ListStoreVO nearbyStores(double userLat, double userLng, int page, int size) { // 1. 从缓存拿全量门店门店数据变动少适合缓存 ListStore all storeCache.getAll(); // 2. 算距离并过滤掉 5 公里外的 ListStoreVO list all.stream() .map(s - { double d DistanceUtil.haversine(userLat, userLng, s.getLat(), s.getLng()); StoreVO vo new StoreVO(); vo.setId(s.getId()); vo.setName(s.getName()); vo.setDistance(Math.round(d)); // 四舍五入到米 return vo; }) .filter(vo - vo.getDistance() 5000) .sorted(Comparator.comparingLong(StoreVO::getDistance)) .collect(Collectors.toList()); // 3. 内存分页 int from Math.min(page * size, list.size()); int to Math.min(from size, list.size()); return list.subList(from, to); }filter里 5000 米是业务阈值配送场景一般 3 到 5 公里门店导航可以放宽到 10 公里。Math.round把 double 转成整数米前端展示「距您 320 米」比「319.87 米」舒服。分页用subList是内存分页门店数量上千时没问题上万就得考虑用 Redis 的 GEO 结构或者 Elasticsearch 的地理查询了。这里sorted用的是comparingLong因为getDistance返回的是long用comparingInt会编译不过——这种小坑在java面试题里也常考。3.3 配送范围判定点在多边形内还是半径圆门店配送范围有两种常见建模圆形半径 R和多边形不规则配送区。圆形判定简单haversine算出来小于 R 就算在范围内。多边形判定要用射线法public static boolean inPolygon(double lat, double lng, Listdouble[] polygon) { boolean inside false; int n polygon.size(); for (int i 0, j n - 1; i n; j i) { double[] pi polygon.get(i); // [lat, lng] double[] pj polygon.get(j); // 判断射线是否穿过边 if (((pi[1] lng) ! (pj[1] lng)) (lat (pj[0] - pi[0]) * (lng - pi[1]) / (pj[1] - pi[1]) pi[0])) { inside !inside; } } return inside; }polygon是顶点列表顺序要首尾相连最后一个点连回第一个点代码里j n - 1初始值就是干这个的。射线法的边界情况——点正好在边上、顶点上——会有争议业务上一般允许几米误差不用抠太细。如果配送区特别复杂建议直接上 JTSJava Topology Suite别自己写几何算法那是另一个深坑。4. 避坑与排查地图定位上线后最容易翻车的 5 个点4.1 真机定位失败但开发者工具正常现象微信开发者工具里定位秒回真机上一直转圈或直接 fail。原因通常是app.json里没声明requiredPrivateInfos: [getLocation]或者用户之前拒绝过授权wx.getLocation不再弹框。解决先补声明再在 fail 回调里判断errMsg是否含auth deny是的话引导wx.openSetting。开发者工具用的是模拟坐标跟真机 GPS 完全两码事定位功能必须真机测。4.2 逆地理编码返回的地址跟实际差一条街现象坐标没错但解析出来的地址是隔壁小区。原因多半是坐标系没统一——前端传了 WGS84后端按 GCJ02 去解析。解决在接口入口打日志把收到的经纬度和坐标系类型一起记下来对比小程序端wx.getLocation的type参数。统一约定后在 DTO 里加个coordType字段做校验不匹配直接拒绝。4.3 门店排序结果每次刷新顺序不一样现象距离相同的门店列表顺序随机变。原因是Comparator.comparingLong只比了距离距离相等时没有兜底比较Java 的sort对相等元素不保证稳定顺序Stream.sorted是稳定排序但如果你用了parallelStream就不一定。解决加二级排序键比如thenComparing(StoreVO::getId)保证结果可复现。4.4 高并发下第三方地图接口被限流现象晚高峰门店列表接口大面积超时日志里全是地图 API 的 429。原因是每个请求都同步调逆地理编码QPS 一高就触发第三方配额。解决逆地理编码结果按坐标网格缓存比如把经纬度各保留三位小数约 100 米精度做 key存 Redis 设 1 小时过期。同一片区域的用户直接命中缓存第三方调用量能降一个数量级。4.5 小程序地图组件在 iOS 上不显示标记点现象Android 正常iOS 上markers不渲染。原因通常是markers里的latitude/longitude传了字符串而不是数字iOS 对类型更严格。解决在setData之前用Number()强转一遍或者在后端返回时就保证是数值类型。这个坑很隐蔽因为 Android 的 JS 引擎会自动做类型转换iOS 不会。5. 用网格缓存和批量算路把定位接口压到 50ms 内前面讲的都是单点逻辑真上线后你会发现性能瓶颈不在算距离而在第三方调用和重复计算。我最后收口的方案是两层缓存加一个批量接口。第一层是坐标网格缓存把用户坐标按 0.001 度约 100 米取整做 key逆地理编码结果存 RedisTTL 设 3600 秒。同一栋楼里的用户基本都命中同一个 key第三方调用量直接砍掉九成。第二层是门店距离缓存门店坐标不变用户坐标网格化后网格到门店的距离也是固定的同样可以缓存。两层加起来大部分请求在 Redis 里就能拼出完整响应。批量算路是另一个技巧。用户打开门店列表时前端一次性把当前坐标传过来后端返回按距离排好序的前 20 家而不是前端每滚动一屏调一次接口。这样既省了往返也避免了分页时距离重算。接口签名大概长这样PostMapping(/api/store/nearby) public ResultListStoreVO nearby(RequestBody NearbyReq req) { // req 含 latitude, longitude, page, size String gridKey String.format(geo:%d:%d, Math.round(req.getLatitude() * 1000), Math.round(req.getLongitude() * 1000)); // 先查缓存 ListStoreVO cached redisTemplate.opsForValue().get(gridKey); if (cached ! null) { return Result.ok(paginate(cached, req.getPage(), req.getSize())); } // 缓存未命中走完整计算 ListStoreVO list storeService.nearbyStores( req.getLatitude(), req.getLongitude(), 0, Integer.MAX_VALUE); redisTemplate.opsForValue().set(gridKey, list, 3600, TimeUnit.SECONDS); return Result.ok(paginate(list, req.getPage(), req.getSize())); }Math.round(lat * 1000)把坐标精度压到三位小数对应大约 100 米网格这个粒度对门店排序足够再细缓存命中率就掉了。缓存里存的是全量排序结果分页在内存里做所以nearbyStores传的size是Integer.MAX_VALUE。TTL 3600 秒是权衡太长门店新增后用户看不到太短缓存没意义。门店数据变更时主动删掉相关网格的 key这个用 Redis 的keys模式匹配删就行门店变动频率低不用怕阻塞。验证这套方案有没有效果别只看接口平均耗时要看 P99 和缓存命中率。我一般会在日志里打三个数缓存命中、第三方调用次数、纯计算耗时。上线后如果命中率低于 70%说明网格粒度太细或者 TTL 太短如果 P99 还是高多半是 Redis 网络抖动或者第三方超时没降级。定位这功能用户感知的就是「快不快、准不准」把这两个指标盯住剩下的都是细节。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?