本文和大家一起探讨在 H5 移动端如何唤起手机导航 APP,并顺带介绍地理坐标系相关的知识点。
URL Scheme Protocol
定义
SchemeURL(通常指 URL Scheme 协议)是一种在移动操作系统中实现应用间通信的机制,允许应用程序通过自定的 URL 格式调用其他应用的功能或传递数据。
格式与参数
它的典型格式如下:
scheme://host:port/path?query#fragment
scheme
:协议标识(如tel
、mailto
或自定义名称);host
:主机名或IP地址(本地应用可用localhost
);path
:资源路径或操作指令;query
:传递参数(如?id=123
);
原理
我们以 Android 为例,在 Android 应用中有三种不同类型的链接,分别是:深层链接
、网页链接
和 Android 应用链接
。
深层链接
(Deep Link)使用自定义的URL Scheme(如baidumap://
),直接唤起应用并传递参数。
网页链接
则是标准的 HTTP/HTTPS 链接,用于在浏览器中打开网页版地图。
Android 应用链接
(App Link)属于深度链接的一种,但需要应用与网站的验证,确保链接指向正确的应用。
在 Android H5 页面中,唤起百度地图 APP 主要依赖的是深层链接
。当用户点击该链接时,Android 系统会解析 URL Scheme 并启动百度地图 APP 的导航功能。
百度在 通用接口说明 中也提到了其实现原理,即在百度地图的清单文件中对主页面如下设置:
json
android:scheme="baidumap"
android:host="map"
属性.指定了接受Uri的scheme为baidumap,host为map。
当接收到指定Uri后,在主界面中对Uri进行解析和业务拆分,实现功能的调用。
其中 native:scheme 为 baidumap,host 为 map web:scheme 为 bdapp,host 为 map
所谓的清单,即清单文件
(AndroidManifest.xml),是每个 Android 应用的核心配置文件,位于项目根目录下,用于向系统描述应用的基本信息、组件声明及权限等关键内容。感兴趣的小伙伴可以翻阅创建指向应用内容的深层链接,这里我们只需要知道 APP 已经实现并提供了 SchemeURL,我们直接使用即可。
示例
比如,在 Android 中,我们可以通过以下示例唤起百度地图中的驾车导航功能:
html
<!-- 网页应用调起 Android 百度地图方式举例 -->
<a href="bdapp://map/navi?query=故宫&src=andr.baidu.openAPIdemo">驾车导航</a>
而在 iOS 中,则为如下形式:
html
<!-- 网页应用调起 iOS 百度地图方式举例 -->
<a href="baidumap://map/navi?location=40.057023,116.307852&coord_type=bd09ll&type=BLK&src=ios.baidu.openAPIdemo">驾车导航</a>
唤起地图 APP
我们以百度地图为例,介绍下如何唤起地图APP。
1. 准备 SchemeURL
打开百度地图调起 API文档,可以看到导航方式有很多种,不同导航对应的参数也不一样, 大致有以下几种:
导航类型 | iOS URL 接口 | Android URL 接口 | 公共参数 | 其他参数 |
---|---|---|---|---|
驾车 | baidumap://map/navi | bdapp://map/navi | coord_type、destination、src | location、query、uid、type、viaPoints |
骑行 | baidumap://map/ridenavi | bdapp://map/bikenavi | coord_type、destination、src | origin |
步行 | baidumap://map/walknavi | bdapp://map/walknavi | coord_type、destination、src | origin、mode |
高德地图文档请参考 👉️高德地图手机版开发指南
腾讯地图文档请参考 👉️路线和导航规划
2. 激活 SchemeURL ★★
我们可以通过 window.location
、<a>标签 href
、iframe
等方式唤起 APP。但是需要提前指出的是,在有的浏览器中,当我们尝试激活 schemaURL 时,若本地没有安装 APP,则会跳转到一个浏览器默认的错误页面去。因此大多数人采用的解决方案都是使用 iframe。
ts
const launchApp = (schemeUrl: string) => {
// 创建 iframe,将 schemeUrl 赋值给 src
const iframe = document.createElement('iframe');
iframe.style.cssText = 'display:none;width=0;height=0';
document.body.appendChild(iframe);
iframe.src = schemeUrl;
}
一个基本的激活方法就完成了。接下来,我们对其进行优化。
3. 优化:检测是否安装了 APP ★★★
其实在 H5 中是无法直接判断应用是否安装的,但是可以通过 定时器 + 页面隐藏事件 的方式推断出来。
思路如下:设置一个 500ms 的 setTimeout 并注册 visibilitychange
或 pagehide
事件。如果触发隐藏事件,证明浏览器被切换,APP 启动成功,此时就可以清空定时任务和注册的事件。如果 500ms 内没有反应,则视为 APP 没有安装,此时应该跳转至对应的下载页面。
ts
const launchApp = (schemeUrl: string) => {
// 1. 开启定时任务,监听是否打开了 APP
let timer: NodeJS.Timeout | null = null;
const iframe = document.createElement('iframe');
iframe.style.cssText = 'display:none;width=0;height=0';
document.body.appendChild(iframe);
iframe.src = schemeUrl;
// 2. 500ms 后如果没有清空以下定时任务,则视为没有安装 APP,自动跳转到下载页面
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
window.location.href = 'https://map.baidu.com/zt/qudao/newfengchao/1012337a/html/slide.html';
iframe.remove();
}, 500);
// 3. 注册页面隐藏事件,如果触发,则视为 APP 唤起成功
const HIDDEN_EVENT = ['visibilitychange', 'pagehide'];
HIDDEN_EVENT.forEach(e => {
window.addEventListener(e, event => {
if (
event.type === 'pagehide' ||
(event.type === 'visibilitychange' && document.hidden)
) {
timer && clearTimeout(timer);
iframe && iframe.remove();
}
});
});
}
由于各个浏览器的支持情况不同,我们需要将 visibilitychange
和 pagehide
事件都绑定上。而且这样能避免成功唤起 APP 后切换回浏览器,页面跳转到下载页面的情况(不保证所有浏览器都有效)。
4. 优化:iOS Safari 可能不支持 iframe 跳转
之所以说可能,是因为在 iOS9 之后,Apple 发布了 Universal Links 通用链接:
Universal Links-Apple Developer
Seamlessly link to content inside your app, or on your website in iOS 9 or later. With universal linksyou can always give users the most integrated mobile experience, even when your app isn'tinstalled on their device.
在 iOS 9 或更高版本中,无缝链接到您 App 内部或网站上的内容。通过通用链接,即使您的 App 未安装在用户的设备上,您始终可以为用户提供最完整的移动体验。
When users tap or click a universal link, the system redirects the link directly to your app without routing through the person's default web browser or your website. In addition, because universal links are standard HTTP or HTTPS links, one URL works for both your website and your app. If the person hasn't installed your app, the system opens the URL in their default web browser, allowing your website to handle it.当用户点击通用链接时,系统会将链接直接重定向到您的App,而无需通过用户的默认网页浏览器或您的网站进行路由。此外,由于通用链接是标准的HTTP或HTTPS链接,因此一个URL既适用于您的网站也适用于您的App。如果用户尚未安装您的App,系统会在其默认网页浏览器中打开该URL,从而允许您的网站处理它。
也就是说,在 iOS9 之后 Universal Links
能够方便的通过传统的 HTTP/HTTPS 链接来启动 App,但是对于没有支持 Universal Links 的 iOS 应用,Safari 则不能通过 iframe 的方式唤起应用(不过您仍可以使用 window.location
打开)。
有开发者在 stackoverflow 讨论过关于 Safari 不支持使用 iframe 激活 schemeURL 的问题👉️iOS 9 safari iframe src with custom url scheme not working
其中,部分解答如下:
you can no longer set iframe src in order to trigger a URI scheme. As you noted, you can, however, still set
window.location = 'custom-protocol://my-app';
. So if you know that a user has your app because you've previously opened their app from the browser and have a cookie stored that can be looked up on your backend, you can still safely firecustom-protocol://
.你已经无法通过设置 iframe 的 src 属性来触发 URI scheme 了。正如你指出的,你仍然可以通过设置
window.location = 'custom-protocol://my-app';
来实现。因此,如果你知道用户安装了你的 app,因为你之前从浏览器打开过他们的 app,并且有一个 cookie 存储在你的后端可以被查找,你仍然可以安全地触发custom-protocol://
。
所以,对于 iOS,我这里依然采用了 location 的方式激活 schemeURL,但是这会带来一个很不好的体验,唤起 APP 会有弹窗而不会直接跳转去 APP,甚至当本地没有 APP 时,会被判断为链接无效,然后还有一个弹窗。
针对该问题,可以尝试打开 schemeURL 后,添加一个 250ms 定时器进行页面跳转,这样对话框会被覆盖,再刷新页面,就能无需确认唤起APP。
然后再添加一个 1s 的定时器进行页面的重新加载,防止出现 APP 未安装,跳 App Store 的请求失败的问题。
如果更好的解决方案,欢迎评论区留言!
ts
// 唤起 APP
location.href = schemeUrl;
// 250ms 后跳转应用商店,覆盖对话框
setTimeout(function () {
location.href = downUrl;
}, 250);
// 1s 后刷新页面,防止 APP 未安装,跳转失败
setTimeout(function () {
location.reload();
}, 1000);
5. 微信内网页跳转 APP
参考 👉️微信内网页跳转APP功能
需要指出的是,使用微信提供的功能,需要确保开放平台账号已认证,这个是需要收费的。如果不想付费,则可以引导用户打开默认浏览器。
不同坐标系之间的转换
请注意,在传递 APP 的坐标参数时,请确认坐标体系是否为 GCJ-02
,因为国内的地图 APP 使用都是国测局规定的GCJ-02
坐标系。除了 GCJ-02,还有 WGS-84
和 BD-09
,这里大致介绍一下。
WGS-84
WGS-84(World Geodetic System, WGS)是使用最广泛的坐标系,也是世界通用的坐标系,GPS 设备得到的经纬度就是在 WGS84 坐标系下的经纬度。通常通过底层接口得到的定位信息都是 WGS84 坐标系。
GCJ-02
GCJ-02(G-Guojia国家,C-Cehui测绘,J-Ju局),又被称为火星坐标系,是一种基于WGS-84制定的大地测量系统,由中国国测局制定。此坐标系所采用的混淆算法会在经纬度中加入随机的偏移。
国家规定,中国大陆所有公开地理数据都需要至少用 GCJ-02 进行加密,也就是说我们从国内公司的产品中得到的数据,一定是经过了加密的。绝大部分国内互联网地图提供商都是使用 GCJ-02 坐标系,包括高德地图,谷歌地图中国区等。
BD-09
BD-09(Baidu, BD)是百度地图使用的地理坐标系,其在 GCJ-02 上多增加了一次变换,用来保护用户隐私。从百度产品中得到的坐标都是 BD-09 坐标系。
如果您的页面需要同时支持多个地图 APP,且获取到的坐标为 WGS-84 标准,那么可能需要在唤起 APP 之前,对坐标进行统一的经纬度转换。但是,GCJ-02 和 BD-09 都是用来对地理数据进行加密的,所以也不会公开逆向转换的方法。
理论上,GCJ-02 的加密过程是不可逆的,但是可以通过一些方法来逼近接原始坐标,并且这种方式的精度很高。这里推荐一个地理坐标系转换工具 gcoord,它使用的纠偏方式达到了厘米级的精度,能满足绝大多数情况。
js
var result = gcoord.transform(
[116.403988, 39.914266], // 经纬度坐标
gcoord.WGS84, // 当前坐标系
gcoord.BD09 // 目标坐标系
);
console.log(result); // [116.41661560068297, 39.92196580126834]
完整代码
1. schemeURL 常量
ts
// 百度
export const BMAP_ORIGIN = {
Drive: { Android: 'bdapp://map/navi', iOS: 'baidumap://map/navi'},
Bike: { Android: 'bdapp://map/bikenavi', iOS: 'baidumap://map/ridenavi' },
Walk: { Android: 'bdapp://map/walknavi', iOS: 'baidumap://map/walknavi' },
};
// 高德
export const ALI_ORIGIN = {
Drive: { Android: 'androidamap://navi', iOS: 'iosamap://navi' },
Bike: { Android: 'amapuri://openFeature', iOS: 'amapuri://openFeature' },
Walk: { Android: 'amapuri://openFeature', iOS: 'amapuri://openFeature' },
};
// 地图 APP 下载
export const MAP_DOWNLOAD_URL = {
Baidu: {
Android: 'https://map.baidu.com/zt/qudao/newfengchao/1012337a/html/slide.html',
iOS: 'itms-appss://apps.apple.com/cn/app/id452186370',
},
Ali: {
Android: 'https://wap.amap.com/',
iOS: 'itms-appss://apps.apple.com/cn/app/id461703208',
},
};
2. 区分操作系统
由于百度地图在 Android 中的 scheme 是 bdapp
,而在 iOS 则为 baidumap
,所以我们必须对当前设备的操作系统进行区分。
ts
export function getHardWareOS() {
let os = 'Unknown';
if (UserAgent.includes('android')) {
os = 'Android';
} else if (UserAgent.includes('windows')) {
os = 'Windows';
} else if (/iphone|ipad|macintosh|ipod/.test(UserAgent)) {
os = 'iOS';
}
return os
}
3. 坐标转换
ts
import Gcoord, { type CRSTypes } from 'gcoord';
interface Point {
lng: number;
lat: number;
pf?: string;
}
/** @description 坐标系转换 */
export const geocodeTransform = (
{ lng, lat }: Point,
from: CRSTypes = Gcoord.BD09,
to: CRSTypes = Gcoord.GCJ02
) => {
const [transferLng, transferLat] = Gcoord.transform([lng, lat], from, to);
return { lng: transferLng, lat: transferLat };
};
4. 获取 APP 的 schemeURL 链接
js
export const getAppURL = ({ mapType, osType, params}) => {
const res = { code: 200, msg: '', data: '' };
return new Promise((resolve, reject) => {
if (osType === 'Unknown' || osType === 'Windows') {
res.msg = '当前操作系统不支持唤起地图应用';
return reject(res);
}
const { start, startName, end, endName, naviType, viaPoints } = params;
// 区分 APP
switch (mapType) {
case 'Baidu': {
const href = BMAP_ORIGIN[naviType];
const src = `&src=${osType === 'Android' ? 'andr.google.chrome' : 'ios.apple.safari'}`;
// 区分出行方式
switch (naviType) {
case 'Drive': {
const location = `?location=${end.lat},${end.lng}`;
const query = `&query=${endName}`;
const title = `&title=${endName}`;
const coord_type = '&coord_type=gcj02';
// 处理途经点:
let viaPointsParam = '';
if (viaPoints.length) {
const listJSON = JSON.stringify(
viaPoints.map(p => ({ name: p.text, lat: p.lat, lng: p.lng }))
);
viaPointsParam = `&viaPoints={"viaPoints":${listJSON}}`;
}
res.url = href[osType].concat(location, query, src, title, coord_type, viaPointsParam);
res.status = 200;
res.msg = 'success';
return resolve(res);
}
case 'Bike':
case 'Walk': {
const origin = `?origin=${start.lat},${start.lng}`;
const destination = `&destination=${end.lat},${end.lng}`;
const coord_type = '&coord_type=gcj02';
res.url = href[osType].concat(origin, destination, src, coord_type);
res.status = 200;
res.msg = 'success';
return resolve(res);
}
default:
res.status = 404;
res.msg = '未知的出行方式';
return reject(res);
}
}
case 'Ali': {
const href = ALI_ORIGIN[naviType];
const sourceApplication = `?sourceApplication=${osType === 'Android' ? 'chrome' : 'safari'}`;
const point = `&lat=${end.lat}&lon=${end.lng}`;
const dev = `&dev=0`; // 是否偏移(0: lat 和 lon 是已经加密后的,不需要国测加密; 1:需要国测加密)
switch (naviType) {
case 'Drive': {
if (viaPoints.length) {
res.status = SchemeUrlStatus.NotSupported;
res.msg = '高德地图暂不支持三方途径点功能';
return reject(res);
} else {
res.url = href[osType].concat(sourceApplication, point, dev);
res.status = 200;
res.msg = 'success';
return resolve(res);
}
}
case 'Bike': {
const featureName = `&featureName=OnRideNavi`;
const rideType = `&rideType=bike`; // elebike
res.url = href[osType].concat(sourceApplication, point, dev, featureName, rideType);
res.status = 200;
res.msg = 'success';
return resolve(res);
}
case 'Walk':
const featureName = `&featureName=OnFootNavi`;
res.url = href[osType].concat(sourceApplication, point, dev, featureName);
res.status = 200;
res.msg = 'success';
return resolve(res);
default:
res.status = 404;
res.msg = '未知的出行方式';
return reject(res);
}
}
default:
res.status = 404;
res.msg = '暂未支持的 APP';
return reject(res);
}
});
};
5. 唤起 APP
js
export const launchApp = ({ osType, schemeUrl, downUrl }) => {
if (!schemeUrl) {
return downUrl ? (window.location.href = downUrl) : showFailToast('未知的程序应用');
}
if (osType === 'Android') {
let timer = null;
const iframe = document.createElement('iframe');
iframe.style.cssText = 'display:none;width=0;height=0';
document.body.appendChild(iframe);
iframe.src = schemeUrl;
// 如果没有安装 app,则过 500 ms 会自动跳转去下载页面
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
window.location.href = downUrl;
iframe.remove();
}, 800);
// 优化:如果安转了 app(拉起后页面隐藏),就取消定时跳转
const HIDDEN_EVENT = ['visibilitychange', 'pagehide'];
HIDDEN_EVENT.forEach(e => {
window.addEventListener(e, event => {
if (
event.type === 'pagehide' ||
(event.type === 'visibilitychange' && document.hidden)
) {
timer && clearTimeout(timer);
iframe && iframe.remove();
}
});
});
} else if (osType === 'iOS') {
location.href = schemeUrl;
setTimeout(function () {
location.href = downUrl;
}, 250);
setTimeout(function () {
location.reload();
}, 1000);
}
};
使用示例
html
<template>
<ul class="text-center h-full">
<li class="cursor-pointer" @click="onLaunch('Ali')">高德地图</li>
<li class="cursor-pointer" @click="onLaunch('Baidu')">百度地图</li>
</ul>
</template>
js
import { getAppURL, getHardWareOS, launchApp, MAP_DOWNLOAD_URL } from '@/utils';
const onLaunch = (mapType) => {
const osType = getHardWareOS();
if (osType === 'Unknown' || osType === 'Windows') {
showFailToast('当前操作系统不支持唤起地图应用');
} else {
getAppURL({ mapType, osType, params: props })
.then(res => {
launchApp({
schemeUrl: res.url,
downUrl: MAP_DOWNLOAD_URL[mapType][osType],
osType,
});
})
.catch(err => {
showFailToast(err.msg);
});
}
};
地理位置限制
我的项目有地理位置的使用限制,超出中心点 5KM 后就不能使用。这里需要运用两个知识点,一个是获取设备的当前位置,另一个是计算两个坐标之间的距离,我都贴出来,需要的小伙伴可以拿去参考。
获取设备当前位置
ts
export const getPosition = (options?: PositionOptions): Promise<Point> => {
return new Promise((resolve, reject) => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
position => {
resolve({
lng: position.coords.longitude,
lat: position.coords.latitude,
});
},
error => {
switch (error.code) {
case error.PERMISSION_DENIED:
reject('用户拒绝了使用地理定位服务。');
break;
case error.POSITION_UNAVAILABLE:
reject('位置信息不可用。');
break;
case error.TIMEOUT:
reject('获取用户位置超时,请稍后重试。');
break;
default:
reject('未知错误。');
break;
}
},
Object.assign(
{
// 开启高精度定位
enableHighAccuracy: false,
// 缓存位置的最大时间(以毫秒为单位):0 -> 不能使用缓存的位置,必须检索当前位置;Infinity -> 必须返回缓存位置
maximumAge: 0,
// 超时时间
timeout: Infinity,
},
options
)
);
} else {
reject('您的设备不支持地理定位服务');
}
});
};
计算两个坐标之间的距离
ts
/**
* @description Haversine 半正矢公式,计算两个经纬度坐标之间的距离
* @return 距离,单位:公里
*/
export const calculateDistance = (point1: Point, point2: Point) => {
const R = 6371.393; // 地球半径,单位为千米
const toRad = (degrees: number) => (degrees * Math.PI) / 180;
const { lng: lon1, lat: lat1 } = point1;
const { lng: lon2, lat: lat2 } = point2;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return distance;
};
使用示例
在全局路由守卫中使用该方法,以达到限制的目的。
ts
router.beforeEach(async (to, _from, next) => {
// 白名单跳过
if (locationWhiteList.includes(to.path)) {
return next();
}
try {
const currentlocation = await getPosition({ enableHighAccuracy: true, timeout: 5000 });
const distance = calculateDistance(
{ lng: '自己的经度点', lat: '自己的维度点' },
currentlocation
);
const ua = window.navigator.userAgent.toLowerCase();
if (distance > 5) {
// 1. 方圆 5KM 内方可使用该系统
next({
name: '403',
query: { error: encodeURIComponent('您当前不在导航服务范围内。') },
});
} else if (/micromessenger/i.test(ua) && to.name !== 'wechat') {
// 2. 微信用户提醒默认浏览器打开
// 自己的提示页面,略...
} else {
next();
}
} catch (error) {
next({ name: '403', query: { error: encodeURIComponent(error as string) } });
}
});
参考资料
- Scheme协议详细介绍
- 处理 Android 应用链接 | Android 官网
- 创建指向应用内容的深层链接 | Android 官网
- iOS URL Schemes | iOS 官网
- Supporting universal links in your app | iOS 官网
- iOS 9 safari iframe src with custom url scheme not working
- 微信内网页跳转APP功能
- 地图调起 API | 百度地图
- 手机版开发指南 | 高德地图
- 导航和路线规划 | 腾讯地图
- 通过浏览器判断是否安装APP
- H5页面判断客户端是iOS或者Android并跳转对应链接唤起APP
- 地理坐标系 | Github
- gcoord | Github