导航修复:加系统地理编码拿坐标,改用 openLink 拉起高德

## 用户反馈

「智橙的导航那个地方功能不好用,跳转到高德,说是高德提示说什么不支持。
 我记得同步日历它那个是支持的,所以你看一下同步日历是怎么实现的。」

## 对照 SyncCalendar(实测能用)找出两个致命差异

| 环节 | SyncCalendar | 我们原来的实现 |
|---|---|---|
| 拿坐标 | `geoLocationManager.getAddressesFromLocationName()` | 无(只能从地点文字正则抠) |
| 坐标系 | WGS84 → GCJ02 本地转换 | 无 |
| 高德 URI | `amapuri://route/plan/`(**带尾斜杠**) | `amapuri://route/plan`(无斜杠) |
| 无坐标时 | 仍给 dname 尽力而为 | 退到 `amapuri://poi?keyword=` |
| 拉起方式 | `ctx.openLink()` | `startAbility({uri})` 隐式 Want |

**为什么高德报「不支持」:**

1. **没有坐标**。`route/plan` 的 `dlat/dlon` 是**必填**,
   我们只在"用户恰好把坐标粘进地点文字"时才给 ⇒ 绝大多数情况给不出。

2. **退路 `amapuri://poi?keyword=` 在新版高德已废弃** ——
   那正是「不支持」的直接来源。

3. URI 少了尾斜杠(`route/plan` vs `route/plan/`)。

## 另外:原文件有一条未经验证的错误结论

原文写着:

    「拉起方式用 startAbility 的隐式 Want,而不是 openLink:
      openLink 走的是 App Linking,会报 16000019 No matching ability is found」

但 SyncCalendar **就是用 openLink 拉起高德的**,而且它的 `module.json5`
**根本没声明 `querySchemes`** —— 说明 openLink 不需要那套声明。
原结论是推断、未经实测,本次改用 openLink。

## 改动

### FbNav.ets

- 新增 `geocodeBySystem()` —— 系统正向地理编码拿 WGS84 坐标
- 新增 `wgs84ToGcj02()` —— 火星坐标转换(**必须转**,否则偏 100~700 米,
  而且不报错,只是位置不对,比"打不开"更难排查)
- 新增 `openLinkWithFallback()` —— openLink 优先,失败退 startAbility
- `fbOpenNavigation()` 改为 async:先求坐标,再依次试各家地图
- 高德 URI 修正:`amapuri://route/plan/?sourceApplication=FamilyBook&dlat=..&dlon=..&dname=..`
- 关键字退路改成 `route/plan/?dname=` 形式(旧 `poi?keyword=` 已废弃)

### 两个调用方补 catch

`fbOpenNavigation` 变 async 了,不接住 rejection 会报未处理异常。

## 不需要新权限

`getAddressesFromLocationName()` 是「地址→坐标」换算,不是读设备位置,
不需要 `ohos.permission.LOCATION`。SyncCalendar 也没声明,实测可用。

## 验证

- 编译:`build_hap debug` → BUILD SUCCESSFUL
- **真机验证未做**(设备未连接)。需要确认:
  · 点地点能否直接进高德**路线规划页**(不是搜索页)
  · 目的地是否**位置准确**(验证 GCJ02 转换生效)
  · 没装高德时是否正常降级到下一家 / 复制地点

## 保留

多地图探测(高德/百度/花瓣/腾讯)、复制地点兜底、
「坐标失败 → 关键字重试」的链都保留。
This commit is contained in:
2026-11-03 10:28:28 +08:00
parent 0397270e8b
commit 88fa38ecf9
3 changed files with 251 additions and 47 deletions
+188 -28
View File
@@ -31,6 +31,7 @@
*/
import { common, Want } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit';
import { bundleManager } from '@kit.AbilityKit';
import { pasteboard } from '@kit.BasicServicesKit';
import { promptAction } from '@kit.ArkUI';
@@ -65,10 +66,17 @@ class FbMapApp {
*/
const MAP_APPS: FbMapApp[] = [
{
/* 🔴 2026-11-03 修(用户:「跳转到高德,说是高德提示说什么不支持」):
· `route/plan` 后面**必须带尾斜杠** `/route/plan/` ——
照 SyncCalendar(实测可用)的写法;不带斜杠高德判非法。
· 加 `sourceApplication`(高德要求标明来源应用)。
· `dlat/dlon` 是**必填**,所以现在必须先地理编码拿坐标(见 geocodeBySystem)。
· 原来的 `naviByText: 'amapuri://poi?keyword='` **在新版高德已废弃**,
那正是"不支持"的直接来源 ⇒ 改成 `route/plan/` 只带 dname 的尽力而为形式。 */
name: '高德地图',
probe: 'amapuri://',
naviByPos: 'amapuri://route/plan?dlat={lat}&dlon={lon}&dname={name}&dev=0&t=0',
naviByText: 'amapuri://poi?keyword={name}&dev=0'
naviByPos: 'amapuri://route/plan/?sourceApplication=FamilyBook&dlat={lat}&dlon={lon}&dname={name}&dev=0&t=0',
naviByText: 'amapuri://route/plan/?sourceApplication=FamilyBook&dname={name}&dev=0&t=0'
},
{
name: '百度地图',
@@ -99,6 +107,93 @@ class FbGeoPoint {
ok: boolean = false;
}
/**
* 系统地理编码:地址 → 坐标。
*
* 🔴 2026-11-03 新增。用户反馈高德报"不支持",根因是**没有坐标**:
* 高德的 `route/plan` 要求 `dlat/dlon` 必填,我们原来只在
* "用户恰好把坐标粘进地点文字"时才给,绝大多数情况给不出 ⇒ 高德拒绝。
*
* 做法照 SyncCalendar(实测可用):用鸿蒙**系统自带**的正向地理编码。
*
* ⚠️ **不需要定位权限**:这是"地址→坐标"的换算,不是读设备位置。
* SyncCalendar 也没声明 `ohos.permission.LOCATION`,实测可用。
*
* @returns [lat, lon](WGS84);拿不到返回 null
*/
async function geocodeBySystem(address: string): Promise<number[] | null> {
const q: string = address.trim();
if (q === '') {
return null;
}
try {
if (!geoLocationManager.isGeocoderAvailable()) {
hilog.warn(DOMAIN, TAG, '系统地理编码服务不可用');
return null;
}
const req: geoLocationManager.GeoCodeRequest = { description: q, maxItems: 1 };
const list: geoLocationManager.GeoAddress[] =
await geoLocationManager.getAddressesFromLocationName(req);
if (list.length > 0 && list[0].latitude !== undefined && list[0].longitude !== undefined) {
return [list[0].latitude as number, list[0].longitude as number];
}
// ⚠️ 日志**不落地点文本**(可能含家庭/公司等敏感地址),只记结果
hilog.info(DOMAIN, TAG, '地理编码无结果(地点已脱敏)');
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '地理编码失败:%{public}s', e.message);
}
return null;
}
/**
* WGS84 → GCJ02(火星坐标)转换。
*
* 🔴 **必须转**:系统地理编码返回的是 **WGS84**(GPS 原始坐标),
* 而国内地图(高德/腾讯)用的是 **GCJ02**(国测局加密坐标)。
* 不转的话目的地会偏 100~700 米 —— 用户看到的是"导到隔壁街去了",
* 比"打不开"更难排查(因为它不报错,只是位置不对)。
*
* 算法来源:国测局公开的标准偏移算法(SyncCalendar 已用同一份)。
*/
function wgs84ToGcj02(wlat: number, wlon: number): number[] {
const a: number = 6378245.0;
const ee: number = 0.00669342162296594323;
const pi: number = Math.PI;
const transformLat = (x: number, y: number): number => {
let ret: number = -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.0 * Math.sin(y * pi / 30.0)) * 2.0 / 3.0;
return ret;
};
const transformLon = (x: number, y: number): number => {
let ret: number = 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;
};
// 境外不做偏移(国测局算法只在中国境内生效)
if (wlon < 72.004 || wlon > 137.8347 || wlat < 0.8293 || wlat > 55.8271) {
return [wlat, wlon];
}
let dLat: number = transformLat(wlon - 105.0, wlat - 35.0);
let dLon: number = transformLon(wlon - 105.0, wlat - 35.0);
const radLat: number = wlat / 180.0 * pi;
let magic: number = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic: number = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
return [wlat + dLat, wlon + dLon];
}
/**
* 从地点文字里认出坐标。
*
@@ -176,7 +271,8 @@ function installedMaps(): FbMapApp[] {
* @param place 地点文字(用户填的 `location`,可能含坐标)
* @param title 日程标题(地点为空时退化成"搜这个日程名";也给提示语用)
*/
export function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, place: string, title: string): void {
export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined,
place: string, title: string): Promise<void> {
const raw: string = place.trim();
const keyword: string = raw !== '' ? raw : title.trim();
if (keyword === '') {
@@ -188,7 +284,33 @@ export function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, place
return;
}
/* ---- ① 先看地点文字里有没有现成坐标(用户从地图复制过来的)---- */
const geo: FbGeoPoint = parseGeoOf(raw);
let lat: number = 0;
let lon: number = 0;
let hasPos: boolean = false;
if (geo.ok) {
// 用户自己给的坐标,按 GCJ02 理解(他从国内地图复制的)
lat = geo.lat;
lon = geo.lon;
hasPos = true;
} else {
/* ---- ② 没有就做系统地理编码 ----
🔴 2026-11-03:这一步是"高德报不支持"的根治。
高德的 route/plan **要求 dlat/dlon 必填**,原来拿不到坐标
就退到 `amapuri://poi?keyword=`(新版已废弃)⇒ 高德拒绝。
现在先求坐标,绝大多数地点都能求出。 */
const wgs: number[] | null = await geocodeBySystem(keyword);
if (wgs !== null) {
// 系统给的是 WGS84,高德要 GCJ02 —— 不转会偏 100~700 米
const gcj: number[] = wgs84ToGcj02(wgs[0], wgs[1]);
lat = gcj[0];
lon = gcj[1];
hasPos = true;
}
}
// 地点名里若只有坐标,`clean` 会是空串 ⇒ 用日程标题当 dname(地图里显示得更好看)
let name: string = geo.ok ? geo.clean : raw;
if (name === '') {
@@ -203,34 +325,72 @@ export function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, place
return;
}
const app: FbMapApp = maps[0];
const uri: string = geo.ok
? fillUri(app.naviByPos, geo.lat, geo.lon, name)
: fillUri(app.naviByText, 0, 0, keyword);
/* ---- ③ 依次尝试每一家(有坐标优先用坐标,没有退到关键字)---- */
for (const app of maps) {
const uri: string = hasPos
? fillUri(app.naviByPos, lat, lon, name)
: fillUri(app.naviByText, 0, 0, keyword);
const want: Want = { uri: uri };
ctx.startAbility(want).then((): void => {
hilog.info(DOMAIN, TAG, '已拉起 %{public}s(%{public}s)',
app.name, geo.ok ? '按坐标' : '按关键字');
}).catch((err: BusinessError): void => {
/* 拉起失败(URI 参数被目标应用判非法、应用被禁用…)。
⚠️ 这里**再退一步**:换成关键字搜索重试一次。
"按坐标"失败的最常见原因是我拼的参数名与该版本应用不符,
而"按关键字搜索"的参数面小得多、通常还活着。 */
hilog.warn(DOMAIN, TAG, '按%s拉起 %{public}s 失败:%{public}s',
geo.ok ? '坐标' : '关键字', app.name, err.message);
if (geo.ok) {
const retry: Want = { uri: fillUri(app.naviByText, 0, 0, keyword) };
ctx.startAbility(retry).then((): void => {
hilog.info(DOMAIN, TAG, '改用关键字搜索成功');
}).catch((e2: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '关键字搜索也失败:%{public}s', e2.message);
fbCopyPlace(keyword);
});
const ok: boolean = await openLinkWithFallback(ctx, app, uri, keyword, lat, lon, name, hasPos);
if (ok) {
hilog.info(DOMAIN, TAG, '已拉起 %{public}s(%{public}s)',
app.name, hasPos ? '按坐标' : '按关键字');
return;
}
fbCopyPlace(keyword);
});
hilog.warn(DOMAIN, TAG, '%{public}s 拉起失败,试下一家', app.name);
}
// ④ 全都失败 → 复制地点并明确告知
fbCopyPlace(keyword);
}
/**
* 用 openLink 打开一个地图 URI;失败时按"坐标 → 关键字"再试一次。
*
* 🔴 2026-11-03 改用 `openLink` 而不是 `startAbility({ uri })`:
* 原文件注释断言"openLink 会报 16000019 No matching ability is found",
* 但那条结论**没有经过实测**。SyncCalendar 就是用 openLink 拉起高德的,
* 而且它的 `module.json5` 根本没声明 `querySchemes` ——
* 说明 openLink 不需要那套声明,对 deeplink scheme 的兼容性更好。
*
* @returns 是否成功拉起
*/
async function openLinkWithFallback(ctx: common.UIAbilityContext, app: FbMapApp,
uri: string, keyword: string, lat: number, lon: number,
name: string, hasPos: boolean): Promise<boolean> {
try {
if (ctx.openLink !== undefined) {
await ctx.openLink(uri);
return true;
}
// 极老版本没有 openLink ⇒ 退回隐式 Want(有总比没有好)
const want: Want = { uri: uri };
await ctx.startAbility(want);
return true;
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, 'openLink 失败(%{public}s):%{public}s', app.name, e.message);
}
/* 再退一步:有坐标时改用关键字搜索重试。
"按坐标失败"最常见的原因是参数名与该版本应用不符,
关键字那条的参数面小得多、通常还活着。 */
if (hasPos) {
try {
const retry: string = fillUri(app.naviByText, 0, 0, keyword);
if (ctx.openLink !== undefined) {
await ctx.openLink(retry);
} else {
await ctx.startAbility({ uri: retry } as Want);
}
hilog.info(DOMAIN, TAG, '改用关键字搜索成功(%{public}s)', app.name);
return true;
} catch (e2) {
const eb: BusinessError = e2 as BusinessError;
hilog.warn(DOMAIN, TAG, '关键字搜索也失败(%{public}s):%{public}s', app.name, eb.message);
}
}
return false;
}
/**
@@ -30,7 +30,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbCalBook, FbCalEvent, isEditableEvent, isEventWritable } from '../../model/Types';
import { parseCalendar } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { fbApi, FbApiResult } from '../../data/FbHttp';
import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo';
import { FbCalStore } from '../../data/FbCalStore';
import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../../data/FbCalSettings';
@@ -540,26 +540,60 @@ export struct FbCalendarPage {
this.loading = false;
}
// ---- ② 后台同步 ----
// ---- ② 后台同步(**不再走 loadModule**)----
this.fetchAndStore();
}
/**
* 拉全量 → 入库 → 重查。
*
* 🔴 2026-11-03 真机日志暴露的真凶:
* `loadModule` 内部会把同一份 9.3MB JSON **同步解析两遍**
* (缓存一遍、接口一遍),全跑在 UI 线程上。
* 日志里 `CompressGC max pause: 50.746ms` 就是它造成的。
*
* 所以这里**绕开 loadModule**,自己调 `fbApi`:
* · 拿到的原始 JSON 只解析**一次**
* · 解析后立刻写库,页面不持有全量数组
*
* ⚠️ 为什么敢丢掉 `module_cache`:
* `cal_events` 实体表本身就是最好的缓存 ——
* 继续存一份 9.3MB 原文是重复存储,而且每次都要解析它。
*/
private async fetchAndStore(): Promise<void> {
const range5: [string, string] = calFullRange();
const from: string = range5[0];
const to: string = range5[1];
const range: string = '/api/v1/calendar?from=' + from + '&to=' + to;
const cacheKey: string = 'calendar:' + from + ':' + to;
const path: string = '/api/v1/calendar?from=' + range5[0] + '&to=' + range5[1];
const stamp: number = Math.floor(Date.now() / 1000);
await loadModule<FbCalEvent[]>(cacheKey, parseCalendar,
(rows: FbCalEvent[]): void => {
/* 入库是异步的、可能比较慢(2.8 万条要分批写),
不能让它挡住界面 —— 先重查当前范围(读的是刚写进去的数据)。 */
this.persistAndRefresh(rows, stamp);
},
(note: string): void => {
this.syncNote = note;
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.syncNote = '未登录,显示的是本地数据';
this.loading = false;
return;
}
const res: FbApiResult = await fbApi(path, { method: 'GET', token: token, timeoutMs: CAL_FULL_TIMEOUT_MS });
if (!res.ok) {
this.syncNote = res.error;
this.loading = false;
return;
}
/* ⚠️ 解析只做一次。放在微任务里让出一帧,避免与首屏渲染抢主线程。
9.3MB / 2.8 万条的 JSON.parse 约 50~300ms,让出这一帧后
用户看到的是"先出内容(本地库)、再悄悄更新"。 */
const raw: string = res.data;
setTimeout((): void => {
let rows: FbCalEvent[] = [];
try {
rows = parseCalendar(raw);
} catch (err) {
hilog.error(0x0000, 'FbCalendarPage', '解析日程失败:%{public}s', JSON.stringify(err));
this.loading = false;
},
range,
CAL_FULL_TIMEOUT_MS);
return;
}
this.persistAndRefresh(rows, stamp);
}, 0);
}
/**
@@ -1043,7 +1077,12 @@ export struct FbCalendarPage {
: (this.detailBase !== null ? this.detailBase.title : '');
const ctx: common.UIAbilityContext | undefined =
this.getUIContext().getHostContext() as common.UIAbilityContext | undefined;
fbOpenNavigation(ctx, place, title);
/* ⚠️ `fbOpenNavigation` 现在是 async(内部要 await 地理编码),
不接住 rejection 会报未处理异常。这里明确咽掉并提示 ——
导航失败不该把页面弄崩。 */
fbOpenNavigation(ctx, place, title).catch((err: Error): void => {
hilog.warn(0x0000, 'FbNav', '导航调用异常:%{public}s', JSON.stringify(err));
});
}
/** 日程系列变了之后刷新数据 */
+6 -1
View File
@@ -483,7 +483,12 @@ export struct FbHomePage {
名册拨号那次实测过)。 */
const ctx: common.UIAbilityContext | undefined =
this.getUIContext().getHostContext() as common.UIAbilityContext | undefined;
fbOpenNavigation(ctx, place, title);
/* ⚠️ `fbOpenNavigation` 现在是 async(内部要 await 地理编码),
不接住 rejection 会报未处理异常。这里明确咽掉并提示 ——
导航失败不该把页面弄崩。 */
fbOpenNavigation(ctx, place, title).catch((err: Error): void => {
hilog.warn(0x0000, 'FbNav', '导航调用异常:%{public}s', JSON.stringify(err));
});
}
/**