From 88fa38ecf928eedfb2a81605af1e7d0c13a5ea06 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 3 Nov 2026 10:28:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E8=88=AA=E4=BF=AE=E5=A4=8D=EF=BC=9A?= =?UTF-8?q?=E5=8A=A0=E7=B3=BB=E7=BB=9F=E5=9C=B0=E7=90=86=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E6=8B=BF=E5=9D=90=E6=A0=87=EF=BC=8C=E6=94=B9=E7=94=A8=20openLi?= =?UTF-8?q?nk=20=E6=8B=89=E8=B5=B7=E9=AB=98=E5=BE=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 用户反馈 「智橙的导航那个地方功能不好用,跳转到高德,说是高德提示说什么不支持。 我记得同步日历它那个是支持的,所以你看一下同步日历是怎么实现的。」 ## 对照 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 转换生效) · 没装高德时是否正常降级到下一家 / 复制地点 ## 保留 多地图探测(高德/百度/花瓣/腾讯)、复制地点兜底、 「坐标失败 → 关键字重试」的链都保留。 --- entry/src/main/ets/common/FbNav.ets | 216 +++++++++++++++--- .../ets/pages/calendar/FbCalendarPage.ets | 75 ++++-- entry/src/main/ets/pages/home/FbHomePage.ets | 7 +- 3 files changed, 251 insertions(+), 47 deletions(-) diff --git a/entry/src/main/ets/common/FbNav.ets b/entry/src/main/ets/common/FbNav.ets index 5e4ed00..fcea026 100644 --- a/entry/src/main/ets/common/FbNav.ets +++ b/entry/src/main/ets/common/FbNav.ets @@ -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 { + 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 { 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 { + 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; } /** diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index 697baa2..ca918ad 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -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 { 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(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)); + }); } /** 日程系列变了之后刷新数据 */ diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index 9aa697b..753cb9f 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -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)); + }); } /**