导航期连续定位静默与光标冻结 (Silent Continuous-Location Loss Freezing the Navigation Cursor)
2026-09-14 定位并修复。一个「兜底路径被删 → 只在未测试环境里静默退化」的完整案例:微信/浏览器全程正常,只有学校壳坏,且坏了近三周无人发现,因为首屏定位仍然正常。
发生条件 (When It Happens)
同时满足这几条时才会踩到,缺一条都看不见:
- 页面运行在能力受限的 WebView(学校壳/自研壳)里,该 WebView 不提供
navigator.geolocation(未实现onGeolocationPermissionsShowPrompt、未授权、或安全上下文不满足); - 同一页面在普通浏览器/微信里能拿到 geolocation——所以测试者看到的一切都是正常的;
- 该场景曾经有一条更宽的兜底路径(走原生桥的周期性单次定位),后来被"更优雅的替代方案"(连续定位流)换掉了;
- 首屏位置另有来源(原生桥)保持正常,把连续流的失效掩盖住。
症状 (Symptoms)
- 进入导航后光标钉在原地,剩余路线不推进;
- 但地图会跟着
setCenter动——这一条最迷惑人:看起来"定位在跑",只是标记不动; - 首屏定位正常,页面无报错、不白屏;
- 换到微信/浏览器打开同一页面,完全正常。
根因 (Root Cause)
一、替换兜底时把"环境覆盖面"一起换窄了
原路径是周期性走完整三级链路(原生桥优先 → SDK → 浏览器 geolocation),环境覆盖面最宽;新路径换成单条 navigator.geolocation.watchPosition,环境覆盖面骤然收窄到"只有浏览器 geolocation 可用的地方"。
关键认知:navigator.geolocation 的可用性不是一个布尔值,它按宿主环境分裂。 同一份代码在 Chrome / 微信 WebView / 自研 WebView 里是三套行为。任何"从多源兜底改成单一源"的重构,都必须先回答:被砍掉的那些源,各自覆盖了哪个环境?
二、被删掉的那条路,恰好是唯一不用过"坐标转换"的路
三级链路里,原生桥返回 BD09(与地图同坐标系),直接可用;浏览器 geolocation 返回 WGS84,必须过一道联网的坐标转换(BMapGL.Convertor.translate(..., 1, 5, cb),带超时)。
于是新路径额外多了一个网络依赖 + 超时才能产出样本。转换一失败,样本被整条丢弃,只在调试日志里留一句。
可迁移判断:判断"两条定位路径是否等价",不能只看它们都能给出 lat/lng,要看它们各自的坐标系、精度、延迟、以及产出样本需要几步。
三、样本"到了"却"不生效"——引擎的单调时间戳闸
导航引擎要求样本时间戳严格单调:
if (lastSample && timestamp <= lastSample.timestamp) {
return { valid: false, reason: "out-of-order" };
}而 watchPosition 回调传的是 position.timestamp——GPS 定位时刻,不是回调时刻,在 WebView 里不保证递增。
更隐蔽的是渲染口径与状态口径分叉:
- 状态:
state.current = ...无条件更新; - 相机:
map.setCenter(state.current.point)无条件执行; - 光标:读的是
state.navigationRenderPoint,而它只在引擎接受样本时才被写。
所以样本被拒时,光标是唯一不动的东西。
可迁移判断:一条数据流被下游拒绝时,如果上游有多个消费者且各自读不同的状态字段,就会出现"部分更新"的假象。要么让拒绝显式冒泡到所有消费者,要么让消费者读同一个真值。
四、吸附修复后的同类冻结——不要把来源时间戳当作接收顺序
2026-09-15 复核发现,即使吸附半径已限制在 10–25 米,光标仍可能表现为固定不动:watchPosition 的 position.timestamp 是 GPS fix 时刻,学校桥/兜底定位使用的是回调时刻,而 WGS84→BD09 转换还是异步的。多源混流时,来源时间戳可能倒退,导航引擎会把样本判为 out-of-order;由于光标只读已接受样本产生的 navigationRenderPoint,最终显示上一个吸附点。
正确分工是:
sourceTimestamp只做诊断,保留原始 GPS fix 时刻;- 导航进度、速度和渲染排序统一使用单调的
receivedAt(收到/进入处理链的时刻); - 同一毫秒到达的样本补 1ms,明确更早到达的旧回调仍拒绝;
- 吸附只改变显示点,不得改变“样本是否新鲜”和“样本是否被引擎接受”的判定。
这类问题的识别信号是:日志出现 忽略定位样本:out-of-order,但 state.current 或地图中心仍在更新。此时应先查时间戳语义和异步转换顺序,不要继续放大吸附半径。
最终修法 (Fix):看门狗,而不是"再加一条定时样本"
直觉修法是"把原来那个 10s 定时定位加回来"。直接加回会打架:
- 两条源交错 → 时间戳非单调 → 互相顶成
out-of-order,反而把本来能用的那条腿踢瘸; - 速度推算会把两源之间的位置跳变除以几百毫秒,算出上百 m/s 的假速度 → 样本被判不可信 → 降级;
- 光标还会在两个源之间来回横跳。
正确形状是看门狗(watchdog)——定时器留着,但只在"主管道静默"时才动手:
var WATCHDOG_TICK_MS = 10000; // 每 10s 醒一次
var WATCHDOG_STALE_MS = 8000; // 静默超过 8s 才认为主管道死了
function runWatchdogTick() {
if (!navigating || tickActive) return;
if (!isStale()) return; // ← 主管道正常时全程空转,不插队
tickActive = true;
fallbackLocateChain().finally(() => { tickActive = false; });
}看门狗模式的三个要点(都可迁移):
- 判活阈值要明显大于主管道的正常间隔。连续定位正常是秒级,取 8s 静默才算死——不会因为一次抖动误触发。
- 只在静默时动作,正常时零开销、零副作用。这解决了"两条源混流"的根问题。
记账口径必须是"下游接受了",不是"上游调用了"。这是个通用陷阱:
// ✗ 错:按回调到达记账 —— 样本一直被拒时,看门狗误判健康,永不兜底 onSampleReceived(() => { lastHealthyAt = now; }); // ✓ 对:按下游接受记账 const r = ingest(sample); if (r && r.accepted) lastHealthyAt = now;健康判据必须落在"效果"上,不能落在"动作"上。 这是本案例里最容易二次踩坑的地方——第一版修复就写错了这一处。
顺带堵掉的一个破坏性失败路径
兜底定位失败时,原代码无条件执行 clearCurrentPosition(),而它内部会把当前路线快照一起清空:
state.current = null; state.routeReady = false;
state.routePath = []; state.routeSnapshot = null; // ← 路线当场消失在导航进行中这比"保留上一个点"糟糕得多——一次兜底失败会摧毁整个会话。
可迁移判断:失败处理要分"会话是否进行中"。会话进行中时,过期数据通常比没有数据有用;清空状态的操作必须显式说明"为什么此刻清空是安全的"。
下次如何识别 (How to Recognize Next Time)
环境矩阵先行。遇到"某人能用某人不能用"的定位问题,第一件事是列矩阵,而不是读代码:
| 浏览器 | 微信 WebView | 自研 WebView | |
|---|---|---|---|
navigator.geolocation | ✅ | ✅ | ❓ |
| 原生桥 | — | — | ✅(通常仅一次性) |
分歧点出现在哪一列,根因就在那个环境的哪一项能力上,不在业务逻辑里。
调试日志要能区分"哪一层的哪一步失败"。本案例里三句话字面不同,一眼分开:
| 日志 | 失败在哪 |
|---|---|
连续定位坐标转换失败 | 样本产出阶段(坐标系转换) |
忽略定位样本:out-of-order | 样本消费阶段(引擎时间戳单调) |
静默超过 8s,看门狗发起兜底定位 + 原生桥成功 | 主管道死了、兜底在工作 |
判断原生桥本身是否完好:只看首屏能否定位。 能 → 桥是好的,问题在连续流的宿主承载上;不能 → 才是桥坏了。
通用教训 (Transferable Lessons)
- "更优雅的替代方案"常常是"环境覆盖面更窄的方案"。 用连续定位流替代周期性单次定位,技术上更先进,但它把"原生桥能兜底"的整个环境类别悄悄排除掉了。重构定位/传感器/权限这类受宿主环境影响的能力时,必须显式写出环境覆盖矩阵的 before/after 对照。
- 静默退化比崩溃危险得多。 崩溃会被发现;"首屏正常、只有某个子功能不灵"可以存活数周。凡是保留兜底路径的地方,都应有一条能证明兜底真的被走到的日志。
- 删除兜底路径时必须留下锚点。 本案例的删除发生在一个标题为
feat: connect Volcengine TTS narration的提交里——没有任何测试或注释记着"这是某环境下唯一可用的来源"。实践:删除兜底分支时,在代码里留一行注释说明它覆盖的环境,或补一条契约测试锁住。 watchPosition与单次getCurrentPosition不可互相替代,它们的时间戳语义、精度、以及是否受安全上下文限制都不同。
关联
- 仓库:
campus-navi-rs,故障记录见docs/导航期连续定位静默故障记录.md,修复提交为看门狗方案(版本戳202609131037) - 地图 SDK 串行阻塞定位与静态资源缓存策略 (Map SDK Serial-Blocking Location & Static Asset Cache Strategy) —— 同类主题:定位链路与 SDK 加载/缓存的耦合
- React 定位副作用引发重复请求 (Geolocation Side Effects Causing Duplicate Requests) —— 定位副作用的另一面:重复触发而非静默失效
- BMapGL 视角跟随 setHeading 内部动画与 noAnimation 瞬转 (BMapGL View-Follow setHeading noAnimation Snap) —— 同一导航页的另一个"数值对但视觉不动"陷阱,可对照阅读
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend