ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

uni-app离线定位完整方案:GPS坐标转换与离线地图实践

uni-app离线定位完整方案:GPS坐标转换与离线地图实践 我不会在正文里出现任何元信息或注解以下就是这篇围绕“uni-app实现网络离线定位”的博文正文。1. 为什么会想给uni-app做离线定位先说一个我实际遇到的场景。去年帮一个户外徒步团队做小程序App双端工具主要功能是记录轨迹、查看当前位置。在市区跑得好好的一进山里、下到地下车库信号直接归零地图上只剩一个灰色的“定位失败”提示。用户很直接地说了一句话“我来山里就是怕迷路你反而让我迷路。”这句话点醒了我。传统移动定位依赖网络本质是“网络辅助定位”。基站定位靠手机信号塔三角测算WiFi定位靠扫描周围路由器MAC地址回传服务器比对数据库这俩完全离不开网络。而真正不依赖卫星之外的任何基础设施的定位只有一种GPS以及北斗、GLONASS等GNSS系统直接接收卫星信号解算出经纬度这个过程不需要SIM卡也不需要WiFi。也就是说手机本身是有定位能力的只是很多应用根本没有把这条链路充分利用起来。所以“网络离线定位”这个需求的核心不是奇技淫巧而是把两件事做扎实第一在离线环境下强制走GPS原始信号而不是等系统默认的混合定位模式第二把地图资源提前放到本地让地图显示也不依赖流量。这篇内容适合谁如果你正在用uni-app做运动健康类App、户外工具、车辆管理、巡检系统、地下空间导航或者只是被“离线地图”这四个字困扰了很久那这套方案可以直接抄作业。我会从技术选型、工程配置、核心代码、坐标系处理、踩坑记录一路讲下来最后把uni-app x离线打包这个概念也一并理清——因为不少人和我一开始一样把这俩“离线”搞混了。2. 离线定位方案选型原生SDK、WebView瓦片还是矢量数据动手写代码之前先选路线。离线定位并不是单一功能它是“定位获取”和“地图渲染”两个模块的组合。根据渲染方式不同主流路线有三条。2.1 原生地图SDK的离线包方案高德、腾讯、百度都提供原生SDK级地图能力其中高德有比较成熟的离线地图接口。在uni-app App端可以通过原生插件封装高德SDK下载“城市离线包”后地图引擎在无网络时使用本地瓦片渲染。这一条路线的优点是定位精准、接口完善、省去自己管理瓦片的心智负担缺点是只支持App端小程序端不支持离线地图SDK而且离线包体积不小一个城市几十MB到上百MB全国包能到几百MB更新策略也需要自己维护。2.2 WebView内嵌Leaflet等JS地图库Leaflet是一套轻量级开源地图库配合OSM或其他瓦片源可以在地图下载型工具比如Mobile Atlas Creator中预先切好某一片区域的瓦片PNG然后把瓦片文件夹打到应用资源里通过WebView加载本地HTML来展示。这一路线的最大优势是跨平台App端的web-view、小程序端的web-view仅业务域名白名单可用以及H5端都能跑同一套HTML代码。缺点是瓦片管理完全靠自己缩放级别、范围裁剪、清晰度都是自己扛。而且要告诉新手一句实话小程序web-view对文件访问限制比较严格实现在线WebView方案容易真正离线跑本地瓦片在小程序里基本走不通所以这一方案真正的主场是App和H5。2.3 矢量离线数据方案Mapbox、MapLibre这类地图引擎支持矢量瓦片mbtiles格式能把道路、建筑等几何数据压缩成很小的本地文件渲染效果和流畅度远高于栅格PNG并且可以做地图样式定制。用Mapbox GL或MapLibre GL配合mbtiles离线包在WebView中运行能实现非常漂亮的离线渲染。缺点是学习成本最高矢量数据需要从OSM或商业数据源加工打包流程复杂。2.4 我的选型结论方案适用平台定位能力离线包体积开发成本维护成本高德原生SDK离线包App端强原生GPS接口中到大低中WebView Leaflet 栅格瓦片App / H5小程序受限中需桥接原生定位小到中中高MapLibre 矢量离线App / H5小程序受限中需桥接原生定位小高中如果今天只做一个App端户外工具我建议选方案一省心。但如果你和我一样需要H5嵌网页或未来可能要移植方案二是折中优选。下面我的代码示例会以“App端using高德或系统定位获取GPS坐标 WebView加载本地Leaflet栅格瓦片”为主线因为这种组合最能体现离线定位的原理也能在H5和App上通用。3. 环境准备权限、隐私与Key一项都不能少不管选哪条路线环境配置是绕不开的。uni-app项目如果只写逻辑代码不配置权限在真机上一跑就是定位失败的黑屏而且官方报错往往很笼统。3.1 manifest.json里的定位模块配置打开项目的manifest.json在App模块配置中勾选Geolocation。这里要注意uni-app的定位模块依赖系统定位如果不勾选使用uni.getLocation时会报“当前环境不支持定位”。同时App权限配置里要手动添加几条permission: { scope.userLocation: { desc: 你的位置信息将用于离线导航与轨迹记录 }, Android: { ACCESS_FINE_LOCATION: 获取精确定位用于离线定位, ACCESS_COARSE_LOCATION: 获取大致位置用于离线定位, ACCESS_BACKGROUND_LOCATION: 后台定位用于持续轨迹记录 } }3.2 Android权限的说明Android 10及以上后台定位权限和前台定位权限是分开的。如果你的应用需要在锁屏后继续记录轨迹只声明ACCESS_FINE_LOCATION不够还要在AndroidManifest里加ACCESS_BACKGROUND_LOCATION。但要注意应用商店审核对后台定位有严格要求必须配合明确的用户授权弹窗逻辑。我通常的做法是首次启动先弹窗说明用途用户勾选“仅使用期间允许”然后在设置页提供“后台定位”开关不默认请求。3.3 iOS端Info.plist配置iOS的定位权限文案是审核重点。在manifest.json中iOS隐私描述里必须填写NSLocationWhenInUseUsageDescription: 需要使用定位信息以实现在无网络环境下显示当前位置, NSLocationAlwaysAndWhenInUseUsageDescription: 需要持续定位以记录户外离线轨迹如果你不需要后台持续定位只写NSLocationWhenInUseUsageDescription就够了。写Always会大幅提升审核被拒概率而且苹果对“始终允许”的触发条件非常敏感。3.4 高德/腾讯地图Key配置如果用地图SDK方案需要在对应开放平台申请Key。这里有一个特别容易踩的坑高德Key是分包名和证书SHA1签名的开发环境、测试环境、发布环境签名不同Key要分别申请。真机调试时显示“鉴权失败INVALID_USER_SCODE”不用怀疑八成是签名不匹配。所以我会在项目的manifest.json的App SDK配置中把高德Key先配好然后打包测试基座再在发行菜单里选择自定义调试基座用正式签名重新生成一遍。这个流程不复杂但新手经常忽略“自定义基座”这一步导致本地能跑、云打包后无法定位。3.5 隐私合规弹窗这里啰嗦一句不是可选项定位属于敏感个人信息无论上架还是内部测试都要先弹隐私政策窗口。uni-app官方提供的“隐私政策弹窗”插件可以满足基础要求但离线定位场景还会高频使用GPS所以弹窗文案最好写明“采集位置用于离线定位与轨迹记录不联网上传”这样既合规也能降低用户恐慌感。4. 核心实现GPS数据获取与坐标系修正环境就绪开始写核心逻辑。我要强调一个容易被忽略的原则用户在线时你可以信任uni.getLocation的默认返回值但离线时一定要强制走GPS。4.1 为什么默认定位在离线时不可靠uni.getLocation在App端底层封装的是plus.geolocation默认的geolocation参数是system也就是系统混合定位模式。系统会优先使用基站、WiFi定位这些方式离线时直接失败即使系统切换到GPS它也不会主动提高GPS信号搜索优先级。所以在离线场景必须显式指定plus.geolocation.getCurrentPosition( (res) { const { longitude, latitude } res.coords; console.log(GPS定位成功, longitude, latitude); }, (err) { console.error(GPS定位失败, err); }, { geolocation: gps, timeout: 20000, maximumAge: 0 } );maximumAge: 0是另一个关键点它告诉系统不要返回缓存位置。如果默认给几秒甚至几十秒的缓存你会发现人已经跑出几百米了坐标还在原地转这对离线导航来说就是灾难。4.2 uni-app组合式封装一个可复用的定位模块既然用了vue3我用组合式API封装一个定位模块。先看代码import { ref } from vue; export interface OfflinePosition { latitude: number; longitude: number; accuracy: number; timestamp: number; } const position refOfflinePosition | null(null); const status refidle | locating | success | error(idle); function toGCJ02(latitude: number, longitude: number) { // 见 4.3 节的坐标修正算法 } export function useOfflineLocation() { function getGpsPosition(): PromiseOfflinePosition { return new Promise((resolve, reject) { // #ifdef APP-PLUS plus.geolocation.getCurrentPosition( (res) { const { latitude, longitude, accuracy } res.coords; position.value { latitude, longitude, accuracy, timestamp: Date.now() }; status.value success; resolve(position.value); }, (err) { status.value error; reject(err); }, { geolocation: gps, timeout: 15000, maximumAge: 0 } ); // #endif // #ifdef H5 navigator.geolocation.getCurrentPosition( (res) { const { latitude, longitude, accuracy } res.coords; position.value { latitude, longitude, accuracy, timestamp: Date.now() }; status.value success; resolve(position.value); }, (err) { status.value error; reject(err); }, { enableHighAccuracy: true, timeout: 15000 } ); // #endif }); } return { position, status, getGpsPosition }; }这段代码的核心是条件编译。APP-PLUS分支走原生GPSH5分支走浏览器地理位置API。为什么不直接用uni.getLocation因为它没办法设置geolocation: gps这个参数。如果你愿意也可以自己扩展uni对象但封装成组合式函数复用起来更顺手。4.3 坐标系修正WGS84与GCJ02这是离线定位里最容易翻车的地方。GPS卫星直接给出的坐标是WGS84标准坐标系。而国内的高德、腾讯地图以及绝大多数在线瓦片使用的是GCJ02“火星坐标系”这是一个由国家测绘部门加密后的偏移坐标系。如果你拿着WGS84坐标直接叠在GCJ02地图上位置会偏移几百米到一公里不等市区尤其明显。所以离线定位的正确做法是把GPS拿到的WGS84坐标先转换成GCJ02再交给地图渲染。我在网上找了很多算法版本最后长期使用的是一套经典的转换代码。它为大众所熟知运行稳定const PI Math.PI; const A 6378245.0; const EE 0.00669342162296594323; function outOfChina(lat, lng) { return lng 72.004 || lng 137.8347 || lat 0.8293 || lat 55.8271; } function transformLat(lat, lng) { let ret -100.0 2.0 * lng 3.0 * lat 0.2 * lat * lat 0.1 * lng * lat 0.2 * Math.sqrt(Math.abs(lng)); ret (20.0 * Math.sin(6.0 * lng * PI) 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(lat * PI) 40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0; ret (160.0 * Math.sin(lat / 12.0 * PI) 320 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0; return ret; } function transformLng(lat, lng) { let ret 300.0 lng 2.0 * lat 0.1 * lng * lng 0.1 * lng * lat 0.1 * Math.sqrt(Math.abs(lng)); ret (20.0 * Math.sin(6.0 * lng * PI) 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(lng * PI) 40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0; ret (150.0 * Math.sin(lng / 12.0 * PI) 300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0; return ret; } export function wgs84ToGcj02(latitude, longitude) { if (outOfChina(latitude, longitude)) { return { latitude, longitude }; } let dLat transformLat(latitude - 35.0, longitude - 105.0); let dLng transformLng(latitude - 35.0, longitude - 105.0); const radLat (latitude / 180.0) * PI; let magic Math.sin(radLat); magic 1 - EE * magic * magic; const sqrtMagic Math.sqrt(magic); dLat (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI); dLng (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI); return { latitude: latitude dLat, longitude: longitude dLng }; }解释一下这段代码在做什么它用一组正弦、余弦级数模拟了WGS84与GCJ02之间的偏差场然后把GPS原始坐标“掰”到火星坐标系的正确位置。outOfChina先判断坐标是否在中国境外境外不需要偏移。这套算法的实际偏移精度能到米级足够离线徒步导航使用。注意如果你用的是高德SDK定位它返回的本身就是GCJ02坐标不要再套一次转换否则会二次偏移。5. 离线地图渲染瓦片准备、加载与覆盖范围判断坐标修正好了下一步是让地图显示出来。我选的路线是WebView Leaflet 本地栅格瓦片这也是跨端维护成本相对低的做法。5.1 瓦片数据的获取方式离线地图的“地图”说白了就是一张张PNG图块按缩放级别z和经纬度网格x、y编号排列文件名形如{z}/{x}/{y}.png。要拿到这些瓦片可以用两类方式一是调用在线瓦片服务的地图下载工具如Mobile Atlas Creator手工圈选区域导出生成标准的{z}/{x}/{y}目录结构二是在线时用脚本定期拉取某个用户高频活动区域的瓦片存入本地沙盒。我个人更推荐第二种让应用在联网状态下根据用户历史轨迹点预下载附近区域的瓦片这比一个永远在膨胀的大离线包实用得多。5.2 瓦片放在哪决定了加载成败瓦片放static目录是最简单的打正式包时会一起打进安装包。但全国地图的资源量太大直接塞static只会让APK膨胀。我的做法是把瓦片放在应用私有目录首次启动或在线时通过后台下载服务把需要的瓦片下载到plus.io转换后的本地路径。在WebView页面里Leaflet不能直接通过file://读取所有目录需要先通过plus.io把本地绝对路径转成_www形式的相对资源路径或者干脆启动一个本地HTTP服务用plus.net或者原生插件实现。简化方案是把离线瓦片打到包里放到static/tiles目录然后HTML里这样写!DOCTYPE html html head meta charsetutf-8 link relstylesheet hrefleaflet.css / script srcleaflet.js/script /head body div idmap/div script var map L.map(map); // 关键是这里本地瓦片没有在线服务的标准xyz头直接指向目录 L.tileLayer(tiles/{z}/{x}/{y}.png, { maxZoom: 18, minZoom: 3, noWrap: true }).addTo(map); function updateMarker(lat, lng) { map.setView([lat, lng], 15); if (window.marker) { map.removeLayer(window.marker); } window.marker L.marker([lat, lng]).addTo(map); } /script /body /html在uni-app的vue页面里用web-view src/static/map.html加载这个本地HTML。App端打包后/static路径会映射到应用资源根目录Leaflet能正常访问。5.3 判断当前坐标是否在离线包覆盖范围内瓦片覆盖范围不是无限的用户一跑出预下载区域页面上就只有空白。所以需要提前判断坐标是否在离线数据范围内。最简单的方式是维护一个边界矩形数组比如“某山区离线包覆盖了北纬N1~N2东经E1~E2”代码里做矩形包含判断interface TileRegion { id: string; name: string; minLat: number; maxLat: number; minLng: number; maxLng: number; } function isInRegion(lat: number, lng: number, region: TileRegion): boolean { return ( lat region.minLat lat region.maxLat lng region.minLng lng region.maxLng ); }实际项目里我会把所有已下载区域的边界存到本地数据库或JSON文件里定位成功后循环判断。如果发现当前坐标落在某个已下载区域就直接用Leaflet展示地图如果不在页面降级为“纯文字坐标 罗盘方向 距离记录”避免出现一个白屏地图让用户误以为App崩了。5.4 Leaflet地图与uni-app的通信定位结果要实时喂给WebView里的地图。uni-app的web-view组件支持evalJS方法在vue侧调用// 获取到新的GPS坐标后 const mapView uni.createSelectorQuery().select(#offline-map); mapView.node().exec((res) { const node res[0].node; node.evalJS(updateMarker(${lat}, ${lng});); });这里有一个uni-app版本差异的坑新版web-view在App端node()接口的使用方式与旧版不同需要根据你的HBuilderX版本调整。实测下来App端用web-view的onMessage事件从HTML反向传数据更稳定我一般把轨迹点存到HTML侧的全局变量里App端需要读取时通过evalJS返回而不是频繁双向通信。6. 在线离线状态机自动切换与轨迹暂存离线定位不能只做“没网时能定位”这一锤子买卖最重要的是让应用在“有网-没网-恢复网络”之间平滑切换用户感知不到技术细节。6.1 网络状态监听uni-app提供了uni.onNetworkStatusChange它可以监听网络类型变化但不能区分“能连外网”和“不能连外网”。更靠谱的做法是监听网络变化后主动发一个轻量HTTP探测请求比如请求自己的一个极小的探针接口能通就认为在线不通就认为离线。这个设计弥补了“WiFi连上了但没外网”的假在线场景。let online ref(true); uni.onNetworkStatusChange((res) { if (res.isConnected) { checkServer((isOnline) { online.value isOnline; }); } else { online.value false; } });6.2 状态切换的联动行为在线状态时使用uni.getLocation的默认混合定位速度快、省电。把定位结果实时上报到服务端写入轨迹点。后台下载当前位置周边一定半径的离线瓦片更新覆盖区域边界。离线状态时自动切换为第4节里的plus.geolocation强制GPS定位模式。停止瓦片下载任务加载本地瓦片。定位点先写入本地SQLite或Storage暂存不上报。恢复网络时把暂存的轨迹点批量上报。清理过期瓦片缓存。重新校验覆盖范围和坐标偏差。6.3 轨迹暂存与断点续传离线轨迹存储在本地我推荐用uni-app的Storage配合分页结构而不是把所有点塞进一个数组。每攒够20个点就作为一个批次写入Storage的独立key比如track_batch_1、track_batch_2。恢复网络后按批次上报上报成功的就从Storage删除。这个小设计能避免一次性写入大量数据导致App被系统杀掉的尴尬也能在弱网环境下做到断点续传。7. 踩坑实录冷启动慢、偏移、权限回调与包体积这套功能我从开发到稳定跑了几个月踩过的坑比文档里写清楚的多。挑几个最有价值的分享出来。7.1 GPS冷启动定位极慢头几次真机测试在室内点“开始定位”等了快三分钟都没结果。后来才意识到GPS接收机冷启动时需要从零搜索卫星并下载星历这个过程在无网络、无辅助数据的环境下可能长达数分钟。解决方案在线时预缓存星历数据部分系统支持或者给出明确的UI提示“请到开阔地带等待定位”同时展示当前搜索到的卫星数量和信号强度。我封装了一个简单的“搜星状态”展示页让用户知道App没死机真机体验立刻好了很多。7.2 WGS84直接叠加GCJ02导致的大偏移第一次上线内测时用户反馈“我的位置在半山腰上的水库里游泳”。查了半天发现是我偷懒没做坐标转换直接把GPS输出坐标丢给地图了。在山区地形图上这种偏移可能直接让人看起来像偏离路线几百米。解决办法就是第4.3节那套转换算法没有捷径。要额外注意部分安卓ROM的“系统定位”会先做一个GCJ02转换你再转一次就会变成“反向漂移”。所以我在定位模块里加了一个配置项coordsAlreadyConverted在高德SDK返回或系统定位已经处理过的情况下置为true不做重复转换。7.3 plus.geolocation后台定位自动停止App切到后台后再切回来发现watchPosition回调停止了这是很多安卓机型的系统省电策略。要解决需要在Android端使用前台服务或使用原生插件将定位绑定到后台服务。如果只是想保住“屏幕关闭但仍记录轨迹”可以申请后台定位权限并使用一个常驻Notification。这一块涉及原生能力最省事的方案是找现成的uni-app原生插件别自己写Android代码。7.4 离线包体积与App包体膨胀我曾把全国离线瓦片直接打进APK结果包体瞬间从40MB膨胀到600MB被所有渠道火力全开地拒绝。后来改成“按需下载”策略首次启动只下载用户当前所在城市的精简包等用户实际导航某个区域时再后台下载对应区域包。用户没下载的区域可以在线看标准瓦片完全不冲突。7.5 权限回调在iOS和安卓上的差异iOS上plus.geolocation.getCurrentPosition失败回调返回的错误码和Android不一样而且iOS如果用户拒绝过一次后续再调用会直接进错误分支而不是重新弹窗。所以处理逻辑里要做“权限被拒”和“GPS关闭”的区分提示引导用户去系统设置页打开权限。用uni.getSettinguni.openSetting配合体验会好很多。8. uni-app x与离线打包概念澄清和一条实际路线最后聊一下很多人都会混的两个“离线”离线定位和离线打包。它们没有任何必然关系。离线打包也叫本地打包、脱机打包指的是使用Android Studio将uni-app项目打成APK全程不依赖云打包服务器适合需要特殊原生模块或想完全掌控构建过程的团队。uni-app x则是DCloud推出的新一代编译器将vue3代码直接编译为原生渲染应用性能更高也可以脱离云打包环境在本地出包。如果你想做的离线定位需要更底层的GPS能力比如始终监听卫星状态、接收原始NMEA语句普通uni-app的原生插件市场里选择并不多。这时可以走一条实际路线用uni-app x 自定义原生模块在Kotlin层直接调用Android LocationManager和GnssStatus把卫星数据抛回给前端。开发门槛高一些但灵活度完全不同。这里要提醒一句uni-app x离线打包的工程结构、依赖版本和普通uni-app差别很大跟着官方最新文档走不要拿旧项目的构建脚本硬套。回到本质上离线定位从来不是单个API的事它是一套“定位策略切换、坐标转换、本地地图资源管理、网络状态识别”的组合工程。先把GPS底子打牢再把离线渲染兜住最后做好状态切换体验用户在山里也好、地下车库也罢都能看到一个可靠的位置点。这套架构搭完之后后续接轨迹回放、离线POI搜索、风险区域提醒都会顺手很多。我现在自己出门徒步反而会刻意断网测试一下这个App——确认它真的能在完全没信号的地方撑住那种踏实感和在山里终于不再迷路是一样的。
返回列表