上周接了个需求,要在公司已有的uni-app项目里加一个“附近门店”功能。用户点进来以后能看到离他最近的一些门店卡片,点击卡片直接唤起导航找路。听起来很简单对吧?真做起来发现坑比想象中多。今天把这个过程完整写出来,给后面做类似功能的朋友趟个路。
我用的开发环境是HBuilderX 4.36 + uni-app(Vue3编译到APP端),地图SDK选的是高德。为什么没选腾讯或百度?因为公司之前的高德key申请好了,就这么简单。
第一步:创建uni-app项目并配置高德
如果你是从老项目改的,可以跳过创建这一步。从我这次经验看,新项目反而好弄,因为很多权限配置在manifest里需要手动勾选。
// 在manifest.json->App模块配置中,手动勾选Maps
// 然后还需要在App SDK配置里找到高德地图,填入你的key
{"app-plus" : {
"modules" : {
"Maps" : {}
},
"distribute" : {
"sdkConfigs" : {
"maps" : {
"amap" : {
"key_android" : "你的安卓key",
"key_ios" : "你的ios key"
}
}
}
}
}}
注意安卓key的签名要和打包证书一致。我这边就是只填了发布版key,忘了加测试包key,导致真机调试白屏,后来两个key都填上才正常。
第二步:申请定位权限
uni-app里定位权限需要两个东西:一个是manifest里的App权限配置,另一个是代码里调uni.getLocation时触发权限请求。
// manifest.json里加上这些权限声明
"permissions" : {
"Android" : {
"android.permission.ACCESS_COARSE_LOCATION" : {},
"android.permission.ACCESS_FINE_LOCATION" : {}
},
"iOS" : {
"NSLocationWhenInUseUsageDescription" : "我们需要获取位置信息以显示附近门店",
"NSLocationAlwaysAndWhenInUseUsageDescription" : "后台定位用于导航播报"
}
}
然后代码里获取定位的时候,最好是先检查一下授权状态,免得用户拒绝以后没有回调。
export function getCurrentPosition() {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 3000,
success: (res) => {
resolve({ latitude: res.latitude, longitude: res.longitude });
},
fail: (err) => {
// 如果有权限失败,引导用户去设置页打开
if (err.errMsg.indexOf('auth deny') > -1 || err.errMsg.indexOf('authorize') > -1) {
uni.showModal({
title: '提示',
content: '需要定位权限才能使用附近门店功能,是否前往设置打开?',
success: (r) => {
if (r.confirm) {
uni.openAppAuthorizeSetting();
}
}
});
}
reject(err);
}
});
});
}
这里我用的type: ‘gcj02’,因为高德地图需要的坐标系就是gcj02,很多人直接拿wgs84坐标丢给高德,那地图上的偏移能大到一条街去。
第三步:在页面中渲染高德地图
uni-app的map组件原生支持高德。用起来很简单,但要注意的是,map组件的markers更新,需要绑定一个数组,并且通过变化数组长度或者key来触发重新渲染。
<template>
<view class="page">
<map
id="map"
:latitude="lat"
:longitude="lng"
:markers="storeMarkers"
:scale="14"
@markertap="onMarkerTap"
@tap="onMapTap"
></map>
<!-- 底部门店列表,滑动切换 -->
<scroll-view scroll-y class="store-list">
<view
class="store-card"
v-for="(item, idx) in storeList"
:key="item.id"
@click="selectStore(item, idx)"
>
<text>{{ item.name }}</text>
<text>距离 {{ item.distanceText }}</text>
</view>
</scroll-view>
</view>
</template>
很多人以为把接口返回的经纬度填进markers就行,其实还要转一下坐标。如果你的后台返回的是百度坐标或者原始GPS坐标,map组件虽然自带convertPoint,但那个是异步的,而且一次性转多个点容易乱。稳妥做法是后端直接存gcj02,或者前端遍历转一下。我这次就是后端给的坐标是百度bd09ll,差点翻车。
第四步:添加自定义marker图标
默认的红色大头针太丑了,我换成了门店风格的图标。markers的iconPath字段,本地路径或者网络路径都行,但要注意APP端不支持直接写远程链接,需要先下载到本地。我把图标放到了static/images/store-pin.png,然后在marker数据里iconPath写相对路径。
function buildMarker(store) {
return {
id: store.id,
latitude: store.lat,
longitude: store.lng,
iconPath: '/static/images/store-pin.png',
width: 28,
height: 34,
callout: {
content: store.name,
display: 'BYCLICK',
borderRadius: 4,
padding: 4,
fontSize: 12,
color: '#333333',
}
};
}
这里有个小坑:callout里内容不能包含emoji,否则某些安卓机器上会崩溃。我在门店名字后面加了个emoji,现场就挂了,去掉以后正常。
第五步:附近门店的排序和距离计算
接口返回的门店列表通常是无序的,需要基于当前定位算距离排序。我写好了一个纯函数,用来计算两个经纬度之间的距离(单位米),然后再转成“500m”、“1.2km”这样。
function getDistance(lat1, lng1, lat2, lng2) {
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;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return 6371000 * c; // 地球半径取6371km
}
storeList.forEach(item => {
item.distance = getDistance(currentLat, currentLng, item.lat, item.lng);
if (item.distance < 1000) {
item.distanceText = Math.round(item.distance) + 'm';
} else {
item.distanceText = (item.distance / 1000).toFixed(1) + 'km';
}
});
storeList.sort((a, b) => a.distance - b.distance);
一开始我用了个更复杂的算法,后来发现高德在gcj02坐标系下,地球上两点距离用这个简化公式误差可以接受。如果门店之间跨度特别大(比如不同城市),那还是得用高德Web服务API的distance接口,但客户只要附近3公里,所以没问题。
第六步:点击门店图标或卡片,调起导航
导航的实现在uni-app里可以直接用uni.openLocation,这个API底层会打开系统自带地图,但有些国产ROM并没有标配高德/百度地图,体验不一。所以最好还是打开高德地图App,如果没有安装则跳转浏览器高德网页导航。我封装了这样一个函数:
function goNavigation(store) {
// 判断是高德地图客户端是否安装
uni.getAppBaseInfo().then(info => {
if (plus.os.name === 'Android') {
plus.runtime.isApplicationExist({
action: 'androidamap://',
success: exist => {
if (exist) {
// 用URL Scheme唤起高德并规划驾车路线
const daddr = store.lat + ',' + store.lng;
const uri = 'androidamap://navi?sourceApplication=uni-app&lat=' +
store.lat + '&lon=' + store.lng + '&dev=1';
plus.runtime.openURL(uri);
} else {
openWebAmap(store);
}
},
fail: () => openWebAmap(store)
});
} else {
// iOS直接采用原生地图,因为苹果自带地图调起体验更好
uni.openLocation({
latitude: store.lat,
longitude: store.lng,
name: store.name,
address: store.address
});
}
});
}
function openWebAmap(store) {
const url = 'https://uri.amap.com/navigation?from=' + currentLat + ',' + currentLng +
'&to=' + store.lat + ',' + store.lng + '&mode=car&src=myapp';
plus.runtime.openURL(url);
}
说下为什么iOS直接用uni.openLocation。因为高德自己的iOS URL Scheme经常被Apple限制,而且iOS13以后跳转第三方App需要URL Scheme白名单,需要额外配置,用系统自带地图最省心。
第七步:解决地图容器高度问题
map组件在APP端如果高度设置为100%,有时候会变成0高度。你需要在style里给map设一个固定的px值,或者用flex布局包裹起来给个计算高度。
<view class="map-container">
<map
class="map-content"
...
></map>
</view>
// css内不能使用行内样式,所以写在style里
.map-container {
height: 65vh;
}
.map-content {
width: 100%;
height: 100%;
}
这一点特别坑,我在H5端跑得好好的,打包到APP后白屏,查了半天发现就是height:100%的问题。
第八步:真机调试时的常见报错
1. getLocation一直返回fail,errMsg包含“getLocation:fail:ERROR_NOCONFIG”。这是因为manifest里没填key,或者填错了,重新检查高德App Key。
2. 地图显示空白,而且控制台报“AMapError: invalid map key”。同样的,去高德开放平台核对key,特别注意包名和签名。
3. 点击marker没反应。marker里的id字段必须为number,如果接口返回的是字符串,就会触发这个bug。用parseInt转一下。
4. 门店列表滚动时,地图marker卡顿。因为我在滚动事件里setData了整个storeMarkers数组,后面改成只更新当前高亮marker的callout,流畅多了。
总结一下这个方案的结构
整体流程并不复杂:拿到定位,后台给门店坐标,前端算距离排序,map组件打点,点击后调起导航。但涉及地图SDK时,细节和坑真的多。我发现只要用的不是H5平台,就经常会碰到坐标系、权限、key配置这些乱七八糟的东西。多花点时间看高德开放平台的官方文档,比东搜西找强。
如果你也被“附近门店”这个需求折磨,希望这篇能帮你避开我踩过的坑。以后大家可以在评论区交流一下,看有没有更优雅的跳转导航方式。

